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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…arent update verdict

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@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
This was referenced Oct 9, 2026
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 28, 2026
…eighteen write affordances read one affordance-to-grant map (objectui#12082) (objectstack-ai#12084)

Part of objectstack-ai#12082 (the seat's answer B, `6094765692`: five write
affordances that read no grant at all are this card's follow-up)
Part of objectstack-ai#12081 (items 1, 5 and 6)
Clause-②: yes

A create form gated its fields on the EDIT grant, so a role that may
create but not edit could not fill the form it is allowed to submit.
This branch puts ONE affordance-to-grant map in `@object-ui/core`, read
by the eighteen console write affordances it names (the five that read
no grant at all are the card's follow-up, see Acceptance notes), and
makes a create form ask the CREATE question, which follows the server's
insert rule.

Implemented by the dispatched os-dev agent for the `domain:ui#3` seat,
session `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. The
dev wrote this body when the draft opened. The seat brought it to round
2 from the dev's report `6094735932` and the claim amendment
`6094765692` on objectui#12082: the reader list, the pins' readings and
the acceptance notes below are round 2.

## Measured on `main` first

- **The card (p1), measured red on `main` `1b2d0160f`.**
`packages/plugin-form/src/createFormGrant-12082.test.tsx` mounts every
`ObjectForm` layout under `MePermissionsProvider` with the card's grant
(`allowCreate: true, allowEdit: false`, no field entries). On `main`:
every field of the create form drawn locked on all nine layouts, and the
create body on the three one-step containers was `{}` (24 failed, 9
passed). With this branch: 33 passed. The empty body is the card's `400
VALIDATION_FAILED`: the outbound filter asked the same `write` question
as the render gate.
- **The server's insert rule (objectstack `main` `76bc1e03`, read in
source).** `plugin-security`'s middleware step "2.5. Field-Level
Security write enforcement" runs on `insert` and `update` alike through
`computeForbiddenFieldWrites` → `FieldMasker.detectForbiddenWrites`,
which refuses only a field in `getNonEditableFields` — a field whose
explicit entry is not `editable`.
`PermissionEvaluator.getFieldPermissions` builds entries only for fields
a permission set names, so a field with no entry passes the field step.
Object admission maps `insert` → `allowCreate`
(`OPERATION_TO_PERMISSION`). So a create-mode field reads: explicit
entry → its `editable`; no entry → the object's create grant. That is
exactly what `checkField(object, field, 'create')` now answers; no
client rule the server lacks is added.
- **Item 1 (no header Edit for a caller whose update is allowed): the
cause is server data, landed upstream.** On objectui `main` the header
Edit is the object's resolved `edit` affordance (managed-object policy ∧
effective API operations) ∧ the explain engine's record verdict ∧ the
`userActions.edit` predicate. For hotclm's `clm_payment_plan`
(`sharingModel: 'controlled_by_parent'`, no `managedBy`, no
`userActions`, no `apiMethods`) the only principal-dependent input is
the explain record verdict, which `useRecordEditable` reads as
`record.visible`. objectstack-ai/objectstack#22529 (the pull request for
objectstack-ai/objectstack#22514, merged 2026-10-09, after the 17.7.0
tag — not an ancestor of the tag commit `4e4e8814`, control leg: the
tag's 30th ancestor answers exit 0) made explain's `update` verdict on a
`controlled_by_parent` record come from the master-detail write check;
its own reproduction table shows the hotclm shape (`allowed: true`,
`record.visible: false`, `PATCH` 200). objectstack-ai/objectstack#19963
is the older card on the same shape. Nothing on objectui's side hid Edit
from that caller; the map's `recordEdit` row now also reads the update
grant, and the pin holds that a caller with `allowEdit` and `update` in
the operation set gets Edit.
- **Item 5 (New / Import with `allowCreate: false`): does not reproduce
as stated.** Both list surfaces (`ObjectView`, `ObjectDataPage`) already
ANDed `can(object, 'create')` on `main` and in 17.7.0. hotclm binds
every position to `clm_requester` as well as to its own set
(`src/security/bind-position-sets.ts`), and `clm_requester` grants
`allowCreate: true` on `clm_contract`, so the records manager's
EFFECTIVE grant allows create: the server would accept the create, and
New / Import were right to show. The rows are pinned anyway, now read
through the map.
- **Item 6 (lookup "Create new" without create on the target):
reproduces on `main` by source.** `LookupField`'s `canCreate` read no
grant at all. It now reads the `lookupCreateNew` row, asked of the
target object.

## The map

`packages/core/src/utils/affordanceGrants.ts`: `AFFORDANCE_GRANTS` names
each affordance's CRUD-affordance bit, the object grant it exercises
and, for an affordance that offers fields, the field question (`create`
= insert rule, `write` = update rule). `resolveAffordance` is the one
verdict — managed-object policy ∧ the server's effective API operation
set (`getObjectApiOperations`, kept inside the map) ∧ the caller's grant
— with the row's `userActions` predicates surfaced only when all three
allow it. `resolveFieldAffordance` asks a field the row's question.

**Why `core`, not `permissions`.** Every reader already depends on both
packages, and `resolveEffectiveCrudAffordances` lives in `core`.
`permissions` depends on `@object-ui/types` alone; homing the map there
would add a `permissions → core` edge. In `core` the principal is a
structural interface (`can`, `getObjectApiOperations`, `checkField`,
`isLoaded`), so `core` gains no edge either.

**Fail-open stays.** With no permission provider mounted,
`usePermissions()` answers `can` with `true` and `isLoaded` with
`false`: every grant reads open and no field question is asked, as the
`fieldWriteGate.ts` docblock states.

## Readers

Rows and readers (the census of `resolveEffectiveCrudAffordances`,
`isObjectInlineEditable`, CRUD-verb `can(` / `check(`, and
`checkField(…, 'write')` readers) — each becomes a map reader or is
named below as outside the family:

- create-form fields / edit-form fields and the form-wide lock — every
`ObjectForm` layout through `fieldWriteGate.ts` (render pass, outbound
filter, `closedFormAffordance`);
- record-header Edit / Delete and the record body's in-place editing —
`RecordDetailView` `resolveRecordHeaderActionGates`;
- list New / Import — `ObjectView`, `ObjectDataPage`; the import
wizard's writable target fields ask the create question
(`importTargetFields`);
- lookup "Create new" — `LookupField`, on the target object;
- create-mode `MasterDetailForm` line cells ask the create question of
the child object, since every line there is a new record;
- the other census readers, each now a map reader:
`RelatedRecordActionsBridge` (related lists), `RecordAttachmentsPanel`
(Upload / delete on `sys_attachment`), `plugin-list` `ListView` (bulk
Delete, inline-edit toggle), `plugin-grid` `ObjectGrid` +
`rowCrudAffordances` (row Edit / Delete, in-place editing, the
add-record row; `rowCrudAffordances` now takes the map's verdicts) and
`ImportWizard` (template download), `plugin-detail` `DetailView`
(operation set + grant; the object's own policy stays the host's channel
per objectui#4419) and `record:details` (in-place editing reads the
update grant), and the console `ProfilePage` (the language field).

**Out of the family, with the reason:** `managedByEmptyState`
(empty-state copy from the managed-object bucket; it shows or hides
nothing); `useFieldPermissions` `canWrite` / `writableFields` (the
resolver's own API); the read gates (`checkField(…, 'read')`).

**Behaviour moves beyond the card's rows, all toward the server's
refusal:** the grid's add-record row now also honours the managed-object
policy and the effective `create` operation; `DetailView`'s object gate
adds the effective operation set; `record:details` in-place editing
reads the update grant; the form-wide lock and its notice also engage on
a denied grant for the form's mode.

## Pins

- the card's repro and its two controls (edit form under the same grant
stays disabled; an explicit `editable: false` stays disabled and out of
the body) — `createFormGrant-12082.test.tsx`;
- the enumeration pin: every row × four grant shapes (create-only,
edit-only, read-only, full) through the real `MePermissionsProvider`,
the row set held to the test's own expectation table, and a census that
refuses a console source file reading a CRUD grant outside the map;
- earlier family pins stay green: objectui#4296, objectstack-ai#10107, objectstack-ai#11000, objectstack-ai#12047.

**Readings (dev report `6094735932`):**
- measured red on `main` `1b2d0160f` first: the card's pin 24 failed / 9
passed (every field locked, create body `{}`); the item-6 pin against
`main`'s `LookupField` 3 failed / 2 passed (controls green).
- at the branch head: `createFormGrant-12082` 35/35,
`affordanceGrantMap-12082` 27/27, `LookupField.createGrant-12082` 5/5;
the family pins' final run 161/161.
- ablations (`scripts/ablation-replace.mjs`, predictions first, every
restore blob-equal): A1 the create form's field question back to
`'write'` ⇒ 19 red; A3 a grant read planted outside the map in
`LookupField` ⇒ the census names that file; A4 `recordEdit`'s grant
swapped ⇒ 4 red; A5 `MasterDetailForm` forced to edit mode ⇒ 1 red.
- the Console's eager closure: +655 B gzipped (3,244,032 → 3,244,687 B
over 290 eager chunks); `check:eager-closure` passes at the head.

## Acceptance notes

- **The family's remainder (the card's follow-up, seat answer B
`6094765692`):** five write affordances read no grant at all, so the
grep census over grant reads could not see them: plugin-view
`ObjectView`'s create button, plugin-calendar's quick-create and
drag-to-reschedule, plugin-kanban's card move, and plugin-form
`LineItemsPanel`'s add / remove lines. The census pin refuses grant
reads outside the map; it does not catch an affordance that reads no
grant, and the follow-up says so or closes it.
- **objectui#12081 items 1 and 5, carried to the hotclm seat (noted, not
filed):** item 1's cause is upstream (objectstack-ai/objectstack#22529,
after the 17.7.0 tag), so hotclm's finance header Edit is re-measured
once its `@objectstack` pin includes that merge; item 5's New / Import
are correct for that user's effective grant.
- `MePermissionsProvider.check` keys `objects` by the name as given,
while `checkField` and `getObjectApiOperations` lowercase it first.
Every reader passes the object's own (lowercase) name, so nothing
diverges today; noted, not changed.
- `LookupField` reads `allow_create ?? allowCreate` (two spellings of
one key) — outside this card, noted.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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