Skip to content

fix(plugin-security): explain answers a transfer of a row the caller cannot read as missing, as its door does - #22610

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22571-explain-write-verdict-door-parity
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22571-explain-write-verdict-door-parity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22571
Clause-②: no

POST /api/v1/security/explain now explains a transfer of a row the caller cannot read with the missing-record shape: record: { recordId, visible: false }, with no decidedBy. That is what the transfer door's 404 RECORD_NOT_FOUND says. The #21771 ruling-A rewrite in explain-engine.ts checked the explained verb as asked (update or delete). It now reads the verb through rlsOperationForVerb, the one mapping the door's step 2.7 already reads, so every write verb whose door is a by-id update or delete is asked the read question.

ExplainEngineDeps is unchanged and nothing is added to index.ts, so this is Clause-②: no with a patch changeset. Measured on origin/main 76bc1e03a. Line numbers are at this PR's head abf492af0 unless marked.

Step 1: the structural option, measured first (triage 6093130738)

The question: can explain take the write verdict by calling the door's own gate functions instead of re-modelling them?

What explain already calls. These are door-owned functions, wired in the explain block of security-plugin.ts (:5492-:5573):

door gate door site explain reads it through
requiredPermissions AND-gate requiredCapsForOperation deps.requiredCaps
object CRUD PermissionEvaluator.checkObjectPermission deps.evaluator
step 2.7 composition and floor decision computeLayeredRlsFilter, resolvePreImageFloorDrop (:3180) deps.computeLayeredRlsFilter with recordWriteFloorOptions (:5472)
step 2.7 read question (#21771) callerCannotReadAddressedRow (:7774), a caller-context readRowById deps.recordAbsentToCaller (:5560), the same absentUnderCallerRead over the same read
step 2.8 master-detail check assertControlledByParentWrite deps.checkControlledByParentWrite (:5569)
plugin-sharing write gate canEdit / canDelete deps.canEditRecord / deps.canDeleteRecord

What explain re-models. Two things:

  1. Step 2.7's orchestration, meaning which verbs reach which question. This is inline middleware code, not a function. It is the pre-image guard list (:3163) and the two branches of addressedByIdWriteId: update (:7749) and delete (:7755).
  2. Step 2.7's pre-image re-read. The door runs ql.findOne for the id AND the write filter under the caller's context (:3243). explain matches a system-read row in memory (matchUnderDeclaredColumns).

Every member of this family slipped on (1), never on a gate's own logic: #22514, #22530, #22550, and this card.

Verdict: the structural route does not fit this card. The gap: taking the verdict whole means extracting step 2.7 into one function of (object, id, verb, context) and handing it to the engine as a new ExplainEngineDeps key. Step 2.7 reads middleware-local state that has no caller-free form today:

  • the D10 delegator sets and context;
  • preImageFloorOpts, written at :3208 and consumed later by step 3.6's post-image check;
  • nestedInOperation and referentialFieldClearWrite, which addressedByIdWriteId takes;
  • the dispatch inputs extractSingleId, opCtx.data and opCtx.options.

That route would rewrite the security middleware's by-id write path and widen a published contract (Clause-② yes, contract-review tier). Both are larger than this card and outside its file surface.

Route taken: the per-axis fix, with ExplainEngineDeps unchanged. For every cell this card measures, it is as correct as the structural route. The orchestration the rewrite needs is read off the mapping the door already reads, not restated. No second verb list is added: the pair the guard compares is the door's own two read-question operations (addressedByIdWriteId), which the guard already named before this change.

Mechanism assumptions, measured

  1. Which verbs the door asks the read question for: engine update and delete only. This is addressedByIdWriteId (:7747-:7760). Its doc says "Update and delete only — the two verbs the ruling names". Step 2.7's pre-image runs for five verbs (:3163), but it asks the read question only for those two. A transfer reaches it because its door is the PATCH that writes owner_id, an engine update; the engine dispatches no transfer. rlsOperationForVerb states that same relation (transfer → update, restore → update, purge → delete). That is the pin's domain: the five verbs the mapping sends to update or delete. The assumption that step 2.7 asks the question for every by-id write it gates is partly falsified: a by-id engine transfer, which nothing dispatches today, would not be asked (see Acceptance notes).
  2. Axis (a), read-absent on update: already closed on main. explain update of an unreadable row gives the missing shape beside the 404. This holds in the single form and in the batch (recordIds) form. The pin still carries the cell.
  3. Axis (b), no-policy fail-closed: falsified as stated. On a caller whose sets declare no write-class policy, the door composes the update scope from the caller's SELECT narrowing (computeLayeredRlsFilter, :8257-:8266). explain composes the same scope through the same function. On main, the only divergence on this axis was the transfer verb's shape: the door answers 404, and explain answered visible: false, decidedBy: 'rls', which is a 403's shape. The same guard fixes it.
  4. Structural option: see Step 1.

Verb × axis, door beside explain (cpg fixture, public_read_write cpg_board, org_member callers)

The door's 404 is security-plugin.ts :3257 / :3300 (recordNotFoundError). Its 403 is :3286 (record_access_denied). explain's missing shape is explain-engine.ts :2269-:2279. Its rls decider is applyRecordAttribution's rlsExcluded branch.

axis verb row door explain before (76bc1e03a) explain after
read-absent update unreadable 404 RECORD_NOT_FOUND visible false, no decider unchanged
read-absent update readable, not theirs / their own 200 / 200 visible true (rls) unchanged
read-absent delete unreadable 404 RECORD_NOT_FOUND visible false, no decider unchanged
read-absent delete readable, not theirs / their own 200 / 200 visible true (rls) unchanged
read-absent transfer unreadable 404 RECORD_NOT_FOUND visible true, decidedBy rls visible false, no decider
read-absent transfer readable, not theirs / their own 200 / 200 visible true (rls) unchanged
read-absent restore, purge every row object gate, no route visible false, object_crud unchanged
no policy update unreadable 404 RECORD_NOT_FOUND visible false, no decider unchanged
no policy update readable, not theirs / their own 200 / 200 visible true (rls) unchanged
no policy delete unreadable 404 RECORD_NOT_FOUND visible false, no decider unchanged
no policy delete readable, not theirs 403 PERMISSION_DENIED (ownership floor owner_only_deletes) visible false, decidedBy rls unchanged
no policy delete readable, their own 200 visible true (rls) unchanged
no policy transfer unreadable 404 RECORD_NOT_FOUND visible false, decidedBy rls (403 shape) visible false, no decider
no policy transfer readable, not theirs / their own 200 / 200 visible true (rls) unchanged
no policy restore, purge every row object gate, no route visible false, object_crud unchanged

Exactly the two bold cells moved. A scratch probe on the same fixture read every other cell byte-identical before and after; the probe was never committed.

#22588 item 3(b) shape, measured on main, unchanged by this PR. The caller held a set with viewAllRecords + modifyAllRecords and no RLS, plus a second set with an update policy name == 'draft'. The row was named approved and readable.

request door explain record
update 403 PERMISSION_DENIED visible false, decidedBy rls
update, batch form (recordIds) 403 PERMISSION_DENIED records[0]: visible false, decidedBy rls (top-level allowed: true is the object question)
transfer 403 PERMISSION_DENIED visible false, decidedBy rls

Pins and ablations

Ablation: the old guard was put back, keyed on the raw verb. Every leg went through scripts/ablation-replace.mjs WRAP mode, with the mutation, the restore and the blob proof on disk. Both legs were run from committed HEAD 9fefec732.

pin domain cells ablation result restore
packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts (unit, real SecurityPlugin over real ObjectQL + SQL driver, engine-level door) explain verbs the mapping sends to update or delete (asserted equal to the door rows), × 2 axes, + vocabulary totality 18 passed 2 failed / 16 passed: read-absent transfer gave visible: true, decidedBy: 'rls', and no-policy transfer gave visible: false, decidedBy: 'rls', each against the expected { recordId, visible: false } blob 1d8f2ff143a5 == HEAD, git diff HEAD empty
packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts (REST, cpg fixture, org_member, assertArmed) all 8 explain verbs classified; 5 write verbs × 2 axes × 3 rows 23 passed ablation-dist-preflight found the marker in 2 built dist/ files before the run. 2 failed / 21 passed, the same two cells blob == HEAD; rebuilt; marker absent from all 6 built files; tree clean; 23 passed

The dogfood pin resolves @objectstack/plugin-security through dist/, so both of its legs were rebuilt and preflighted. The ablation spelling ['update', 'delete'].includes(operation) is semantically the old guard and appears nowhere else in dist/. The old spelling operation === "update" || operation === "delete" already appears 3 times in dist/, so it could not serve as a marker.

Tests (at abf492af0)

  • pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new test file is in the tsconfig.test.json program (--listFiles count 1), which the script's check:test-typecheck compiles.
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 194 files, 4093 passed, 45 skipped.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0. The new pin is in the program (--listFiles count 1).
  • The dogfood files that touch explain (git grep -il explain packages/qa/dogfood/test, 15 test files, the new one included): 15 files, 212 tests passed, in two batches of 8 and 7.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 70 commands. Each ran with its exit code recorded, and --ran reconciles: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN". check:dual-build-cjs-loads first answered PREREQUISITE NOT MET because 8 unrelated packages had no dist/. After building them it answered exit 0, and that is the recorded code.
  • Lint is narrowed to the 4 changed TS files: eslint --no-inline-config --format json read 4 files, 0 errors, 0 warnings.
    • Population: eslint --print-config exits 0 for each file, so none is ignored.
    • Invariance: eslint.config.mjs enables no type-aware linting for any file (its own statement at :327-:328), so this diff cannot move an untouched file's verdict.
    • The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

…ed verb through the door's mapping, so a transfer of an unreadable row answers missing

The #21771 ruling-A rewrite was guarded on the raw explained verb (update or
delete). The door asks the read question of the by-id update and delete the
caller addresses, and a transfer's door is the PATCH that writes owner_id, an
update. The guard now reads rlsOperationForVerb, the one mapping step 2.7
reads, so a transfer of a row the caller cannot read is explained with the
missing-record shape the door's 404 is.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…ite verb the mapping names, beside the engine door, on both axes

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…read-absent and no-policy rows (cpg fixture)

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/lifecycle-verb-rls-operation.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/lifecycle-verb-rls-operation.ts) — pages documenting those are invisible to this run
  • 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 — 16 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 d748ae80afe6ed63d1c61a51a69f4fb18280c844 → packageMentionDocs.

Which tree this was computed on

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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 05:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 05:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 18d9990 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22571-explain-write-verdict-door-parity branch October 10, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants