Skip to content

fix(plugin-security): explain's delete and transfer verdicts on a controlled_by_parent record come from the master-detail write check - #22549

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22530-explain-write-verb-parity
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22530-explain-write-verb-parity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22530
Clause-②: no

POST /api/v1/security/explain now answers every by-id write of a controlled_by_parent record the way that verb's own door answers it. PR #22529 did this for update. This PR does it for delete and transfer, and lists restore and purge the same way. The record verdict comes from the master-detail write check (ADR-0055) that the write path runs at step 2.8. explain asks it through the served member checkControlledByParentWrite, the same call PR #22529 wired. There is no second copy of the check and no packages/spec change.

Step 1: the measurement that decides whether a spec stage is needed

The unlock asked for this first: can delete and transfer reach the door's verdict through a declared member? They can. No spec change is needed. Read on origin/main faf634850. Line numbers are at this PR's head.

  • What the door runs, per verb. Step 2.8 (security-plugin.ts:3321) calls assertControlledByParentWrite(sets, object, opCtx.operation, ...) for insert, update, delete, transfer, restore and purge, then again for the delegator. The verb reaches only two places inside that call:
    • the insert branch (:9342, which reads the master id from the request body) and the insert stand-down (:9462);
    • the refusal prose and the error constructors' arguments.
  • Why every by-id verb gets the update's outcome. assertMasterRowEditable (:9568) never reads the verb. All three of its legs ask update of the master. controlledByParentWriteOutcomeOf (:544) classifies by error class and the leg WeakMap, never by prose. So for every by-id verb, the outcome step 2.8 reaches equals the outcome the served member computes for an update (:9174, :9191).
  • plugin-security already says so. masterGateCoversOperation (platform-ownership-policies.ts:353) states it for the floor hand-over: "a detail DELETE is covered by the same master check an UPDATE is".
  • The member is plugin-security's own. explain's dependency is ExplainEngineDeps.checkControlledByParentWrite, wired to plugin-security's own private method (security-plugin.ts:5559). The spec member does not need to take a verb, and no new member or outcome type is needed. PM assumption 3 holds.
  • The transfer door. The engine dispatches no transfer operation: its middleware vocabulary is pinned in objectql's engine-middleware-operation-vocabulary.test.ts, and engine.ts:3097 lists seven operations. A transfer reaches a record as the PATCH that writes owner_id. That is an update, so it runs step 2.7 and step 2.8, plus step 3.5's transfer grant (:3472, :3553). On that door, step 2.8's answer is the member's update answer.

PM assumption 1 holds as stated. PM assumption 2 holds, refined by the verb table below.

The verb enumeration: each write verb explain answers × step 2.8

explain verb engine op does step 2.8 run on it? explain asks the master check (this PR)
create insert yes (:3321), on the insert branch: the master comes from the request body (:9342) no. An explanation carries no body and addresses no existing record
update update yes, by-id PATCH yes (PR #22529)
delete delete yes, by-id DELETE yes (was no)
transfer transfer yes. The live door is the PATCH writing owner_id (an update, :3472). The pre-wired transfer middleware op also runs it (controlled-by-parent-detail-write-authority.test.ts §1) yes (was no)
restore restore listed (:3321), but the object gate refuses it to every principal first (permission-evaluator.ts:53, DESTRUCTIVE_OPERATIONS, its grant retired). No route dispatches it yes. The object gate still decides first, so explain stays the door's on the day the verb can be granted
purge purge same as restore yes, same
read, export find no, not a write no

Reproduction (before)

The new dogfood pin, run against plugin-security built from faf634850 (test commit 48e239fe4, dist/ from that source, confirmed by grep). The steward holds every write verb on cpg_contract and holds org_member, so both floors bind. Each child was inserted by the system, so the steward did not create it.

verb principal the door explain record before
delete master's non-editor DELETE 403 PERMISSION_DENIED, names the master's row-level security visible: false, decidedBy: 'rls' (the floor; no word about the master)
delete master's editor DELETE 200 visible: false (the floor)
transfer master's non-editor PATCH {owner_id} 403 PERMISSION_DENIED, names the master's row-level security visible: true, decidedBy: 'object_crud'
transfer master's editor PATCH {owner_id} 200 visible: true (already the door's answer)
update both 403 / 200 already the door's answer (PR #22529)

The transfer row is new. The card named transfer as a read-only inference, and this measures it: explain told a principal who may not edit the master that they may transfer the record, beside a 403.

What changed

  • explain-engine.ts:
    • MASTER_CHECKED_BY_ID_WRITES lists the verbs step 2.8 runs on that address one existing record by id: update, delete, transfer, restore, purge. The engine asks the member for each of them where it asked for update alone.
    • The refusal prose names the verb, e.g. "the by-id delete runs refuses this delete on its 'row_level_security' leg … The delete answers 403". The update's text is byte-identical to before.
    • The unresolved-reason details are split into the condition and the answer, so each verb names its own answer.
    • The deps docblock says why the update's answer is each verb's answer.
  • security-plugin.ts, the explain wiring only. The record write path's step 2.7 coverage vouch is now !actsOnBehalfOf(c) for update and delete alike, which is step 2.7's own !delegatorSets. The floor itself still comes off only where masterGateCoversOperation covers the verb. The vouch knob's docblock and the two wiring comments now describe this.
  • .changeset/22530-explain-cbp-write-verb-parity.md: patch for @objectstack/plugin-security. No key is added to ExplainEngineDeps and no signature changes, so Clause-②: no.

Pins

pin where proves
Enumeration, REST. ExplainOperationSchema.options equals the door table's keys, so a new verb with no row fails. For each verb with a by-id door (update, delete, transfer), the master's non-editor gets the door's 403 (naming the master's row-level security) and explain visible: false, decidedBy: 'sharing', naming the verb and leg. The master's editor (not the creator) gets the door's 200 and explain visible: true. restore / purge get object_crud for both principals packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts (new nested block; the boot adds a cpe_contract_steward set with assertArmed on org_member and the set) the card's pin, per verb, end to end, with the floors armed
Enumeration, registered service. A total classification against the spec vocabulary. For update, delete and transfer: four refused legs (record_sharing, row_level_security, object_permission, master_chain), each beside the middleware's by-id write of that verb refusing on that leg. Four admitted records, each beside the same write being admitted. restore / purge: the middleware refuses before step 2.8, and explain says object_crud controlled-by-parent-write-member.test.ts each verb's door runs the check whose update answer explain asks
Engine, per verb. For each of the three verbs: every outcome's mapping (deny × 4 legs, unresolvable × 3 reasons naming the verb's answer, a rejection, an unknown outcome, the record's own RLS first). allow / not_applicable are byte-identical (toEqual) to the report without the member. Asked once with the explained context. restore / purge are asked, and object_crud decides. read / export / create are never asked, nor is an object-level request or a missing record. The classification is total explain-controlled-by-parent-write.test.ts the mapping, and which verbs ask

Ablations

Each leg went through scripts/ablation-replace.mjs in wrap mode (anchor hit 1 → 0, blob changed), with an outer trap that restores to HEAD on EXIT, INT and TERM. Then pnpm --filter @objectstack/plugin-security build, then scripts/ablation-dist-preflight.mjs with the marker (--source-marker for the single-quoted source spelling), and only then the runs. Before the mutation the marker had 0 hits in the pristine dist/. On the restore leg, the blob equals HEAD's and git diff HEAD is empty. The restore leg rebuilt, and the preflight with --absent read the marker absent from all 6 built files with the tree clean.

ablation mutation predicted observed
A: master check for update only MASTER_CHECKED_BY_ID_WRITES.has(engineOp) → engineOp === 'update' delete and transfer red at every layer; update and the controls green engine + member: 24 red, 50 green. That is 9 cells per verb for delete and transfer, plus "asked once" for each, plus restore/purge "asks it too" (engine), plus the delete and transfer cases (member). Dogfood: 2 red (delete and transfer non-editor: visible: true, decidedBy: 'object_crud' beside the 403), 11 green
B: floor vouch for update only masterGateCoversThisWrite = !actsOnBehalfOf(c) → engineOp === 'update' && !actsOnBehalfOf(c) dogfood delete red on both sides; the plugin suites green (their harnesses arm no floor) dogfood: 2 red (the non-editor gets decidedBy: 'rls'; the editor gets visible: false beside DELETE 200), 11 green. Plugin suites: 74/74 green

Ablation A's first attempt was void. Its dist preflight passed the dist/ reading but refused the tree reading, because the source spells the marker with single quotes. The inner script stopped before any test ran (exit 90). It was re-run with --source-marker, and the table reports that run. Ablation A also shows why the vouch and the check go together: with the vouch and no master check, the non-editor's delete reads visible: true.

Local verification (HEAD 0b582c6d9, which merges origin/main ce78ff7bc, a CI-only change)

  • pnpm --filter @objectstack/plugin-security test: 192 files, 4033 passed, 45 skipped, exit 0.
  • pnpm --filter @objectstack/plugin-security typecheck and pnpm --filter @objectstack/dogfood typecheck: exit 0. --listFiles shows the two edited plugin tests in the test-layer program (tsconfig.test.json) and the dogfood file in its program.
  • Dogfood: the ten files that touch security/explain or controlled_by_parent passed (186 tests), including cbp-explain-master-write (13), cbp-parent-attachment-comment-gates, owd-public-read-write-write-floor (explain delete on a non-controlled_by_parent object, unchanged) and showcase-invoice-cbp. The full dogfood suite (three CI shards) is declared to CI.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 commands at 0b582c6d9; all 71 ran, each exit code recorded. --ran reads "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3: eight unrelated packages had no dist/); after building those eight (turbo, all cache hits) it answered exit 0, which is the reading recorded.
  • Lint, narrowed (CI's pnpm lint owns the full run):
    • Population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}, so the five changed .ts files are the touched population.
    • Count: --format json read 5 files, 0 errors, 0 warnings.
    • Invariance: the config enables no type-aware linting (no parserOptions.project, no typed rules, as the config states), and this diff edits no lint config. So it cannot move any untouched file's verdict.

Acceptance notes

  • No spec stage. Step 1 finds that the served member's update answer is step 2.8's answer for every by-id verb, and explain reaches it through plugin-security's own wiring. The member's TSDoc in security-service.ts still describes an update only. That stays true: explain asks it the update question, and plugin-security uses the equality it already declares at masterGateCoversOperation. The pins hold the equality at the door for each verb.
  • create is out of the domain. Step 2.8 judges an insert by the master id in the request body. An explain request carries no body, and a record-grained create addresses no existing record, so there is no door answer to be at parity with. The enumeration pins classify it explicitly.
  • restore / purge are asked the master check because step 2.8 lists them. Today the object gate refuses them to everyone and decides first. On those verbs the sharing layer can name a master leg below an object_crud verdict, as an update already does when CRUD refuses.
  • Delegated writes are unchanged. Neither verb vouches on behalf of a delegator, as step 2.7 does not. explain's record path keeps its declared non-modelling of the delegated depths.
  • Cost. One more member call per record-grained delete or transfer explain. It is a memo hit on the explained context's permission sets.
  • Product effect. A console that gates a Delete or a Transfer affordance on record.visible now shows it to a master editor who did not create the child, and hides it from a non-editor, naming the master leg.

Out-of-lane findings (for the seat to file)

  1. class (a). explain transfer outside controlled_by_parent: its record-level row-level security is computed for the raw transfer operation. rls-compiler.ts mapOperationToRLS (:1238) sends any other verb to select, and computeLayeredRlsFilter (:8050) treats it as a read. So no update-class policy and no ownership floor ever reach a transfer's verdict. The transfer door is the update that writes owner_id, and it meets both. Step 2.7 maps transfer to update (:3173).
    • reach (public door, measured once with an untracked scratch dogfood probe at this head, on the cpg fixture, then deleted). A public_read_write cpg_board row is excluded by an app-authored update policy (name == 'open', the row is closed) for a member holding edit and transfer. explain update answers visible: false, decidedBy: 'rls'. explain transfer answers visible: true, decidedBy: 'object_crud', with the rls layer reading "No business RLS policy applies to this record". The transfer door, PATCH /api/v1/data/cpg_board/ID with owner_id, answers 403 PERMISSION_DENIED.
    • Seam: runtime:explain-engine.ts applyRecordAttribution (computeLayeredRlsFilter(sets, object, engineOp, …) with engineOp transfer) → runtime:rls-compiler.ts mapOperationToRLS (default select); the door maps it at security-plugin.ts:3173.
    • Dedupe words: explain transfer rls select mapOperationToRLS, explain transfer update policy owner_id door, transfer record verdict row-level security.

Generated by Claude Code

claude added 3 commits October 9, 2026 22:30
…ed_by_parent record, beside each verb's door

Enumerates every operation security/explain answers against step 2.8 of
the write path, at three layers: the engine over a deps bag, the
registered service beside the middleware's by-id write of each verb, and
the REST explain route beside each verb's REST door (PATCH, DELETE, and
the PATCH that writes owner_id) for the master's editor and a non-editor.
Red on this commit for delete and transfer; the fix follows.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…trolled_by_parent record come from the master-detail write check

The write path runs the master-detail write check (step 2.8) on every
by-id write of a controlled_by_parent record and hands the record's
ownership floor over to it (step 2.7). explain asked the check for an
update only, so a delete kept its owner_only_deletes floor and a
transfer read the sharing gate's abstention as writable.

explain now asks the served member for every by-id write step 2.8 runs
on. The check judges edit access to the master whatever the record's
verb, so the update answer it computes is each verb's answer. The
explain wiring carries step 2.7's coverage vouch for a delete as it did
for an update. No second copy of the check, no spec change.

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

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin))
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 — 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 f782f1764410dcf7ac80108c3526eab4043032d7 → packageMentionDocs.

Which tree this was computed on

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

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

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

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