Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-25012-cbp-write-path-master-edit
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-25012-cbp-write-path-master-edit

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #25012

Clause-②: no (narrowing)

What this changes

ADR-0055 §3 (docs/adr/0055-master-detail-controlled-by-parent.md): "editing/deleting/creating a detail requires edit access to its master". The controlled_by_parent detail write gate (assertControlledByParentWrite, step 2.8 of the security middleware) decided one write shape the engine routes without asking that question. It now asks it on every shape.

  • The remaining shape is judged per master. The gate collects the masters of the detail rows the write would reach, as the caller's own read returns them, and runs on each the same chain walk a by-id write runs. The first refusal is the answer, in the by-id leg's own envelope (PERMISSION_DENIED, 403, the same message), and nothing is written. On an on-behalf-of write both principals are asked, as on a by-id write.
  • A verdict, never a scope. No master set is composed into the write. The read derivation (computeControlledByParentFilter), packages/objectql/** and packages/spec/** are untouched, and plugin-security: a controlled_by_parent child's read loads every readable master id with no limit (twice per request), so its CPU grows with the master table and the read answers 500 "too many SQL variables" past ~32.8k masters #24932's ruling B (6303721609) on the master-scope mechanism gets no second spelling here.
  • The chain walk is extracted verbatim into assertControlledByParentChainEditable, so the insert, by-id and new legs run one copy. Git shows the walk's lines as unchanged context; one comment line in it is reworded.
  • It fails closed where the rows cannot be enumerated. A refused read of the detail refuses the write with that read's own refusal. Above the existing 10,000-row read cap, the existing INVALID_FILTER refusal answers.

Unchanged: inserts and by-id writes (same path, same walk), system contexts, the read scope, the platform ownership floor (still applied on this shape; nothing here vouches it away), and every object that does not declare controlled_by_parent.

Reach, stated abstractly under the disclosure ruling on #9387 (5323310017): it was re-measured before the change on a real ObjectQL + SqlDriver + SecurityPlugin + sharing stack, across every refusal leg, three principal classes and both tenancy postures. The measurement covers what bounded the rows. The readings are held privately by the seat.

Tests

New file: packages/plugins/plugin-security/src/controlled-by-parent-write-shape-master-edit.test.ts, 15 tests on a real stack (ObjectQL over better-sqlite3, the real SecurityPlugin, the real sharing service and middleware, contexts from resolveAuthzContext + assembleExecutionContext, isolated tenancy). Pinned behaviours:

  1. A master the caller may not edit refuses the write. The refusal equals the by-id door's answer for the same caller and master (code, status and message, compared to the live by-id answer, not to a literal). This holds for each refusal leg, for a member, an org admin and an agent acting for a member, and for both verbs. Nothing is written.
  2. One such master among editable ones refuses the whole write, and nothing is written under either.
  3. A write whose rows all sit under editable masters lands as before. The rows it writes are unchanged: own rows only for a member and its agent (the floor), every row for an org admin.
  4. A row the caller cannot read is not judged and is not written.
  5. A caller whose read of the detail is refused gets that read's own refusal, and nothing is written.

Ablation, one leg per pinned behaviour. Each leg was mutated through scripts/ablation-replace.mjs (anchor hit 1 to 0, blob changed on disk) and restored with git checkout HEAD -- PATH. Each restore was proven by blob equality with HEAD (cdc1192e52) and an empty git diff HEAD. No build step was needed: the suite imports the subject from src/ relatively.

leg mutation observed
A the new leg returns without judging 11 red / 4 green: every refusal pin red, plus pin 5; pins 3 and 4 stay green
B every such write refused outright 15 red / 0 green. The first attempt was a no-op: the replacement contained its anchor and the tool refused, so nothing ran. Re-anchored and re-run.
C rows enumerated as system instead of through the caller's read 2 red: pins 4 and 5
D the read's refusal swallowed as "no rows" 1 red: pin 5
E only the first master judged 1 red: pin 2

Verification (head 2f80e0db95, base origin/main 3b3830ca22)

  • pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build: lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-security test: 200/200 files, 4129 passed, 45 skipped (taken at 44eef53692; the merge from main touches none of this package's sources or dependencies).
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0. This includes check:test-typecheck, which reports 0 files and 0 errors held in the debt ledger, so the new test compiles.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 69 commands (identical at 44eef53692 and 2f80e0db95). All 69 ran under the lock at 2f80e0db95, each exit captured before any pipe: 69 exit 0. --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. At 44eef53692, before the dogfood closure existed, check:dual-build-cjs-loads and check:i18n had exited 3 (PREREQUISITE NOT MET). They are green here.
  • Full dogfood battery (owed by (narrowing) and the title's !): dogfood closure built, then pnpm --filter @objectstack/dogfood test under the lock with no shard and no path. VERDICT command-exit 0: Test Files 284 passed and 3 skipped (287); Tests 2361 passed and 18 skipped (2379); no failures.
  • node scripts/check-nul-bytes.mjs: OK.

Acceptance notes

  • Two docblock sentences outside the claimed region now describe the old behaviour. I left them untouched under the claim's hard stop (no other region of security-plugin.ts). The seat can approve a comment-only rider, or name a carrier.
    • The third bullet of the RlsFilterOptions.masterGateCoversThisWrite docblock says the gate "returns early and nothing replaces the floor" when no single id is addressed. Proposed: "does not vouch: the master gate judges this shape, but the floor stays, because dropping it here would widen the rows a member writes (a separate decision)".
    • Step 2.7's coverage-vouch parenthetical "(extractSingleId, or it returns early)". Proposed: "(extractSingleId; with no single id step 2.8 judges every master the write reaches instead)".
  • Writes issued from inside another operation are judged too, under the caller's context (a hook's or a cascade's), as step 2.8 judges their by-id twins. The write doors' readable narrowing excludes such writes, so for them the judged set is the rows the caller can read.
  • Cost. Each such write on a controlled_by_parent detail adds one read under the caller's context, one system read of the master references, and one chain walk per distinct master, each hop about three reads. The bound is the existing 10,000-row cap.
  • Object-grained security/explain (no record) for update or delete on a controlled_by_parent detail answers allowed with no master leg. It is a record-agnostic question; noted, not filed.

Open question for the seat

  • Refuse versus narrow, as the long-term answer for this shape. This PR refuses: a verdict, with no master scope composed. Narrowing to editable masters would compose the caller's editable-master set into the write. That is a master scope, which ruling B decides, and ADR-0139 D1 refuses the engine leaf on write verbs. Refusing now is forward-compatible: a later move to narrowing would be a widening.
  • Whether the floor should yield on this shape as it does by-id (commit 6feac91) is a widening, and it is not taken here.

Seat's append (domain:services seat 1, from the dev's patch-round report 6307856898 on #25012; the dev writes its PR body once):

Patch round 1 (head 033fc75a09)

  • The two docblock sentences named in the Acceptance notes are now fixed in this PR, as a comment-only rider. This follows the seat's ruling on the open question, letter A. git diff HEAD~1 -- packages/plugins/plugin-security/src/security-plugin.ts shows 7 lines in and 3 out, and every changed line is a comment line (0 non-comment lines). No code token changed.
  • The refuse-versus-narrow question is ruled A: keep refusing.
  • origin/main (dfe60e3a0c) was merged first, because it moved files the suite depends on (packages/objectql, packages/drivers/driver-sql, packages/core).
  • Re-verified on 033fc75a09 under the lock:
    • closure build exit 0;
    • pnpm --filter @objectstack/plugin-security test: 200/200 files, 4129 passed, 45 skipped;
    • typecheck exit 0;
    • full dogfood battery (no shard, no path): VERDICT command-exit 0, Test Files 284 passed and 3 skipped (287), Tests 2361 passed and 18 skipped (2379), no failures.

Seat's append (from the dev's patch-round-2 report 6308106727 on #25012):

Patch round 2 (head e71bf1e576)

  • The changeset headline and its ADR-0087 note are scoped to update and delete, as the reviewer asked; the diff from 033fc75a09 is that one changeset file, with no code change, so the 033fc75a09 dogfood battery stands.

Generated by Claude Code

…ster-edit question on every write shape (ADR-0055 §3)

The master-detail write gate decided one write shape of a
controlled_by_parent detail without asking whether the caller may edit
the masters involved. It now judges that shape per master, through the
same chain walk a by-id write runs, and refuses with the by-id leg's
envelope. The chain walk is extracted verbatim so both legs run one copy.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…d_by_parent detail write shape (ADR-0055 §3)

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…olled_by_parent detail write shape

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

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 5 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 50169dcecd571b63317a584448a9b2d1ba5bf751 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 50169dcecd571b63317a584448a9b2d1ba5bf751

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

…p-write-path-master-edit

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…t gate as it now answers every write shape

Comment-only: the RlsFilterOptions coverage-vouch bullet and step 2.7's
superset parenthetical no longer say the gate returns early when no single
row is addressed. No code token changes.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 033fc75a09f8205f05a060cd29b05e449c9afea0
Local-runs: none
Scope: packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/controlled-by-parent-write-shape-master-edit.test.ts, .changeset/25012-cbp-write-shape-master-edit.md + packages/metadata-core/src/engine-update-dispatch.ts, packages/metadata-core/src/engine-delete-dispatch.ts, packages/objectql/src/engine.ts, packages/spec/src/data/bulk-write-hook-conformance.ts, docs/adr/0055-master-detail-controlled-by-parent.md, docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md

Inputs: card #25012 (body and every comment through the patch-round report 6307856898), PR #25042 (body, three-path file list, net diff against main at the base dfe60e3a0c), the check-runs on the head, and the cited code read at the head. Disclosure: the maintainer's ruling on #9387 (5323310017) governs this record; reach is stated abstractly, no probe, grant shape, request body or served value is written here.

① Derived judgments

Governing text and the ruling it must not cross. ADR-0055 §3 (docs/adr/0055-master-detail-controlled-by-parent.md:60): editing, deleting or creating a detail requires edit access to its master, no write shape exempt. Ruling B on #24932 (6303721609): the master-scope mechanism moves to the engine semi-join leaf, with no two spellings of one scope during the transition; ADR-0139 D1 (:128) refuses that leaf on a write verb.

  • The new leg is a verdict, not a scope — RIGHT. assertControlledByParentWrite (security-plugin.ts:9066), when extractSingleId (:7418) names no row, reads the engine's own dispatch verdict (resolveEngineUpdateDispatch / resolveEngineDeleteDispatch, :9125–:9146, the same two predicates the write doors read at :7346), and on the dispatch's multi verdict collects masters through controlledByParentPredicateWriteMasters (:9429) and runs assertControlledByParentChainEditable (:9296) once per distinct master. The method only throws or returns: it writes nothing into opCtx.ast, opCtx.options.where or the composed extra scopes (:4395–:4445); its read takes copyOfCallerPredicate. No master set is composed into the write, so ruling B gets no second spelling.
  • Read scope, objectql and spec untouched — RIGHT. computeControlledByParentFilter (:8646) carries no hunk; the file list is three paths, none under packages/objectql/** or packages/spec/**.
  • Coverage, dispatch verdict by verdict, against what extractSingleId reads — RIGHT for the two verbs the card measured.
    • by-id (a truthy scalar id, from the payload or a pure id where): extractSingleId returns that id, the by-id leg runs as before.
    • multi with no scalar where.id: the new leg, per master.
    • multi with a scalar where.id beside other keys (the dispatch's compare-and-set arm): extractSingleId returns that id and the by-id leg judges the one row the predicate can reach (the id equality rides the AST, so the written set is at most that row). Judged.
    • multi with a falsy scalar id: extractSingleId tests type, not truth, so the by-id leg runs on that id — either that row's master is judged or DetailRecordNotFoundError (404) refuses. Refused or judged, never unjudged; the envelope quirk predates this card.
    • reject: with no single id the gate returns, and the engine refuses the call on the same executeWithMiddleware pass before any driver write; with a payload id the by-id leg runs and the engine then refuses. Nothing written either way.
    • delete mirrors update minus the payload arm (engine-delete-dispatch.ts:resolveEngineDeleteDispatch).
    • The lifecycle verbs in step 2.8's list are outside the middleware vocabulary (pinned in packages/objectql/src/engine-middleware-operation-vocabulary.test.ts): dormant, unchanged.
    • A nested write (a hook's or cascade's, under the caller's context): the new leg has no nested exclusion, so it is judged on the rows the caller can read; the write's AST carries the derived read filter regardless of nesting (:4412, under if (opCtx.ast) alone), so the rows it writes sit under readable masters. The judged and written sets part only where an authored policy on the detail admits for update what it withholds for select, which a derived object does not author (ADR-0055 §1). The dev disclosed this in the Acceptance notes; residual, not a defect of this diff.
    • Single-row insert: unchanged (:9121, the A missing required master-detail parent still answers 422 MISSING_REQUIRED_FIELD with no fields[] and a [Security] message — while the same field, present-but-unresolvable, answers 400 VALIDATION_FAILED with fields[] (#7474 residual, 17.0.0 GA) #8688 stand-down at :9267 holds as before).
    • One further shape in the same gate family, not of the card's measured path, is not shown judged by this diff. Held out of this record under the disclosure ruling; escalated in ③.
  • Envelope — RIGHT. Every refusal on the new path is built by the gate's own masterEditDenied factory passed into the walk: PermissionDeniedError, 403, named for the caller's object and operation, leg recorded in MASTER_EDIT_DENIAL_LEG. The test compares the live by-id answer (code, status, message) with the other route's answer for the same caller and master, not a literal.
  • Nothing written on refusal — RIGHT. Step 2.8 (:3246, delegator pass :3256) runs inside the middleware before await next() (:4498) and before the readable narrowing (:4441); a throw ends the pass before the driver. The test reads every seeded row back unchanged after each refusal.
  • Fail closed where rows cannot be enumerated — RIGHT. The new leg's this.ql.find is not wrapped in readUnlessRefused (:740), so a declared 4xx on the read propagates as the write's answer (pinned: equal to the read's own refusal); above PREDICATE_WRITE_READABLE_ID_CAP (:772, MAX_BULK_PER_ROW_HOOK_ROWS = 10,000 at bulk-write-hook-conformance.ts:282) it throws predicateWriteReadableCapError (400 INVALID_FILTER, :775); a store fault propagates; a stored row with a null master reference throws MasterReferenceMissingError (422) as the by-id leg does; a missing engine throws. Contrast kept on purpose: readableRowsScopeForPredicateWrite (:7384) swallows a refusal as "no narrowing", the gate does not, and the gate runs first.
  • Chain walk extracted verbatim, one copy — RIGHT. Compared the inline walk at the base dfe60e3a0c with assertControlledByParentChainEditable at the head: 86 lines each, one comment line differs (the "read above" wording the PR body declares). The file holds exactly one walk header and one for (;;); assertMasterRowEditable stays the single copy of the three legs.
  • On-behalf-of — RIGHT. The gate runs per principal (:3246, :3256); the row read is under opCtx.context on both passes, the walk under each principal's sets and context, as the by-id leg does.
  • No widening of the floor — RIGHT. masterGateCoversThisWrite is set only at step 2.7 (:3095) and on a middle hop (:9625); the new path sets it nowhere, so the platform ownership floor stays on this shape, as the reworded docblock says.
  • Tests. The new file drives a real stack (ObjectQL over better-sqlite3, the real plugin, sharing service and middleware); pins 1–5 match the PR body. The ablation table is the dev's claim, read, not re-run.

② Semver level

③ Boundary flags

  1. Refuse versus narrow (dev recommends A; seat ruled A in the patch round) — answered, A is right. Narrowing would compose an editable-master set into the write: a second spelling of a master scope while ruling B retires the id-list for the leaf, and ADR-0139 D1 refuses the leaf on write verbs. Refusing adds no mechanism and is forward-compatible; a later move to narrowing is a widening and reviewable as one.
  2. Comment-only docblock rider at 033fc75a09 — answered, verified. The commit touches one file, 7 lines in and 3 out, every changed line a comment line, no code token. Sentence one (the RlsFilterOptions bullet) is true of the code: the new path sets no vouch and throws rather than scopes, so the floor stays. Sentence two (step 2.7's parenthetical) is true: with no single id, 2.8 judges every master on the multi verdict, and on reject the write reaches none.
  3. Dev out-of-scope finding, class b, "one further reading in the same write-gate family", handed to the seat privately — escalated. My own read of the gate also finds one shape in the same family, outside the card's measured path, that this diff does not show judged. Detail withheld here under the skills: five principle gaps the #9296 wave exposed in checklist-test / checklist-author / dogfood-verification #9387 ruling and returned to the dispatching seat with this record. The seat matches it against the dev's hand-off; if the two differ, it is a second card. For this PR: the headline's "every write" is wider than the body's own "Unchanged: inserts" line; the seat decides whether to narrow the headline to the verbs the card measured before the CHANGELOG line publishes, or to carry the residual on its card. Not introduced by this diff.
  4. Nested writes — disclosed in the Acceptance notes; judged in ①; residual, not blocking.
  5. Object-grained security/explain with no master leg — record-agnostic, noted by the dev, no carrier; not this card.
  6. Dev deviations — the first merge commit lacked the trailer pair (the round-2 merge carries it); package tests ran at 44eef53692 before the merge — the head's CI covers it (Build Core and Test Core 1–6 success). Answered.
  7. Check-runs on the head — 41 names, latest run per name: 32 success, 9 skipped, each skipped name on the roster of scripts/pm/check-expected-skips.mjs (the two opt-in label gates, the two event-gated automation jobs, the docs and console filter gates, and the three full-battery-only jobs including the shard-template dogfood name). CI complete; no failure, no cancellation.

Implemented-by: claude/issue-25012-cbp-write-path-master-edit
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS

…e to update and delete

Changeset text only; no code, no test.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-security)!: every write to a controlled_by_parent detail asks for edit access to its master (ADR-0055 §3) 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) Oct 21, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 662183604c, not against the reports (os-dev reports 6307509981, 6307856898 and 6308106727 on #25012).

Disclosure read first (#9387, 5323310017): the PR title and body, the commit messages, the changeset and every test label carry no grant shape, request or served value. The private evidence is held by the seat.

Head under review:

e71bf1e5764a9e4c0715bb773551ad0cda59dff1
  1. Shape.
    • Draft, base main, first line Fixes #25012.
    • Title fix(plugin-security)!:, with Clause-②: no (narrowing) at line start in the body and the changeset.
    • 3 files, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean.
  2. Review of record.
    • The face is narrowing at CONTRACT_REVIEW_TIER.
    • The isolated contract review 6308053259 reads PASS on 033fc75a09 (Served-tier: CONTRACT_REVIEW_TIER, Local-runs: none).
    • This head adds only patch round 2: git diff --stat 033fc75a09 e71bf1e5764a9e4c0715bb773551ad0cda59dff1 touches .changeset/25012-cbp-write-shape-master-edit.md alone (2 lines in, 2 out). The seat confirms it below, sentence by sentence, as release text on a reviewed head.
      • Headline.
        • BEFORE: "fix(plugin-security)!: every write to a controlled_by_parent detail asks for edit access to its master (ADR-0055 §3)"
        • AFTER: "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)"
        • Why: the AFTER says only what the diff makes true, the verbs the card measured. It carries the review's ③.3 wording flag.
      • ADR-0087 comment.
        • BEFORE: "it now asks the master-edit question that every other shape already asks"
        • AFTER: "it now asks the master-edit question a by-id update or delete already asks"
        • Why: same reason.
      • PR title. Set to the AFTER headline, which is the squash subject.
  3. The fix, read in the code (security-plugin.ts).
  4. Open questions, ruled by the seat.
    • ① Refuse versus narrow: A, refuse. This is the review's ③.1.
      • Narrowing would compose an editable-master scope, which is ruling B's mechanism, and ADR-0139 D1 refuses the engine leaf on writes.
      • Refusing is forward-compatible.
      • On the four axes: no measured pull for partial landing; no second scope spelling; a loud refusal is better than silent narrowing; nothing new.
    • ② The two stale docblock sentences: A, a comment-only rider in this PR (033fc75a09: 10 changed lines, all comment lines). The review verified each sentence is true of the code.
    • Classification. This brings the runtime to ADR-0055 §3, which is already declared: an implementation gap. It ships as a BREAKING minor under the amended 「甲」 (6303865025), because a write that landed is now refused.
  5. Pins and ablation.
    • controlled-by-parent-write-shape-master-edit.test.ts: 15 cases on a real stack, including the on-behalf-of half. Each compares the refusal with the live by-id answer.
    • Five ablation legs, A–E, each red as designed. Every restore was proven by blob equality.
  6. Gates.
  7. Accepted deviations.
    • Two merges of main; the first has git's default message.
    • Package tests ran before the first merge, and the head's CI covers that gap.
    • Throwaway probes, never committed.
    • Model-free trailers.
  8. Out-of-scope disposition (all abstract, under the disclosure rule).
  9. Landing clause. Tier S: not governed and under the human-merge threshold, a BREAKING minor under the launch-window convention. Next step: 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

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