Skip to content

controlled_by_parent admits an optional master (2/3): the write gate judges null-master rows (owner or modify-all), clearing a master needs edit on it, and the verify proof #30906

Open

Description

@objectstack-fleet

epic:#3600#1 · hotlong · session_01Q4EjjPXwX1YfHyKJUh4agu

Filing-gate category: ② maintainer-directed task. Maintainer ruling, verbatim (Claude Code session, 2026-11-06): 「A」. The ruling: controlled_by_parent admits an optional master, and a detail row with no master is visible only to its owner (Salesforce parity: a contact without an account is a private contact). Rejected: B, a membership key collecting its object's own id; C, one-hop RLS traversal; D, no platform change. Routing, verbatim: 「能交给车队开发的你不要自己开发」. The plan was re-read by an isolated contract-review-tier reviewer before filing (PASS WITH FIXES, fixes folded in). It lands in three serial cards (this one and its two siblings), each safe on its own.

Why (measured 2026-11-06, the first live scenario run, objectstack-ai/cloud#3600)

The rule "a rep sees the contacts of accounts they own; a contact need not have an account" has no declarative form in spec 18.3.0:

  • RLS account.owner == current_user.id is refused: rls-predicate-unenforceable (packages/lint/src/validate-rls-predicate-enforceability.ts:316).
  • A membership key with valueField: 'id' is refused: membership-key-value-field-invalid (validate-membership-key-declarations.ts:80).
  • controlled_by_parent with required: false on the master is refused at two doors: ObjectSchema.create() (packages/spec/src/data/object.zod.ts:4406-4417) and the parse refinement, shape master-not-required (object.zod.ts:2743-2752, controlled-by-parent-master.ts:178-185).

The only working route today is a copied owner field plus two hooks. The AI-built app took that route, and its hook failed verify.

Clause-②: yes. Card 2 of 3: write gate and proof.

Blocked-by: #30905

Ask

  1. Null-master writes inside the vouched gate. Step 2.7 of the authz middleware drops the platform ownership floor for every cbp detail on the vouch that the master gate covers the write (security-plugin-authz-middleware.ts:1103, security-plugin.ts:4153-4158, platform-ownership-policies.ts:326-337). So the null-master rule must be judged by assertControlledByParentWrite itself:
    • update / delete of a null-master row: admit only when owner_id is non-null and equals the caller's userId, or the caller holds modifyAllRecords on the detail. Deny otherwise, including when the object has no owner_id column;
    • the predicate path (controlledByParentPredicateWriteMasters, cbp-write-gate.ts:694) applies the same rule per selected null-master row instead of throwing;
    • the on-behalf-of path runs it on both passes.
  2. Create with a null master: allowed with create on the detail; the creator becomes the owner (today: 422 MasterReferenceMissingError, cbp-write-gate.ts:384-477).
  3. Setting a master (null → M, M1 → M2): the existing gate, judged on the new master.
  4. Clearing a master (M → null): the §3 walk with edit on the stored master. A body that sets the master to null is not covered by the ownerMayEdit leg (:526-540; askBodyNamedMaster returns early on a null value, :311-313). Without this, an owner with read-only access to the account could detach a contact and make it private to themselves. After clearing, the row follows the owner rule, which narrows and never widens.
  5. Chains, write side. A hop whose master row has a null master ends the chain there. The caller must satisfy item 1 on that row (today the walk denies master_chain on a null FK, :636-643).
  6. Ownership transfer stays gated by allowTransfer (permission-evaluator.ts:57-59); no change.
  7. Proof (ADR-0054). @objectstack/verify synthesis already skips non-required relations (parent-write-reach.ts:643,793), so it produces null-master details by default. Prove both an explicit-master case and a null-master case.
  8. Tests: each write case, the ownerMayEdit clearing refusal, the chain hop, the on-behalf-of pass, the predicate path.

Reader: the domain:server lane seat, from pm:queue once card 1 closes. Contract-tier review owed.

Activity

  1. added
    priority:p1High: required for production / M2
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 6, 2026
  2. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: audit · p1 kept · domain:server · pm:blocked · 2026-11-06T10:57Z

    ⛔ Not a claim.

    • controlled_by_parent admits an optional master (2/3): the write gate judges a null master. p1 kept as filed; it is an access-path card. Blocked-by holds.

    Generated by Claude Code

  3. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    pm:queueReady for the PM dispatch loop
    on Nov 6, 2026
  4. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · sunhaolin · session_017xRBcTJTE7kSbcMoJQ4Txf

    Claim: PM loop round 1 · 2026-11-07T02:54Z
    Session: session_017xRBcTJTE7kSbcMoJQ4Txf
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-30906-null-master-write-gate
    Worktree: objectstack-issue-30906
    Domain: domain:server
    Seat: domain:server#1
    File surface: packages/plugins/plugin-security/src/ (security-plugin-cbp-write-gate.ts and its predicate and on-behalf-of paths; security-plugin-cbp-relation.ts only if the gate needs a relation fact it does not carry, plus its omissionRefusedByValidation comment this change makes false; explain-engine.ts for the null-master refusal's narration), packages/verify/src/parent-write-reach.ts (the ADR-0054 proof: an explicit-master case and a null-master case), their tests, one changeset. ⛔ packages/spec/** (card 1, #30905, landed the spec half; a needed spec edit stops and reports), ⛔ skills/** and content/docs/** (card 3, #30907), ⛔ docs/adr/**. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: xhigh (p1, access-path write gate)
    Clause-②: yes
    Metadata-protocol: semantic
    Responsibility: the code that produces the risk is assertControlledByParentWrite in packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts in objectstack (a null master throws MasterReferenceMissingError on create, update and the predicate path) | no platform path covers it: #30905 admitted the declaration and the read owner branch only | it is reached by any app that declares a controlled_by_parent object with an optional master since #30940 merged; used today by the objectstack-ai/cloud#3600 scenario
    Thread-read: 6683232535
    Premise: holds (read on origin/main 3f61e4f8 at 2026-11-07T02:54Z: MasterReferenceMissingError is still thrown on a null master in the create, update and predicate paths of security-plugin-cbp-write-gate.ts; Blocked-by: #30905 is closed, its PR #30940 merged as 6106b730)
    Serial constraints cleared: none. No in-flight domain:server claim declares plugin-security or verify (#31048, #30922, #30892, #30803 and #29594 read). Same-day merges on these paths: 6106b730 (#30940, card 1), cad764d3 (#31063), eb96480d (#31031). The dev works on the merged code. #30907 (card 3) stays pm:blocked on this card.

    On Clause-② (corrected in review of PR #31082): yes. The seat first wrote no and missed the public surface: ParentWriteReachReach, exported from @objectstack/verify's entry, gains masters: 'none', masterRead: 'none' and an optional unreadableDetails. That widens a published type, so a TIER_DEFAULT review record by the lane seat is owed. The runtime permission change itself is the human-floor class, which the maintainer ruled (「A」, quoted in the card body).

  5. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · sunhaolin · session_017xRBcTJTE7kSbcMoJQ4Txf
    os-dev-report

    {
     "issue": 30906,
     "status": "done",
     "branch": "claude/issue-30906-null-master-write-gate",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/31082",
     "session": "session_017xRBcTJTE7kSbcMoJQ4Txf",
     "premise_still_valid": true,
     "summary": "assertControlledByParentWrite now judges a row whose OPTIONAL master is empty instead of refusing it 422: its owner (owner_id equal to the principal) or a holder of modifyAllRecords on the row's object writes it, everyone else gets 403 PERMISSION_DENIED (details name the row, leg master_chain); this one rule (nullMasterRowWritable) runs on the by-id path, on each null-master row a predicate write selects, on a chain hop whose master names no master of its own, and on both on-behalf-of passes. An insert naming no optional master is admitted (creator owns it); setting a master asks the row's owner rule and then edit on the new master; emptying one asks edit on the stored master and the ownerMayEdit leg steps aside for such a body (card item 4); required masters and the frozen #9137 branch are byte-for-byte unchanged, ownership transfer stays on allowTransfer. security/explain names the owner rule on a null-master refusal, and @objectstack/verify's parent write reach family proves an optional-master detail in both cases (explicit master as every detail; a new masters: 'none' reach judged by the owner rule). Zone 2 measured: (1) holds - step 2.7 hands the floor to the gate (masterGateCoversThisWrite = !delegatorSets), and ablation A1 shows a View-All reader's by-id update of another's row LANDS with the owner rule removed; (2) partly - verify's seedRecord skips non-required relations, but every probe pinned the master explicitly, so no null-master record was produced until this change. Card assignee untouched; the PR carries no assignee (see deviations).",
     "tests": "Full suites on final head e6bb5230 (after merging origin/main 0f920b94): pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 → Test Files 248 passed (248), Tests 5796 passed | 45 skipped (5841), VERDICT command-exit 0; pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2 → Test Files 48 passed (48), Tests 409 passed (409), VERDICT command-exit 0; pnpm --filter @objectstack/plugin-security typecheck and pnpm --filter @objectstack/verify typecheck (each incl. check:test-typecheck OK, 0 debt) → exit 0. Target file src/controlled-by-parent-optional-master.test.ts 47/47 (new real-engine block: SQL driver, real SharingService, member_default floor; every refusal asserts code+status+details{operation,object,recordId} and the stored row). verify src/parent-write-reach.test.ts 11/11; the summary pin moved to measured totals (objects 4, reaches probed 41 / unreadable 4, probes edit 79 delete 79 create 41, held 199), matching the hand derivation written beside it. Ablations via scripts/ablation-replace.mjs (anchor must hit, restore proven blob==HEAD, git diff HEAD empty), plugin-security imports src so no build leg: A1 owner rule admits all → 10 failed | 35 passed; A2 ownerMayEdit covers an emptying body → 1 failed; A3 predicate asks body master only with masters → 1; A4 null→M body unasked → 1 (first attempt was a NO-OP: tool refused because the replacement text already occurred; reading void, re-run in --delete mode, red); A5 chain hop unjudged → 1; A7 predicate null rows unjudged → 1; A8 by-id null row unjudged → 8; A9 explain without the null-master arg → 1 of 47. Dist-mediated verify ablation (A1): mutate → build plugin-security → ablation-dist-preflight marker present in dist/index.js and index.mjs → verify run: 'pwr_contact ... CONTRADICTED on 2 of 64 write(s)' (position:pwr_manager edit/delete of another's null-master contact admitted) → restore → rebuild → preflight --absent dist reading PASS (tree reading exit 3 only because my uncommitted pin update was dirty; source blob == HEAD) → green re-run 11/11. eslint --no-inline-config over the 6 changed source files → exit 0.",
     "gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; 7 paths vs merge base 0f920b941, head e6bb5230) derived 63 commands; each run with exit recorded before any pipe; --ran reconciliation: '63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)', 0 UNRUN, reconcile exit 0. NOT MEASURED: pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: every package's dist/, a whole-tree build), pnpm check:i18n (exit 3, PREREQUISITE NOT MET: the built CLI + its extract closure) - both run in CI Lint & Repo Gates. node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3 (pinned positive-control commit 621a4876 absent from this shallow clone); fetched that one commit, re-run exit 0. All other 60 exit 0. check-platform-checklist --changed-from not run: no exported symbol renamed or deleted (one export added in a non-re-exported module). Repo-wide pnpm lint not run locally (CI-owned per os-dev.md; see deviations). CI on PR #31082 head e6bb5230 at report time: in_progress (Lint · scoped · tooling self-tests, Lint · ESLint queued, Type Check · source/consumer gates/workspace/debt ledger, Lint · repo & tooling gates, Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size all in_progress/queued; opt-in jobs skipped). Not waited on.",
     "dogfood_battery": "n/a — no `!` in the PR title and Clause-② is no (no (narrowing) arm)",
     "mcp_calls": "0 — no MCP GitHub calls",
     "api_writes": "2 — (1) pr_create through the fleet-write relay (repository_dispatch request fw-20261107T040121Z-b4b1a3, run 42336570901 success → POST /repos/objectstack-ai/objectstack/pulls, draft forced; read-back 11243 bytes sent = stored, assignees none); (2) this os-dev-report comment through scripts/pm/post-stamped.mjs → POST /repos/objectstack-ai/objectstack/issues/30906/comments. git push (not a REST write): branch probe + 6 pushes. Write budget used: git push, pr_create, os-dev-report — the three budgeted, no other.",
     "open_questions": [
      {
       "question": "Which ControlledByParentWriteDenialLeg does the null-master owner-rule refusal carry? The vocabulary is a closed spec type and packages/spec is outside this claim, so this PR uses master_chain.",
       "options": [
        "A keep master_chain: its spec text already covers 'a master that is itself controlled_by_parent ... has an empty master reference' (the chain hop), and for the record itself the chain ends at the row; explain names the owner rule from the record (cost: none; the leg name is approximate for the record's own case)",
        "B add a dedicated leg (e.g. null_master_owner) to the closed spec union and to explain's MASTER_DENIAL_LEG_DETAIL (cost: a spec contract change on a type consumers branch on exhaustively, contract-tier review, a minor on @objectstack/spec; ~1 small PR)"
       ],
       "recommendation": "A, because the served member's consumers (attachment and comment parent gates) only distinguish allow/not_applicable from the rest, explain now narrates the rule from the record, and B buys a name at the price of a contract change"
      },
      {
       "question": "ADR-0055's 2026-11-06 amendment says §3 is unchanged and that the record 'is amended again for §3 then'; this PR changes §3 but docs/adr is outside the claim (Tier H). Where does the §3 amendment land?",
       "options": [
        "A fold it into card 3 (#30907), which is already a Tier H docs/skills PR teaching the same ruling",
        "B a separate ADR-only governed PR by the seat after this one lands"
       ],
       "recommendation": "A, because #30907 already carries a Tier H surface and the same ruling, so it adds no new tier and keeps the record and the teaching text in one review"
      }
     ],
     "security_findings": "none",
     "out_of_scope_findings": [
      "carrier: #30907 · noted, not filed — security-plugin-cbp-relation.ts's comment on omissionRefusedByValidation still says 'the write gate is unchanged by that ruling: such an insert is still refused' (relation file is outside this claim unless the gate needs a new relation fact; it did not)",
      "carrier: #30907 · noted, not filed — spec contract jsdoc of ControlledByParentWriteDenialLeg.master_chain and ControlledByParentWriteUnresolvedReason.master_reference_missing (packages/spec/src/contracts/security-service.ts) do not mention the optional master: an empty stored reference of an optional master is now allow or deny, not unresolvable",
      "carrier: domain:server#1 seat · noted, not filed — ADR-0055 §3 amendment owed (see open_questions)"
     ],
     "line_budget": "1085 changed lines (+1033 / -52) over 7 files vs the 3000 human-merge threshold: under. Per file: changeset +21; plugin-security optional-master test +501/-16; explain-engine.ts +17/-1; security-plugin-cbp-write-gate.ts +199/-10; security-plugin.ts +61/-7; verify parent-write-reach.test.ts +72/-10; verify parent-write-reach.ts +162/-8. skills/** untouched: no skill line-ratchet reading owed.",
     "deviations": [
      "PR assignee NOT set: the order and os-dev.md rule 2 say pr_create carries sunhaolin, but AGENTS.md (wins over other instruction files) says a dispatched executor never writes the PR's assignee ('pr_create carries none; the seat sets it at hand-over') and the relay's pr_create descriptor says 'a dev's create carries no assignees'. Followed AGENTS.md; the seat assigns at hand-over.",
      "Repo-wide pnpm lint not run locally: os-dev.md makes the repo-wide scan CI's (Lint · ESLint on the PR); ran pnpm exec eslint --no-inline-config over the 6 changed source files instead (exit 0). Conflict with the order noted, not silently resolved.",
      "Touched packages/plugins/plugin-security/src/explain-engine.ts (+17/-1): inside the claim's packages/plugins/plugin-security/src/ directory surface but outside the files its parenthetical names; without it security/explain narrates a null-master refusal as an unwalkable chain (before this change it accurately said 'master reference is empty, 422').",
      "The first derived-gate union ran while explain-engine.ts was still being edited; stopped it by its recorded PID and re-ran the whole union on final head e6bb5230 (readings above are only from the re-run).",
      "Merged origin/main 0f920b94 into the branch before opening the PR (AGENTS.md §10) and rebuilt verify's closure; origin/main has since moved 3 commits (cb8f5200) that dispatch-gates reports touch none of this answer's inputs; not merged again.",
      "Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By line.",
      "Ablation A4's first attempt was a no-op (refused by the tool); re-run in --delete mode. The verify ablation's restore-leg tree reading exited 3 because my own uncommitted pin update was dirty (not mutation residue); the dist reading passed and the source blob matched HEAD."
     ],
     "files_changed": [
      ".changeset/30906-cbp-null-master-write-gate.md",
      "packages/plugins/plugin-security/src/controlled-by-parent-optional-master.test.ts",
      "packages/plugins/plugin-security/src/explain-engine.ts",
      "packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts",
      "packages/plugins/plugin-security/src/security-plugin.ts",
      "packages/verify/src/parent-write-reach.test.ts",
      "packages/verify/src/parent-write-reach.ts"
     ]
    }
  6. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#1 · sunhaolin · session_017xRBcTJTE7kSbcMoJQ4Txf
    os-dev-report

    {
     "issue": 30906,
     "status": "done",
     "round": "patch round 1 on PR #31082",
     "branch": "claude/issue-30906-null-master-write-gate",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/31082",
     "session": "session_017xRBcTJTE7kSbcMoJQ4Txf",
     "head": "5fe5cdb4 (was e6bb5230; one commit, pushed, remote ref verified)",
     "premise_still_valid": true,
     "summary": "Two edits in one commit. (1) .changeset/30906-cbp-null-master-write-gate.md: 'Clause-②: no' → 'Clause-②: yes' (Metadata-protocol and Skills-docs unchanged). (2) security-plugin-cbp-relation.ts, the omissionRefusedByValidation comment: the false clause 'the write gate is unchanged by that ruling: such an insert is still refused' now says the gate admits such an insert by the optional master's own rule (the row is then its creator's), never by handing the omission to validation. Comment only, no code change. No merge of origin/main: GitHub reports mergeable true (no conflict). Seat answers acknowledged: Q1 A (keep master_chain, no spec change), Q2 A (ADR-0055 §3 amendment rides #30907).",
     "pr_body": "NOT patched by this dev — os-dev.md: 'PR 正文 dev 只在 pr_create 写,⛔ 不 PATCH;改法报告点名,席位经 seat-post-protocol.md 代写', which conflicts with the order's relay issue_patch; os-dev.md wins on a conflict, flagged here rather than chosen silently. The patched body is ready for the seat at /tmp/claude-0/-home-user-objectstack/b4c2ffe8-1f36-5713-97f1-a214e3c1b75f/scratchpad/issue-30906/pr-body-patch1.md (11086 bytes): the stored body (11243 bytes, live read equal to what pr_create sent) with exactly two lines changed — line 2 'Clause-②: no' → 'Clause-②: yes', and line 59 with the clause about security-plugin-cbp-relation.ts's comment removed from 'Prose owed elsewhere'; the footer last line kept byte-identical.",
     "tests": "On head 5fe5cdb4: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/controlled-by-parent-optional-master.test.ts → exit 0, Test Files 1 passed (1), Tests 47 passed (47), VERDICT command-exit 0; pnpm --filter @objectstack/plugin-security typecheck → exit 0 (check:test-typecheck OK, 0 debt), VERDICT command-exit 0. The worktree was recreated (removed after round 0), pnpm install --frozen-lockfile exit 0, plugin-security's dependency closure restored from the turbo cache (18 of 18 cached).",
     "gates": "Gates reading the two changed files, on 5fe5cdb4, exit recorded before any pipe: node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 ('this PR adds no declared-breaking changeset'); node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0; node scripts/check-empty-changeset.mjs --base origin/main :: exit 0; pnpm check:doc-authoring :: exit 0; pnpm check:nul-bytes :: exit 0. The full derived union was not re-run for this round (the order scoped it to the test file and typecheck; the round-0 union on e6bb5230 stands for the unchanged files). CI on the new head: not waited on.",
     "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
     "mcp_calls": "0",
     "api_writes": "1 — this delta comment through scripts/pm/post-stamped.mjs → POST /repos/objectstack-ai/objectstack/issues/30906/comments. git push: 1. No PR body write (see pr_body).",
     "open_questions": [],
     "security_findings": "none",
     "out_of_scope_findings": [
      "carrier: #30907 · noted, not filed — spec contract jsdoc of master_reference_missing / master_chain does not mention the optional master (Acceptance note, as the seat ruled)"
     ],
     "deviations": [
      "PR body not patched: os-dev.md forbids a dev PATCH of the PR body after pr_create; the exact patched body is handed to the seat (see pr_body).",
      "Commit trailers: AGENTS.md's model-free pair, not the harness reminder's model-named line."
     ],
     "files_changed": [
      ".changeset/30906-cbp-null-master-write-gate.md",
      "packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts"
     ]
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:serverDomain lane — seat card indexed by label:pm:seatpm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-setpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions