Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22761-explain-layers-read-absent
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22761-explain-layers-read-absent

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22761

Clause-②: no

It narrows what explain's layers say about a row the caller cannot read to what the door already answers. No accepted input changes and no public type moves.

What changed

  • packages/plugins/plugin-security/src/explain-engine.ts, the read-absent rewrite in explainAccess only. The read-absent question is now asked before the record story is written. A row the caller cannot read is then attributed through the same path as an id no row carries. The record verdict, every row-scoped layer's record block and the tenant layer's verdict all come from that one path. Before, only the record verdict was replaced, after the layers had been written.
  • The guard now reads the gate inputs (capsDeny, crudAllowed) directly. It used to read the decider names the attribution had written. The cases it admits are the ones the comment always named: past the capability and object CRUD gates, for a principal with an identity, for rlsOp update or delete.
  • No change to any door, to masterWriteCheckRefusal, to applyRecordAttribution or to any layer's own logic. The edit reaches the applyRecordAttribution call that opens the same if (input.recordId) block, a few lines above the cited range, because the question now comes before that call.

Measurement first

Measured at origin/main e84aeb36c with the real SecurityPlugin over a real ObjectQL, the SQL driver and the real SharingService middleware. The cases were update, delete and transfer on a controlled_by_parent detail:

  • (a) a row the caller cannot read;
  • (b) an id no row carries;
  • (c) CONTROL: a readable row whose master the caller may not edit.

Results:

  • Doors: (a) and (b) both answered 404 RECORD_NOT_FOUND, and (c) answered 403 PERMISSION_DENIED, for all three verbs.
  • Record verdict: (a) and (b) were identical, the missing-record shape.
  • Layers: (a) and (b) were NOT identical, and the difference was not one sentence. All six row-scoped layers' record blocks differed: tenant_isolation, owd_baseline, depth, sharing, vama_bypass and rls.
    • All six differed in detail.
    • owd_baseline and sharing also differed in outcome and matchesRecord, and owd_baseline in its rules.
    • tenant_isolation, sharing and rls also carried a rowFilter key that (b) does not.
    • The object-level fields (allowed, principal, each layer's own verdict, detail and contributors) were identical.
    • So the layers told (a) from (b): the existence bit the door withholds. The sharing record named the master check's 403 beside the door's 404.
  • After the fix, (a) and (b) are equal field by field for all three verbs, and (c) is unchanged.

Pins (explain-read-absent-record-story.test.ts, 16 cases)

  • Armed: the subject rows exist under a system read, the caller's read returns none of them, and the caller reads every control row.
  • Per verb: (a)'s whole explanation equals (b)'s, with only the record id set aside. Both doors answer RECORD_NOT_FOUND with status 404 (code and status asserted), and the record verdict is still the missing-record shape.
  • Per verb: every status any layer names equals the status the door gives.
  • Per verb, CONTROL (c): the door answers PERMISSION_DENIED with status 403, the record verdict is unchanged (visible: false, decidedBy: 'sharing'), the sharing record is excluded and names the master check's leg, and the only status any layer names is the door's 403.
  • Per verb, at the engine level: a stored row behind the tenant wall that the read does not return is explained exactly as the id no row carries. CONTROL: the same deps bag without the read question reaches the wall's own refusal. The detail objects above are not tenant-scoped, so this is the case that reaches the tenant layer's verdict.

Ablation (one-time proof, no permanent file)

The old behaviour was put back with scripts/ablation-replace.mjs: the attribution read the stored row, and the record verdict was replaced afterwards.

  • The anchor hit 1 time, then 0, and the blob went from f0f52a3c to 4ac19bb4.
  • Run at 02f40a098: 9 failed, 7 passed of 16. The 9 are the negative pins: the equality pin, the status pin and the engine-level tenant pin, for each of the three verbs. The 7 are the armed case and the six controls.
  • Quoted failures:
    • AssertionError: a status named in explain update's layers: expected '403 PERMISSION_DENIED' to be '404 RECORD_NOT_FOUND'
    • AssertionError: expected { allowed: true, …(5) } to deeply equal { allowed: true, …(5) }. The diff starts at the tenant_isolation record.
  • Restored with git checkout HEAD -- PATH on the absolute path. The blob equals the HEAD blob f0f52a3c, and git diff HEAD is empty.
  • The test imports the engine through src/ (relative imports), so no dist/ leg applies.

Verification

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 at 4da36f554: 199 files passed, 4030 tests passed, 45 skipped. The only commit since adds cases to the new test file. That file alone at 02f40a098: 16 passed.
  • pnpm --filter @objectstack/plugin-security typecheck at 02f40a098: exit 0. The test layer is included (tsconfig.test.json lists the new file).
  • Gates derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 02f40a098: 66 derived. 63 were run, all exit 0. 3 are NOT MEASURED:
    • check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET: it needs a whole-workspace build.
    • check:i18n exited 3, PREREQUISITE NOT MET: it needs the CLI and its extract closure built.
    • check:type-check-debt: its re-measure leg is a whole-workspace build plus every package's tsc. It was killed at the foreground cap at 4da36f554 and not re-run.
    • --ran reconciles 66 of 66, 0 UNRUN. CI runs all three.
  • The three artifact-roster gates whose rosters sit under these paths all exit 0 at 02f40a098: check-changeset-fixed, check:error-code-casing and check:filter-alias-parity.
  • Lint, narrowed to the two TypeScript files: eslint --no-inline-config --format json over 2 files reports 0 errors and 0 warnings. The repository's eslint config enables no type-aware linting: the resolved parserOptions are only ecmaVersion and sourceType, with no project. So this diff cannot move any untouched file's verdict.

Acceptance notes

  • The old guard skipped the rewrite whenever the decider was object_crud. The attribution also writes that name on a visible: true verdict that no other layer decided. The new guard keys on the gate inputs, so that path is covered too if it is reachable. Whether any configuration reaches it is NOT MEASURED. The one route tried, an update policy on the master, is refused by the master check first.
  • The read question is now also asked for an id no row carries, which costs one caller-context read per by-id write explanation of such an id. The answer is the same as before, by construction.

Generated by Claude Code

…aller cannot read exactly as an id no row carries

The read-absent question is now asked before the record story is written,
and a row the caller cannot read is attributed through the same path as a
nonexistent id: the decision's record verdict, every row-scoped layer's
record and the tenant layer's verdict. Previously only the record verdict
was rewritten after the fact.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…f a row the caller cannot read beside the door's 404

For update, delete and transfer on a controlled_by_parent detail: the
explanation equals the nonexistent id's whole explanation, every status a
layer names is the door's, and the readable control keeps the master
check's 403 on both faces.

Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp
Co-authored-by: Claude <noreply@anthropic.com>
…ite explanation at the engine level

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 3 documentable anchor(s).

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

  • content/docs/permissions/authorization.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))
  • content/docs/permissions/explain.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))
  • content/docs/permissions/rls.mdx (via object_crud (literal, a string literal in explainAccess))

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

  • content/docs/releases/v13.mdx (via object_crud (literal, a string literal in explainAccess), required_permissions (literal, a string literal in explainAccess))

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 — 15 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 a2e94c2a053ab004b5cb9fea27824bb614e5759c → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 07:19
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 07:19
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 680a86b Oct 11, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22761-explain-layers-read-absent branch October 11, 2026 07:39
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/m tests tooling

Projects

None yet

2 participants