Skip to content

fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check - #22529

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

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

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22514
Clause-②: yes (widening)

POST /api/v1/security/explain now answers an update of a controlled_by_parent record the way that record's own by-id PATCH is answered. The record verdict comes from the master-detail write check (ADR-0055) that the write path runs at step 2.8. explain reads it through ISecurityService.checkControlledByParentWrite, served since PR #22513, and keeps no second copy of the check or of its controlled_by_parent predicate.

Reproduction (before)

Measured on origin/main ee8751d41, with an untracked scratch dogfood probe on PR #22513's fixture (cbp-parent-gates-fixture.ts, org-bound boot, so the platform ownership floor owner_only_writes binds org_member). The probe was deleted after the reading. cpg_contract is controlled_by_parent under cpg_account (public_read, edited by its owner).

principal request explain allowed explain record the door
member, cannot edit the master update, child of the admin's master true visible: false, decidedBy: 'rls' PATCH 403 PERMISSION_DENIED (master, row-level security)
admin explaining that member (userId) same true visible: false, decidedBy: 'rls' (the member's PATCH: 403)
member, edits the master, did not create the child update, child of the member's own master true visible: false, decidedBy: 'rls' PATCH 200
deleter, edits the master, did not create the child delete, child of their own master true visible: false, decidedBy: 'rls' DELETE 200
member update, cpg_board (public_read_write) true visible: true PATCH 200

What the reading says about the card. The card's mechanism holds: explain never asks the master-detail write check, and its sharing gate (canEdit) abstains on controlled_by_parent and reads as writable. Its symptom needs restating. The card quotes allowed: true, which is the object-level field (may this principal update cpg_contract at all). By the published contract and the parity table in explain-enforce-parity.test.ts, a record question is answered by record.visible. On main that verdict was decided by the ownership floor, which the write path hands over to the master check through step 2.7's masterGateCoversThisWrite vouch. explain did not vouch, because it ran no master gate (the comment in the wiring said so). The result was a two-sided divergence:

  • the master's non-editor was refused on the wrong layer (rls), with no word about the master;
  • the master's editor was refused beside a PATCH that answers 200.

What changed

  • explain-engine.ts: a new optional dependency, checkControlledByParentWrite. For every update of a record that exists, the engine asks it with the context being EXPLAINED. Asking for every update keeps the predicate in the member: not_applicable changes nothing.
  • security-plugin.ts (the explain wiring only):
    • It hands the engine the served member, this.checkControlledByParentWrite. That is the write path's own composition: the context prologue, step 2.8 for the principal and then for the delegator.
    • The update's Layer 1 now carries step 2.7's coverage vouch, masterGateCoversThisWrite, on step 2.7's condition (not on behalf of anyone). The ownership floor is handed over to the master check here exactly as the write path hands it.
    • delete keeps its floor, because explain asks no master check for it.
    • The knob's docblock lists this new caller.
  • .changeset/22514-explain-cbp-update-master-check.md: minor for @objectstack/plugin-security, not the patch the dispatch suggested. See the acceptance notes.

Outcome to layer mapping

This mirrors the gates PR #22513 built and the review 6085973866's truth table. The door proceeds on allow and not_applicable, and refuses on every other outcome.

member outcome record verdict sharing layer record rest of the report
allow the existing path's unchanged byte-identical to a deps bag without the member
not_applicable the existing path's unchanged byte-identical
deny + leg visible: false, decidedBy: 'sharing' excluded; the detail names the leg (object_permission / row_level_security / record_sharing / master_chain) and the 403 unchanged
unresolvable + reason visible: false, decidedBy: 'sharing' not_evaluated; the detail names the reason and the status the update answers (422 / 404 / 422) unchanged
rejection (refused context, store fault) visible: false, decidedBy: 'sharing' not_evaluated, no predicate; fail-closed fault detail unchanged
an outcome outside the vocabulary visible: false, decidedBy: 'sharing' not_evaluated unchanged
member absent (a deps bag without it) the existing path's unchanged; claims nothing about a master unchanged
  • Where the master is named. The spec's layer vocabulary is closed, with no master layer. The refusing leg is named on the sharing layer, the record's write gate. describeOwd's baseline wording stays, per triage.
  • Precedence. The record's own row-level security (rls) still decides first, because step 2.7 runs above step 2.8. The not-found answer for a record the principal cannot read (the read question) still overrides both.
  • No master named on allow. The member answers allow for a system context on ANY object, which is the write path's first exit. So allow is not evidence that the record's access derives from a master.
  • Absent member. Feature detection on the engine's optional dependency keeps today's answer, as in the gates (PM assumption 4 holds).
  • The context. It is the TARGET's, not the caller's, because explainAccessForCaller already hands the engine the explained user's context (PM assumption 2 holds, and it is pinned below).

Pins

pin where proves
parity, refused: the member cannot edit the master. PATCH 403 naming the master's row-level security; explain visible: false, decidedBy: 'sharing', leg row_level_security named packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts (REST explain beside the REST PATCH) the card's pin, end to end
target principal: the admin explains the member by userId and gets the member's refusal; the admin's own explain and PATCH admit same dogfood file the member is asked with the explained context
control, the master's editor: explain visible: true beside PATCH 200, for a child they did not create, with the floor armed (assertArmed, org_member) same dogfood file the floor is handed over as the door hands it
control, non-controlled_by_parent: cpg_board explain visible: true beside PATCH 200 same dogfood file unchanged
every leg (record_sharing, row_level_security, object_permission, master_chain) refused beside a refused PATCH; four admitted updates explained writable beside an admitted PATCH; a system caller explaining the viewer by userId gets the viewer's object_permission refusal controlled-by-parent-write-member.test.ts (the registered service, the member's own double) the wiring, through security.explain
each outcome's mapping, as in the table above. Also: allow and not_applicable are byte-identical (toEqual) to the report without the member, on controlled_by_parent, public_read_write, and private objects with the gate admitting and refusing. The record's own RLS decides first. The member is asked once, with the explained context object itself, and never for read, create, delete, transfer, export, an object-level request or a missing record explain-controlled-by-parent-write.test.ts (engine, deps bag, no engine double) the mapping

Ablations

Each leg went through scripts/ablation-replace.mjs (wrap mode, with an anchor hit, and a restore proven by the blob equal to HEAD and an empty git diff HEAD). Then pnpm --filter @objectstack/plugin-security build, then scripts/ablation-dist-preflight.mjs (marker present in dist/), then the run. The restore leg rebuilt and ran the preflight with --absent (marker absent from all 6 built files, tree clean against HEAD).

ablation mutation predicted observed
A: the canEdit-only verdict back the explain wiring's checkControlledByParentWrite key renamed, so the engine sees no member the refusal pins red, the controls green dogfood: 2 red (visible: true, decidedBy: 'object_crud' beside the 403), 2 green. member suite: 2 red (the same shape for c_other), 33 green
B: no floor hand-over masterGateCoversThisWrite forced false dogfood refusals red on decidedBy, the editor control red, the plugin suites green (no floor in their harness) dogfood: 3 red (decidedBy: 'rls' on both refusals; the editor control visible: false beside PATCH 200), the board green. plugin suites: 35/35 green

Ablation B's first attempt is void: its marker was a comment, which the build strips, so the dist preflight could not prove the mutation landed (exit 1). It was re-run with a string-literal marker; the table reports the second run.

Local verification (HEAD 16a937c47)

  • pnpm --filter @objectstack/plugin-security typecheck: exit 0. The test-layer program (tsconfig.test.json) lists both edited test files, counted with --listFiles.
  • pnpm --filter @objectstack/plugin-security exec vitest run: 192 files, 3994 passed, 45 skipped, exit 0.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Dogfood: cbp-explain-master-write, cbp-parent-attachment-comment-gates, controlled-by-parent and showcase-invoice-cbp: 4 files, 22 tests passed. The full dogfood suite (three CI shards) is declared to CI.
  • node scripts/pm/dispatch-gates.mjs --commands: 71 commands derived and 71 run, each exit code recorded. The --ran reconciliation reads "71 run, 0 NOT-MEASURED (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 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,…}, 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, stated in the config itself) and loads no import-graph plugin, so this diff cannot move any untouched file's verdict.

Acceptance notes

  • Changeset level minor, not the dispatch's patch. ExplainEngineDeps is exported from @objectstack/plugin-security's index, and it gains an optional key. Under the WHICH LEVEL rule in pr-automation.yml's Check Changeset step, an additive widening of a published package's public surface takes at least minor. The Clause-② line is yes (widening) (the seat amended the claim and line 2 at review): the optional deps key is a widening of a published type. explain's response payload gains no key, and no runtime accept set widens. The same reading is the one the review on PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 gave its additive types.
  • allowed is unchanged. It answers the object question and stays true for a member who holds update on the object. The record's bottom line is record.visible, as for every other record-level refusal (a private record the caller does not own reads the same way).
  • delete keeps today's answer. The member is declared for an UPDATE, so explain asks no master check for a delete and keeps the floor there. The measured gap is in the out-of-lane findings below.
  • A context the write path refuses before any gate. Examples: a principal-less context, permission sets that cannot be resolved, a dangling delegator. The member rejects for these on any object, and explain now reports the sharing layer not_evaluated, with the fault detail, on an update of an existing record. That is refuse-only and matches the door, which refuses these contexts first. explain's earlier layers (object_crud, the D10 delegator handling) already decide those records, so decidedBy does not move. The review on PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 named the same shape for the gates.
  • On-behalf-of. The update's Layer 1 keeps the floor for a delegated context, as step 2.7 does. The member still runs both legs (principal, then delegator), as step 2.8 does.
  • Cost. One more permission-set resolution per update explain. It is a memo hit: the member is handed the same context object the engine resolved.
  • Product effect. Consoles that gate an Edit affordance on record.visible for an update now show it to a master editor who did not create the child, and hide it from a non-editor for the right reason.
  • transfer was not measured. explain's transfer record verdict asks the sharing gate, and the door runs step 2.8 for transfer too. This is a read-only inference, noted here and not filed.

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

  1. class (a). explain delete on a controlled_by_parent record asks no master-detail check and keeps the owner_only_deletes floor, which the door hands over to that check.
    • reach (public door, measured with the scratch probe at ee8751d41, unchanged at this head): a principal who edits the master and holds delete on the child, but did not create it, gets explain delete record.visible: false, decidedBy: 'rls' beside DELETE /api/v1/data/cpg_contract/ID 200. For the non-editor the refusal is reported on rls with no word about the master, beside a DELETE 403 that names the master's row-level security.
    • Seam: spec:ISecurityService.checkControlledByParentWrite (declared for an update only) → runtime:explain-engine.ts applyRecordAttribution (the delete branch asks canDeleteRecord alone).
    • Route: the member's contract would have to name delete, or a sibling would have to be declared (spec lane) before explain can ask it. The legs themselves are operation-independent in assertControlledByParentWrite.
    • Dedupe words: explain delete controlled_by_parent master check, explain canDeleteRecord owner_only_deletes floor, checkControlledByParentWrite delete operation.

Generated by Claude Code

claude added 3 commits October 9, 2026 19:19
…ntrolled_by_parent record comes from the master-detail write check

explain asks ISecurityService.checkControlledByParentWrite for every update
of a record that exists, with the explained context; deny and unresolvable
refuse and name their leg or reason on the sharing layer, allow and
not_applicable leave the report unchanged, and a rejection is reported
fail-closed. The update's Layer 1 carries step 2.7's master-gate coverage
vouch, so the ownership floor is handed to that check as the write path
hands it.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <[email protected]>
…ate verdict beside the PATCH

Engine cells for every outcome of the master-detail write check, the
registered service's explain beside the PATCH for every leg (and for another
user), and the REST explain beside the REST PATCH on the parent-gates
fixture, with the ownership floor armed.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/l 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 18 documentable anchor(s).

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

  • content/docs/permissions/authorization.mdx (via not_applicable (literal, a string literal in masterWriteCheckRefusal))
  • content/docs/permissions/explain.mdx (via not_applicable (literal, a string literal in masterWriteCheckRefusal))
  • content/docs/permissions/rls.mdx (via not_applicable (literal, a string literal in masterWriteCheckRefusal))
  • content/docs/permissions/system-context.mdx (via checkControlledByParentWrite (symbol, a field of interface ExplainEngineDeps), 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 ee8751d41e61a18f7819e4d3ad2c340f51ab2418 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 16a937c47dc85d93c986a9479012e44bb27318b2
Local-runs: none

Inputs read: card #22514 (body, comments 6085501266, 6087274724, 6087453179 as amended, 6088159183), PR #22529 (body, 6-file list, net diff against merge-base ee8751d41), the head's check-runs (two reads, the later governs), and packages/spec/src/contracts/security-service.ts plus packages/spec/src/security/explain.zod.ts at the head. Nothing built, run or re-run.

① Derived judgments

  1. Published surface — one widening, no narrowing. RIGHT. ExplainEngineDeps gains the optional key checkControlledByParentWrite?, an async function of (object, recordId, context) resolving a ControlledByParentWriteOutcome (explain-engine.ts :393-397). The interface is a type re-export of plugin-security's entry (index.ts :146), so it is published surface under the package's exports; explainAccess(deps, …)'s parameter type widens by the same key (one fact, two spellings). Nothing is removed or renamed; the explain payload gains no key; every value written into the report stays inside the closed enums (layer, decidedBy, attribution outcome — all unchanged in the spec at this head); new text lands only in free-text detail strings. No runtime door's accept set moves: the data doors are untouched and POST /api/v1/security/explain accepts the same request.
  2. Verdict source — the served member, no second copy. RIGHT (triage 6085501266, unlock 6087274724). The engine asks deps.checkControlledByParentWrite(object, recordId, context) for every update of a record that exists (explain-engine.ts :1442-1444) and holds no controlled_by_parent predicate: not_applicable leaves the report as computed. The wiring hands this.checkControlledByParentWrite (security-plugin.ts :5553-5554), which is the door's own composition (resolveOperationPrincipals prologue, then assertControlledByParentWrite for the principal and the delegator — step 2.8's two calls, in order). The vouch at :5465 carries no predicate either; the predicate is read where it always was, in platformFloorYieldsToObjectWriteModel (:7971-7975), the write path's own consumer. describeOwd's baseline wording stays, as triage allowed.
  3. Outcome mapping against the member's TSDoc and the closed layer vocabulary. RIGHT. deny + leg becomes sharing.record.outcome: 'excluded', the leg named in the detail with 403 PERMISSION_DENIED (the spec: every leg is that refusal); unresolvable + reason becomes not_evaluated, naming the reason and the status the update answers (422 INVALID_METADATA / 404 RECORD_NOT_FOUND / 422 MISSING_REQUIRED_FIELD, matching ControlledByParentWriteUnresolvedReason's TSDoc), never admitted (the spec's ⛔ on reading unresolvable as not_applicable is honoured); a rejection settles to DEPENDENCY_FAULT and is reported not_evaluated, visible: false, decidedBy: 'sharing' with the fault detail — never caught into allow (the member's ⛔), in the same fail-closed vocabulary the sharing gate's own throw already uses; an outcome outside the vocabulary refuses (default arm). allow and not_applicable return undefined and move nothing. allow is deliberately not named on any layer — right, since the TSDoc says allow also covers a system context. The layer enum is closed (ten values, no master layer), so the refusing leg lands on sharing, the record's write gate; decidedBy: 'sharing' is inside the closed decidedBy enum. An absent member keeps today's answer and claims nothing about a master (the spec's "absence is the absence of a master check"). Precedence in applyRecordAttribution: masterRefusal is judged after rlsExcluded / layeredFault / sharingFaultDetail and before businessRowAdmits (:1712), so the record's own row-level security still decides first (step 2.7 above 2.8), and the security(data): a by-id write answers 403 for a row the caller cannot read and 404 for an id that does not exist, for principals the write pre-image check does not bind: an existence signal the read door withholds #21771 not-found override still runs after (:2183-2194). RIGHT.
  4. The vouch mirrors step 2.7 and hands nothing over on behalf of anyone. RIGHT, for update. The door sets masterGateCoversThisWrite = !delegatorSets (:3207) when it reaches step 2.7 (permissionSets.length above zero, userId and ql present, single target id); explain sets engineOp === 'update' && !actsOnBehalfOf(c) (:5465) on the ONE computeLayeredRlsFilter call the engine makes, with the explained (live) context (explain-engine.ts :1308-1309); the engine composes no delegator filter on the record path, so no delegator's floor is ever handed over. The guard differences cannot move a verdict: a principal with no sets or no userId is refused at object_crud in explain before rls is reached, and at the door's CRUD gate before step 2.7. The door refuses step 2.8 on every outcome but allow / not_applicable; explain refuses on exactly the same set. I find no update of an existing record on the non-delegated path where explain now admits what the door refuses or refuses what it admits: non-editor (floor handed over, member deny → refused on the leg; door 403 on that leg), master's editor who did not create the child (floor handed over, member allow → admitted; door 200), the creator who cannot edit the master (member deny; door 403), a row the principal cannot read (not-found shape on both sides), system context (member allow, door bypass), non-controlled_by_parent object (member not_applicable; the vouch is inert without declaresControlledByParent).
  5. Target principal — the EXPLAINED context, on every path. RIGHT. explainAccess(…, { context: targetContext }) (:5556) is the single entry; applyRecordAttribution receives that context and passes the same object to the member (:1443). The caller's context has no route to the member. Pinned at three levels: engine toBe(EXPLAINED), member suite (a system caller explaining the viewer gets the viewer's object_permission refusal), dogfood (the admin explaining the member by userId gets the member's row_level_security refusal beside the admin's own admitted PATCH). Census row 9's premise is the one the wiring already serves.
  6. The restated premise. RIGHT. allowed is computed before the record augmentation (!capsDeny && crudAllowed && !denyAll && !delegatorMissing, :2162) and is unchanged by this diff; the record question is record.visible. The two-sided divergence the measurement found (non-editor refused on rls with no word about the master; editor refused beside a 200) is closed by the two halves together (member + vouch), and the card's direction — the verdict from the member, the layers naming the leg — is met.
  7. Changeset, sentence by sentence against the code. Every sentence holds: the abstention-as-writable mechanism, the kept floor and decidedBy: 'rls', the two-sided symptom, asking the member with the explained context for every update of an existing record, deny/unresolvable on sharing naming leg or reason, fail-closed rejection, the vouch never on behalf of a delegator, the unchanged set (non-cbp objects, object-level reports, allowed, delete), the optional dependency and absence claiming nothing. One imprecision: "allow and not_applicable leave the report exactly as before" is true of the engine's handling of the outcome, but for a controlled_by_parent record the report DOES change on allow through the vouch (the editor flips to visible: true); the next sentence says so, yet the bullet read alone could mislead a CHANGELOG reader. Prose only; recommend rewording to "leave the verdict to the rest of the pipeline". Not a gate matter.
  8. Tests against the card's three pins. Pin (non-editor refused on the master leg, parity with PATCH 403): dogfood test 1 and the member suite's four legs. Control (master's editor admitted, PATCH 200): dogfood test 3 with the floor armed (assertArmed, org_member), and the member suite's four admitted updates. Ablation: reported A (member removed → refusals red) and B (vouch forced false → editor control red, refusals on rls) with dist preflight; read, not re-run. Extra controls: public_read_write record, byte-identical toEqual for allow / not_applicable on cbp, public and private objects, the member never asked for read / create / delete / transfer / export / object-level / missing record.
  9. File list against the amended claim's surface. Six files; four inside (explain-engine.ts, the explain wiring in security-plugin.ts, the two plugin-security tests, the .changeset). Two outside the named surface, both accepted: (a) packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts — a test in a private QA package, not another package's source, and it is the card's own pin (REST explain beside REST PATCH, which no unit test in plugin-security can measure end to end); listed in the report's files_changed but not flagged there as outside the claim — a report gap, named here; (b) the docblock bullet on RlsFilterOptions.masterGateCoversThisWrite (security-plugin.ts :471-476), outside "the explain wiring only", flagged by the dev (deviation 5); it keeps that docblock's call-site enumeration true now that explain sets the knob. No packages/spec, no other package's source, no content/docs, no governed surface.

② Semver level

  • Changeset .changeset/22514-explain-cbp-update-master-check.md: @objectstack/plugin-security: minor. The only package whose published source moves is plugin-security (17.7.0, published); @objectstack/dogfood is private and the changeset names no other package. Correct package, correct count.
  • Clause-②: yes (widening) (PR body line 2; the claim as amended): RIGHT. The diff makes exactly one additive widening of a published surface (①1) and no narrowing, so the (widening) arm is the true direction, no (narrowing) arm, no BREAKING banner and no ADR-0087 disposition marker are owed. yes takes at least minor (Post-Task Checklist Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3; the WHICH LEVEL ruling: a fix( that widens an index is minor). Level and declaration agree; the record is owed on this head because of that yes, and this is it.
  • Flag — the PR body contradicts its own declaration. The Acceptance notes bullet reads "Clause-②: no stays as claimed: explain's response payload gains no key and no runtime accept set widens", and the os-dev-report's deviation (1) says the same. That is stale text from before the seat's amendment and it is WRONG on the published-type widening. The fleet's one reader (scripts/pm/clause2-line.mjs, CLAUSE2_KEY_LINE anchored at line start) reads line 2 only and reports the inline mention as a placement near-miss, so the gates read yes (widening); the sentence should still be corrected (a body edit; no new head). Not a FAIL: declaration line, changeset and diff agree.
  • Check-runs on the head at the later read: Check Changeset success (reads the declaration against the levels), Governed Surface Queue Guard success. Lint & Repo Gates (which runs check-changeset-no-major's level axis and check-adr-0087-registration) was still in progress — not yet reported.

③ Boundary flags

open_questions: []. The dev's seven deviations and two out-of-scope findings, each answered:

  1. minor, not the dispatch's patch — RIGHT (②). Its second half, "Clause-2 stays 'no' as claimed" — WRONG, superseded by the seat's amendment to yes (widening); the stale sentence in the PR body's Acceptance notes is the residue (②, flag).
  2. The claim's "No export changes, and no accepted input widens" not literally true at the TS level — RIGHT to flag; the amended claim now states the widening.
  3. The vouch beyond the suggested route — judged in ①4: required by the editor control (without it the floor, not the member, decides), set on step 2.7's own condition, never on behalf of anyone, and inside the ruling's intent (verdict from the member, no second copy).
  4. allow not named on the sharing layer — RIGHT (①3; the spec's allow covers a system context).
  5. Docblock bullet outside the wiring lines — accepted (①9b).
  6. Tests added to the existing member-suite double rather than a new engine double — fine; the engine suite uses a deps bag.
  7. Trailers: the three commits end with Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ and the Co-authored-by: Claude trailer at the noreply anthropic.com address (spelled out here so the body sanitizer keeps it) — AGENTS.md's model-free pair, which CLAUDE.md names the source of truth over any harness text. RIGHT.

Out-of-scope, judged not this diff's:

  • Delete. The door hands the delete floor to the master gate too (masterGateCoversOperation covers update and delete; step 2.7 vouches for every pre-image op) and runs step 2.8 for delete; explain keeps the floor and asks no master check for delete. The member is declared for an UPDATE only (ISecurityService.checkControlledByParentWrite's TSDoc), so closing it needs a spec-lane change first. Escalated to the seat to file as the dev's finding 1 describes (class a, measured at ee8751d41, unchanged at this head; seam spec → applyRecordAttribution delete branch). Not a defect of this PR.
  • Transfer. Read-only inference, unmeasured: the door's steps 2.7/2.8 run for transfer, and explain's record path composes no floor options for it at all (recordWriteFloorOptions returns undefined outside update/delete, unchanged by this diff). Noted, carrier none, as the dev noted it.
  • Delegated path residual (not raised by the dev; pre-existing and documented in the wiring comment :5446-5451): the engine does not compose the delegator's write filter on the record path, so a delegated explain of a cbp child the agent created can read admitted where the door's second floor refuses. This diff leaves that path byte-identical (vouch false, member asked for both legs); not this diff's.

Check-runs on 16a937c47dc85d93c986a9479012e44bb27318b2 at the later read (latest run per name): success — Build Core, Check Changeset, Check Documentation Links, Dogfood Regression Gate (aggregate and 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core 1/6, 2/6, 4/6, 5/6, 6/6, The card this PR closes must claim this branch, Type Check · consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, filter (27). Skipped by path — Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (5). Not yet reported (in progress at read) — Lint & Repo Gates, Test Core (3/6) (2). None failed; no annotations to read.

Implemented-by: claude/issue-22514-explain-master-write-parity
Reviewed-by: session_01WYYhVJ78u7PhwFViWo1EmQ

VERDICT: PASS

Read at 2026-10-09T20:06Z · isolated contract-review subagent of the seat named on Reviewed-by:.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 20:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 20:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit d303b3e Oct 9, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22514-explain-master-write-parity branch October 9, 2026 20:33
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