Repository navigation
fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's - #22847
Conversation
…read Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…n text Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…ted --fix) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…nnot read (ruling C) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…ion control) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…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>
Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Rendered by an isolated reviewer subagent of the seat in Inputs, read at 2026-10-11T18:59Z: card #22792 (its body and every comment, the seat's ACCEPT ① Derived judgments
② Semver level
③ Boundary flagsEvery flag in the two
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22792
Clause-②: no
This PR delivers item 2 of the card, built to ruling A of the explain-asker decision (ruling record
6107185582on #22795, maintainer 「同意」; director pointer6107202712). Item 1 landed as #22813, so this PR closes the card. Claim6108485513.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 (explainAccessForCallerinsecurity-plugin.ts), and two description strings inpackages/spec/src/security/explain.zod.ts.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:recordwith 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.readandexport: the principal's own verdict, decider included (item 1's master-leg verdict among them), unchanged.update,deleteortransfer(throughrlsOp) 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's404 RECORD_NOT_FOUND, so the decider and the layer trace agree (the spec defines the decider as the layer whoserecord.outcomeexcluded the record).readandexportit is new: the card's item 2.recordIds): the REST route answers each id through the singular service call, so it follows by its own definition. Pinned below.recordverdict's description and therecordIds"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 rangen:docs).No exported type moves (Clause-② no)
explainAccessForAskerinexplain-engine.ts, which the package index does not export. The publishedexplainAccess(deps, input)keeps its signature and answers a self-explanation.ExplainEngineDeps,ExplainInputandSecurityPlugin.explainAccessForCallerkeep their signatures (comment-only diffs there);src/index.tsis untouched.842f1ca03):dist/index.d.tsstill declaresexplainAccess(deps: ExplainEngineDeps, input: ExplainInput), its export list carries no new name, andexplainAccessForAskeroccurs 0 times indist/index.d.tsanddist/index.d.mts.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 realSecurityPluginover a realObjectQL, the SQL driver and the platform grant tables, on onepublic_read_writeobject. 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.404 RECORD_NOT_FOUNDfor the hidden row and for a nonexistent id.manage_usersadministrator held to the member's read; a D12 delegated administrator held to it, whose boundary check is stubbed): forread,export,update,deleteandtransfer, the hidden row's whole explanation equals the nonexistent id's, layers included.manage_usersadministrator who reads the row:readis the member's verdict with its decider; each write verb isvisible: falsedecided by the member's READ (rls), therlslayer 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's404. Both equal the system asker's answer.Also:
explain-controlled-by-parent-write.test.tsgains the write arm on acontrolled_by_parentrecord (decided by the principal's read on the master leg, the master write check never reached, no layer naming its403; control: a record the principal reads keeps the check's refusal and its403).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 (eachrecords[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
decidedByassertion 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.tsandexplain-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, therlsrecord 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_FILTERrefusals, the door statuses), #22791's record-story pins (they are self-explanations and hold as written), andexplain-write-door-parity.dogfood.test.ts(self-explanations; its object-gate cells keepdecidedBy: 'object_crud', which precedes the missing-record branch).Ablation (one-time, no permanent file)
Each leg through
scripts/ablation-replace.mjsin wrap mode (the anchor hit 1 then 0, the blob changed, restore proven by blob equality with HEAD and an emptygit diff HEAD). The plugin-security suites importsrc/; the dogfood suite readsdist/, so its legs rebuilt the package, read the marker indist/withablation-dist-preflight.mjsbefore the run, and after the restore rebuilt and read the marker absent with a clean tree.controlled_by_parentwrite arm)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 on46dcaa54a; between it and the head the engine changed only by that exemption, and theexplainAccessForCallercall 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) andexplain 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
842f1ca03unless named (the branch mergedorigin/mainup tod8c7d3864; 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.jsonincludessrc/**/*).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 rebuiltdist/.pnpm --filter @objectstack/dogfood typecheck: exit 0 at8cf36e560(the dogfood file is unchanged since).8cf36e560(the spec and rest sides are unchanged since):@objectstack/specsrc/security/explain.test.tsandexplain-zero-rows-sentinels.pin.test.ts, 38 passed;@objectstack/restcross-class-refusal-remedy-on-the-wire,security-explain-envelopeandsecurity-routes, 47 passed.pnpm --filter @objectstack/spec check:generatednamed one stale artifact (the explain reference page);--fixrebuilt the spec, rangen:docsand re-checked it green.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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:i18nand thecheck:type-check-debtre-measure among them).--ranover thecommand :: exit coderecord: 115 derived, 115 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.eslint --print-config, and the changeset and the generated.mdxmatch no configuration; (2)eslint --no-inline-config --format jsonover those 8 files: 8 files, 0 errors, 0 warnings; (3) invariance:eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move any untouched file's verdict. Repo-widepnpm lintis declared to CI.Acceptance notes
explainAccess(a self-explanation, for a direct embedder that wiresrecordAbsentToCaller) answers areadorexportof a row the principal cannot read as missing, as the security service now does..changeset/22792-explain-read-master-leg.md) says a withheldcontrolled_by_parentrecord "is reported not visible, decided by thesharinglayer". 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.recordAbsentToCalleranswerstrueonly for absence; a declared 4xx refusal of the asker's own read (for example, no read grant on the object at all) answersfalse, 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)manage_usersadministrator, 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:unreadableUnderCallerReadinsecurity-plugin.ts, besideabsentUnderCallerRead, over the sharedreadUnlessRefusedclassification; a store fault still fails the explanation.recordAbsentToCaller: absent means no row), so a refused read leaves its explanation as its own doors answer.{ context, cannotRead }on the module-internalexplainAccessForAsker(itsExplainAskertype is not exported from the package index).explainAccess,ExplainEngineDeps,ExplainInputandSecurityPlugin.explainAccessForCallerkeep their signatures. Measured on the builtdist/index.d.tsat026209205: none of the new names occurs, andexplainAccessis declared as before.Clause-②: noholds.recordverdict's description and therecordIds"Missing records" clause inexplain.zod.tsnow 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 owngen:docs; the changeset says the same.explain-asker-gate.test.ts, now 62 cases):manage_usersadministrator and a D12 delegate whose own reads of the object are refused, forread,export,update,deleteandtransfer: a stored row's whole explanation equals a nonexistent id's, layers included (a row the explained user cannot read, and one it reads);visible: truebeside its own door's admission;explain-enforce-parity.test.tswent red at6cc56c243(7 of 98). Cause: that rig's explaining administrator heldmanage_usersand 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 twosingle-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 thereadFilterdivergence row stays.scripts/ablation-replace.mjs; restore proven by blob equality with HEAD and an emptygit diff HEAD; the subject resolves throughsrc/, so nodist/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 at6cc56c243; the mutated lines are unchanged at the head.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, norecordIdorrecordIds, 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.026209205(the branch mergedorigin/mainto0253192f7throughscripts/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; dogfoodcbp-explain-master-writeandexplain-write-door-parity, 43 passed, and dogfood typecheck exit 0; gates, 115 derived bydispatch-gates --commands, all 115 run and exit 0 (check:docsandcheck:generatedamong them),--ran0 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.Generated by Claude Code