Skip to content

fix(plugin-security,core): security/explain answers enforcement's refusal at the object level, and explains another user in their organization (#20604) - #20629

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20604-explain-enforce-closeout
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20604-explain-enforce-closeout

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20604

Clause-②: no

What was wrong

POST /api/v1/security/explain gave answers that disagreed with what the same principal's own request gets from enforcement. Measured on main at 889139ce with the pin this PR adds (45 of 103 rows red before any fix):

  • Position 1. A row-level policy compares two fields of no shared comparison class (record.status != record.amount, and the other ordering record.amount > record.status). The find refuses every read it scopes with INVALID_FILTER / 400. A by-id update or delete fails closed with 403, and an insert is refused with INVALID_FILTER / 400. An object-level explanation (no recordId) answered allowed: true, the rls layer narrows, and the predicate as readFilter, for read, update, delete and create. That is 10 rows.
  • Position 2. Under the same policy, a recordId that no row carries was answered visible: false with no decidedBy, while the find for that id refuses with INVALID_FILTER / 400. REACHED.
  • Position 3. An administrator explained a CURRENT member of their organization. The explained context carried no organization:
    • (i) Under isolated, on a tenant object, the explanation answered allowed: false, rls denies, and the fail-closed sentinel as readFilter. The member's own find returns their organization's row. REACHED under isolated only: under group, the wall reads accessible_org_ids, which the context already carried.
    • (ii) A permission set authored as a sys_permission_set row scoped to that organization did not load for the explained user, under isolated, group and single. Enforcement resolves it. REACHED under all three postures.

What changed

  • explain-engine.ts, the object-level pass. Before any verdict is computed, the composed row filter (the caller's, and a delegator's) is judged the way the find is judged: by the record matcher, with the object's declared columns. A comparison the spec's classification refuses now fails the explanation with the matcher's own refusal. This is the same function, envelope (INVALID_FILTER / 400), message and cause that the record-grained pass has given since PR fix(plugin-security): security/explain answers enforcement's refusal for a row-level policy comparing two fields of no shared comparison class #20598. No second refusal dialect is added. The matcher judges declared columns before it reads a record, so it is asked with no row. It is asked only when findCrossFieldClassRefusal finds a refused comparison, so no filter the find runs is evaluated there. The refusal runs before the record-grained pass, so position 2 gets the find's answer too. A request that the capability or CRUD gate denies is still explained as denied there, as enforcement denies it (see the boundary row). The helper that names the refused policy now also walks the $and that puts the tenant wall next to a policy.
  • security-plugin.ts, explainAccessForCaller. The explained context's tenantId is set to the organization vetOrganizationClaim resolved the user in. That is the same value, and the same assignment, that resolveDelegatorContext makes for a delegator. A removed member (claim dropped) keeps no organization, as enforcement resolves them.
  • One column source (A5). A new internal module, declared-comparison-columns.ts (not exported from the package), holds declaredComparisonColumns. It is now the one reading of an object's declared columns for both the row-level write check (writeCheckFieldOptions) and explain.
  • @objectstack/core, cross-lane notice for domain:engine. In packages/core/src/security/resolve-authz-context.ts, the API-key arm of resolveAuthzContext changes from if (keyPrincipal?.tenantId && input.tenancyPosture) { if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId)) to if (keyPrincipal?.tenantId) { if (vetOrganizationClaim(keyPrincipal.tenantId, grants.accessible_org_ids, input.tenancyPosture) === undefined). This is a refactor with no behaviour change. The two conditions are equal term by term: a truthy claim, a posture present, a walled posture, and the claim absent from accessible_org_ids. The consequence (the refusal, [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under isolated — the wall compares against the caller's own unvetted claim #15256 2A) is unchanged. The vetOrganizationClaim docblock names the key arm as a reader, so its sentence "Nothing else spells this rule" stays true. A git grep for accessible_org_ids.includes / accessibleOrgIds.includes in packages/*/src (tests excluded) finds one spelling, inside vetOrganizationClaim. No export, signature or error changed. The existing key-arm cases pass unchanged.
  • The plugin-security: security.explain shows a member removed from an organization that organization's grants, which enforcement refuses since PR #20540 (explain ≠ enforce, split from #20431) #20580 keep-pin, re-ruled in place (explain-removed-member-principal.test.ts). "A current member's explanation is unchanged" is now "a current member's explanation matches enforcement". It asserts that the member's sets equal enforcement's resolved sets (with enforcement's tenantId asserted as org_alpha), and that allowed equals whether the member's own read is admitted. Nothing is deleted.
  • Changeset .changeset/20604-explain-enforce-closeout.md: patch for @objectstack/plugin-security and @objectstack/core.

The enumeration pin (explain-enforce-parity.test.ts)

One table, 104 rows, one invariant function (expectParity). For each row, the explanation and the same principal's own request run through the real SecurityPlugin, a real ObjectQL, and better-sqlite3. Where needed, the real SharingService and its middleware run too. Each row also asserts enforcement's own outcome. The positions are: object-level allowed, object-level readFilter (compared by the rows it admits as a system read), record-grained visible for read / update / delete, and the principal's permission sets. The rows are the family's shapes: #19963 (write depth), #19986 (read depth), #20002 (a throwing sharing read filter), #20431 (the record matcher under a cross-class comparison, both orderings), #20580 (removed member, under isolated / group / single), and this card's positions 1 to 3. There is also a boundary row: under the cross-class policy, a principal with no CRUD grant gets allowed: false, not a refusal, because enforcement denies at the CRUD gate. It is a test, not a gate. It runs on better-sqlite3 only; the per-driver coverage stays in explain-cross-class-refusal.test.ts.

Two measured divergences are findings of this card. They are not fixed here. Their rows assert the disagreement itself, so each row turns red the day either face moves (see Acceptance notes).

Ablations (fix removed, then restored)

All three were run with scripts/ablation-replace.mjs in WRAP mode, inside a driver script with trap 'git checkout HEAD -- ABS_PATH' EXIT INT TERM, at 6d23ba1ca. The three source blobs are unchanged at the final head.

  • A: object-level refusal deleted (explain-engine.ts). Anchor 1 → 0, blob fe299ae87b2d → 0f870a48cdda. 11 rows red: the 10 position-1 rows and the position-2 row. First red row: explain answered allowed: true, rls: narrows, readFilter: {"status":{"$ne":{"$field":"amount"}}}; enforcement {"kind":"refused","code":"INVALID_FILTER","status":400}: expected 'answered' to deeply equal { code: 'INVALID_FILTER', status: 400 }. Restore: blob == HEAD fe299ae87b2d, git diff HEAD empty.
  • B: explained organization deleted (security-plugin.ts). Blob faa10fb1e8ae → e98f01b20caf. 21 rows red, all position-3 rows under isolated, group, single, plus the A4 current-member rows. Examples: the member user's permission sets ... enforcement {"kind":"sets","names":["member_default","qa_parity_reader","qa_parity_alpha_notes"]}: expected [ Array(2) ] to deeply equal [...], and the member user, object-level read of LEDGER · object.allowed: explain answered allowed: false, rls: denies ...; enforcement {"kind":"rows","ids":["l_alpha"]}. Restore: blob == HEAD faa10fb1e8ae, diff empty.
  • C: core key-arm predicate replaced by false. Blob cc4b63ba7b11 → b8fe722cd614. 5 existing key-arm cases red, among them "refuses a key whose owner is no longer a member of its organization" and "the same key under group is refused too". So those cases do exercise the refactored line. Restore: blob == HEAD cc4b63ba7b11, diff empty.

These tests import plugin-security from src and core's suite imports core from src, so no ablation leg depends on a dist/ build.

Verification (head 571cf85e3)

  • Build: pnpm --filter '@objectstack/plugin-security...' build, which includes @objectstack/core. Exit 0. The rebuilt core dist/index.js carries vetOrganizationClaim(keyPrincipal.tenantId (count 1).
  • pnpm --filter @objectstack/plugin-security --filter @objectstack/core run typecheck: exit 0. plugin-security: test layer 0 file(s) / 0 error(s) held in the debt ledger. core: 4 file(s) / 4 error(s), unchanged.
  • @objectstack/plugin-security full vitest: 147 files, 3202 passed, 23 skipped. @objectstack/core full vitest: 59 files, 1570 passed.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 67 commands against this diff, the same 67 as at dispatch. 64 exited 0. 3 are NOT MEASURED: check:dual-build-cjs-loads, check:i18n and check:type-check-debt. Each printed PREREQUISITE NOT MET (exit 3), because it reads a whole-tree build and this worktree built only the plugin-security closure; CI builds first. --ran reconciliation: 67 derived famil(ies) accounted for — 64 run, 3 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config --format json over the 6 changed .ts files returned 6 file results, 0 errors and 0 warnings. None were ignored (an ignored file reports a warning). eslint.config.mjs enables no type-aware linting (its own note, near line 326: "never enables type-aware linting (no parserOptions.project ...)"), so this diff cannot change the verdict for any untouched file. The full pnpm lint is CI's.

Measured, not assumed (A4, A5)

  • A4, the posture-source asymmetry: REACHED in-process, and enforcement's side. The composition is org-scoping without a tenancy service. Admission reads no posture, so a removed member's org_alpha claim is kept. Their own reads return org_alpha's ledger row, the global probe, and the organization-authored set's object. Meanwhile, this plugin walls Layer 0 at isolated, and explain vets the claim under that walled posture and drops it. The defect is enforcement's: a claim is never dropped while Layer 0 enforces a wall. Per the dispatch it is not changed here. See Acceptance notes for its reach.
  • A5, the column source: no verdict difference measured. The ObjectQL registry never returns an array-shaped fields. A probe that registered an object with fields: [...] got back a keyed map ("0", "1", plus the injected system fields), so the write check's array branch cannot differ from explain's reading. The write check's metadata fallback is reached only when the registry holds no field map for the object, which is an object the find cannot compile against. With the shared function, both judges read one declaration one way. 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 suite (explain-cross-class-refusal.test.ts) and the table's cross-class rows stay green on it.

Acceptance notes

  • NATIVE_SCOPING_UNDER_SINGLE (explain's side, not fixed). Under single, the engine still stamps the context's organization on a tenant object's read (driver-native tenant scoping, engine.ts, the hasTenant branch). A caller whose context carries org_alpha does not see org_beta's row. Explain's tenant layer contributes nothing under single, so it reports readFilter: null and record.visible: true for the org_beta row. This was measured for an administrator explaining another user, after this PR's position-3 change. The same holds by construction for a caller explaining themselves, which was not measured. It needs rows of more than one organization under single, and no producer of that data is named here.
  • CLAIM_KEPT_UNDER_A_WALL (enforcement's side, not changed). It was measured in-process only. I found no public door that reaches it: without plugin-auth there is no session (no auth service), and no API key, because sys_api_key is registered by plugin-auth's manifest. So no request carries an organization claim in that composition.
  • The F3 residual that remains. The refused-policy naming is still spelled twice: explain's refusedPolicyNamesOf and the write check's inline log attribution. Explain's now also walks $and. That affects log and message attribution only. The next PR that touches the write check in security-plugin.ts could carry it.
  • rest: POST /api/v1/security/explain answers a service refusal carrying INVALID_FILTER / 400 as 500 EXPLAIN_FAILED — the route's catch maps only PERMISSION_DENIED and OBJECT_NOT_FOUND #20603 remains open: the REST route answers explain's refusals as 500. After this PR the object-level refusals reach that same door.

Patch round 1 (the seat's append; the dev writes a body only once)

  • The red: the at-tier record 5888953451 FAILed on 571cf85e. Lint & Repo Gates was red at the error-code casing guard, because the plugin-security: explain reports a record VISIBLE when the sharing read filter throws — explain-engine catches the rejection into null and the record matcher reads null as "no filter", while enforcement refuses the same read #20002 row of the parity table spelled an absent error envelope as the string code 'undefined'.
  • The fix, in the test file only:
    • Envelope.code and Envelope.status are optional, and envelopeOf answers undefined for an error that carries no envelope.
    • The row states enforced: { kind: 'refused', code: undefined }, and both halves of an envelope are compared strictly, so "no envelope" is its own value.
    • An explain refusal that answers an un-enveloped failure must carry no envelope itself.
    • The row still asserts that the find fails with the sharing service's own error and that explain answers visible: false for it.
    • No guard exemption, and no placeholder code.
  • The red was reproduced first at 571cf85e (exit 1 at :841). pnpm check:error-code-casing exits 0 at b8fe3069.
  • A second test-only commit: the RLS and sharing rigs collect lifecycle hooks unfired, so no bootstrap read races engine teardown. No row or expected outcome changed.
  • At b8fe3069: all 67 derived gates exit 0, including the 3 whole-tree gates, now measured after a full build. --ran reconciles 67 / 0 / 0. The three ⛔-marked roster families exit 0. The Lint & Repo Gates steps from the casing guard onward were run locally: 76 of 77 exit 0, and the last one reports on CI's own step outcomes, so it has no local run. plugin-security 3202 passed, typecheck exit 0.
  • Not added: a sharing-rule-criterion boundary row. A rule's criterion is read only when the rule materializes, never in a reader's find, and a refused criterion query grants nothing, which fails closed. So there is no refusal for the table to hold.

Generated by Claude Code

…r every verdict position (red on the unfixed tree)

One table drives each explain verdict position (object-level allowed and
readFilter, record-grained visible for read / update / delete, the
principal's permission sets) against the same principal's own request
through the real SecurityPlugin, ObjectQL and better-sqlite3, and asserts
enforcement's own outcome as well.

Measured on 889139c, before any fix (45 of 103 rows red):
- object-level read / update / delete / create under a cross-class
  row-level predicate answer allowed: true while find refuses
  INVALID_FILTER / 400 (both orderings of the pair);
- a missing record id under that predicate answers visible: false with no
  decider while find refuses INVALID_FILTER / 400;
- a current member explained by an administrator: denied a tenant object
  under isolated (their find reads it), and an organization-scoped
  permission set missing under isolated, group and single.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…at object level, and resolves the explained user in their organization

- The object-level pass refuses a composed row filter the find cannot run
  (a field-to-field comparison of no shared comparison class) with the
  record matcher's own refusal, INVALID_FILTER / 400, the one the
  record-grained pass already gives. It used to answer allowed: true, rls
  narrows and the predicate as readFilter, for every operation. The
  refusal runs before the record-grained pass, so a record id no row
  carries is refused too, as its find is. A request the capability or CRUD
  gate denies is still answered as denied there.
- explainAccessForCaller sets the explained context's tenantId to the
  organization vetOrganizationClaim resolved the user in, as
  resolveDelegatorContext sets a delegator's. A current member was
  explained with no organization: denied a tenant object under isolated,
  and missing a permission set their organization authored.
- One reading of an object's declared columns (declaredComparisonColumns)
  for the row-level write check and for explain.
- The removed-member keep-pin now asserts parity with enforcement for a
  current member, instead of an unchanged explanation.
- The parity table marks two measured divergences it does not fix.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…zationClaim for its membership rule

No behaviour change. The key arm spelled the rule inline (a walled
posture, and the key's organization absent from accessible_org_ids); it
now asks the function the session arm and security/explain ask. Only
the consequence stays the arm's own: an unbacked key is refused, where a
session's claim is dropped. The key-arm cases pass unchanged.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…et list as the type declares it

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl 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 13 documentable anchor(s).

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

  • content/docs/kernel/runtime-services/sharing-service.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/permissions/authorization.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v14.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/releases/v17/17-1.mdx (via resolveAuthzContext (symbol, a top-level function))
  • content/docs/releases/v17/17-3.mdx (via resolveAuthzContext (symbol, a top-level function))

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 3f45b6cc13fb4646fab723a517225aa911cf17b8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fd4bf99aa7012628355dab1a4b3d7101b5659e75 — the merge of head b8fe3069cbb780b5686984677357126741f6bea4 into base 3f45b6cc13fb4646fab723a517225aa911cf17b8, 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 fd4bf99aa7012628355dab1a4b3d7101b5659e75 && git checkout fd4bf99aa7012628355dab1a4b3d7101b5659e75
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f45b6cc13fb4646fab723a517225aa911cf17b8 b8fe3069cbb780b5686984677357126741f6bea4 && git checkout -B drift-repro 3f45b6cc13fb4646fab723a517225aa911cf17b8 && git merge --no-ff b8fe3069cbb780b5686984677357126741f6bea4

node scripts/docs-audit/affected-docs.mjs --json 3f45b6cc13fb4646fab723a517225aa911cf17b8

⚠️ 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 3f45b6cc13fb4646fab723a517225aa911cf17b8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… as absence

The #20002 row spelled enforcement's un-enveloped failure as a code
string, which the error-code casing guard reads as a lowercase code in a
code position. Envelope's code and status are now optional, envelopeOf
answers undefined for an error that carries none, the row states no code
and no status, and the comparison treats no envelope as its own value:
enforcement's envelope is compared with both halves spelled, and an
explain refusal answering an un-enveloped failure must carry none either.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…lifecycle hooks unfired, so no bootstrap read races teardown

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 12:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit cd901d7 Sep 29, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20604-explain-enforce-closeout branch September 29, 2026 12:54
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…s that decided them (objectstack-ai#20634)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the third stage of the `domain:services` lane of the
dead-citation sweep. It covers `packages/plugins/plugin-auth/src/**` and
nothing else. By census, it is the largest package in the lane that no
open PR or in-flight claim holds (the claim, `5888562941`, gives the
order). Later stages cover the other packages, so this PR says `Part of`
and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 and 2 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`). That is **95 sites on 95 lines
in 31 files, covering 16 numbers**:

- the 52 census sites (all of this package's census sites);
- 38 sites in test comments, which the census defers;
- 5 sites in the hyphen-joined spelling `objectstack-ai#13398-class`, which the gate's
extractor does not match at all (see Acceptance notes).

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and it says in its own words what that
commit decided. No ADR or ruling-record file records the decision behind
any of the 16 numbers, so every anchor is a commit: **15 distinct shas**
(`objectstack-ai#11477` and `objectstack-ai#12029` share one, because `objectstack-ai#12029` was the pull request
that settled `objectstack-ai#11477`). No number was dropped.

Only comments changed. Every touched source file keeps its line count
(107 lines out, 107 in, over 31 files), so no line citation into these
files moves. 12 of those 107 lines hold no dead citation; they are
reflow or a lost referent, listed under Wordings below. No code token
moves (see the guard below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. Over the whole diff, added minus
removed is 0 or negative for every number (the gate's own
`extractCitations` over the diff: 103 citations removed, 13 added, all
13 kept resolving numbers), and no number is new to the diff. No PR
number stands on an added line.

Twenty-one dead sites are left on purpose, all of them test titles (see
the list below).

One more file: a `patch` changeset for `@objectstack/plugin-auth`,
because the rewritten docblocks ship (see Changeset below).

## Census: `plugin-auth`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/plugins/plugin-auth/`. Each run counts as a reading only
because its board frontier equals the newest issue number, read by a
separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
plugin-auth sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `b80ab579d`, run 2026-09-29T10:43:31Z to 10:47:03Z |
enumerated, 185 pages, frontier objectstack-ai#20629 (newest objectstack-ai#20628 before, objectstack-ai#20629
after), 18,456 numbers | 1,955 | **52** | 52 | 13 | 12 |
| after | head `5ae64e8b8`, run 11:12:05Z to 11:15:37Z | enumerated, 185
pages, frontier objectstack-ai#20630 (newest objectstack-ai#20630 before and after), 18,457 numbers
| 1,903 | **0** | 0 | 0 | 0 |

The before count matches the 52 that census `5884031174` read at
`f11b5f20`. The whole-repo drop is 52, exactly this diff's census sites.
The `resolves` tally is 32,832 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. No run was truncated or discarded: all three
enumerations in this stage (two census runs and the supplementary board
below) read 185 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts` file under `plugin-auth/src` (178 files). It uses one board,
enumerated by the gate's own `enumerateBoard` at 10:50:47Z (185 pages,
frontier objectstack-ai#20629, equal to the newest).

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `b80ab579d` | 2,150 | **111** | 52 | 38 | 0 | 21 |
| after, `9fd0ebf10` | 2,060 | **21** | 0 | 0 | 0 | 21 |

Its src-comment column equals the census's 52, which is the control on
the second instrument. The 1,966 resolving, 46 pull-request and 27
cross-repo citations are the same in both readings. Neither instrument
sees the 5 `objectstack-ai#13398-class` sites; a plain grep for the 16 numbers over
`plugin-auth/src` at the head finds only the 21 test titles (and the
digits `11477` inside test fixture e-mail addresses and a password,
which are code tokens, not citations).

## Per-number table

Sites and files count all dead sites the gate sees in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject.

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#8676` | 22/6 | 18/4 | `d6e80b28b`: `sys_account.password` and
`previous_password_hashes` are flagged `internal: true`, and every
reader is recovered through the engine's privileged accessor (the
adapter readback table gains `password`; plugin-auth's own raw-engine
reads get `recoverInternalFieldsForSystemRead`). Its subject names
`objectstack-ai#8676` |
| `objectstack-ai#8734` | 4/2 | 3/1 | `f8eb73601`: the last-admin guard's standing-key
lists are bound to what `resolveAuthzContext` actually reads
(`STANDING_KEYS_BY_TABLE` / `STANDING_KEY_EXCLUSIONS` and the
correspondence gate). Its subject names `objectstack-ai#8734` |
| `objectstack-ai#10165` | 1/1 | 1/0 | `801296050`: lifecycle `ttl` gains an
`onlyWhen` row filter (maintainer ruling option A on `objectstack-ai#10165`, quoted in
its message). The same anchor the spec stages gave this number |
| `objectstack-ai#10366` | 3/2 | 2/1 | `bbe643c08`: the localhost trusted-origin
substitution is gated to non-production. Its diff writes both rewritten
lines and its changeset names `objectstack-ai#10366` |
| `objectstack-ai#11343` | 19/8 | 18/1 | `c0714eb5d`: walled platform-admin elevation
requires a VERIFIED owner-email match (a fail-closed allow-list over
`email_verified`), the bootstrap replays on the verifying `sys_user`
update, and the dev-admin seed stamps its account verified. Its message
names `objectstack-ai#11343` as the card it completes |
| `objectstack-ai#11477` | 6/3 | 3/3 | `6dd3e6968`: `/admin/remove-user` gets the
raw-mount shading `/admin/ban-user` has, so authorization runs before
the break-glass guard (ruled option A on `objectstack-ai#11477`, as its message
records) |
| `objectstack-ai#11626` | 1/1 | 1/0 | `a6eca9223`: `check:engine-double-contract`
admits a single-verb engine double on the contract it DECLARES, a second
admission route beside sibling inference. Its diff names that route
`objectstack-ai#11626` |
| `objectstack-ai#11640` | 11/6 | 7/4 | `bf8d129b5`: a walled deployment whose
declared owner has no verification path gets a loud, named warning at
boot, and boot proceeds (maintainer ruling 2026-08-25, option A). Its
subject names `objectstack-ai#11640` |
| `objectstack-ai#11741` | 4/2 | 2/2 | `b706af987`: `SendEmailInput` gains an optional
`organizationId`, threaded from the producers that hold one (the
invitation among them). The same anchor stages 1 and 2 and the spec
stages gave this number |
| `objectstack-ai#11757` | 4/4 | 4/0 | `4d25d22d4`: the rc.1-era `sys_scim_provider`
platform object is retired. Every `objectstack-ai#11757` site in the tree before it
says the object "retires under objectstack-ai#11757" |
| `objectstack-ai#12029` | 2/2 | 2/0 | `6dd3e6968`: `objectstack-ai#12029` was the pull request
itself; this is its squash commit, the gate-then-delegate mount on
`/admin/remove-user` |
| `objectstack-ai#13398` | 6/2 | 3/3 | `e238c79f0`: the published-sink ruling, that
raising a log level must never widen a published sink. No record of the
ruling exists in the repo; this commit's pin is the earliest text in
history that records it (see Wordings) |
| `objectstack-ai#14762` | 21/4 | 19/2 | `35e94c96b`: auth OTP SMS and auth mail read
the recipient's own `sys_user.locale`, one rung above the request and
the deployment default, in the order ruled for `objectstack-ai#14788`. Its diff
carries `objectstack-ai#14762` 24 times |
| `objectstack-ai#14902` | 3/2 | 3/0 | `61821e54c`: a plain unique index over
duplicate rows is loud and non-fatal (the boot continues), and `os
migrate plan` stops calling it `safe`. Its message names `objectstack-ai#14902` as the
card it ends |
| `objectstack-ai#14998` | 2/1 | 2/0 | `f1e91595f`: the batch-6 admin endpoint graphs
load at module top, not inside each clocked case, which removed the
cold-import timeout flake |
| `objectstack-ai#15092` | 2/1 | 2/0 | `9e9f03abe`: `settleSelfRegistrationGrant`'s
trailing filter no longer silently DROPS a malformed permission-set row;
it refuses. The only commit in history that names `objectstack-ai#15092` |

Plus 5 `objectstack-ai#13398-class` sites the gate does not extract, anchored like the
other `objectstack-ai#13398` sites: `boot-sign-in-reachability.ts:109`, `:512`,
`boot-sign-in-reachability.test.ts:595`, `tenancy-service.ts:249`,
`:257-258`.

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each), and every one is an ancestor of the
base (`merge-base --is-ancestor`, exit 0 for all 15; the history is
complete, `--is-shallow-repository` false, 15,083 commits). A
line-origin pickaxe (`git log -S` on each dead line's exact text) found
each line entering either in its anchor commit or in a later commit that
cites that commit's decision: for example `4d5b4f832` (the
operator-provisioned stamp) and `4f65837a7` (the L3 re-anchor) cite
`c0714eb5d`'s verified-owner rule, `f074616e6` (invitation locale) cites
`35e94c96b`'s stored rung, `8064e6da1` (the has-permission mount) cites
`6dd3e6968`'s seam, and `9bd4344e4` carries the
`account-identity-preflight` text that cites `61821e54c`.

## Wordings to check

- **`objectstack-ai#13398` → `e238c79f0`, and not stage 2's `953a81f4a`.** Stage 2
anchored its one `objectstack-ai#13398` site at `953a81f4a` (2026-09-02) as the
earliest application of the published-sink ruling. In this package,
`e238c79f0` (2026-08-31) already records it: its pin in
`durability-swallow-repair.test.ts` says raising the level "means
widening a published sink — refused as actively harmful by the
maintainer's" ruling. It is earlier, and it is in this package, so it is
the anchor here. Its own commit message still calls the level "objectstack-ai#13398's
question", which is why the lines say "the published-sink ruling (commit
e238c79)" rather than claiming that commit made the ruling.
- **Reflow, 11 lines with no dead site** (every file keeps its line
count):
- `auth-manager.ts:7554-7557`: 「routes that LEVEL question to the
published-sink ruling (commit e238c79) and tells this batch to fix the
SILENCE only」, the rest of the paragraph reflowed unchanged (3 lines).
- `durability-swallow-repair.test.ts:36-40` (4 lines) and `:527-529` (2
lines): the same substitution, and 「which routes that question there」
became 「which keeps that question」, because "there" pointed at the
number.
- `tenancy-service.ts:257-258`: 「exactly what the sink ruling (commit
e238c79) forbids」 (1 line).
- `find-envelope-limb-removal.test.ts:47-48`: 「also carried the
silent-DROP shape, and commit 9e9f03a fixed it in the OPPOSITE
direction」 (1 line).
- **A lost referent, 1 line.** `auth-plugin.ts:2738-2739`: 「(the objectstack-ai#12029
worked reading — a shadow is accounted for …)」 became 「(as it read
commit 6dd3e69's remove-user mount — a shadow is accounted for …)」.
`check:auth-mount-ledger` has counted a shadowing mount since
`26dea1495`; the "worked reading" was that PR's application of it to
`/admin/remove-user`, which `6dd3e6968` mounts.
- `sys-session-ttl-sweep.test.ts:230`: 「the naive policy commit
8012960 existed to make avoidable」, where `801296050` is the
`ttl.onlyWhen` filter the ablation removes.
- `durability-swallow-repair.test.ts:62`: the flake report became a
pointer to the commit that removed the flake (`f1e91595f`), with
`objectstack-ai#15603` kept beside it.
- `auth-manager.ts:5629`: 「the pre-objectstack-ai#14762 deployment-default behaviour」
became 「the deployment default, as before commit 35e94c9」.

## The 21 sites left

- **Test titles (21 sites).** `describe` / `it` titles, which are string
tokens: `admin-remove-user-gate-ordering.test.ts:207`, `:263`, `:298`
(`objectstack-ai#11477`), `auth-email-locale.test.ts:528` and
`auth-manager.test.ts:2545` (`objectstack-ai#14762`), `auth-manager.test.ts:1562`
(`objectstack-ai#10366`), `:2866`, `:2880` (`objectstack-ai#11741`), `:4105` and
`internal-field-readback.test.ts:219`, `:230`, `:286` (`objectstack-ai#8676`),
`auth-plugin-walled-owner-verification-path.test.ts:87`, `:193`, `:317`,
`:384` (`objectstack-ai#11640`), `durability-swallow-repair.test.ts:159`, `:567`,
`:670` (`objectstack-ai#13398`), `last-admin-standing-keys.test.ts:61` (`objectstack-ai#8734`) and
`walled-owner-operator-stamp.test.ts:355` (`objectstack-ai#11343`). Tokens, left as
they were, as stages 1 and 2 left theirs.
- There is no non-test string, no generated file and no quoted ruling
carrying a dead number in this package.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes excluded, base `b80ab579d` against head. Template
literals are therefore read in context. It ran over all 31 touched `.ts`
files.

- Real run: 158,646 base tokens, **0 files with a token change** (exit
0).
- Comment control in `auth-manager.ts` (`As above — the flagged column`
to `Likewise — the flagged column`): 0 files changed, as expected (exit
0).
- Positive control, a code token changed in `auth-manager.ts` (a fourth
element added to the `fields` projection of the password-reuse read):
DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`admin-remove-user-gate-ordering.test.ts:207`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`c0bdef025a39`, `ec83f09f556e`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/plugin-auth`
(`.changeset/20596-plugin-auth-provenance-anchors.md`) is included. It
says only that the provenance comments were re-anchored.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build, the rewritten comments reach `dist`:
`35e94c96b` appears 8 times in each of `dist/index.d.ts`, `index.d.mts`,
`index.js` and `index.mjs`; `f8eb73601` twice in each declaration file;
`bf8d129b5` and `e238c79f0` once in each of the four; `d6e80b28b` and
`4d25d22d4` twice in each runtime file; `c0714eb5d` and `61821e54c` once
in each declaration file; `b706af987` once in each runtime file.
Positive control: the unchanged line 「read best-effort off the identity
row.」 beside a shipped rewrite is found once in `index.d.ts` and once in
`index.js`. A never-written negative phrase appears nowhere. No dead
number of the 16 is left anywhere in `dist`.

## Gates (head `5ae64e8b8`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 5 citations across 14 files, and all 5
resolve.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0, with the
sibling-package prose ids at their baseline and no growth.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `5ae64e8b8` derived 65 commands:
all 57 derived at dispatch, plus `check:duration-unit-keys`,
`check:engine-double-contract`, `check:logger-receiver-detach`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. It was re-derived after a fresh `git fetch`
(`origin/main` `a918fe7fd`, 2 commits ahead, neither touching
`plugin-auth`): the same 65. Each ran with its exit code captured before
any pipe, and all 65 exit 0. `--ran`, fed each command with its exit
code, reports 65 run, 0 NOT MEASURED (a derived zero), 0 unrun, and
exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*`
ran first under the shared verify lock (71 of 71 tasks, exit 0), so no
gate hit an unbuilt workspace.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/plugin-auth test`: 115 files and 2,464
tests pass. That is every test file in the package, the 17 touched ones
included.
- `pnpm --filter @objectstack/plugin-auth typecheck` exits 0 (`tsc`
main, `tsconfig.examples.json`, and `check:test-typecheck` held at its
ledger). The main program reads 63 non-test files; the
`tsconfig.test.json` program reads all 178 files under `src/`, the 115
test files included, and all 31 touched files are in it (`--listFiles`).
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 31 touched `.ts` files gives 31 files, 0 errors and 0
warnings. All 31 are in eslint's own population (`isPathIgnored` is
false for each). `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, as its own line 328 states), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 32 changed files for control bytes finds none.

## Acceptance notes

- **The gate's extractor does not see a hyphen-joined number.**
`CITATION_RE` ends in a lookahead that refuses a following hyphen, so
`objectstack-ai#13398-class` is not a citation to either the diff gate or the census,
dead or alive. This stage rewrote the 5 such sites in `plugin-auth`
because they are the same dead number in the same comment prose. At the
head, 10 dead `#N-word` sites remain in `packages/**/src` (a raw line
scan of `.ts` files against the cached board): `service-automation` 5
(all `objectstack-ai#13398-class`), `rest` 2, `plugin-security` 1, `runtime` 1, `spec`
1. The census cannot count them, so a later stage reaching those
packages has to look for them by hand. No instrument change here.
- **The census instrument did not truncate in this stage.** Three
enumerations read 185 pages each at the newest frontier.
- **Anchors the next stages can reuse.** These numbers stand elsewhere
on the census at the head: `objectstack-ai#11343` in `plugin-security` (6) and `types`
(2), anchor `c0714eb5d`; `objectstack-ai#14902` in `driver-sql` (7) and `cli` (1),
anchor `61821e54c`; `objectstack-ai#13398` in `service-automation` (4, plus the 5
hyphen-joined sites), anchor `e238c79f0`; `objectstack-ai#8734` in `core` (2), anchor
`f8eb73601`; `objectstack-ai#10165` in `objectql` (2) and `platform-objects` (1),
anchor `801296050`; `objectstack-ai#11757` in `platform-objects` (2), anchor
`4d25d22d4`; `objectstack-ai#11741` in `plugin-email` (2), anchor `b706af987`; `objectstack-ai#8676`
in `platform-objects` (1), anchor `d6e80b28b`.
- **Base.** The branch is 2 commits behind `origin/main` (`a918fe7fd`,
read at 11:20Z). Neither touches `plugin-auth`, this changeset or any of
these 16 numbers, so there was no merge.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants