Skip to content

fix(core,plugin-security)!: a grants resolution with no active organization applies only global grants (#20515) - #20540

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20515-orgless-grants-global-only
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20515-orgless-grants-global-only

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20515
Clause-②: yes (narrowing)

What changed

resolveUserAuthzGrants (packages/core/src/security/resolve-authz-context.ts) now states one rule, once, as the module-private predicate grantAppliesInTenant: a grant row with no organization is global and applies everywhere; a row scoped to an organization applies only while that organization is the active tenant. With no active organization, only the global grants apply. There is no "every organization" option and no keep-all fallback.

Three sites ask the predicate:

  • §4 sys_user_position (the triage's :802).
  • §6 sys_user_permission_set (the triage's :829).
  • §6a the sys_position rows whose bound permission sets the resolver collects. This one is a declared deviation from the claim's two sites; see H6 below for the measurement that put it here. With a tenant it is a no-op, because the driver's tenant scope already returned only that organization's rows and the organization-less ones.

The §4 and §6 comments, which already stated this rule, are now true. §3 (sys_member) is not edited.

@objectstack/plugin-security: buildContextForUser(ql, userId, nowMs?, tenantId?) takes the organization to resolve in (H3):

  • resolveDelegatorContext resolves the on-behalf-of delegator in the live principal's organization. That is an enforcement input: the D10 intersection.
  • explainAccessForCaller resolves an explained user in the caller's organization.

No second check was added to requireManageMetadata or to any other door. The plugin-sharing adminOrgScope guard is untouched.

Not in this card, per triage: revoking custom organization-scoped grants when a member is removed. Once this rule holds, those grants no longer apply.

H0: the defect at the public door, before and after

These readings use the #20492 rig (dispatch() with real identity resolution, resolveRequestScope into resolveExecutionContext into resolveAuthzContext, under an isolated posture). The base is unmodified 397572ed5, which already includes PR #20514. The "after" column is the committed pin file packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts: every cell of it has an arm there, and the file is green at 4e3e4f5e4. Patch round 1 added the three arms that were probe-only readings at c9e6463ce: the control at PATCH disable, the platform admin with org_alpha active at PATCH disable, and the org-less no-grant row.

The gate is observable at two doors:

Arm (every session names org_alpha) DELETE, base DELETE, after PATCH disable, base PATCH disable, after
removed from org_alpha, still in org_beta, holds an org_alpha-scoped manage_metadata set (a2) 400 TENANT_SCOPE_REQUIRED (gate passed) 403 PERMISSION_DENIED 200 (package disabled) 403
same, no membership left anywhere (a2b) 400 (gate passed) 403 200 403
control: current org_alpha member, same grant, org_alpha active 200 200 200 200
removed member holding the same set globally 400 400 (unchanged) 200 200 (unchanged)
platform admin (unscoped admin_full_access), org_alpha active 200 200 200 200
platform admin, no active organization 400 400 (unchanged) 200 200 (unchanged)
org-less user with no grant 403 403 403 403

The base readings come from a throwaway probe at 397572ed5; the first "after" reading was taken at 514e681cd, and the committed file reproduces every "after" cell. The committed file adds a third removed-member arm: org_alpha bound the set to its own copy of org_member, and the member is still an org_member in org_beta. That arm is refused 403 on both doors. With the §6a predicate ablated it passes the gate (see Verification).

H1: census of the no-tenant callers (source, packages/**, at c9e6463ce)

Caller Tenant it passes Class What it loses under the rule Right?
resolveAuthzContext first resolution (resolve-authz-context.ts:428) the API key's active_organization_id, else the session's activeOrganizationId sometimes none every organization-scoped §4, §6 and §6a grant, when the key or session names no organization Yes, per ruling. A request with no active organization acts in none. Most exposed are group-posture principals with no active organization: their data wall still spans every member organization, but their organization-scoped grants now need that organization active.
resolveAuthzContext dropped-claim re-resolution (:548) none, by construction never the left organization's grants: the defect. It also loses every other organization's scoped grants. Yes. This is the fix.
hasPlatformAdminStanding (:1184, { nowMs } only) none never nothing. PLATFORM_ADMIN derives only from the unscoped admin_full_access user grant or the declared-administrator config (H2). Yes
plugin-auth customSession (auth-manager.ts:3987) activeOrganizationId ?? undefined sometimes positions[] loses organization-scoped sys_user_position names when no organization is active. isPlatformAdmin is unchanged. Yes. Its docblock already scopes the payload to the active organization.
plugin-auth isPlatformAdminUserId (:7009), through hasPlatformAdminStanding none never nothing Yes
plugin-hono-server makeExecutionContextResolver (current-user-endpoints.ts:424) activeOrganizationId ?? undefined sometimes same as resolveAuthzContext Yes
explainer buildContextForUser (explain-engine.ts:581) was none; now the caller's choice H3 see H3 changed
resolveDelegatorContext into buildContextForUser (explain-engine.ts:686), an enforcement path was none (the live tenant was stamped on afterwards); now the live principal's tenant always, when the live principal has one Before: every organization's delegator grants. Under the rule with no caller change: only global grants, a regression for an OAuth agent acting for an organization admin. Now: the delegator's grants in the organization the request runs in. fixed here
explainAccessForCaller into buildContextForUser (security-plugin.ts:4690) was none; now the caller's tenant sometimes grants the explained user holds in organizations other than the caller's changed (H3)
invitation placement assertIssuable (invitation-placement.ts:153) the invitation's organizationId ?? undefined always in practice (a better-auth invitation belongs to an organization) nothing in practice Yes
automation runAs:'user' (service-automation/src/plugin.ts:909) the triggering run's tenantId sometimes organization-scoped grants for a run triggered with no organization Yes. The run matches the user's own direct request in that state.
transports calling resolveAuthzContext: rest-server.ts:2968, runtime/src/security/resolve-execution-context.ts:217, sharing-plugin.ts:945, marketplace-install-local-plugin.ts:1806, service-datasource/admin-routes.ts:480, service-settings/settings-service-plugin.ts:296, service-storage/storage-service-plugin.ts:1152 session or API key sometimes same as the first row Yes
MCP stdio (mcp/src/plugin.ts:164), API key only the key's organization sometimes (an org-less key, allowed under single / group) organization-scoped grants for an org-less key Yes, per ruling

No caller needs every organization's grants, so no option was added to ResolveUserAuthzGrantsOptions and the grants-cache key is unchanged (H4 moot). tenantId already keys cache entries; a new pin checks, with the cache on, that an org_a entry and a no-tenant entry never serve each other. Clause-② stays yes (narrowing): the yes arm is now carried by buildContextForUser's new optional parameter, a public widening of @objectstack/plugin-security.

H2: platform-admin standing is global (measured)

This was measured on a real SqlDriver (better-sqlite3) with the shipped bootstrapPlatformAdmin, under single, after the fix:

  • the minted admin_full_access row reads organization_id: null;
  • hasPlatformAdminStanding answers true;
  • an org-less resolution answers posture PLATFORM_ADMIN, with manage_metadata held.

hasPlatformAdminStanding loses nothing, so it needs no answer of its own. Core pins cover both polarities: the unscoped grant is PLATFORM_ADMIN with and without a tenant; an organization-scoped admin_full_access confers no standing with or without that tenant.

H3: the explainer takes a tenant, (b), not the triage's (a)

The explainer's own contract decided it. The module header says the report "can never drift from enforcement". buildContextForUser's docblock says it is called "with the exact arguments" enforcement uses. Its parity suite asserts, field by field, that it equals resolveUserAuthzGrants.

Option (a), an explicit every-organization option, breaks that parity by construction. It would also have kept every organization's grants in an enforcement path, because resolveDelegatorContext builds the D10 delegator leg through buildContextForUser. So buildContextForUser takes the organization to resolve in, and each caller names it.

Pinned in explain-engine.test.ts, security-plugin.test.ts and the parity suite, which now runs two cases in org1 on both sides.

What the explainer shows for the removed member, against enforcement:

Explained by Explain shows Enforcement (the member's own session: claim dropped, no tenant) Agree?
a caller with no active organization, or in org_beta global grants only; no manage_metadata refused yes
an admin in org_alpha (the left organization) the org_alpha-scoped manage_metadata set refused no: explain overstates
(before this PR) anyone every organization's grants passed (the defect) yes, both wrong

The disagreement in the second row is the #20431 class (explain ≠ enforce). It is reported, not fixed here. The explain API resolves the explained user in the caller's organization, and it does not model the session arm's membership check. Explain-of-another-user's record-level Layer 0 still evaluates with no active organization; that is pre-existing and unchanged.

H5: section 3, measured

Measured on a real SqlDriver over the shipped per-organization built-in catalog (bootstrapBuiltinRoles for org_jia and org_yi). The user is a current member of org_jia (admin) and org_yi (member), with no organization active.

  • §3 projects both organizations' roles: positions: [org_admin, org_member, everyone].
  • The gate that read them was §6a. At the pre-§6a state, those names pulled every organization's copy of org_admin, org_member and everyone, and their bindings. A member removed from org_jia and still in org_yi kept org_jia's org_member-bound manage_metadata set with no tenant. A user with no membership at all picked up org_jia's everyone binding.
  • After the §6a predicate: the same resolutions carry no organization's bindings. With org_jia active, only org_jia's apply.

The verdict on §3 itself: not the same class once §6a holds, and not edited. Its rows are the user's own current memberships, not grant rows, and every capability a role name can confer now arrives through organization-scoped rows that answer the rule. What remains is display: positions[] with no active organization names every membership's role.

H6: the smallest fix that satisfies the stated rule

Sections 4 and 6 alone did not satisfy the rule. The real-driver measurement in H5 shows the removed member keeping the left organization's manage_metadata through §6a after the §4 and §6 fix, so §6a asks the same predicate.

Verification (head 4e3e4f5e4, after merging origin/main 288611e3e with a true merge; the first round's merge was 31d281d3b)

  • Build: turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2: 71/71.

  • Tests at 4e3e4f5e4:

    • @objectstack/dogfood, the whole suite: vitest run --shard=1/3, 2/3 and 3/3, all exit 0. 47 files and 378 passed; 47 files and 321 passed, 1 skipped; 46 files and 451 passed, 1 file and 2 tests skipped. That is 141 files and 1150 tests passed.
    • sharing-rule-org-less-caller.dogfood.test.ts alone: 16 passed. That is the 13 it had, plus 3 for the organization-scoped persona.
    • @objectstack/core vitest run --project local: 56 files, 1518 passed.
    • @objectstack/plugin-security explain-engine, security-plugin and per-organization-catalog: 361 passed.
    • @objectstack/runtime the door file (13 pins) plus packages-uninstall-refuse-before-mutate: 21 passed.
    • @objectstack/plugin-sharing sharing-rule-positions-name-authority: 7 passed.
    • Typecheck: @objectstack/runtime and @objectstack/dogfood exit 0; the runtime test-typecheck ledger is unchanged.
  • Full suites at c9e6463ce's source, before the first merge (the two origin/main merges since then brought main's own packages/rest changes, rest-server.ts, meta-item-read-gate.ts and four test files, and main's packages/runtime test meta-list-projection-parity.test.ts; this PR's patch round moved its runtime door-pin file. For those suites, the verdict is the head's Test Core runs):

    Package Files Tests
    plugin-security 143 3052 passed, 16 skipped
    runtime local 287 4181 passed
    rest local 221 4231 passed
    plugin-auth 115 2464
    plugin-hono-server 27 324
    service-automation 149 1837
    plugin-sharing 37 913
    plugin-approvals 51 791
    organizations 8 108
    mcp 32 344
    cloud-connection 30 397
    service-datasource 34 693
    service-settings 33 584
    service-storage 40 627
    client 50 641

    All green. The consumer direction is the downstream importers of @objectstack/core named in the H1 census, plus their own consumers plugin-approvals and client.

    This table omitted @objectstack/dogfood in the first round, and its shard 3/3 was red on c9e6463ce. The whole dogfood suite is the first bullet above.

  • Typecheck: @objectstack/core, @objectstack/plugin-security and @objectstack/runtime typecheck all exit 0. Each check:test-typecheck is OK with its debt ledger unchanged.

  • Fixture triage (dogfood, patch round 1): sharing-rule-org-less-caller.dogfood.test.ts (plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158's HTTP proof) gave its exposed org-less persona manage_sharing through a grant scoped to org_8158_a. That grant reached adminOrgScope only through the defect, so shard 3/3 went red on "the refusal names the ORGANIZATION".

    • The exposed persona now holds the set globally. It keeps pinning adminOrgScope (plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158's defence in depth) with every assertion unchanged: 403, the "active organization" message, by-name and by-id refused, evaluate / delete / create refused, no cross-tenant read.
    • A new persona holds the grant as plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158 filed it: scoped to org_8158_a, no membership, no active organization. It is refused 403 PERMISSION_DENIED at the capability gate ("requires the manage_sharing capability"), with no rows returned, and refused by name too. Its session is pinned to carry no active organization.
    • The control keeps the scoped grant with org_8158_a active and still reads only its own tenant.
    • The new persona's red direction without the fix is the old test's green on main: that case measured exactly this persona reaching adminOrgScope's message.
    • Census of the rest of packages/qa/dogfood:
      • No other fixture writes an organization-scoped sys_user_permission_set or sys_user_position row. The two other hits, in membership-actor-attribution, are reads of the auto-grant row.
      • test/armed.ts:215 resolves through the real resolveAuthzContext (whatever the session carries), and its users are armed through memberships.
      • The authz-conformance.matrix.ts rows cite §3/§4/§6 as enforcement sites, and none of them states the old no-tenant reading.
      • The whole suite is green, as above.
  • Fixture triage (plugin-sharing, one file, two cases): sharing-rule-positions-name-authority.test.ts gave an org-less caller manage_sharing through an organization-scoped grant, which is exactly the defect's behaviour. The grant is re-spelled as global, the one way an org-less caller still holds it; it is still sharing_admin, never admin_full_access. Three explain fixtures were re-judged to resolve in org1, where the scoped set applies.

  • Ablations, each through scripts/ablation-replace.mjs in WRAP mode, with a script-level trap restore on the absolute path. Core resolves from src in both the core and runtime suites, and explain-engine is imported relatively, so no dist/ leg applies. Each is labelled with the source state it was measured at.

    1. Re-run at 4e3e4f5e4 (resolver blob 1f0d2889e626, which includes §6a). The predicate was put back to the old skip condition. Anchor 1 to 0, blob 1f0d2889e626 to a03a16f630a3.
      • Red: 5 core pins (§4/§6 with no tenant; §6a with no tenant; u_ex; u_gone; cache on) and the 6 removed-member door pins (DELETE and disable, for a2, a2b and the position-bound arm).
      • Green, 7 door pins: both controls, the global grant, the platform admin, the org-less no-grant caller, and the claim-drop proof.
      • Restored: blob == HEAD, git diff HEAD empty.
      • The first round's run of this ablation was taken before §6a landed (blob 490bd8a377af) and is superseded.
    2. Measured before the first merge; 92716c91af53 is still explain-engine.ts's blob at 4e3e4f5e4. buildContextForUser stopped passing its tenant. Blob 92716c91af53 to 372ec2454029. Red: 6 pins, which are the three re-judged fixtures, explain-in-an-organization, delegator-in-org_alpha and the route caller-in-org_alpha. Restored and proven the same way.
    3. Measured before the first merge; 1f0d2889e626 is still the resolver's blob at 4e3e4f5e4. The §6a predicate was replaced by a filter that keeps every row. Blob 1f0d2889e626 to 404c23da10ec. Red: both §6a core pins and 4 door pins: the position-bound arm, plus the a2 arm, whose org_beta membership also reaches org_alpha's org_member binding. Restored and proven the same way.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4e3e4f5e4 derived 72 commands. That is 68, plus four @objectstack/spec families: check:empty-state, check:liveness, check:strictness-ledger and check:variant-docs. All 72 were run with exit codes recorded before any pipe, and all ended 0.

    • check:type-check-debt first exited 3 (PREREQUISITE NOT MET): ablation 1's restore left core's source newer than its dist/. Core was rebuilt and the gate re-run: 0.
    • --ran: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 10 changed .ts files at 4e3e4f5e4 gave 10 files, 0 errors, 0 warnings.

    • The population is eslint.config.mjs's **/*.{ts,...} object minus NEVER_LINTED and packages/spec/**, and all 10 files are in it.
    • The config enables no type-aware linting (no parserOptions.project, as the config itself states), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.

Acceptance notes

  • Review nit ①5, not carried: explainAccessForCaller reads tenantId where resolvePermissionSetsForContext reads organizationId ?? tenantId. Patch round 1 does not otherwise touch security-plugin.ts, so it is left as reviewed.

  • Explain ≠ enforce for a removed member explained from the left organization (H3, second row). This is the plugin-security: security.explain reports a record visible under a row-level using that compares two fields of different classes, while find refuses the same read with INVALID_FILTER / 400 #20431 family, reported and not fixed. The explained user is resolved in the caller's organization, without the membership check the session arm applies.

  • §6a no-tenant page cap: the organization-less sys_position read is installation-wide and capped at 200 rows, so with many organizations the organization-less rows can fall outside the page. That was already true before this change; the predicate only decides which of the returned rows apply.

  • The position-name fold with no tenant (resolvePermissionSetsForContext requesting position names as permission-set names, loaded through dbLoaderForContext) is a separate seam. NOT MEASURED here.

  • group posture: a principal with no active organization keeps a data wall spanning every member organization, but it now holds no organization-scoped grant until one is active. That is the ruling; it is named here because it is the most visible population.


Generated by Claude Code

… grants

Sections 4 (sys_user_position) and 6 (sys_user_permission_set) of
resolveUserAuthzGrants now ask one predicate, grantAppliesInTenant: a row
with no organization is global; a row scoped to an organization applies only
while that organization is the active tenant. With no tenant, the old skip
condition kept every organization-scoped row.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…n arm and the cache

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…nts in an organization

buildContextForUser takes the organization to resolve in. The delegator of an
on-behalf-of principal is resolved in the live principal's organization, and
the explain API resolves the explained user in the caller's organization, so
neither relies on a no-tenant resolution to see organization-scoped grants.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…egator leg resolving in an organization

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…ember's organization-scoped manage_metadata

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
… bindings from answer to the same rule

With no tenant the sys_position read is installation-wide, so every
organization's copy of a held position name fed its bindings in. Each row now
answers grantAppliesInTenant, a no-op when a tenant is given.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…ility through a global grant

An organization-less resolution no longer applies an organization-scoped
grant, so the arms' precondition is now spelled as the one grant an org-less
caller still holds.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/plugin-security, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/authorization.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/delegated-administration.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/positions.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin), sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/protocol/backward-compatibility.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v13.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v14.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v15.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v16.mdx (via resolveUserAuthzGrants (symbol, a top-level function))
  • content/docs/releases/v17/17-1.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_position (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v17/17-2.mdx (via sys_position (literal, a string literal in resolveUserAuthzGrants))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ba5927f714af7516105706b36a05cedf34d5fa1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3490b2eb34b79ee6105cd8ebf8caac53985aa2de — the merge of head 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1 into base ba5927f714af7516105706b36a05cedf34d5fa1b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3490b2eb34b79ee6105cd8ebf8caac53985aa2de && git checkout 3490b2eb34b79ee6105cd8ebf8caac53985aa2de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba5927f714af7516105706b36a05cedf34d5fa1b 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1 && git checkout -B drift-repro ba5927f714af7516105706b36a05cedf34d5fa1b && git merge --no-ff 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1

node scripts/docs-audit/affected-docs.mjs --json ba5927f714af7516105706b36a05cedf34d5fa1b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ba5927f714af7516105706b36a05cedf34d5fa1b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Dogfood Regression Gate (3/3) is red on c9e6463ce. It is this PR's, and the fix rides the patch round after the review

domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-29T01:10Z.

  • Failing: packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts, "plugin-sharing: a manage_sharing holder whose session has no ACTIVE organization reads every tenant's sharing rules (adminOrgScope falls open) #8158 — … the refusal names the ORGANIZATION, which is also how we know the capability gate was cleared" (line 232). It expected /active organization/ and received "sharing-rule administration requires the manage_sharing capability (ADR-0111 D6)". The other 447 dogfood tests on the shard pass.
  • Why it is this PR's:
  • The fix, for the patch round (test only):
    • Keep this test pinning adminOrgScope (defence in depth, which triage keeps) by giving the exposed persona a global manage_sharing grant.
    • Add an organization-scoped persona that is now refused at the capability gate, pinning this PR's rule on the dogfood face.
    • packages/qa/dogfood is admitted test-side only.
  • What blocks it now: the at-tier contract review of this head is running, and its findings ride the same push. No re-run: the failure is deterministic.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c9e6463ce1b126ddd524a7bbd0da8451d14a2bbe
Local-runs: none

Inputs read: card #20515 (body and all 4 comments: triage 5879424995, claim 5880440445, os-dev-report 5881640095, seat answer 5881665885); PR #20540 (body, 10-file list, net diff against the merge-base 31d281d3b with origin/main, +818/−28); the check-runs on the head, read twice (first at 01:08Z with 13 in progress; last at the end of this review, see ③). Context named by the brief: PR #20514 (merged 23:02Z, merge d1c01ffd7), the #15409 ruling B comments, #8158, #20431. Reads were read-only git fetch/show/diff/grep on the shared checkout and REST GETs. The record template script was not run (denied by the harness); the shape used is the one the brief carries inline.

① Derived judgments

1. grantAppliesInTenant (packages/core/src/security/resolve-authz-context.ts:651) — RIGHT.

  • One module-private predicate, !organizationId || organizationId === tenantId, asked at exactly three sites: §4 sys_user_position (:839), §6 sys_user_permission_set (:865), §6a sys_position rows (:967). Truth table, all row shapes: org null/undefined → applies in every resolution (global); org equal to the tenant → applies; org other than the tenant → does not apply; org set and no tenant → does not apply (x === undefined is false). A no-tenant resolution is therefore global-only in all three. RIGHT, and identical to the rule triage stated once.
  • Empty-string organization_id reads as global (!''). That is the repo's own reading of '' as UNSCOPED (bootstrap-platform-admin.ts:745-747), so the predicate is consistent with the standing derivation rather than a new fail-open.
  • Spellings: §6 still reads organization_id ?? organizationId (unchanged). §4 reads organization_id only — exactly what base read (ur.organization_id ?? null), so its direction on a camelCase-only row is unchanged. §6a reads organization_id only; base applied no filter at all there, so on any row shape §6a is no wider than base. The §4/§6a-versus-§6 spelling asymmetry is pre-existing, not introduced; noted, not a defect of this PR.
  • Nothing else in resolveUserAuthzGrants moved: the diff touches the tenantId docblock, the predicate, the §4 loop line, the §6 comment and filter, and the §6a comment and filter. unscopedUserPsIds (standing), §3, §5, §5b, §6b, §6b-config, §6c, §6d, §7 and the cache commit are byte-identical. RIGHT.
  • §3 sys_member (:775-831) is unedited — confirmed, no hunk. The dev's "display-only once §6a holds": every framework gate I can find reads systemPermissions or the posture rung, not positions[] (requireManageMetadata, packages.ts:296, reads systemPermissions; hasPlatformAdminStanding reads the rung; the plugin-sharing platform-authority read was moved to the rung by Four server-side readers derive platform authority from the NAME in ExecutionContext.positions — same species as #15948's blocked escalation, and already reachable on main #15981). positions[] is copied onto contexts (assemble-execution-context.ts:302, actor-user.ts, automation.ts:143, body-runner.ts:278 for scripts) and — the one seam — folded into permission-set NAMES by resolvePermissionSetsForContextUnmemoized (security-plugin.ts:6022+, requested = [...positions, ...permissions]) through dbLoaderFor(callerOrganizationId(context)), whose by-name read is organization-less when the caller has no organization. §3's projected names are the four ADR-0068 built-ins; reserved-identity-names.ts guards sys_position.name and sys_user_position.position at the object layer, and I found no equivalent guard on sys_permission_set.name. So: no gate grants a capability from §3's positions[] with no tenant that this PR could have changed; the fold seam is pre-existing (base behaved identically and wider), honestly declared NOT MEASURED by the dev, and is escalated in ③ as a follow-up measurement, not a fix owed here. Verdict on §3 "not the same class, not edited": RIGHT.

2. Platform-admin standing — RIGHT, unchanged.

  • hasPlatformAdminStanding (:1177) passes { nowMs } only, so it resolves organization-less; §6 keeps the global rows; the unscoped admin_full_access user grant lands in unscopedUserPsIds (:871-876, unchanged) and derives PLATFORM_ADMIN under single (walled postures derive it from the OS_PLATFORM_OWNER_EMAIL anchor, also unchanged). The bootstrap mints organization_id: null (bootstrap-platform-admin.ts:1055-1059). Pinned both ways in core (unscoped → PLATFORM_ADMIN with and without a tenant; org-scoped → never) and at the door (admin and admin_orgless arms).
  • No shipped path mints admin_full_access scoped to an organization: auto-org-admin-grant.ts mints organization_admin (org-scoped, by design); per-organization-catalog.ts copies catalog SET rows per organization, and standing reads the USER grant row's organization, not the set row's. An operator-authored org-scoped admin_full_access never conferred standing and now, with no tenant, confers none of its capabilities either — that is the intended narrowing.

3. Every no-tenant caller in packages/** source (my own census at the head, non-test; matches the PR's H1 line for line):

  • resolve-authz-context.ts:428 first resolution (key's org, else session's activeOrganizationId; sometimes none) → loses org-scoped §4/§6/§6a grants when nothing is active. RIGHT per ruling. :548 dropped-claim re-resolution (never a tenant) → loses the left organization's grants: the fix. RIGHT. :1184 hasPlatformAdminStanding → loses nothing (item 2). RIGHT.
  • plugin-auth/auth-manager.ts:3987 customSession (activeOrganizationId ?? undefined) → the session payload's positions[] loses org-scoped sys_user_position names with no active org; isPlatformAdmin reads the rung, unchanged. RIGHT (its docblock already scopes the payload to the active organization). :7009 → hasPlatformAdminStanding, nothing. RIGHT.
  • plugin-hono-server/current-user-endpoints.ts:424 (activeOrganizationId ?? undefined) → same as the first row. RIGHT.
  • service-automation/plugin.ts:909 runAs:'user' (the run's tenantId) → an org-less run holds only global grants, which is the user's own direct request in that state. RIGHT; a legitimate org-less automation keeps every global grant.
  • plugin-security/invitation-placement.ts:153 (the invitation's organization) → nothing in practice. RIGHT.
  • plugin-security/explain-engine.ts:581/686 and security-plugin.ts:4690 → item 5.
  • plugin-approvals: no direct call to any of the four entry points; it consumes the envelope through the context (approval-service.ts:1380-1437 reads tenantId/posture off it). Nothing to answer.
  • Transports calling resolveAuthzContext (rest-server.ts:2968, runtime/security/resolve-execution-context.ts:217, sharing-plugin.ts:945, marketplace-install-local-plugin.ts:1806, service-datasource/admin-routes.ts:480, service-settings/settings-service-plugin.ts:296, service-storage/storage-service-plugin.ts:1152, mcp/src/plugin.ts:164) → same as the first row. RIGHT.
  • Two non-shipped consumers the PR's census does not list: plugin-sharing/src/exec-context-seam.testkit.ts:126 and packages/qa/dogfood/test/armed.ts:215. The second matters for ③: the dogfood suite is not in the PR's list of suites run, and its CI shard 3/3 is red on this head.
  • Legitimate flows: a user with no active organization holding a GLOBAL grant is unchanged (pinned: u_global_ex in core, global at the door). A group-posture principal with no active organization keeps its membership-wide data wall (§3(b) accessible_org_ids, unchanged) and loses org-scoped grants until an organization is active — the ruling, and the changeset names the remedy. No legitimate flow is broken by the rule; one un-run consumer suite is red (③).

4. Grants cache — RIGHT. resolve-user-grants-cache.ts:251-258 keys on [userId, tenantId ?? null, seedEmail ?? null, seedPermissions], so a no-tenant entry and an organization entry can never serve each other. The added pin (resolve-user-grants-cache.test.ts) turns the cache on, resolves org_a then no-tenant, asserts opposite manage_metadata answers, then asserts both hits with zero reads keep their own answers — both directions, real cache path. The explainer bypasses the cache, so its new tenant never enters it.

5. The explainer, option (b) — RIGHT; the residual is pre-existing.

  • buildContextForUser(ql, userId, nowMs = Date.now(), tenantId?) threads tenantId into resolveUserAuthzGrants({ tenantId, nowMs, bypassGrantsCache: true }). collectGrantProvenance is not given the tenant; it is the additive dropped/delegated annotation, presentation only. Threaded correctly.
  • resolveDelegatorContext (:683-686) resolves the delegator in the live principal's tenantId (string, non-empty), then still stamps dctx.tenantId/org_user_ids from the live principal as before. Right for delegated access: the docblock's construction is that agent and delegator are in the same organization; the delegator's grants where the request runs are the D10 input. Before this PR the leg held EVERY organization's delegator grants; now the live organization's; a delegator with no live tenant holds global grants only. Strictly narrower than base on every arm. Residual, pre-existing: the delegator's own membership in the live organization is not checked here (only the LIVE principal's claim is vetted by the key/session arms) — not widened by this PR.
  • explainAccessForCaller (security-plugin.ts:4686-4690) resolves the explained user in the caller's tenantId — the organization the manage_users / delegated-scope right was just checked in (resolvePermissionSetsForContext(callerContext) uses organizationId ?? tenantId; the new code reads tenantId only — a nit, every shipped context carries tenantId when it carries organizationId).
  • No all-organizations reading survives anywhere: ResolveUserAuthzGrantsOptions gained no option; hasPlatformAdminStanding, an omitted buildContextForUser tenant, and a delegator under a tenant-less live principal all resolve global-only. No keep-all fallback exists in the diff or at the head.
  • The out-of-scope finding (explain, by an admin in org_alpha, shows a member removed from org_alpha the org_alpha-scoped manage_metadata set that enforcement refuses): plugin-security: security.explain reports a record visible under a row-level using that compares two fields of different classes, while find refuses the same read with INVALID_FILTER / 400 #20431 class (explain ≠ enforce) and pre-existing in shape, not newly introduced by option (b). The membership check lives in resolveAuthzContext's session arm (:532-554, [decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409), which buildContextForUser never called; before this PR explain showed that same grant (and every other organization's) for that user. Option (b) removed the other organizations from the overstatement and introduced nothing; option (a) would have shown at least as much. It does not have to be fixed here. Note for the seat: plugin-security: security.explain reports a record visible under a row-level using that compares two fields of different classes, while find refuses the same read with INVALID_FILTER / 400 #20431 as filed is a single-point p2 card (row-level using vs find INVALID_FILTER), pm:blocked; "family card" is the seat's framing, and the fold is acceptable only as a visible pointer there.

6. The door pins (packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts, test only) — real, RIGHT.

  • Rig: HttpDispatcher.dispatch with a session double (auth.api.getSession), a table double (objectql.find with $in), tenancy.posture: 'isolated', a protocol double refusing an org-less deletePackage; resolveRequestScope → resolveExecutionContext (:217) → resolveAuthzContext is the real resolver. Not mocked past the resolver. Identity resolution is real and proven by the pin that asserts the Session organization claim dropped … organization=org_alpha line.
  • Pinned: three removed-member arms (exmember, gone, posex — the position-bound arm) 403 PERMISSION_DENIED at DELETE /packages/:id (and deletePackage never asked, package still registered) and at PATCH /packages/:id/disable (package not switched off); control member 200 with deletePackage({ packageId, organizationId: org_alpha }); posmember control 200; global 400 TENANT_SCOPE_REQUIRED at DELETE and 200 (switched off) at disable; admin 200 at DELETE; admin_orgless 400 at DELETE and 200 at disable. Triage's pins are all met.
  • Not in the committed file (probe-only readings in the PR's H0 table): control at PATCH disable, platform admin with org_alpha active at PATCH disable, and the "org-less user with no grant" row. Not required by triage.

7. The plugin-sharing fixture (sharing-rule-positions-name-authority.test.ts, test only) — faithful, RIGHT. The file's stated population is an ORG-LESS caller (resolveUserAuthzGrants(ql, USER), no tenant) holding manage_sharing as the precondition; that precondition was a HOME_ORG-scoped set, i.e. held only through the defect. Re-spelling the one grant row as organization_id: null keeps the org-less caller, the axis under test (the platform_admin NAME versus the rung) and the sharing_admin set (never admin_full_access). The diff changes one fixture row and two comment blocks; zero assertion lines are added, removed or weakened. adminOrgScope (sharing-rule-service.ts:702) is untouched: the file is not in the PR's file list and no +/− diff line names it.

② Semver level

  • @objectstack/core minor — RIGHT. A breaking narrowing of what an organization-less resolution applies; the launch-window guard (scripts/check-changeset-no-major.mjs) forbids major and ships breaking changes as minor; AGENTS.md: (narrowing) is BREAKING. @objectstack/plugin-security minor — RIGHT: buildContextForUser is exported from the package index (index.ts:145) and gained an optional fourth parameter, a public widening; patch would have been wrong.
  • Clause-②: yes (narrowing) — yes is RIGHT (open question, arm A), on two grounds: the plugin-security widening, and the [decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 precedent that a published authorization path changing what it resolves is Clause-②: yes on its own. yes plus one arm from the closed pair is a legal declaration; both moved packages are at minor, satisfying the level axis; Check Changeset concluded success on the head.
  • BREAKING banner: present ("BREAKING for a principal acting with no active organization"). ADR-0087 disposition: the adr-0087 comment marker not-required (no-migration-prescription) is present and is the honest arm — nothing an author writes moves (no packages/spec key, export or stored row shape), so there is nothing for objectstack migrate meta to convert; the body's remedy is operational (act in the organization / grant globally), not a FROM → TO conversion, so the arm's own refusal does not bite; the marker closes the other arms on facts. check-adr-0087-registration is inside the green Check Changeset run.
  • The narrowing is stated on the prose face as what a caller sees, before → after: before, "no organization" read as "every organization"; now such a principal holds its global grants and nothing organization-scoped. Stated.
  • Security-sensitive: the changeset names the mechanism (a session naming a left organization, claim dropped, grants kept) at the level the card body already states; no door, no request sequence, no recipe beyond the card.
  • Changeset sentences: all TRUE. One nuance: "kept the capabilities that organization had granted until someone revoked each grant by hand" is true of operator-authored scoped grants and position bindings; the organization_admin auto-grant was already reconciled on removal — the sentence is broad, not false in the population it names.

③ Boundary flags

The four deviations, answered.

  1. §6a asks the predicate (beyond the claim's two sites) — RIGHT and required: without it the H5 shape (removed from org_jia, still org_member in org_yi, the left organization's org_member-bound set with no tenant) stays open; the resolver-level and door-level pins (posex, u_ex) drive it; it is the grant rule after the driver's read, not a second tenant wall. "With a tenant it is a no-op" holds for a driver that applies applyTenantScope; on a non-walling driver it filters other organizations' rows, which is the same rule — correct either way.
  2. Option (b) instead of triage's (a), plus the security-plugin.ts call site — RIGHT: the census found no caller that "really needs" every organization; the delegator leg is an enforcement input, so an all-organizations option there would have reopened the defect for delegated access; (b) keeps "never by default, no keep-all fallback" intact. One cross-lane call site, told on [PM seat] domain:services — 🟢 os-justin #6021 per the claim.
  3. A test-only file in packages/runtime — RIGHT: triage mandated the door pin; dispatch() lives there; packages.ts is untouched (file list).
  4. The plugin-sharing fixture — RIGHT (①7).
  5. (The report's fifth item) changeset grading and the kept Clause-② — RIGHT (②).

The open question — A. Confirmed at ②.

The four out-of-scope notes.

  1. Explain ≠ enforce for a removed member explained from the left organization — class (b), pre-existing in shape, not introduced by (b) (①5). Not owed here. Carry as a pointer on plugin-security: security.explain reports a record visible under a row-level using that compares two fields of different classes, while find refuses the same read with INVALID_FILTER / 400 #20431 or a family card of its own; plugin-security: security.explain reports a record visible under a row-level using that compares two fields of different classes, while find refuses the same read with INVALID_FILTER / 400 #20431 is a single-point p2 card as filed.
  2. §6a no-tenant page cap (200 rows, installation-wide read) — pre-existing; the failure direction is a caller LOSING a global binding, never gaining one. Acceptable to carry.
  3. The position-name fold with no tenant — pre-existing and NOT MEASURED; my read (①1) is that the fold's by-name read is organization-less for an org-less caller and that sys_permission_set.name is not under the reserved-identity-name guard. Escalate: a follow-up measurement card (a set named after a built-in role, read org-less), not a change to this PR.
  4. §3 positions[] display with no active organization — consistent with ①1; carry.

PR-body sentences judged (everything not named here is TRUE against the head and the inputs).

  • "The 'after' column is the committed pin file … green at c9e6463ce": PARTLY FALSE — three cells (control at PATCH disable, platform admin with org_alpha active at PATCH disable, the no-grant row) have no arm in the committed file and are probe readings; "green at c9e6463ce" is not yet established by the check-runs (Test Core shards 1/6, 5/6, 6/6 still in progress at the last read).
  • "The base is unmodified 397572ed5, which already includes PR fix(runtime): DELETE /packages/:id refuses an org-less uninstall before it touches the registry (#20492) #20514": TRUE (d1c01ffd7 is an ancestor of 397572ed5, which is an ancestor of the head).
  • H1 census rows and line numbers: TRUE, each verified at the head. "No caller needs every organization's grants": TRUE on the census. "tenantId already keys cache entries": TRUE.
  • H2/H5 real-SqlDriver measurements: not verifiable read-only; consistent with the bootstrap source (organization_id: null) and the resolver.
  • H3 table and "Explain-of-another-user's record-level Layer 0 still evaluates with no active organization; pre-existing and unchanged": TRUE (buildContextForUser returns no tenantId on the context).
  • H6 "With a tenant it filters nothing": TRUE for SqlDriver; driver-conditional as an unconditional statement (see deviation 1).
  • Verification, "Ablations … (head c9e6463ce)": ablation 1 names blob 490bd8a377af → that is the resolver at commits 514e681cd…b96b10f67, BEFORE §6a landed in 67903f273; the head blob is 1f0d2889e626. So ablation 1 was measured at a pre-§6a state, not at the head as the heading places it — the body is self-disclosing (it prints the blob), and the red set it reports is coherent for that state. Ablations 2 (92716c91af53) and 3 (1f0d2889e626) name head blobs: TRUE.
  • Verification table, "All green": TRUE only of the 15 packages listed; packages/qa/dogfood is not listed, and its CI shard is red on this head (below). Build/test/typecheck/gate counts and the lint narrowing: not verifiable read-only; the check-runs are the verdicts.
  • Acceptance notes: TRUE.

Check-runs on c9e6463ce1b126ddd524a7bbd0da8451d14a2bbe, final read (32 runs).

  • success (22): Auto Label · Build Core · Check Changeset · Check Documentation Links · Check PR Size · Dogfood Regression Gate (1/3) · Dogfood Regression Gate (2/3) · Dogfood Verify CLI · Flag docs affected by code changes · Governed Surface Queue Guard · No other open PR may claim the same issue · No other open PR may claim the same single-writer path · Part-of PR must not also close its card · Temporal Conformance (live PG + MySQL) · Test Core (2/6) · Test Core (3/6) · Test Core (4/6) · The card this PR closes must claim this branch · Type Check · consumer gates · Type Check · debt ledger · Type Check · source gates · filter.
  • skipped (3): Build Docs · Console Pin Gate · Packed-tarball smoke (opt-in).
  • failure (2): Dogfood Regression Gate (3/3) — annotation: command (packages/qa/dogfood) pnpm run test --shard=3/3 exited (1); Dogfood Regression Gate (rollup) — annotations: "1 of 3 declared shard(s) of dogfood published no positive attestation (dogfood-3-of-3)" and "leg dogfood reported result 'failure' — a declared negative is never overridden by a full roster". The check-run payload names no test file. packages/qa/dogfood consumes the resolver directly (test/armed.ts:215 calls resolveAuthzContext; authz-conformance.matrix.ts cites §3/§4/§6 as enforcement sites) and is absent from the PR body's list of suites run, so an org-less dogfood arm that relied on an organization-scoped grant is a live hypothesis; a flake is not excluded either. Read-only here: no re-run, no log read beyond the check-run's own annotations.
  • in progress (5): Test Core (1/6) · Test Core (5/6) · Test Core (6/6) · Type Check · workspace · Lint & Repo Gates.

What the verdict rests on. The code judgments in ① and ② are all RIGHT: the rule is stated once and true for every row shape, no principal gains or keeps a grant it should not have, platform-admin standing and global grants are unchanged, the pins are real, the fixture edit is faithful, the changeset is honest. The head is nevertheless not green: a derived gate family on this exact head concluded failure, and five runs had not concluded. The check-runs' conclusions are the gate verdicts, so this record cannot pass this head. To clear: read the shard-3 log; if a dogfood arm relied on the defect, re-spell its fixture the way the plugin-sharing fixture was (global grant, or resolve in the organization) and say so in the PR body's fixture triage; if it is a flake, show a green re-run; either way the next record is rendered on the new head once every run has concluded.

Implemented-by: claude/issue-20515-orgless-grants-global-only
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: FAIL

…g globally; an org-scoped grant with no organization is refused at the capability gate

The #8158 dogfood proof's exposed persona held manage_sharing only through an
organization-scoped grant, which reached adminOrgScope through the defect this
branch fixes. It now holds the set globally and keeps pinning adminOrgScope; a
new persona holding the grant as filed (scoped, no active organization) is
refused at the capability gate. The runtime door pins gain the control and the
platform admin at the disable door, and the no-grant org-less caller.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1
Local-runs: none

DELTA review on the review of record 5881827545 (FAIL on c9e6463ce, red CI only; every code judgment RIGHT), read in full first. Re-rendered on the SAME head after every check-run concluded and after the seat corrected one PR-body sentence; the diff has not moved, so ① and ② stand as first rendered. Inputs: card #20515 (body and all 5 comments: triage 5879424995, claim 5880440445, os-dev-report 5881640095, seat answer 5881665885, patch-round os-dev-report 5882332070); PR #20540 (body, re-read after the correction and diffed against the version first judged: one line changed; 11-file list; net diff against the merge-base 288611e3e with origin/main, +907/−37, and the delta c9e6463ce..4e3e4f5e4); the PR thread (docs-drift bot 5881622540, the seat's CI diagnosis 5881717473, the record 5881827545); the five docs pages at the head; the check-runs on the head, read last (02:20:10Z, 02:22:12Z, and the final read at 02:33:33Z). Reads were read-only git fetch/show/diff/grep/merge-tree --write-tree on the shared checkout and REST GETs by curl; the record template script was read, not run. Only what moved since c9e6463ce is judged below; the ① judgments 1–7 of 5881827545 are not re-derived, because the delta touches none of the code they judged.

① Derived judgments

1. What moved (the delta census) — exactly two commits. 6b3414e87 (test only: packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts and packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts, 2 files, +93/−13) and 4e3e4f5e4, a true two-parent merge of origin/main 288611e3e (the first round's merge was 31d281d3b, c9e6463ce's second parent). Nothing else is on the branch.

2. The merge is clean and no hunk was lost — RIGHT.

  • git merge-tree --write-tree 288611e3e 6b3414e87 yields tree 6d70606e5cda…, byte-identical to the head's tree: a conflict-free three-way merge with no manual resolution.
  • The PR's net diff against its merge-base for packages/core, packages/plugins, .changeset/20515-orgless-grants-global-only.md and the plugin-sharing fixture is byte-identical (index lines aside) at 4e3e4f5e4 vs 288611e3e and at c9e6463ce vs 31d281d3b. The head blobs are the ones the body names: resolver 1f0d2889e626… (unchanged since 67903f273), explain-engine.ts 92716c91af53…, security-plugin.ts edf38d7a99….
  • Main's side of the merge (31d281d3b..288611e3e, 191 files, mostly packages/spec) touches no file this PR touches (the intersection is empty). The only .changeset file in the net diff is the PR's own; the other seven changesets in the scoped delta are main's. The runtime and dogfood test-typecheck ledgers are unchanged.

3. sharing-rule-org-less-caller.dogfood.test.ts (admitted test-side in 5881717473) — RIGHT, faithful, nothing weakened.

4. packages-orgless-grants-capability-gate.test.ts — the three probe-only cells are now real arms, RIGHT.

  • All three go through rig().call(...), i.e. HttpDispatcher.dispatch with real resolveRequestScope, resolveExecutionContext and resolveAuthzContext (the rig is unchanged from c9e6463ce, judged real in 5881827545 ①6): (a) CONTROL member at PATCH /packages/:id/disable: 200 and switchedOff(r) true, the positive control for the disable door; (b) admin (unscoped admin_full_access, org_alpha active) at PATCH disable: 200, added into the existing platform-admin case; (c) u_orgless, a new sys_user row with a session naming no organization, no sys_member and no grant row: 403 PERMISSION_DENIED (httpStatus 403) on both doors, deleteRequests empty, package not switched off.
  • Fixture additions are minimal and consistent (u_orgless in sys_user, SESSIONS.orgless, the Who union, the docblock). Pin count 13 = 2×3 removed-member arms + the claim-drop proof + 6 unchanged-behaviour pins (11 before). Every one of the H0 table's 14 cells (7 rows × 2 doors) now maps to an arm by name: a2 → exmember, a2b → gone, control → member, global → global, platform admin → admin, platform admin no org → admin_orgless, no-grant org-less → orgless; the position-bound arm (posex) and its control (posmember, DELETE) are the file's extras beyond the table.

5. Docs (the five pages the docs-drift bot lists; none edited by the PR; each listed for the sys_position literal, plus resolveUserAuthzGrants on environment-variables and explainAccessForCaller on system-context). No hand-written sentence on any of the five states something FALSE about an organization-scoped grant with no active organization, or about what the explainer resolves:

  • content/docs/permissions/authorization.mdx: the standing paragraph (:126-135: an unscoped admin_full_access row anchors standing only under single; "A scoped grant, or piecemeal platform capabilities … grant Studio/admin functions but never widen the tenant data boundary") is still TRUE and nowhere says a scoped grant applies with no active organization; the explain section (:281-311: "walks the SAME code paths the middleware enforces with … explained by construction"; another user needs manage_users or a covering adminScope) is TRUE and, under option (b), kept true, because the explainer now hands the resolver the tenant enforcement hands it; the validity and deactivation passages (:351-353, :400-417) are unchanged truths.
  • positions.mdx: capability is "the union of every set reached that way plus direct grants" (true within the resolution's tenant); the assignment example carries no organization_id; nothing about scoped assignments with no active organization.
  • delegated-administration.mdx (:85-93, explaining delegated decisions): unchanged truths.
  • system-context.mdx: row 9 (explain() may target another principal: "no manage_users / delegated-admin check", anchored on explainAccessForCaller) is still TRUE, since option (b) changes the tenant the target is resolved in, not the gate the row describes; row 23b (sys_position catalog check, "its organization's rows plus the organization-less ones") describes assertPositionNamesCatalogRow, untouched.
  • deployment/environment-variables.mdx (OS_AUTHZ_GRANTS_CACHE_TTL_MS, :70): "per (user, organization, seed)" is TRUE (the cache key is userId, tenantId, seedEmail, seedPermissions) and "The permission explainer and runAs:'user' automation runs always read uncached" is TRUE (bypassGrantsCache: true is kept in buildContextForUser).
  • Silence, not falsity: none of the five states the new rule either; nothing must ride this PR (the changeset carries the rule into the release notes). A sentence stating "with no active organization only global grants apply" can be carried in a docs-only PR. The bot's sixth page, protocol/backward-compatibility.mdx, is outside the brief's five; its one sys_position mention (:73, the retired sys_position.permissions) is unrelated.

6. PR body — the three sentences 5881827545 found PARTLY FALSE, re-judged, and every sentence added this round.

  • "The 'after' column is the committed pin file … every cell of it has an arm there, and the file is green at 4e3e4f5e4": TRUE. The arm claim is established by item 4; "green at 4e3e4f5e4" is now established by the check-runs: every Test Core shard (1/6 through 6/6) and the Test Core rollup concluded success on this head.
  • Ablation-1 heading, now "Re-run at 4e3e4f5e4 (resolver blob 1f0d2889e626, which includes §6a)" with the first round's run labelled pre-§6a (490bd8a377af) and superseded: TRUE. Verified: the head's resolver blob is 1f0d2889e626…; 490bd8a377af… is the blob at 514e681cd through b96b10f67; 67903f273 introduced 1f0d2889e626…. The ablated blob and the red/green sets are not verifiable read-only; the sets are coherent with the 13-pin file (6 removed-member pins red, 7 green). Nit: the green enumeration ("both controls, the global grant, the platform admin, the org-less no-grant caller, and the claim-drop proof") names 6 of the 7; the file has three control pins (member DELETE, member PATCH, posmember).
  • Verification table "All green", and its scoping sentence as corrected by the seat after the first rendering of this record. The corrected parenthetical now reads: "the two origin/main merges since then brought main's own packages/rest changes, rest-server.ts, meta-item-read-gate.ts and four test files, and main's packages/runtime test meta-list-projection-parity.test.ts; this PR's patch round moved its runtime door-pin file. For those suites, the verdict is the head's Test Core runs". TRUE, clause by clause against the measurement: the first merge (397572ed5..31d281d3b) brought packages/rest/src/rest-server.ts, packages/rest/src/meta-item-read-gate.ts, three packages/rest/src/*.test.ts and packages/runtime/src/domains/meta-list-projection-parity.test.ts; the second merge (31d281d3b..288611e3e) brought packages/rest/src/data-date-write-iso-only.test.ts (three plus one = the four test files); the branch's own 6b3414e87 moved the runtime door file; and every Test Core shard concluded success on this head. The body diff against the version first judged shows this one line and nothing else changed. "All green" of the 15 packages remains unverifiable read-only, as before; the dogfood omission is disclosed and repaired (the whole dogfood suite is listed above the table, and the three Dogfood Regression Gate shards and the rollup concluded success on this head).
  • Added this round, TRUE: "Patch round 1 added the three arms that were probe-only readings at c9e6463ce: …" (item 4); "Verification (head 4e3e4f5e4, after merging origin/main 288611e3e with a true merge; the first round's merge was 31d281d3b)" (item 1); the dogfood shard arithmetic (47+47+46 files passed, +1 skipped file = 141; 378+321+451 = 1150); "16 passed. That is the 13 it had, plus 3" (base count 13, head 16); "the door file (13 pins)"; "the runtime test-typecheck ledger is unchanged"; "This table omitted @objectstack/dogfood in the first round, and its shard 3/3 was red on c9e6463ce"; the whole "Fixture triage (dogfood, patch round 1)" paragraph, each clause checked against the base and head files (the scoped grant at base :137; every assertion unchanged; the new persona's status, code, message, no rows, by-name refusal, session pinned; the control; the red direction without the fix); the dogfood census ("No other fixture writes an organization-scoped sys_user_permission_set or sys_user_position row": grep of packages/qa/dogfood at the head finds organization_id on such a write only in this file; the two membership-actor-attribution hits at :207/:313 are findRows reads of the auto-grant row; test/armed.ts:215 is the real resolveAuthzContext call; the conformance matrix carries no "no tenant" or "every organization" wording); ablations 2 and 3 "still the blob at 4e3e4f5e4" (92716c91af53…, 1f0d2889e626…); "10 changed .ts files" (10 of the 11 listed); the acceptance note "Review nit ①5, not carried … does not otherwise touch security-plugin.ts" (blob unchanged). Not verifiable read-only, with the head's check-runs as the verdict (all concluded success): the build 71/71, the per-package pass counts, the 72-command gate sweep (the four @objectstack/spec families are plausible: main's merge moved 140-plus files under packages/spec), the check:type-check-debt retry, and the lint narrowing.

② Semver level

  • Unchanged from 5881827545 and still RIGHT: the changeset is byte-identical at the head; @objectstack/core minor (a breaking narrowing shipped as minor under the launch-window guard; (narrowing) is BREAKING) and @objectstack/plugin-security minor (buildContextForUser's new optional fourth parameter is a public widening); the BREAKING banner, the before → after prose, and the adr-0087 marker not-required (no-migration-prescription) are present.
  • The delta publishes nothing: two test files only, no source or export moved, so no further changeset is owed and skip-changeset does not apply.
  • Clause-②: yes (narrowing) in the PR body and in the changeset: RIGHT (open question arm A, answered by the seat in 5881665885 and confirmed by 5881827545 ②). Check Changeset concluded success on the head (all three runs, the third after the body correction).

③ Boundary flags

Patch-round os-dev-report 5882332070, deviations — all answered.

  1. Two pushes (the intermediate head 6b3414e87 also saw CI): procedural; the record is rendered on 4e3e4f5e4 and nothing else is on the branch. Answered.
  2. The packages/qa/dogfood fixture edit, test only, admitted by the seat in 5881717473: the edit matches the admission exactly (exposed persona global, new organization-scoped persona refused at the capability gate). RIGHT (①3).
  3. Review nit ①5 not carried: security-plugin.ts is unchanged this round; acceptable, recorded in the body's acceptance notes. Answered.

open_questions: none this round. The round-1 question (Clause-②) stands answered A (②).

Carriers from round 1 (5881827545 ③): unchanged. The explain ≠ enforce residual (#20431 class, pre-existing in shape), the §6a no-tenant page cap, the position-name fold with no tenant (escalated as a follow-up measurement card), and the §3 positions[] display note. Nothing in the delta touches them.

The flag raised by this record's first rendering — the PR body's verification parenthetical — is closed. The seat corrected the one sentence through a relay issue_patch (PR updated_at 02:25:38Z); re-read and judged TRUE in ①6; no other line of the body changed. No code judgment was affected.

Check-runs on 4e3e4f5e43bfcc8814dff9700a914f5eec19aae1, final read at 02:33:33Z (49 runs, every one concluded).

  • success (42): Auto Label · Build Core · Check Changeset ×3 · Check Documentation Links · Check PR Size · Dogfood Regression Gate · Dogfood Regression Gate (1/3) · (2/3) · (3/3) · Dogfood Verify CLI · Flag docs affected by code changes · Governed Surface Queue Guard · Lint & Repo Gates · No other open PR may claim the same issue ×3 · No other open PR may claim the same single-writer path ×3 · Part-of PR must not also close its card ×3 · Spec property liveness · Temporal Conformance (live PG + MySQL) · Test Core (rollup, 02:31:23Z) · Test Core (1/6) (02:24:45Z) · Test Core (2/6) · (3/6) · (4/6) · Test Core (5/6) (02:30:55Z) · Test Core (6/6) · The card this PR closes must claim this branch ×3 · Type Check · consumer gates · Type Check · debt ledger · Type Check · source gates · Type Check · workspace · TypeScript Type Check · filter.
  • skipped by roster (7): Auto Label ×2 · Build Docs · Check PR Size ×2 · Console Pin Gate · Packed-tarball smoke (opt-in).
  • failure (0); in progress (0); queued (0). The two shards recorded as in progress at the first rendering, Test Core (1/6) and (5/6), concluded success. The eight runs added since the 02:22:12Z read are the pull-request-edited re-triggers of the roster checks after the body correction; all concluded success or skipped. The Dogfood Regression Gate (3/3) that failed on c9e6463ce concluded success on this head, and so did the rollup.

What the verdict rests on. Every judgment in ① and ② is RIGHT: the merge is clean and lossless, the dogfood fixture keeps every original assertion and pins both faces of the rule, the three probe-only cells are real arms through the real resolver, no docs sentence is false, the changeset is unchanged and honest, and the one PR-body sentence found partly false is corrected and now TRUE. Every check-run on this head is concluded and green or skipped by roster; no derived gate family concluded failure. Nothing is owed on this head.

Implemented-by: claude/issue-20515-orgless-grants-global-only
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants