Skip to content

fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's - #22847

Merged
objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-22792-explain-asker-gate
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 18 commits into
mainfrom
claude/issue-22792-explain-asker-gate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22792
Clause-②: no

This PR delivers item 2 of the card, built to ruling A of the explain-asker decision (ruling record 6107185582 on #22795, maintainer 「同意」; director pointer 6107202712). Item 1 landed as #22813, so this PR closes the card. Claim 6108485513.

What this changes

Class: explain's disclosure of a record's existence. Positions: the record pass of the explain engine (packages/plugins/plugin-security/src/explain-engine.ts), the caller-authorized explain (explainAccessForCaller in security-plugin.ts), and two description strings in packages/spec/src/security/explain.zod.ts.

  • The asker gate. Every record-grained explanation first asks the read door's own by-id read, recordAbsentToCaller (the question the write doors ask), with the ASKER's context. An asker the door does not return the record to gets, for every operation, exactly what an id no row carries gets: record with no decider, and every layer's per-record attribution the missing record's, through the one missing-record path fix(plugin-security): explain answers a by-id write of a row the caller cannot read with the nonexistent id's whole answer, layers included #22791 built. There is no exemption beside the door's answer: a system asker is asked too, and the door returns it every row.
  • An asker who can read the record gets the explained principal's full record story:
    • read and export: the principal's own verdict, decider included (item 1's master-leg verdict among them), unchanged.
    • a by-id update, delete or transfer (through rlsOp) of a row the principal cannot read: visible: false, because the principal's write answers what a nonexistent id answers, with the decider of the principal's READ. Each row-scoped layer carries that read's attribution, prefaced with the write's 404 RECORD_NOT_FOUND, so the decider and the layer trace agree (the spec defines the decider as the layer whose record.outcome excluded the record).
    • The ruling's confidence gap, measured: one write-operation pass cannot yield the read's decider. It composes the write's row-level security, the write gate, the write depth and the modify bypass, and none of them is the read's (shown below, where the write composition admits the row and the read excludes it). So a second, read-operation pass of the engine yields it, asked only in that arm.
  • Self-explanation (the asker is the explained principal): an unreadable row is the missing record for every verb. On the write verbs that is what fix(plugin-security): explain answers a by-id write of a row the caller cannot read with the nonexistent id's whole answer, layers included #22791 already answered. On read and export it is new: the card's item 2.
  • Batch (recordIds): the REST route answers each id through the singular service call, so it follows by its own definition. Pinned below.
  • Spec text: the record verdict's description and the recordIds "Missing records" clause now say that an id the asker cannot read answers as missing, alongside filtered, deleted and never-existed ids. Description text only; the reference page is regenerated by the spec's own tooling (check:generated --fix, which ran gen:docs).

No exported type moves (Clause-② no)

  • The asker reaches the engine through a module-internal entry, explainAccessForAsker in explain-engine.ts, which the package index does not export. The published explainAccess(deps, input) keeps its signature and answers a self-explanation. ExplainEngineDeps, ExplainInput and SecurityPlugin.explainAccessForCaller keep their signatures (comment-only diffs there); src/index.ts is untouched.
  • Measured on the built declaration (the package rebuilt at 842f1ca03): dist/index.d.ts still declares explainAccess(deps: ExplainEngineDeps, input: ExplainInput), its export list carries no new name, and explainAccessForAsker occurs 0 times in dist/index.d.ts and dist/index.d.mts.
  • Why not a per-call binding of recordAbsentToCaller: the engine asks that question of the explained principal too (the by-id write guard, item 1's master leg), so rebinding it to the asker would judge the principal's own outcome by the asker's read. The engine needs both contexts at once, and the internal entry carries the second one without widening a published type.

Pins

New file packages/plugins/plugin-security/src/explain-asker-gate.test.ts, 42 cases. The real SecurityPlugin over a real ObjectQL, the SQL driver and the platform grant tables, on one public_read_write object. The explained member's read policy hides one row and its write-class policies admit every row, so the write composition alone narrows nothing the read does.

  • Armed: who reads which row, and the member's three write doors answer 404 RECORD_NOT_FOUND for the hidden row and for a nonexistent id.
  • An asker who cannot read the row (the member itself; a manage_users administrator held to the member's read; a D12 delegated administrator held to it, whose boundary check is stubbed): for read, export, update, delete and transfer, the hidden row's whole explanation equals the nonexistent id's, layers included.
  • A manage_users administrator who reads the row: read is the member's verdict with its decider; each write verb is visible: false decided by the member's READ (rls), the rls layer carries the read policy's predicate (never the write composition's), every row-scoped layer carries the read preface, and the only status any layer names is the door's 404. Both equal the system asker's answer.
  • Controls: a nonexistent id is the missing record for every asker; a row every asker reads gets one answer whoever asks.
  • Engine level: which contexts the question is asked with, and how often (once per context per explanation); a system asker is asked like any other; a rejection of the asker's question fails the explanation (an outage is neither absence nor a row); and the write arm's decider is the READ's where the write composition admits the row.

Also: explain-controlled-by-parent-write.test.ts gains the write arm on a controlled_by_parent record (decided by the principal's read on the master leg, the master write check never reached, no layer naming its 403; control: a record the principal reads keeps the check's refusal and its 403). cbp-explain-master-write.dogfood.test.ts (the family's REST parity pin) gains, beside the GET door, an administrator who reads the record (the steward's verdict, decider included) and the batch case (each records[i] equals the singular verdict, for the steward and for the administrator).

Pin sweep

Whole-repo grep for the same semantics (record-grained explain answers for a row the asking principal cannot read, every decidedBy assertion beside an explain call) over plugin-security, plugin-sharing, rest, client, spec and the dogfood suite. Flipped, each to a load-bearing assertion of the new answer:

  • explain-controlled-by-parent-write.test.ts, the read block: item 1's verdict cells now explain to an asker who reads the record, so the decider stays pinned where ruling A keeps it; new self-explanation cells pin the missing record's whole answer (equal to the no-row explanation), and the read-question cells pin which contexts are asked.
  • explain-cross-class-refusal.test.ts and explain-json-column-refusal.test.ts, the controls: a self-explained read of a row the caller cannot read now pins the missing record (no decider, the rls record not evaluated, the whole explanation equal to a nonexistent id's), matching the write side those cells already pinned.
  • cbp-explain-master-write.dogfood.test.ts, the read row: the steward explaining itself the unreadable record pins the missing record, whole.

Kept verbatim: every refusal-shape assertion (the INVALID_FILTER refusals, the door statuses), #22791's record-story pins (they are self-explanations and hold as written), and explain-write-door-parity.dogfood.test.ts (self-explanations; its object-gate cells keep decidedBy: 'object_crud', which precedes the missing-record branch).

Ablation (one-time, no permanent file)

Each leg through scripts/ablation-replace.mjs in wrap mode (the anchor hit 1 then 0, the blob changed, restore proven by blob equality with HEAD and an empty git diff HEAD). The plugin-security suites import src/; the dogfood suite reads dist/, so its legs rebuilt the package, read the marker in dist/ with ablation-dist-preflight.mjs before the run, and after the restore rebuilt and read the marker absent with a clean tree.

leg mutation red green
A the asker gate off 48 of 192 in five suites (every cannot-read cell, the engine read-question cells, the flipped controls, #22791's self-explanation pins) and 2 of 20 dogfood (the self-explained read, the batch) every control and every can-read cell
B the write arm's read pass off 10 of 141 (the write arm's decider, layer and status cells, plugin and engine, and the controlled_by_parent write arm) everything else
C the caller passes no asker 7 of 42 (every can-read cell) and 2 of 20 dogfood (the administrator's read, the batch) every cannot-read cell and control

Leg A ran on 842f1ca03, after the asker check lost its system-asker exemption (a system asker is asked like any other, and the read door returns it every row); B and C ran on 46dcaa54a; between it and the head the engine changed only by that exemption, and the explainAccessForCaller call is unchanged.

Quoted, record ids elided: … to deeply equal { Object (recordId, visible) } (A, a cannot-read cell, told the stored row's verdict with its decider) and explain read for the steward: expected { recordId: ID, …(1) } to deeply equal { recordId: ID, …(2) } (C, the dogfood administrator cell, told the missing record).

Verification

All at 842f1ca03 unless named (the branch merged origin/main up to d8c7d3864; a full workspace build ran after the merge).

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 198 files passed, 4057 tests passed, 45 skipped, exit 0.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0 (tsc, scripts, and the test layer, check:test-typecheck: OK; tsconfig.test.json includes src/**/*).
  • pnpm --filter @objectstack/dogfood exec vitest run test/cbp-explain-master-write.dogfood.test.ts test/explain-write-door-parity.dogfood.test.ts: 2 files, 43 passed, against the package's rebuilt dist/. pnpm --filter @objectstack/dogfood typecheck: exit 0 at 8cf36e560 (the dogfood file is unchanged since).
  • At 8cf36e560 (the spec and rest sides are unchanged since): @objectstack/spec src/security/explain.test.ts and explain-zero-rows-sentinels.pin.test.ts, 38 passed; @objectstack/rest cross-class-refusal-remedy-on-the-wire, security-explain-envelope and security-routes, 47 passed.
  • pnpm --filter @objectstack/spec check:generated named one stale artifact (the explain reference page); --fix rebuilt the spec, ran gen:docs and re-checked it green.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115; all 115 ran, every one exit 0, each exit code captured before any pipe (check:generated, check:docs, check:api-surface, check:nul-bytes, check:system-context-census, check:empty-changeset, check:dual-build-cjs-loads, check:i18n and the check:type-check-debt re-measure among them). --ran over the command :: exit code record: 115 derived, 115 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.
  • Lint, narrowed and proved: (1) population, read from eslint's own config: all 8 touched TypeScript files resolve under eslint --print-config, and the changeset and the generated .mdx match no configuration; (2) eslint --no-inline-config --format json over those 8 files: 8 files, 0 errors, 0 warnings; (3) invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move any untouched file's verdict. Repo-wide pnpm lint is declared to CI.
  • Control-byte self-scan over the changed files: 0 hits.
  • Not run locally, declared to CI: repo-wide lint, the full dogfood suite, the path-scheduled jobs.

Acceptance notes

  • The read question is now asked for every record-grained explanation, of the asker (and, in the write arm, of the principal), memoized per explanation: one caller-context by-id read more per explanation than before on most verbs. A second engine pass runs only in the write arm for an asker who reads the record.
  • A principal refused at the object gate whose row is absent to it now gets the missing record's layers too. Its verdict is unchanged (the capability and CRUD deciders precede the missing-record branch).
  • The published explainAccess (a self-explanation, for a direct embedder that wires recordAbsentToCaller) answers a read or export of a row the principal cannot read as missing, as the security service now does.
  • Drift case, NOT MEASURED as reachable: if the principal's read story admits a row its read door withholds (a leg explain does not model), the write arm's verdict carries no decider, and the layers show that read's admitting attribution under the preface.
  • Item 1's pending release note (.changeset/22792-explain-read-master-leg.md) says a withheld controlled_by_parent record "is reported not visible, decided by the sharing layer". Under ruling A that holds for an asker who can read the record. This PR leaves that note untouched (correcting a pending note is a release decision, and the empty-changeset gate keeps such a PR red) and its own note states the qualification. If the seat prefers the pending note corrected in place, that is the gate's deliberate-correction path.
  • Open question for the seat (not built): a refused asker. Under the ruled notion, recordAbsentToCaller answers true only for absence; a declared 4xx refusal of the asker's own read (for example, no read grant on the object at all) answers false, so that asker is answered as one who can read. Its read door answers the same refusal for a stored row and a nonexistent id, while explain answers them differently. The ruling's headline and its D12 bullet read the other way; its sub-question 4 names this exact question. Built as ruled; the options are in the report.

Patch round: ruling C of #22851 (decision record 6110031776)

  • What changed. The asker gate classifies the asker's caller-context by-id read by asker kind, on that one read:
    • An asker explaining another user (a manage_users administrator, a D12 delegate): no row, or a declared 4xx refusal of its own read, is "cannot read", and it gets, for every operation, the answer an id no row carries gets. Position: unreadableUnderCallerRead in security-plugin.ts, beside absentUnderCallerRead, over the shared readUnlessRefused classification; a store fault still fails the explanation.
    • A principal explaining itself keeps the write path's notion (recordAbsentToCaller: absent means no row), so a refused read leaves its explanation as its own doors answer.
    • The engine carries the asker as { context, cannotRead } on the module-internal explainAccessForAsker (its ExplainAsker type is not exported from the package index). explainAccess, ExplainEngineDeps, ExplainInput and SecurityPlugin.explainAccessForCaller keep their signatures. Measured on the built dist/index.d.ts at 026209205: none of the new names occurs, and explainAccess is declared as before. Clause-②: no holds.
  • Spec text and changeset. The record verdict's description and the recordIds "Missing records" clause in explain.zod.ts now say which ids each asker cannot read (explaining another principal: no row, or a refusal; a principal explaining itself: no row, a refused read not counting). The reference page is regenerated with the spec's own gen:docs; the changeset says the same.
  • Pins (explain-asker-gate.test.ts, now 62 cases):
    • a manage_users administrator and a D12 delegate whose own reads of the object are refused, for read, export, update, delete and transfer: a stored row's whole explanation equals a nonexistent id's, layers included (a row the explained user cannot read, and one it reads);
    • CONTROL: a principal that may edit the object but not read it, explaining itself, keeps the stored row's story (equal to a system asker's account of that principal) and, on the write verbs, visible: true beside its own door's admission;
    • CONTROL: a reading administrator and a nonexistent id are unchanged;
    • engine level: the third-party classification decides for another principal's explainer, a self-explanation is never asked it, and a store fault in it fails the explanation.
  • A CI regression in this round, its cause and fix. explain-enforce-parity.test.ts went red at 6cc56c243 (7 of 98). Cause: that rig's explaining administrator held manage_users and no read grant on the probed tenant object, so its own by-id read of the record is refused (measured: a declared 4xx). Under ruling C that asker is told the nonexistent id's answer, while those cells hold explain's record verdict against the explained user's door. The classification is right: a third-party asker, read with its own context, genuinely refused. Fix, fixture only: the rig's administrator also holds the reader set the explained users hold, so it reads what it explains and the record cells keep measuring the explained user's story. The five failing record cells are byte-identical. The two single-posture record rows for the other organization's row (a measured divergence, asserted as a disagreement) turned to parity, because the asker's own read is scoped to the context organization the same way; per the file's own rule those rows joined the invariant (their divergence marker removed), and the readFilter divergence row stays.
  • Ablation (scripts/ablation-replace.mjs; restore proven by blob equality with HEAD and an empty git diff HEAD; the subject resolves through src/, so no dist/ leg): the plugin's refused-asker classification put back to the absence notion turned 10 of 161 red (every refused-asker cell) with every control green; the engine's third-party branch switched to the write path's question turned 12 of 161 red (those 10 and the two engine classification cells) with the controls green. Both at 6cc56c243; the mutated lines are unchanged at the head.
  • Hand-written page re-verified (content/docs/permissions/explain.mdx, listed by the Docs Drift Check): read "What a decision looks like" (lines 15-45), "Calling it" (47-84), "Who may ask" (86-100) and "What it powers" (102-112). It names no record verdict, no recordId or recordIds, and no missing-record definition; its "another user" rule is the request's authorization, which rulings A and C leave as it is. Every sentence holds, so it is not edited.
  • Verification at 026209205 (the branch merged origin/main to 0253192f7 through scripts/pm/os-regen-merge.sh, then a full workspace build): the plugin-security suite, 197 files, 4045 passed, 45 skipped; plugin-security typecheck exit 0; dogfood cbp-explain-master-write and explain-write-door-parity, 43 passed, and dogfood typecheck exit 0; gates, 115 derived by dispatch-gates --commands, all 115 run and exit 0 (check:docs and check:generated among them), --ran 0 NOT-MEASURED and 0 UNRUN; narrowed lint, 9 files, 0 errors and 0 warnings (population read from eslint's own config, no type-aware linting); control-byte scan 0.
  • Acceptance note update: the body's "Open question for the seat: a refused asker" is answered by ruling C and built in this round.

Generated by Claude Code

claude added 10 commits October 11, 2026 11:42
…ries the qualification

Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA
Co-authored-by: Claude <noreply@anthropic.com>
…answer for the asker

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

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/authorization.mdx (via ExplainRequestSchema (symbol, a top-level const))
  • content/docs/permissions/explain.mdx (via ExplainRequestSchema (symbol, a top-level const))
  • 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), tenant_isolation (literal, a string literal in attributeThePrincipalsRead))
  • content/docs/permissions/rls.mdx (via tenant_isolation (literal, a string literal in attributeThePrincipalsRead))
  • 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))

⛔ 2 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), tenant_isolation (literal, a string literal in attributeThePrincipalsRead))
  • content/docs/releases/v15.mdx (via tenant_isolation (literal, a string literal in attributeThePrincipalsRead))

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 — 142 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 0253192f760b37b5d3da4c4f845aee1c239e6ec2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0253192f760b37b5d3da4c4f845aee1c239e6ec2

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

@github-actions github-actions Bot added size/xl and removed size/l labels Oct 11, 2026
…s what it explains (ruling C)

Under ruling C an administrator whose own read of a record is refused is
answered as for an id no row carries, so the rig's asker now holds the
reader set and the record cells keep measuring the explained user's story.
The single-posture record row for the other organization's row joins the
invariant: the asker's own read is natively scoped the same way.

Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA
Co-authored-by: Claude <noreply@anthropic.com>
…ch asker cannot read (ruling C)

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 02620920532ca719a86343a3c643bb324898843e
Local-runs: none

Rendered by an isolated reviewer subagent of the seat in Reviewed-by, which adopts it.

Inputs, read at 2026-10-11T18:59Z: card #22792 (its body and every comment, the seat's ACCEPT 6112454274 read as data and re-judged); ruling A 6107185582 on #22795 and ruling C 6110031776 on #22851; PR #22847 (body, the 11-file list, the net diff of the head against origin/main, whose merge base 0253192f7 equals the PR's base); the 46 check-runs on the head (latest per name: none failed, 4 skipped as path-scheduled or opt-in jobs, and every one of the seven required contexts success). The card is security: classes and positions only below.

① Derived judgments

  • Accept-set of POST /api/v1/security/explain: unchanged — right. No request key is added or removed; recordId and recordIds keep their validation; the batch form answers each id through the singular service call (the dogfood pin holds it for a self-asker and for an administrator). No route, query parameter or body field moves anywhere in the diff.
  • Public surface of @objectstack/plugin-security: unchanged — right. src/index.ts is untouched (explainAccess, buildContextForUser, ExplainEngineDeps, ExplainInput); explainAccess(deps, input) keeps its signature and now delegates as a self-explanation; ExplainEngineDeps.recordAbsentToCaller and SecurityPlugin.explainAccessForCaller(request, callerContext?) are comment-only diffs. The new names (explainAccessForAsker and ExplainAsker in explain-engine.ts; unreadableUnderCallerRead in security-plugin.ts) are module-internal: the package's exports map publishes the single . entry and files ships dist only, so no subpath reaches them — checked on the exports map, not only on the built declaration the dev measured.
  • Published behaviour of explainAccess for a direct embedder that wires recordAbsentToCaller: a self-explained read or export of a row the principal's door withholds now answers as a missing record — right, and declared. It is ruling A sub-ruling 1, and the changeset's "Before" sentence states it.
  • The asker gate — right as ruled. In explainOnce (explain-engine.ts), every record-grained explanation first asks the asker's own caller-context by-id read: a principal explaining itself through recordAbsentToCaller (absent means no row), an asker explaining another principal through asker.cannotRead, which the plugin binds to unreadableUnderCallerRead (no row, or a declared 4xx, over the shared readUnlessRefused; a store fault propagates and fails the explanation). "Cannot read" routes through the one existing missing-record path (fetchRecord answering null), so the record verdict and every layer's record are the nonexistent id's; the pins hold whole-decision equality with a nonexistent id, record id set aside, at engine, plugin and REST level. Self versus third party is decided by context identity, and explainAccessForCaller keeps targetContext as the very callerContext object on both self paths (no userId, or the caller's own id), so the classification cannot drift between them. No exemption sits beside the door: a system asker is asked and the door returns it the row (pinned).
  • The write arm for an asker who reads a row the explained principal cannot — right. principalCannotRead keeps fix(plugin-security): explain answers a by-id write of a row the caller cannot read with the nonexistent id's whole answer, layers included #22791's visible: false; a memoized read-operation pass then yields the principal's READ decider, and attributeThePrincipalsRead carries that read's per-record attribution onto each row-scoped layer under a 404 RECORD_NOT_FOUND preface, so the only status any layer names is the door's 404 (pinned, with the 403 control for a row the principal reads). This satisfies the published decidedBy definition (the layer whose record.outcome excluded the record), which a write-pass trace cannot: the engine pin where the write composition admits the row and the read excludes it confirms the dev's measurement that one write pass composes none of the read's legs.
  • Ordering and read count — right. The asker's read precedes the capability and CRUD gates, so a principal refused at the object gate gets, for an unreadable id, exactly what it gets for a nonexistent id (that gate's decider kept, missing-record layers): the ruled invariant. Each (principal, record) pair is read once per explanation (askedOnce, oncePerRecord), the read pass included; a create carrying a recordId is asked the asker's read too — one read, nothing disclosed past the door.
  • Spec, packages/spec/src/security/explain.zod.ts: description text only — right. One .describe() string on ExplainDecision.record and two TSDoc blocks; no schema shape, enum or optionality moves. The sentences match the code: "finds no row for or refuses" for a third-party asker is unreadableUnderCallerRead; "finds no row for" for a self-explanation is absentUnderCallerRead. The regenerated content/docs/references/security/explain.mdx row equals the new string; no api-surface, json-schema.manifest or authorable-surface shard changes, and check:generated and check:docs ran green inside the required TypeScript Type Check job. Precision note, carrier none: "visible false, decidedBy omitted" describes the shape past the object gate; a principal the capability or CRUD gate refuses keeps that gate's decider for an unreadable and a never-existed id alike, which the same sentence's "like a never-existed id" states exactly, and the pre-existing "Missing records" clause carried the same shorthand.
  • Tests touched — right, none weakened. explain-enforce-parity.test.ts: no parity assertion is edited; the rig's explaining administrator gains the explained users' reader set, because under ruling C a refused third-party asker is told the missing record and those cells would stop measuring the explained user's door; the two single-posture record:l_beta rows join the invariant because the asker's own by-id read is natively scoped to the context organization, and the readFilter:LEDGER divergence row stays measured. explain-cross-class-refusal and explain-json-column-refusal flip the self-explained unreadable read to the whole missing-record answer with load-bearing toEqual assertions. explain-controlled-by-parent-write re-homes item 1's decider cells on a reading asker and adds the self-explanation and write-arm cells. The dogfood parity pin gains the reading administrator and the batch case beside the GET door.
  • Governed surfaces: none. None of the 11 paths is under docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md or CLAUDE.md; Governed Surface Queue Guard is success; 1125 changed lines, under the 3000-line human-merge threshold. This record is owed for the published-schema face (explain.zod.ts), as the seat said.
  • Runtime strings carry no tracker number (principalReadPreface, the .describe() text); issue tags sit in code comments only.

② Semver level

  • .changeset/22792-explain-asker-gate.md: @objectstack/plugin-security: patch and @objectstack/spec: patch — right. Both are released packages (private unset, at 17.7.0); the card is bug; nothing authorable, exported or accepted moves, so skip-changeset does not apply and minor is not owed. The spec patch is owed because a published description string and its generated reference page change.
  • Clause-②: no — right, and it stands on all three carriers: PR body line 2, the changeset body, and the claim 6108485513. No (widening) or (narrowing) arm, correctly: the accept-set is unchanged and no published type widens or narrows (the dev chose a module-internal entry over an optional key on ExplainEngineDeps, so this family's earlier yes (widening) precedent is not triggered). Not breaking, so no ADR-0087 disposition marker is owed. Check Changeset is success.
  • Changeset prose against the diff: the "Before" sentences are true of main (no asker gate; item 1's decider shown to every asker); "Description text only; no schema shape changes" matches explain.zod.ts; "no exported type or signature moves" matches index.ts and the exports map.

③ Boundary flags

Every flag in the two os-dev-reports (6109766929, 6112399009), the PR body's acceptance notes and the seat's ESCALATE and ACCEPT, judged:

  1. Round 1 open question — a refused asker. Escalated by the seat to decision [Decision] explain: does an asker whose own read is REFUSED (a declared 4xx) count as an asker who cannot read, under ruling A of #22795? #22851 and ruled C (6110031776); built in round 2 as unreadableUnderCallerRead and the { context, cannotRead } asker, with the refused manage_users and D12 cells, the edit-only self-explanation CONTROL and the engine classification cells. Answered. Round 2's open_questions is empty.
  2. Route: a module-internal entry instead of a per-call recordAbsentToCaller binding. Right — the engine needs both contexts (the by-id write guard and the master leg keep the explained context), and no published type moves.
  3. System-asker exemption removed. Right — the door returns a system read every row (pinned), and an isSystem read site in the engine would be a second notion of "can read".
  4. A second read-operation pass in the write arm. Right and bounded — only for a third-party asker who reads a row the principal cannot, memoized per explanation.
  5. Interpretive choice: the write arm's layers carry the principal's read attribution under a 404 preface. Accepted — it is what makes decidedBy agree with the layer trace as the spec defines it; the two alternatives the dev names would break either the decider's own layer or the door's status.
  6. Item 1's pending changeset (22792-explain-read-master-leg.md) left byte-identical. Acceptable — unreleased, it ships in the same release as this PR's note, which states the asker qualification and the "Before"; the release-time compiler reads both notes together. Carrier: the seat, at release time. Not a FAIL input.
  7. Drift case not measured as reachable (the principal's read story admits a row its door withholds): the arm then answers visible: false with no decider, the fail-closed direction, and the PR body says so. Accepted.
  8. One more caller-context by-id read per record-grained explanation. Accepted — the price the ruling sets, once per (principal, record) per explanation.
  9. A principal refused at the object gate gets missing-record layers for an unreadable id. Right — equal to a nonexistent id, the invariant.
  10. The explain-enforce-parity fixture change and the two divergence rows joining the invariant, which the dev asked the seat to review against its order. Reviewed, right (section ①): no parity assertion weakened; the readFilter divergence stays measured.
  11. Process flags (gates run outside the verify lock per the agent definition; a killed check:generated --fix later completed and check:docs is green on the head; two merges through os-regen-merge.sh with no regeneration owed; a stray pid file outside every repository): none bears on the diff; CI on the head is the gate.
  12. Out-of-scope findings (the write-arm drift case; the pending-note qualification): carried as noted; by Prime Directive chore: version packages #10's test neither owes a card (no reproducible defect, no contract violation, no authoring trap).

Implemented-by: claude/issue-22792-explain-asker-gate
Reviewed-by: session_013LbZ9MhPp1iriZEtgJqioA

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 19:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 19:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit b1d701b Oct 11, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22792-explain-asker-gate branch October 11, 2026 19:35
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