Skip to content

docs(spec): the cbp-optional-master-admitted entry states the current write rule, and the security-service contract names ownerMayEdit - #31210

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9crw47
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9crw47

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #31169
Fixes #31180

Clause-②: no
Metadata-protocol: none

维护者速读

What changes

Release text only, measured against the write gate PR #31082 landed (037371559b, packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts: nullMasterRowWritable, the masterId == null && rel.optional branch of assertControlledByParentWrite, and its 403 PERMISSION_DENIED refusal nullMasterOwnerDenied):

  1. packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts, replacement: the sentence "Writes are unchanged by this entry: an insert or update that leaves the master reference empty is still refused with 422" is replaced by the current rule. An insert that names no master is admitted and its creator owns the row; its owner and a holder of Modify All on the detail update or delete a row whose master reference is empty, and everyone else who reaches the write gate, View All included, is refused 403 PERMISSION_DENIED. A row with no owner, or on an object without an owner_id column, is written through Modify All alone. An update that empties a named master asks edit access on the master it leaves. A required master is unchanged: a stored empty required master reference is still refused with 422 MISSING_REQUIRED_FIELD. The wording follows content/docs/protocol/kernel/error-handling.mdx and content/docs/api/error-catalog.mdx (PR docs: error-handling, error-catalog and cli pages teach the optional controlled_by_parent master #31164) and the permissions pages of PR docs(skills): teach controlled_by_parent with an optional master in the data skill, permission docs and ADR-0055 #31092, so the four texts agree. The replacement string carries no tracker number (check:doc-authoring).
  2. Same file, header comment: a paragraph naming the write side, ADR-0055 §3 as amended, the same ruling A, citing PR feat(security, verify): a controlled_by_parent row with no master is written by its owner or by Modify All on the detail #31082 beside the read-side citation.
  3. packages/spec/src/migrations/registry.ts.template, the step-18 rationale fragment cbp-optional-master-admitted (the upgrade-guide paragraph whose D3 record is this entry): "Writes are unchanged: an empty master reference is still refused with 422" now reads that a detail row naming no master is written by its owner and by Modify All on the detail and refused with 403 for everyone else, and only an empty required master reference is still refused with 422. Fixed in place under os-dev rule 3's bounded exemption (same stale sentence about the same entry, mechanical, no other claim on the paragraph, the same check:upgrade-guide / check:spec-changes family). The claim's file surface does not name this file; the seat is asked to amend it.
  4. spec: the security-service contract never names the ownerMayEdit opt-in, so its allow bullet and "requires EDIT access to its master" are incomplete for an opted-in controlled_by_parent detail #31180 (folded, its own commit): packages/spec/src/contracts/security-service.ts, JSDoc only. The allow bullet of ControlledByParentWriteOutcome and the ISecurityService.checkControlledByParentWrite docblock now name the sharing.controlledByParent.ownerMayEdit: true opt-in, citing ADR-0055 §3 as amended 2026-10-26. Conditions measured from the owner leg in assertControlledByParentWrite (security-plugin-cbp-write-gate.ts, the operation === 'update' && detailRow !== null && rel.ownerMayEdit && … branch): a by-id update; not on behalf of another user (opCtx.context.onBehalfOf.userId absent); the stored owner_id is the caller's userId; the body does not empty an optional master; and the caller can read the master row through the read door (readUnlessRefused(readRowById(rel.master, masterId, context))). When it holds, the gate returns before the chain walk, so no master is asked for edit. The served member builds a body-less by-id update with the caller's context, so it takes the same leg and answers allow.
  5. .changeset/31169-cbp-optional-master-entry-write-text.md: one '@objectstack/spec': patch covering both cards. The registry ships in the package (dist, spec-changes.json, protocol-upgrade-guide.md are in files[]) and so does the contract's JSDoc (in dist).

Unchanged: the entry's id, surface, reason and acceptanceCriteria; every type, export and runtime answer.

Tests

No behaviour change, so no test is added and none is ablated. The opt-in leg the JSDoc describes is pinned by packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts. grep -rn "still refused with 422" packages/ and a search for the template's phrasing found no pin or snapshot quoting the old sentences. The behaviour the new text describes is pinned by packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts (PR #31082). The regenerated (gitignored) packages/spec/src/migrations/registry.ts carries the new text and no "Writes are unchanged" sentence.

Acceptance notes

  • .changeset/30905-cbp-optional-master.md (pending, from the read-side PR) still ends its plugin-security bullet with "Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422." It was true of that PR, and .changeset/30906-cbp-null-master-write-gate.md states the write change, so both will compile into the same release's notes. Editing a landed changeset is outside this card; noted for the seat, not changed here.

Verification

Local gates on the final head d3adb0d1: 78 derived by dispatch-gates --commands, 77 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3: it needs every package's dist, CI's). --ran reconciles 78 accounted, 0 unrun. pnpm --filter @objectstack/spec run typecheck exit 0; vitest run src/migrations/ src/contracts/ 57 files / 673 tests passed. Details in the card's os-dev-report; CI runs the full set.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t

… write rule for an empty optional master

The entry's replacement and its step-18 upgrade-guide paragraph still said a
write leaving an optional controlled_by_parent master empty is refused with
422. The write gate now admits it by the owner rule: the owner or Modify All
on the detail writes such a row, everyone else is refused 403
PERMISSION_DENIED, and only an empty required master reference still answers
422 MISSING_REQUIRED_FIELD. Text only; id, surface and reason unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t
…r leg of the controlled_by_parent write check

The allow bullet of ControlledByParentWriteOutcome and the docblock of
ISecurityService.checkControlledByParentWrite read as if every by-id update
of a controlled_by_parent record needed edit access to its master. A detail
declaring sharing.controlledByParent.ownerMayEdit: true lets the record's
owner, not on behalf of another user, update it by id with read access to the
master, unless the body empties an optional master (ADR-0055 section 3 as
amended). Both docblocks now say so. The changeset covers this and the
migration-entry text. JSDoc only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/migrations/registry.ts.template), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/attachments-access.mdx (via ISecurityService (symbol, a top-level interface))

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

  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-6.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-7.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v18/18-0.mdx (via ISecurityService (symbol, a top-level interface))

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 changed file(s) yielded no anchor (packages/spec/src/migrations/registry.ts.template) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 171 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 66bf5b4a019c62f50ed1824b35fe6ba345cf51e3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 66bf5b4a019c62f50ed1824b35fe6ba345cf51e3

⚠️ 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 66bf5b4a019c62f50ed1824b35fe6ba345cf51e3 → 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

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract-review-claim: session_01FEkQ5mw7HFYn9o4LEGMQdK · head d3adb0d · 2026-11-07T17:45Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d3adb0d1cdbf4bebe67ae162616037ecf1f31d0a
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts, packages/spec/src/migrations/registry.ts.template + packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts (assertControlledByParentWrite, nullMasterRowWritable, bodyEmptiesMasterReference, askBodyNamedMaster, checkControlledByParentWrite), packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts (ownerMayEdit resolution), packages/plugins/plugin-security/src/permission-evaluator.ts (hasSuperuserWriteBypass), packages/plugins/plugin-security/src/errors.ts (PermissionDeniedError, MasterReferenceMissingError), docs/adr/0055-master-detail-controlled-by-parent.md (section 3 and the amendments of 2026-10-26, 2026-11-06 and 2026-11-07), packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts and packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts (the pins the new text is accepted against), .changeset/30905-cbp-optional-master.md and .changeset/30906-cbp-null-master-write-gate.md (for ③). Cited code read on origin/main, which is byte-identical to the merge base for every cited file; reviewFace reads the four-path file list as the schema face with the three hits above and no rules hit.

Inputs: card #31169 (body; comments 6709432110, 6710037976, 6710851706, 6710876574), card #31180 (body; comments 6710232004, 6710378726), PR #31210 body and file list, the net diff origin/main...d3adb0d1 (4 files, +51/-8, two commits 0fcd5b1 and d3adb0d), and the 41 check-runs on the head. The PR is a draft on an internal branch (head repo equals base repo), labels documentation, size/s, tooling; no governed-surface path is in the file list, so the review is owed by the schema face alone.

Check-runs on the head at review time: 18 completed success (Auto Label; Check Changeset; Check PR Size; Dogfood Regression Gate; Flag docs affected by code changes; Governed Surface Queue Guard; Lint · CI, release & engine gates; Lint · ESLint; Lint · PM dispatch-gates; Lint · docs & code guards; Lint · repo & tooling gates; Lint · scoped · query-options erasure; Lint · scoped · tooling self-tests; PR-body guards; Plan Test Core shards; Spec property liveness; Type Check · source gates; filter), 8 skipped (opt-in and path-filtered jobs), 15 still in progress (Build Core; Build UI Deps; Lint · scoped · slot lookup and corpus; Live E2E UI; Test Core 1/8 to 8/8; Type Check · consumer gates; Type Check · debt ledger; Type Check · workspace). Not waited for. The gate families that judge the hits text directly are already green: check:spec-changes and check:upgrade-guide run in Type Check · source gates, check:doc-authoring in Lint · repo & tooling gates, the changeset declaration lines in Check Changeset and PR-body guards. check:api-surface (Type Check · consumer gates) is in progress; it cannot be moved by this diff, see ①.

① Derived judgments

  • Accept set: unchanged — right. No zod schema, type, export, signature or runtime branch is touched; git diff shows only string literals and comment text. Metadata-protocol: none is the correct kind: nothing an author can write is added, narrowed or re-meant. Clause-②: no with no arm is right for the same reason. Spec property liveness is green.
  • Published surface, what actually moves: (a) the JSDoc of two exported declarations, ControlledByParentWriteOutcome and ISecurityService.checkControlledByParentWrite, which ship in dist/*.d.ts; (b) the replacement value of the SemanticMigration entry cbp-optional-master-admitted, which is registry data read by os migrate meta and compiled into spec-changes.json; (c) the STEP18 rationale fragment of the same id, rendered into protocol-upgrade-guide.md. All three are text a consumer reads, none is a contract a consumer compiles against. Judged right: this is exactly the published-text patch the two cards ask for.
  • Generated artifacts: nothing owed in the PR — right. packages/spec/src/migrations/registry.ts is gitignored and regenerated on install and at build (gen:migration-registry in prepare, build, test, typecheck); spec-changes.json and protocol-upgrade-guide.md are gitignored and pinned by check:spec-changes / check:upgrade-guide, which are green on this head. The tracked packages/spec/api-surface/*.json reports carry no docblock text and do not even name checkControlledByParentWrite, so a JSDoc-only diff cannot change them; the PR leaves them untouched, correctly.
  • Pins: a grep of the head tree for the three replaced sentences finds no test, snapshot or doc quoting them; the only hit is the new template sentence itself. The dev's claim on this point holds.
  • Merge state: origin/main has moved past the merge base (620b6e6 to d57be87) and touches registry.ts.template in a different STEP18 fragment (marketplace manifest entries); a merge-tree dry run of the head onto origin/main is clean. None of the cited gate, ADR, contract or test files changed between the merge base and origin/main, so the judgment below is against the live tree.

② Semver level

.changeset/31169-cbp-optional-master-entry-write-text.md declares '@objectstack/spec': patch and carries both line-initial declarations, Clause-②: no and Metadata-protocol: none, identical to the PR body's. Right: a none kind on a released package takes patch; the text ships in the npm package (dist, spec-changes.json, protocol-upgrade-guide.md are in files[]), so skip-changeset would be wrong and is not used. No arm, no BREAKING banner, no ADR-0087 marker owed. Check Changeset and PR-body guards are green on the head. One changeset for the fold is what claim 6710378726 ordered (fold gate ②). Clause-②: no.

③ Boundary flags

Read off os-dev-report 6710851706 on #31169, the PR body's Acceptance notes, and the two claims.

  1. registry.ts.template outside the claimed file surface (dev flag; rule-3 bounded exemption). Answered: claim amendment 6710876574 adds the path to claim 6710037976. The hunk is the one stale sentence about the one entry, in the family both triage audits (6709432110, 6710232004) name. Closed.
  2. .changeset/30905-cbp-optional-master.md still says "Writes are unchanged ... still refused with 422" (dev out_of_scope_findings, PR Acceptance notes). Confirmed on origin/main: that pending changeset's plugin-security bullet ends with the stale sentence, and 30906-cbp-null-master-write-gate.md in the same pending set states the write rule, so one release's CHANGELOG will carry both. It is outside both cards' surfaces, and a pending changeset is an ordinary tracked file, not a landed record. Escalated to the seat as a should-fix follow-up, not a blocker on this PR: either a one-line edit of that bullet folded here before landing (same family, same package, same patch level unaffected), or a follow-up card in the spec: security-service contract jsdoc still describes a controlled_by_parent master as always required (optional master, cards #30905/#30906) #31094/spec: migration entry cbp-optional-master-admitted still says writes leaving the optional master empty are refused with 422 (the write gate admits them since #31082) #31169/spec: the security-service contract never names the ownerMayEdit opt-in, so its allow bullet and "requires EDIT access to its master" are incomplete for an opted-in controlled_by_parent detail #31180 family. Not a FAIL: the published text this PR owns is right, and the release notes' write-side statement already exists.
  3. check:dual-build-cjs-loads NOT MEASURED locally (needs every package's dist). CI's: Build Core is in progress at review time. The Tier rule that lands a PR waits on every check green; this record does not pre-empt that and does not need to.
  4. open_questions: [], security_findings: none, premise_still_valid: true, two commits one per card with one Fixes line each as the fold claim ordered. No other flag.

④ Design judgment

Every sentence the new text states about the write gate was read against assertControlledByParentWrite and what it calls, and tried against the pinned scenarios.

packages/spec/src/contracts/security-service.ts (card #31180).

The allow bullet of ControlledByParentWriteOutcome — holds, sentence by sentence:

  • "the detail opts in with sharing.controlledByParent.ownerMayEdit: true": the leg tests rel.ownerMayEdit, which security-plugin-cbp-relation.ts resolves as optedIn && hasOwnerColumn (an opt-in without an owner_id column resolves false and is reported once). The bullet's "the record's stored owner_id" carries the column requirement; consistent.
  • "its owner leg admits the principal in place of the walk": on the leg's success the gate returns before assertControlledByParentChainEditable; true.
  • "the context is not on behalf of another user": !opCtx?.context?.onBehalfOf?.userId. For the served member opCtx.context is the caller's context on both passes of a delegated call, so a delegated context skips the leg on the agent's and the delegator's pass alike; true.
  • "the record's stored owner_id is the principal's own userId": detailRow[OWNER_COLUMN] != null && String(detailRow[OWNER_COLUMN]) === String(context.userId); true, and a row with no owner admits nobody.
  • "the principal can READ the master the record names": readUnlessRefused wrapping this.readRowById(rel.master, masterId, context), admitted only when not refused and the row is non-null; the leg is reachable only when masterId != null (an empty optional master was answered earlier by the owner rule, an empty required one by the 422 branch), so "the master the record names" is exact.
  • "so no leg asks EDIT on that master or any master above it": the walk is not entered; askBodyNamedMaster([masterId]) with the served member's body-less opCtx returns at once. True for the outcome this type describes. The pin: owner-may-edit test "allow for the owner who reads the master; deny for the same grants without ownership".
  • The citation "ADR-0055 §3 as amended 2026-10-26" matches the ADR's amendment line and section-3 note.

The ISecurityService.checkControlledByParentWrite docblock — holds with one should-fix:

  • "a by-id update": operation === 'update' && detailRow !== null; an insert, delete and predicate (multi) update never reach the leg (the predicate path returned earlier). True; pinned by the "what the opt-in does NOT widen" describe.
  • "by the record's owner (its stored owner_id is the caller's own userId), not on behalf of another user": as above; true.
  • "that does not empty an optional master": !(rel.optional && bodyEmptiesMasterReference(opCtx.data, rel.fk)); true, and the ADR's 2026-11-07 amendment says the opt-in does not cover an emptying body. Pinned by "emptying a master asks EDIT on the master it leaves ... even under the ownerMayEdit opt-in". A body emptying a REQUIRED master passes this condition but is refused by askBodyNamedMaster (422) or by required-field validation; the docblock's "optional" is the right word.
  • "needs only READ access to the master the record names, asked of the read door under the caller's own context": readRowById(rel.master, masterId, context) under the caller's context; true.
  • "Every other caller, and every insert, delete, predicate update and on-behalf-of write, keeps the edit rule": matches the gate and the ADR amendment line word for word.
  • "The leg is part of the check, so such an owner who can read the master gets allow here as on the update": the served member builds { operation: 'update', options: { where: { id } }, context } and calls the same function; true, pinned.
  • S1 (should-fix): "and walks no chain" over-claims for one by-id update shape. When the leg's master read passes, the gate runs askBodyNamedMaster([masterId]) before returning; a body that re-points the row under a different master (data[fk] set to another id) makes that call enter assertControlledByParentChainEditable on the new master and ask EDIT on it (ADR-0055 2026-10-26 amendment, "What the leg does not change"; pinned by "a body that RE-POINTS the row under another contract the owner only reads is refused"). So an owner's by-id update meeting every listed condition can still walk a chain, the new master's. The served member carries no body, so every outcome this member contracts is still exactly right, and the allow bullet's own wording ("no leg asks EDIT on that master or any master above it", the stored master) is exact; the over-claim is confined to the one clause describing the write path a consumer of this member never runs. Fix: "... and walks no chain for the master the record names; a body that re-points the row under another master still asks EDIT on that master, as every body naming a new master does." Not a must-fix: it changes no answer the contract promises and the sentence beside it already scopes the read to "the master the record names".

packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts (card #31169). id, surface, reason, acceptanceCriteria unchanged, as the card required. The new replacement sentences, each against the gate:

  • "an insert that names no master is admitted, and its creator owns the row": the masterId == null && rel.optional block returns on insert; the owner stamp is the platform's (step 3.5). Pinned by "an insert with no master reference, and one with account: null, land owned by the caller". True. The "admitted" is this gate's admission; the detail's own create grant still runs before it, which the entry's reader expects of every write.
  • "its owner and a holder of Modify All on the detail update or delete a row whose master reference is empty": nullMasterRowWritable admits hasSuperuserWriteBypass (the modifyAllRecords bit, permission-evaluator.ts) or a non-null owner_id equal to the caller; a delete takes the same block. True; pinned by "the owner updates and deletes it" and "Modify All on the detail updates and deletes it without owning it".
  • "everyone else who reaches the write gate, View All included, is refused 403 PERMISSION_DENIED": nullMasterOwnerDenied is a PermissionDeniedError, code = 'PERMISSION_DENIED', statusCode = 403. "who reaches the write gate" correctly leaves the hidden-row 404 at the read door outside the sentence. True; pinned by "a principal who READS it (View All) and does not own it is refused both".
  • "a row with no owner, or on an object that carries no owner_id column, is written through Modify All alone": !rel.ownerColumn returns false, owner == null returns false; true; pinned.
  • "An update that empties a named master asks edit access on the master it leaves": the owner leg steps aside for such a body, the walk asks EDIT on the stored master, then askBodyNamedMaster returns for an optional master. True; pinned.
  • "A required master is unchanged: a row whose stored required master reference is empty is still refused with 422 MISSING_REQUIRED_FIELD": the masterId == null branch throws MasterReferenceMissingError (code = 'MISSING_REQUIRED_FIELD', statusCode = 422) for every non-insert; "stored" is the right qualifier, since an insert omitting a required master is handed to required-field validation (400) where omissionRefusedByValidation. True, and more precise than the sentence it replaces.
  • The header comment names ADR-0055 section 3 as amended and ruling A as the provenance, with PR feat(security, verify): a controlled_by_parent row with no master is written by its owner or by Modify All on the detail #31082 as the convenience link beside the read-side citation; it is a source comment, not a runtime string, and the replacement string carries no tracker number; check:doc-authoring is green. The wording matches the error-handling and error-catalog pages and the 30906 changeset, so the texts agree as the card wanted.

packages/spec/src/migrations/registry.ts.template (STEP18 fragment cbp-optional-master-admitted). "A detail row that names no master is written by its owner and by Modify All on the detail, and refused with 403 for everyone else; only an empty required master reference is still refused with 422." Both clauses hold against the gate as above; the paragraph's subject is the optional master, so "names no master" is read in scope. The summary is at the precision of the ADR's own amendment line ("everyone else 403"), which is the register this upgrade-guide paragraph writes in; the entry's replacement carries the precise form. No finding.

No must-fix. One should-fix (S1), one escalation (③ item 2).

Implemented-by: claude/project-thread-9crw47
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: PASS

…rsede the pending read-side changeset's write line

The checkControlledByParentWrite docblock said the owner leg walks no chain;
it walks none for the master the record names, while a body that re-points
the row under another master still asks edit on that master
(askBodyNamedMaster). The spec changeset now names the stale "Writes are
unchanged ... 422" sentence of the pending 30905 changeset as superseded by
the write rule. Text only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5odNVgHTd2MQbQCp5t
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 082e8bf80665184fce72aa9120aaea14e3b9bcb5
Local-runs: none
Scope: packages/spec/src/contracts/security-service.ts, packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts, packages/spec/src/migrations/registry.ts.template + .changeset/31169-cbp-optional-master-entry-write-text.md (the delta's second file, judged as the coordinator ordered), packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts (assertControlledByParentWrite, the owner leg and askBodyNamedMaster, assertControlledByParentChainEditable), .changeset/30905-cbp-optional-master.md and .changeset/30906-cbp-null-master-write-gate.md (the pending changesets the new sentence names), packages/plugins/plugin-security/src/controlled-by-parent-owner-may-edit.test.ts (the re-pointing pin), docs/adr/0055-master-detail-controlled-by-parent.md (2026-10-26 amendment, "What the leg does not change"). Cited code read on origin/main (now fe97d85), which is byte-identical to the merge base 620b6e6 for every cited file; a merge-tree dry run of the new head onto origin/main is clean.

Inputs: the one-commit delta d3adb0d1..082e8bf8 (2 files, security-service.ts +2 net lines in the member docblock, the PR's own changeset one sentence added), the PR's file list at the new head (the same four paths, unchanged elsewhere), the prior PASS record 6710982325 on d3adb0d, and the 64 check-runs on 082e8bf: 55 completed success, 9 skipped (the opt-in and path-filtered jobs: 18.x upgrade harness, Build Docs, Dogfood matrix shards, Dogfood Verify CLI, Lint UI, Packed-tarball smoke, Schedule gate, Temporal Conformance, UI Published Artifacts), none in progress, none failed. Every family that was in progress on d3adb0d (Build Core, Test Core 1/8 to 8/8, Type Check · consumer gates incl. check:api-surface, Type Check · workspace and debt ledger, Lint · scoped · slot lookup and corpus, Live E2E UI, Build UI Deps) is now success, so ③ item 3 of the prior record is closed by the head itself.

① Derived judgments

This is a delta review over PASS 6710982325 (head d3adb0d): everything that record judged on the three hits files stands unchanged except the one docblock sentence below; the entry file and the template are byte-identical between the two heads. The delta adds no accept-set change, no type, export, signature or runtime branch, and no new published surface: it edits one JSDoc sentence that ships in dist/*.d.ts and one sentence of the PR's own changeset that ships as CHANGELOG text. Clause-②: no and Metadata-protocol: none remain right. The api-surface reports are still correctly untouched (they carry no docblock text). The grep of the head tree for the replaced sentences still finds no pin; the 30905 changeset is identical at the new head to origin/main, so the delta edits no landed file, as the coordinator's question requires.

② Semver level

Unchanged: '@objectstack/spec': patch, both line-initial declarations (Clause-②: no, Metadata-protocol: none) present and identical to the PR body's; Check Changeset is success on the new head. The added sentence declares no new package and rightly does not add @objectstack/plugin-security: this diff moves nothing in that package, and a changeset names only the packages the diff moves. Clause-②: no.

③ Boundary flags

  1. S1 of record 6710982325 ("walks no chain" over-claim): addressed by the delta's docblock sentence; judged in ④, holds. Closed.
  2. ③ item 2 of record 6710982325 (the stale write line in .changeset/30905-cbp-optional-master.md): addressed by the delta's changeset sentence, which supersedes the stale line in writing without editing that file; judged in ④, holds. Closed. One residual noted there, no fix owed.
  3. ③ item 3 of record 6710982325 (check:dual-build-cjs-loads NOT MEASURED locally): Build Core is success on the new head. Closed.
  4. No new dev flag, no new open question: the delta commit carries no os-dev-report of its own and raises none. Nothing left open.

④ Design judgment

packages/spec/src/contracts/security-service.ts, ISecurityService.checkControlledByParentWrite docblock. The reworded clause: "... asked of the read door under the caller's own context, and walks no chain for that master; a body that re-points the row under another master still asks EDIT on that master, as every body naming a new master does." Read against the gate on origin/main:

  • "walks no chain for that master" ("that master" is the master the record names, the stored masterId): when the owner leg's conditions hold and readUnlessRefused of readRowById(rel.master, masterId, context) returns a non-refused, non-null row, the gate calls askBodyNamedMaster([masterId]) and returns; assertControlledByParentChainEditable is never entered for masterId. True.
  • "a body that re-points the row under another master still asks EDIT on that master": inside askBodyNamedMaster([masterId]), a body carrying rel.fk as an own property with a non-null value not in judged calls assertControlledByParentChainEditable(permissionSets, object, rel, named, context, opCtx, masterEditDenied, denyMasterEdit), the same three master-edit legs the §3 walk runs, refusing with the walk's own 403 sentence. True; pinned by the owner-may-edit test "a body that RE-POINTS the row under another contract the owner only reads is refused: the owner may change the row, not move it", and the ADR's 2026-10-26 amendment says exactly this under "What the leg does not change".
  • "as every body naming a new master does": the §3 path ends in askBodyNamedMaster([masterId]) too, and the null-master block in askBodyNamedMaster([]), so a body naming a master not already judged is asked EDIT on every shape the gate judges; a body naming the stored master is in judged and is not asked again, which is why the sentence says "another master". True.
  • Tried to break it: an owner whose master read fails takes the §3 walk on the stored master and then the same askBodyNamedMaster([masterId]), so a re-pointing body is asked EDIT on the new master there too; the sentence stays true on that branch. A body that empties an optional master is excluded one clause earlier ("that does not empty an optional master") and unchanged by the delta. The served member builds no body, so the member's own allow is untouched. No finding.

.changeset/31169-cbp-optional-master-entry-write-text.md. The added sentence: "This supersedes the sentence "Writes are unchanged: an insert or update that leaves the master reference empty is still refused with 422" in the @objectstack/plugin-security note of .changeset/30905-cbp-optional-master.md: that was the state before the write gate followed, and the rule above is the one that ships."

  • The quoted sentence exists verbatim in .changeset/30905-cbp-optional-master.md on origin/main, at the end of its @objectstack/plugin-security bullet; the file is untouched at the new head. The attribution is exact.
  • "that was the state before the write gate followed": true; 30905 is the read-side PR's changeset and its sentence was true when written, as the ADR's 2026-11-06 amendment records and its 2026-11-07 note marks "no longer".
  • "the rule above is the one that ships": the rule the bullet states was verified sentence by sentence in record 6710982325 against the gate and is unchanged here. True.
  • Does it supersede without editing the landed file: yes. Both changesets declare @objectstack/spec, so both compile into packages/spec/CHANGELOG.md for the same release and the superseding sentence sits in the same notes as the stale one. Residual, noted and no fix owed: 30905 also compiles into packages/plugins/plugin-security/CHANGELOG.md, where this spec-only changeset does not reach; there the correction is carried by .changeset/30906-cbp-null-master-write-gate.md (@objectstack/plugin-security: minor, same release), whose body states "Writing such a row was refused with 422 MISSING_REQUIRED_FIELD. It is now judged" and the full owner rule. Adding @objectstack/plugin-security to this changeset to close that gap would be wrong, since the diff moves nothing in that package (②). The seat's choice is the correct one.
  • The sentence carries no tracker number beyond the changeset file name it must cite to name its target; Check Changeset and PR-body guards are success.

packages/spec/src/migrations/entries/semantic/18.cbp-optional-master-admitted.ts and packages/spec/src/migrations/registry.ts.template. Byte-identical to d3adb0d; the judgment of record 6710982325 stands: every sentence holds against the gate, no finding.

No must-fix, no should-fix. Both findings of record 6710982325 are closed by this head.

Implemented-by: claude/project-thread-9crw47
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

维护者速读

席位核对

Verdict: ACCEPT · head 082e8bf80665184fce72aa9120aaea14e3b9bcb5 · cards #31169 + #31180 (folded) · claims 6710037976 (+ amendment 6710876574) and 6710378726 · report 6710851706

  1. Shape: draft, base main, Fixes #31169 and Fixes #31180 on their own lines (both cards close on merge, as ordered); no other closing keyword. 4 files, +53/-8; three commits, one per card plus the review patch.
  2. Premise measured: the stale 422 sentence was on main at 620b6e6d; the new text was judged sentence by sentence against security-plugin-cbp-write-gate.ts by the review.
  3. Gates: every check-run on this head success (55) or skipped by its own path filter (9); mergeable_state: clean. check-governed-merges --pr 31210: NOT governed, 61 changed lines.
  4. Accepted deviations: registry.ts.template added to the file surface by claim amendment 6710876574 (same stale sentence, renders into the upgrade guide). check:dual-build-cjs-loads not measured locally; Build Core is green on the head.
  5. Out of scope and where it went: the stale write line in the pending .changeset/30905-cbp-optional-master.md is superseded in writing by this PR's own changeset (that file untouched, foreign-changeset rule); the plugin-security CHANGELOG carries the correction through .changeset/30906-cbp-null-master-write-gate.md. Nothing filed.
  6. Landing: Clause-②: no / Metadata-protocol: none, patch on @objectstack/spec; CONTRACT_REVIEW_TIER PASS 6710982325 plus delta PASS 6711327528 on this head. Release sentence checked: the changeset's two bullets and the supersede sentence match the diff.

Ruling-ref: the maintainer, 2026-11-07T16:00Z: 「你有 fable ,车道契约复审可以自己做」; 2026-11-07T16:59Z: 「你应该使用 pm-dispatch 技能审核和落地pr」; standing landing authorisation 2026-10-25T11:35Z: 「按照 pm-dispatch 规则复核过的都授权」.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 18:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 18:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 2b752f1 Nov 7, 2026
65 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9crw47 branch November 7, 2026 18:58
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/s tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant