Skip to content

fix(plugin-security)!: a batch create of a controlled_by_parent detail asks for edit access on every row's master, and an update no longer empties the master reference (ADR-0055 §1, §3) - #25090

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25049-cbp-write-shape-master-edit
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25049-cbp-write-shape-master-edit

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25049
Fixes #25068

Clause-②: no (narrowing)

What changed

ADR-0055 §3 requires edit access to the master for every write to a controlled_by_parent detail, and it reads a create's master from the master id in the incoming body. ADR-0055 §1 derives a detail's access from exactly one required master. The master-detail write gate (assertControlledByParentWrite, step 2.8) held neither on two write shapes. It now holds both.

  • A batch create (plugin-security: the controlled_by_parent detail write gate may not ask the master-edit question on one more write shape, where ADR-0055 §3 requires it (reach not measured; detail withheld under the disclosure ruling) #25049). The engine runs the middleware chain once for an array of rows, and the gate read a create's master only from a body that is a single record. So a batch create asked about no master at all. The gate now asks about every master the rows name. It uses the same chain walk a single create runs (assertControlledByParentChainEditable, the one walk; no second copy), once per master, in row order, before anything is written. The first refusal is the answer, in the by-id leg's own envelope (403 PERMISSION_DENIED, naming the caller's detail and operation, leg recorded), and nothing is written. After the walk, the gate's empty-master branch answers the batch exactly as it did before.
  • An update that empties the master reference (plugin-security: under one master-reference declaration, a write to a controlled_by_parent detail is not held to ADR-0055's master invariant on every write shape (reach measured; detail withheld under the disclosure ruling) #25068). On an update, a body that sets the reference to null names no master and empties the one the row's access derives from. The gate now answers it with its empty-master rule, the one an insert already gets. Where required-field validation provably judges the same field, the gate hands it to validation (the unchanged 400 VALIDATION_FAILED). Otherwise it refuses with 422 MISSING_REQUIRED_FIELD and nothing is written. It is asked after the stored masters' question, so a stored master the caller may not edit is still the answer. This covers all three update shapes: addressed by where, addressed in the body, and by predicate.
  • The [#25044] docblock sentence, "on every shape it asks EVERY master the write involves", is now true of the batch create too. The gate's docblock, the #7474 list, the #8688 paragraph, the freeze note's hand-over paragraph and the chain walk's docblock each gain the matching sentence.
  • No master scope is composed into any write. computeControlledByParentFilter, packages/objectql/** and packages/spec/** are untouched. Only assertControlledByParentWrite and its docblocks change.

Files:

  • packages/plugins/plugin-security/src/security-plugin.ts: the gate only.
  • packages/plugins/plugin-security/src/controlled-by-parent-every-row-master.test.ts (new): 18 cases on a real stack. That is ObjectQL over better-sqlite3, the real SecurityPlugin, the real sharing service and middleware, the real protocol (createManyData, insertManyData), and contexts built by resolveAuthzContext and assembleExecutionContext, the agent's with the mcp_agent_data_write OAuth ceiling.
  • .changeset/25049-cbp-every-row-master.md: @objectstack/plugin-security minor. It is BREAKING under the launch-window convention for an accept-set narrowing. It carries the Clause-② line and the ADR-0087 disposition not-required (no-migration-prescription).

The door table (ADR-0055 §4's proof, public copy)

Triage R555 asked for every read and write door to a controlled_by_parent detail. The table below is enumerated from the code. Its verdicts were measured on a real ObjectQL + SqlDriver + SecurityPlugin, for a member, an organization admin and an agent acting for a member, with throwaway probes that are not committed. Under the disclosure ruling recorded on #9387, no shape is named for a gap this PR leaves open. The full table is held privately by the seat.

# door reached from master read master edit, before master edit, this PR
1 find / findOne / count / aggregate REST list, get and export; the analytics read scope yes, the read derivation n/a unchanged
2 create, one record REST create; the batch route's create and upsert rows; clone; import's per-row fallback through the edit check yes, the body's master unchanged
3 create, many records (array insert, insertMany) REST createMany; import no; now through the edit check no yes: every row's master, once each, in row order
4 update by id, body leaves the reference out REST update; the batch route; updateMany (one id at a time) yes, the addressed row's read yes, the stored master unchanged
5 update by id or by predicate, body names a master the same doors yes yes, the stored master and the body's (PR #25066) unchanged
6 update by id or by predicate, body empties the reference the same doors yes the stored master only; the emptied reference was not judged where validation does not judge it refused 422 where validation does not judge it; validation's 400 where it does
7 update by predicate, body leaves the reference out engine callers yes, the readable narrowing yes, every selected row's master (PR #25042) unchanged
8 delete by id or by predicate REST delete; the batch route; deleteMany (one id at a time) yes yes unchanged
9 record-grained explanation of update, delete, transfer, restore and purge; the attachment and comment parent gates security/explain; attachments; comments n/a yes, the by-id update check unchanged
10 record-grained explanation of create security/explain n/a not asked, by design: an explanation carries no body unchanged
11 system context (seeds, platform writes) n/a bypassed by design bypassed by design unchanged

Reach

Reach was measured before this change on origin/main 2b1382a5b3, with PR #25066's gate diff applied transiently and restored, and again on this head. Row 3 landed for all three callers through the engine and through the protocol's createManyData and insertManyData. Row 6 landed for all three callers on all three update shapes. On this head both are refused and nothing is written. The import door degrades a refused batch to one create per row. There, the rows under masters the caller may edit land, and the other row is refused on its own (measured).

Verification

Every run below ran under the shared verify lock.

  • Build. The dependency closure was built with pnpm exec turbo run build --filter='@objectstack/dogfood^...' --concurrency=2. That is a superset of @objectstack/plugin-security^... and includes plugin-security itself. Its tasks were 66 successful of 66, and it exited 0.

  • The new pins (at 1b27f979b4). controlled-by-parent-every-row-master.test.ts passes 18/18. The package run below includes it, beside PR fix(plugin-security)!: an update or delete of a controlled_by_parent detail asks for edit access to its master on every write shape (ADR-0055 §3) #25042's and PR fix(plugin-security)!: an update of a controlled_by_parent detail also asks for edit access on the master its body names (ADR-0055 §3) #25066's suites.

  • The package (at 1b27f979b4). pnpm --filter @objectstack/plugin-security test: Test Files 205 passed (205), Tests 4195 passed and 45 skipped (4240). pnpm --filter @objectstack/plugin-security typecheck exited 0. That covers tsc on both programs and check:test-typecheck. The new test file is in the tsconfig.test.json program (--listFiles, 1 hit).

  • Dogfood battery (pnpm --filter @objectstack/dogfood test, no shard and no path, owed by the ! title). It ran on d8dc243c15, this head. The lock's verdict was command-exit 0. Test Files: 286 passed, 3 skipped (289). Tests: 2387 passed, 18 skipped (2405).

  • Derived gate families. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 69 families, the same list at 1b27f979b4 and at d8dc243c15. All 69 ran on d8dc243c15, after the final commit, each exit code captured before any pipe; all 69 exited 0. --ran reports: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero (every line carries its exit code). The same 69 also ran on 1b27f979b4, where check:dual-build-cjs-loads answered PREREQUISITE NOT MET (eight unrelated packages had no dist/); it was rerun after building them, and passed.

  • Ablation, one leg per pinned behaviour. The fix was committed first. Each leg is a mutation made through scripts/ablation-replace.mjs, where the anchor must hit and the blob must change. Each restore is git checkout HEAD --, proven by blob equality with HEAD and an empty git diff HEAD. The subject is imported as ./security-plugin.js from source, so no dist/ sits on its path. The legs ran at 1b27f979b4:

    leg mutation went red
    L1 skip the walk for a batch row the batch refusal pins (5), the count pin, and the refusal pin under a reference validation does not judge
    L2 a blanket refusal in place of the walk the lands-as-it-did pins (5), and 8 more: the refusal envelopes, the count pin, the no-master validation pin and the unjudged-reference pin
    L3 drop the once-per-master skip the count pin
    L4 walk a row that names no master the validation pin for a row naming no master
    L5 let a batch stand down in the empty-master branch the still-refused-whole pin
    L6 drop the refusal of an emptied reference the emptied-reference pins (3 callers)
    L7 drop the hand-over to validation the validation pin for an emptied reference
    L8 judge the emptied reference before the stored master the stored-master-first pin

    The first attempts at L1, L2 and L8 were refused by the tool: each replacement contained its anchor, so the anchor count did not move. They measured nothing, and they were rerun with non-overlapping anchors (the table above). The unmutated control passed 18/18. The final tree is byte-identical to HEAD.

  • Base. The branch is main at f46ef4d70e (PR fix(plugin-security)!: an update of a controlled_by_parent detail also asks for edit access on the master its body names (ADR-0055 §3) #25066 landed) plus this change, merged with origin/main a39389eb51 (PR fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C) #25076, service-messaging, disjoint from this diff).

Acceptance notes

  • The partial-success insertMany now refuses the whole call when one row names a master the caller may not edit, rather than reporting that row. The middleware chain runs once for the batch, so a per-row answer there would need the engine, which this PR does not touch. The import door's fallback to one create per row keeps import's per-row reporting.
  • A batch row naming a master id with no row behind it now meets the walk's 403 first, as a single create already does, where validation used to answer 400.
  • Under a reference required-field validation does not judge, a batch create is still refused whole, exactly as before. It is now refused on the master-edit question first, when a row names a master the caller may not edit. Whether such a batch should instead be answered row by row, as a single create is, is left open. It goes to the seat as a decision, because it would admit writes this gate refuses today.
  • On a write made on a user's behalf, the agent's own pass runs first and whole. For the batch in the previous note, the agent's pass can therefore meet the empty-master answer before the member's pass asks the master. The write is refused either way, and nothing is written. This is pinned.
  • The emptied-reference rule reads the body before the engine's field strips run, as PR fix(plugin-security)!: an update of a controlled_by_parent detail also asks for edit access on the master its body names (ADR-0055 §3) #25066's question does. On a reference the engine would strip from the update, a body that empties it is now refused, where before the key was dropped. That fails closed. Mirroring the engine's strip rules in the gate would make a second copy of them. A value of undefined is read as empty, which is how validation reads it on a judged reference.

Generated by Claude Code

…l asks for edit access on every row's master, and an update no longer empties the master reference (ADR-0055 §1, §3)

The master-detail write gate read a create's master only from a body that
is a single record, so a batch create (the engine's array insert and
insertMany, and the doors that call them) asked about no master. Every
master the rows name is now asked by the one chain walk, once per master,
in row order, before anything is written; the empty-master branch then
answers the batch as before.

An update whose body sets the master reference to null is answered by the
empty-master rule: handed to required-field validation where it judges the
field, refused 422 otherwise, after the stored masters' question.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…s an unjudged-reference batch first

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
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 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v18/18-0.mdx (via SecurityPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 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 4feccc26851b89dcb8f4d78dfab011e73d12c128 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4feccc26851b89dcb8f4d78dfab011e73d12c128

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

@objectstack-fleet

objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d8dc243c15cf9d4ab12b5905d3f66b3f70a47c26
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/controlled-by-parent-every-row-master.test.ts, .changeset/25049-cbp-every-row-master.md + packages/objectql/src/validation/record-validator.ts, packages/core/src/utils/required-emptiness.ts, packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/plugins/plugin-security/src/errors.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/metadata-protocol/src/protocol.ts, packages/core/src/utils/import-runner.ts, docs/adr/0055-master-detail-controlled-by-parent.md

Face: narrowing. reviewFace on the three changed paths decides no face and defers to the changeset's Clause-②: no (narrowing) line; hits is empty, so the Scope floor is every path of the diff and the rest is cited code. Read against main at the merge base a39389eb51 (the head is that base merged into 1b27f979b4): three files, +616/−8, all inside @objectstack/plugin-security plus one changeset. No governed path, no packages/spec/**, no packages/objectql/**, and computeControlledByParentFilter is untouched — the claim's file-surface fence held. ⛔ The disclosure ruling recorded on #9387 (5323310017) applies to this record: reach is stated abstractly and no shape is named for any path this PR leaves open.

① Derived judgments

  1. Verdicts, never a scope (ruling record 6303721609, letter B) — RIGHT. Both new questions end in a throw (PermissionDeniedError from the one walk, or MasterReferenceMissingError) or a return; nothing is written onto opCtx.data, opCtx.options.where or opCtx.ast. No master set is composed into any write and no second spelling of a master scope appears anywhere in the diff.

  2. One walk, once per master, in row order, before anything is written; nothing written on refusal — RIGHT. The batch loop (security-plugin.ts 9314–9333) calls assertControlledByParentChainEditable, the same extraction every other shape calls, and assertMasterRowEditable stays the single leg set — once per distinct master (asked.includes), rows iterated in order, the first refusal propagating. It runs inside the security middleware, which ObjectQL.insert invokes through executeWithMiddleware ahead of the executor that writes (engine.ts 7034–7075, 15901–15940); insertMany is insert with __partialRowErrors (17117–17120), so one chain pass covers it too and a middleware throw never reaches the executor. The protocol's createManyData and insertManyData call exactly those two engine methods (protocol.ts 17080–17190). On a delegated write both principals' passes precede the executor. Pinned: refusal-envelope equality with the single create and null storage for every row across four doors on five leg × caller cases; the order pins asked: [f2, f1] and asked: [f1, h].

  3. The batch then meets the empty-master branch exactly as before (seat ruling B on the PR's question 2, 6312332660) — RIGHT. Line 9334 is unchanged: masterId stays undefined for an array, so the branch at 9368 answers as it did — stands down for insert under omissionRefusedByValidation, else 422 whole. The one ordering consequence is that the walk is asked first, so under an unjudged declaration a batch naming a master the caller may not edit now answers 403 where it answered 422 — still a refusal, nothing admitted. The agent-pass-first consequence is declared in the acceptance notes and pinned.

  4. The emptied-reference rule — RIGHT on each point the brief asks, with one residual carried in ③.

    • Hand-over exactly where validation provably judges the same field: omissionRefusedByValidation is master_detail + required + not readonly + not system (8565). validateRecord's update branch (record-validator.ts 1989–2015) walks Object.entries(data), reaches a field only when isInReadonlyScope admits it (686–689: a system field never, a readonly field shape-only), and refuses def.required && isEmptyForRequired(def, value) as required_cleared. The mirror is exact for the required-cleared check, and the predicate is not widened (the freeze note's bar).
    • Asked after the stored masters' question: by-id at 9489 then 9494; predicate at 9353 then 9360. Pinned (stored-master-first 403 on all three shapes).
    • All three update shapes: extractSingleId (7545) reads a scalar body id or a scalar where.id; every other shape resolveEngineUpdateDispatch calls multi takes the predicate path. Pinned on all three, for three callers, with the leave-it-out control landing. A predicate update that selects no readable row is not asked and writes nothing, consistent with the write doors' narrowing.
    • Own-property undefined read as empty: RIGHT. Object.entries enumerates an own key whose value is undefined, and isMissing(undefined) is true (required-emptiness.ts 51–53), so validation refuses it on a judged declaration exactly as the gate refuses it on an unjudged one.
    • The acceptance note on the engine's update strip ("where before the key was dropped … fails closed") is accurate, read against rule-validator.ts. [Seat redaction, under the skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387 ruling: one clause naming which declaration the engine strips is held privately.]
  5. Changeset headline and bullets — the headline and every Now / Unchanged bullet claim nothing the diff does not make true; each is pinned or read in code (the import door's writeBatch throws on a refused batch and the runner degrades to per-row createData, import-runner.ts 1032–1075, 1083, 1232). The two Before bullets describe the defect at its upper bound: "landed whatever masters" held only under the judged declaration (an unjudged one was already refused whole), and "landed and stored the null" held only for one of the unjudged declarations. Neither over-states the accept set now shipped; both over-state what landed before. Not a correction this record demands.

  6. Docblocks — the [#25044] sentence is now true of the batch create; the #7474 list, the #8688 paragraph, the freeze note and the chain-walk docblock each gained the matching sentence, and the freeze note's "do not widen omissionRefusedByValidation" is honoured.

  7. Test file — 18 cases on a real stack (ObjectQL over better-sqlite3, the real SecurityPlugin, sharing service and protocol, contexts from resolveAuthzContext / assembleExecutionContext); it reads nothing outside its package; Test Core ran it on this head.

② Semver level

  • @objectstack/plugin-security: minor with a ! title and Clause-②: no (narrowing) on the PR body, the changeset and both amended claims (6312286163, 6312297497) — RIGHT. Nothing is admitted that was refused before, and two shapes that landed are refused: a narrowing no conversion carries. Under the amended 「甲」 (6303865025, rule 2) that ships now as a BREAKING minor in the changeset shape the amendment names, which this changeset mirrors (BREAKING paragraph, Before / Now / Unchanged, What to do). No other package publishes from this diff. Check Changeset is green.
  • ADR-0087 not-required (no-migration-prescription) — RIGHT: no spec key, authorable spelling, stored shape or export moves; the "What to do" is a grant or a kept-populated reference, not a rewrite of consumer code; Lint & Repo Gates, which carries the registration gate, is green on this head.
  • Full dogfood battery on the final head (the os-dev rule for a ! / narrowing PR): recorded in the report's dogfood_battery field and in the PR body for d8dc243c15 — 286 files passed, 3 skipped; 2387 tests passed, 18 skipped; no shard, no path; lock verdict exit 0. Recorded as the rule requires; this review re-ran nothing.

③ Boundary flags

Implemented-by: claude/issue-25049-cbp-write-shape-master-edit
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS

Rendered 2026-10-21T09:33Z; the head's check-runs were read in the same act.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-21T09:38Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 28c9a2cb66, not against the report (os-dev report 6312194163 on #25049).

Disclosure read first (#9387, 5323310017).

Head under review:

d8dc243c15cf9d4ab12b5905d3f66b3f70a47c26
  1. Shape.
    • Draft, base main, first lines Fixes #25049 and Fixes #25068. Title fix(plugin-security)!:, and line-start Clause-②: no (narrowing) in the body, the changeset and both amended claims (6312286163, 6312297497).
    • 3 files, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean, and main has not touched plugin-security or the engine's write path since the merge base.
  2. Review of record. The face is narrowing at CONTRACT_REVIEW_TIER. The isolated contract review 6312751822 reads PASS on this same head (Served-tier: CONTRACT_REVIEW_TIER, Local-runs: none). The seat's redaction of two clauses left every governing line and the verdict as posted.
  3. The fix, read in the code (security-plugin.ts, inside assertControlledByParentWrite and its docblocks only).
  4. Pins and ablation.
    • controlled-by-parent-every-row-master.test.ts has 18 cases on a real stack, for a member, an organization admin and an agent acting for a member, across four batch doors and three update shapes.
    • Red before the fix: 10 of 18 failed. Eight ablation legs (L1–L8) each go red as designed, and every restore was proven by blob equality.
  5. Gates.
  6. Classification. This brings two write shapes to ADR-0055 §1 and §3. They are implementation gaps, shipped as a BREAKING minor under the amended 「甲」 (6303865025), with ADR-0087 not-required (no-migration-prescription).
  7. Accepted deviations.
  8. Open questions and out-of-scope disposition (all abstract, under the disclosure rule).
  9. Landing clause. Not governed, under the human-merge threshold, a BREAKING minor under the launch-window convention. The next step is ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 09:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 09:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 3782b2d Oct 21, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25049-cbp-write-shape-master-edit branch October 21, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment