Repository navigation
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
Conversation
…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>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Face: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered 2026-10-21T09:33Z; the head's check-runs were read in the same act. Generated by Claude Code |
席位核对 · ACCEPT ·
|
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_parentdetail, 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.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.400 VALIDATION_FAILED). Otherwise it refuses with422 MISSING_REQUIRED_FIELDand 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 bywhere, addressed in the body, and by predicate.[#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#7474list, the#8688paragraph, the freeze note's hand-over paragraph and the chain walk's docblock each gain the matching sentence.computeControlledByParentFilter,packages/objectql/**andpackages/spec/**are untouched. OnlyassertControlledByParentWriteand 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 isObjectQLover better-sqlite3, the realSecurityPlugin, the real sharing service and middleware, the real protocol (createManyData,insertManyData), and contexts built byresolveAuthzContextandassembleExecutionContext, the agent's with themcp_agent_data_writeOAuth ceiling..changeset/25049-cbp-every-row-master.md:@objectstack/plugin-securityminor. It is BREAKING under the launch-window convention for an accept-set narrowing. It carries theClause-②line and the ADR-0087 dispositionnot-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_parentdetail. The table below is enumerated from the code. Its verdicts were measured on a realObjectQL+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.find/findOne/count/aggregateinsertMany)createMany; importupdateMany(one id at a time)422where validation does not judge it; validation's400where it doesdeleteMany(one id at a time)security/explain; attachments; commentssecurity/explaintransfer,restoreandpurgenever reach the engine's middleware as operations (the vocabulary pin inpackages/objectql), so they are not doors here.Reach
Reach was measured before this change on
origin/main2b1382a5b3, 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'screateManyDataandinsertManyData. 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.tspasses 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 typecheckexited 0. That coverstscon both programs andcheck:test-typecheck. The new test file is in thetsconfig.test.jsonprogram (--listFiles, 1 hit).Dogfood battery (
pnpm --filter @objectstack/dogfood test, no shard and no path, owed by the!title). It ran ond8dc243c15, 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/objectstackderives 69 families, the same list at1b27f979b4and atd8dc243c15. All 69 ran ond8dc243c15, after the final commit, each exit code captured before any pipe; all 69 exited 0.--ranreports: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero (every line carries its exit code). The same 69 also ran on1b27f979b4, wherecheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET (eight unrelated packages had nodist/); 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 isgit checkout HEAD --, proven by blob equality withHEADand an emptygit diff HEAD. The subject is imported as./security-plugin.jsfrom source, so nodist/sits on its path. The legs ran at1b27f979b4: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
mainatf46ef4d70e(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 withorigin/maina39389eb51(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
insertManynow 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.403first, as a single create already does, where validation used to answer400.undefinedis read as empty, which is how validation reads it on a judged reference.Generated by Claude Code