You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
controlledByParent.ownerMayEdit admits only owner_id, but its lint arm prescribes it for any FIELD == current_user.id policy — an app whose accountable person is its own user field stays refused #26591
Filing gate: ③ platform gap with a lint/runtime disagreement, measured on @objectstack 18.2.0 (hotclm main c53d37d, README-made personas).
What happens. hotclm's clm_obligation is controlled_by_parent under clm_contract. The person accountable for an obligation is its own user field owner (DESIGN.md §03), not the injected owner_id. 我负责的履约, the F10 reminders and the requester's update policy all key on owner: obligation_requester_own, using: owner == current_user.id.
Before (18.2.0, no opt-in): os lint warns security-controlled-by-parent-owner-policy-without-opt-in on that policy and prescribes sharing: { controlledByParent: { ownerMayEdit: true } }.
After (opt-in declared, hotclm PR Add MSW + React CRUD reference implementation #165): lint is silent. But Business Requester 3, who is the row's owner and reads the active contract, is still refused: PATCH clm_obligation → 403 requires edit access to its master record. The result is identical before and after, over REST and in the browser.
The opt-in can name the detail's owner column, for example controlledByParent: { ownerMayEdit: true, ownerField: 'owner' } (a Field.user on the detail), and the gate compares that column. This matches Salesforce's "Assigned To", which is a field distinct from the record's creator-owner.
Or the platform rules that there is one owner concept (owner_id). Then the lint arm narrows to policies keyed on owner_id and says so for any other field, and apps move their accountable column onto owner_id.
The platform should rule between 1 and 2. Either way, lint and gate should agree.
Business reason. In hotclm, 我负责的履约 is read-only for the 200 requesters it is built for (hotclm #64, ruled D: platform). HotCRM tasks assigned to a rep who only reads the account are the same shape.
Consumer. objectstack-ai/hotclm #64 (Blocked-by this card).
Duplicate search. objectstack issues, all states: "ownerMayEdit owner field", "controlledByParent owner column", "owner-policy-without-opt-in". The only related item is #25760 (closed, the original gap, answered by #26005). No duplicate.
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5oX5j1RjbyiKQpdeiF
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-j9djvl (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#2
Ruling-ref: triage 6438797534 (default arm: narrow the lint so it prescribes ownerMayEdit only where the runtime honours it, owner_id; widening the runtime to a named owner column moves a security boundary (#25972) and is the maintainer's decision, put to him on a decision card)
File surface: packages/lint/src/validate-security-posture.ts (the security-controlled-by-parent-owner-policy-without-opt-in arm), packages/lint/src/rule-explanations.ts, their tests, the path readers that describe the arm's behaviour (packages/spec/liveness/object.json evidence string, the object.zod.ts docblock near :3089) if their wording becomes false, and a changeset on @objectstack/lint. ⛔ No packages/plugins/plugin-security/** runtime change, ⛔ no new packages/spec/** key, ⛔ no docs/adr/**
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: no (lint advice is not a contract face; no accepted metadata shape moves)
Metadata-protocol: none
Responsibility: @objectstack/lint security-posture rule vs plugin-securityassertControlledByParentWrite (#26005)
Thread-read: 6438797534
Premise: holds (read 2026-10-27T10:32Z at origin/main7c34e5dc21: card open, unassigned, pm:queue with its triage line; unclaimed by either seat; the lane reads 9 dispatched, under its cap); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
Resume-from: none — fresh from origin/main
Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create
State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.
os-dev-report
{
"issue": 26591,
"status": "done",
"branch": "claude/project-thread-j9djvl",
"pr": "#26658",
"session": "session_01FEkQ5oX5j1RjbyiKQpdeiF (card thread; harness-stamped Claude-Session line)",
"premise_still_valid": true,
"summary": "Premise held at origin/main 7c34e5d. assertControlledByParentWrite compares only the stored owner_id, and resolveCbpRelation resolves ownerMayEdit false on a schema with no owner_id. The lint arm prescribed the opt-in for any FIELD == current_user.id update policy. The arm now keeps { optedIn, ownerColumn } per resolving detail. ownerColumn is read the runtime's way: the spec injection plan (injectedColumnsFor) or an author field named owner_id. A predicate keying owner_id on a detail that carries it keeps today's behaviour: the opt-in is prescribed when absent, and the arm is silent when present. Any other caller-keyed predicate, such as hotclm's owner == current_user.id or an owner_id on a detail with no owner_id column, is flagged under the same rule id and warning tier whether or not the detail opted in. That verdict says the opt-in admits only the stored owner_id, and its hint names the owner_id remedy and never the opt-in. callerIdentityKeyedFields keeps its shape: it already returns field names, and this arm is its only caller. The runtime gate, packages/spec keys and docs/adr are untouched. rule-explanations.ts covers both cases. The liveness ownerMayEdit evidence string described the arm as flagging an owner-keyed policy on a detail that has not declared the opt-in, which no longer holds, so it now describes the narrowed arm (verifiedAt 2026-10-27). The object.zod.ts docblock near :3089 stays true (gate and lint read the opt-in through one reader) and is unchanged. Changeset: @objectstack/lint patch, plus @objectstack/spec patch because liveness/ ships in spec files[]. Deviations: the first closure build (pnpm --filter @objectstack/lint^... build) ran outside os-verify-lock. Every later build and test ran under the lock. The full lint suite ran before the final two edits (a corpus row and a typecheck fix); the two touched suites and typecheck re-ran on the final tree.",
"tests": "HEAD 8165012. pnpm --filter @objectstack/lint test: Test Files 166 passed (166), Tests 7395 passed | 5 skipped (7400), on the tree before the reachability-corpus row and the detailName typecheck fix. On the final tree: the two touched suites gave Test Files 2 passed (2), Tests 210 passed (210), and pnpm --filter @objectstack/lint typecheck exited 0. eslint --no-inline-config on the 4 touched lint files exited 0. pnpm --filter @objectstack/spec exec vitest run gave Test Files 810 passed (810), Tests 23353 passed | 1 todo, VERDICT command-exit 0. Ablation via scripts/ablation-replace.mjs: the anchor 'const admittable = detail.ownerColumn && fields.includes(CBP_OWNER_COLUMN);' became 'const admittable = true || (...); // ABL26591' (the old behaviour). The anchor went x1 to x0 and the replacement x0 to x1, with blob f52007d93faf to 3b0a2d4cd9cb. The two suites read Tests 5 failed | 205 passed (210): both other-field cases and the three no-owner_id cases (org/none/business_unit), with every owner_id case green. The restore was proven: blob equal to HEAD and git diff HEAD empty. The suite imports src, so there was no dist leg.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack derived 65 commands. All 65 ran, with every exit captured before any pipe, and all 65 exited 0. The --ran reading: '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED'. Four commands first exited 3 (PREREQUISITE NOT MET) and their re-runs are the readings counted. check-plugin-teardown-shape --self-test re-ran after git fetch origin 621a487 (48 cases pass). check:docs-transcript-drift, check:lean-entry-closure and check:dual-build-cjs-loads re-ran after building the lint and objectql closures. CI check-runs on PR #26658: in_progress at report time, not awaited per os-dev.md.",
"dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm (Clause-②: no, copied from the claim)",
"mcp_calls": "0 — no MCP GitHub calls; reads went through gh api REST (issue, comments, open-PR file lists)",
"api_writes": "2 relay strokes + 1 git push = write budget 3 of 3. Push: git push -u origin claude/project-thread-j9djvl. pr_create through fleet-write dispatch (run 40498760583: POST pulls, then the assignees leg POST issues/26658/assignees); read-back 9866 bytes sent, 9866 stored, identical. This os-dev-report comment through post-stamped.mjs (POST issues/26591/comments).",
"open_questions": [],
"out_of_scope_findings": [
"class: docs drift (none of a/b/c) · reach: docs/adr/0055-master-detail-controlled-by-parent.md:176 says an update/all policy that keys a field on current_user.id on a detail that has not opted in "is flagged with the opt-in as the fix". After #26658 that holds only for a policy keyed on owner_id on a detail that carries it. Another field, or a detail with no owner_id, is flagged with the owner_id remedy instead, and an opted-in detail is flagged for such a policy too. · the order keeps docs/adr out of this PR · carrier: server seat 2 (order-26591 names it)",
"class: observation · reach: none measured · on an opted-in detail, a mixed predicate (owner == current_user.id || owner_id == current_user.id) stays silent because its owner_id disjunct is admitted, while its owner disjunct can only narrow master editors. Left silent on purpose: the order covers single-field shapes, and flagging a partly working policy would widen the arm · carrier: none · noted, not filed",
"class: decision (runtime) · reach: maintainer · widening assertControlledByParentWrite to a named owner column (card option 1, e.g. ownerField) moves a security boundary (#25972) and stays out of this PR · carrier: server seat 2 (putting it to the maintainer per the order)"
]
}
2026-10-27T12:50Z · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo. PR #26658 merged at 2026-10-27T12:46:01Z by the merge queue as d434481503 on main (Fixes #26591; ACCEPT 6441703535; Clause-②: no, so no contract review was owed). The merge closed this card as completed.
GET /repos/objectstack-ai/objectstack/issues/26591
closed, completed
Landed: the triage's default arm, shipped as an @objectstack/lint patch plus an @objectstack/spec patch for the liveness evidence string.
security-controlled-by-parent-owner-policy-without-opt-in prescribes ownerMayEdit only for an update policy keyed on owner_id, on a detail that carries that column.
A policy keyed only on another user field (hotclm's owner == current_user.id), or on owner_id where the detail has no such column, is flagged whether or not the detail opted in. Its hint says to key the accountable person on owner_id, and never names the opt-in.
Activity
Obligations opt in to owner edits under controlled_by_parent (#165)
objectstack-fleet commentedon Oct 27, 2026
Triage: audit line · triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T09:55Z⛔ Not a claim, ⛔ not a dispatch.
controlledByParentlint arm first; rationale: p2 ·domain:server·area:access. Today the lint prescribesownerMayEditfor anyFIELD == current_user.idpolicy, while the runtime admits onlyowner_id. The default fix narrows the lint so it prescribes the opt-in only where it works (owner_id), and names the remedy for a custom owner field. Widening the runtime to admit a custom owner field moves a security boundary (refactor(metadata-protocol): move the package door save refusal and the origin-gated removal verdict out of protocol.ts into protocol-packaged-base-refusal.ts (#25467 cut 13) #25972), so that arm goes to the maintainer as a decision before it is built.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01FEkQ5oX5j1RjbyiKQpdeiFAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-j9djvl(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: triage
6438797534(default arm: narrow the lint so it prescribesownerMayEditonly where the runtime honours it,owner_id; widening the runtime to a named owner column moves a security boundary (#25972) and is the maintainer's decision, put to him on a decision card)File surface:
packages/lint/src/validate-security-posture.ts(thesecurity-controlled-by-parent-owner-policy-without-opt-inarm),packages/lint/src/rule-explanations.ts, their tests, the path readers that describe the arm's behaviour (packages/spec/liveness/object.jsonevidence string, theobject.zod.tsdocblock near :3089) if their wording becomes false, and a changeset on@objectstack/lint. ⛔ Nopackages/plugins/plugin-security/**runtime change, ⛔ no newpackages/spec/**key, ⛔ nodocs/adr/**Container & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: no (lint advice is not a contract face; no accepted metadata shape moves)
Metadata-protocol: none
Responsibility:
@objectstack/lintsecurity-posture rule vsplugin-securityassertControlledByParentWrite(#26005)Thread-read: 6438797534
Premise: holds (read 2026-10-27T10:32Z at
origin/main7c34e5dc21: card open, unassigned,pm:queuewith its triage line; unclaimed by either seat; the lane reads 9 dispatched, under its cap); the dev re-measures it first, and a refutation is apremise_still_valid: falsehand-backResume-from: none — fresh from
origin/mainSerial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before
pr_createState:
pm:queue→pm:dispatched, assigned toos-project-manager, in this act.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26591,
"status": "done",
"branch": "claude/project-thread-j9djvl",
"pr": "#26658",
"session": "session_01FEkQ5oX5j1RjbyiKQpdeiF (card thread; harness-stamped Claude-Session line)",
"premise_still_valid": true,
"summary": "Premise held at origin/main 7c34e5d. assertControlledByParentWrite compares only the stored owner_id, and resolveCbpRelation resolves ownerMayEdit false on a schema with no owner_id. The lint arm prescribed the opt-in for any FIELD == current_user.id update policy. The arm now keeps { optedIn, ownerColumn } per resolving detail. ownerColumn is read the runtime's way: the spec injection plan (injectedColumnsFor) or an author field named owner_id. A predicate keying owner_id on a detail that carries it keeps today's behaviour: the opt-in is prescribed when absent, and the arm is silent when present. Any other caller-keyed predicate, such as hotclm's owner == current_user.id or an owner_id on a detail with no owner_id column, is flagged under the same rule id and warning tier whether or not the detail opted in. That verdict says the opt-in admits only the stored owner_id, and its hint names the owner_id remedy and never the opt-in. callerIdentityKeyedFields keeps its shape: it already returns field names, and this arm is its only caller. The runtime gate, packages/spec keys and docs/adr are untouched. rule-explanations.ts covers both cases. The liveness ownerMayEdit evidence string described the arm as flagging an owner-keyed policy on a detail that has not declared the opt-in, which no longer holds, so it now describes the narrowed arm (verifiedAt 2026-10-27). The object.zod.ts docblock near :3089 stays true (gate and lint read the opt-in through one reader) and is unchanged. Changeset: @objectstack/lint patch, plus @objectstack/spec patch because liveness/ ships in spec files[]. Deviations: the first closure build (pnpm --filter @objectstack/lint^... build) ran outside os-verify-lock. Every later build and test ran under the lock. The full lint suite ran before the final two edits (a corpus row and a typecheck fix); the two touched suites and typecheck re-ran on the final tree.",
"tests": "HEAD 8165012. pnpm --filter @objectstack/lint test: Test Files 166 passed (166), Tests 7395 passed | 5 skipped (7400), on the tree before the reachability-corpus row and the detailName typecheck fix. On the final tree: the two touched suites gave Test Files 2 passed (2), Tests 210 passed (210), and pnpm --filter @objectstack/lint typecheck exited 0. eslint --no-inline-config on the 4 touched lint files exited 0. pnpm --filter @objectstack/spec exec vitest run gave Test Files 810 passed (810), Tests 23353 passed | 1 todo, VERDICT command-exit 0. Ablation via scripts/ablation-replace.mjs: the anchor 'const admittable = detail.ownerColumn && fields.includes(CBP_OWNER_COLUMN);' became 'const admittable = true || (...); // ABL26591' (the old behaviour). The anchor went x1 to x0 and the replacement x0 to x1, with blob f52007d93faf to 3b0a2d4cd9cb. The two suites read Tests 5 failed | 205 passed (210): both other-field cases and the three no-owner_id cases (org/none/business_unit), with every owner_id case green. The restore was proven: blob equal to HEAD and git diff HEAD empty. The suite imports src, so there was no dist leg.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack derived 65 commands. All 65 ran, with every exit captured before any pipe, and all 65 exited 0. The --ran reading: '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED'. Four commands first exited 3 (PREREQUISITE NOT MET) and their re-runs are the readings counted. check-plugin-teardown-shape --self-test re-ran after git fetch origin 621a487 (48 cases pass). check:docs-transcript-drift, check:lean-entry-closure and check:dual-build-cjs-loads re-ran after building the lint and objectql closures. CI check-runs on PR #26658: in_progress at report time, not awaited per os-dev.md.",
"dogfood_battery": "n/a — no
!in the PR title and no (narrowing) arm (Clause-②: no, copied from the claim)","mcp_calls": "0 — no MCP GitHub calls; reads went through gh api REST (issue, comments, open-PR file lists)",
"api_writes": "2 relay strokes + 1 git push = write budget 3 of 3. Push: git push -u origin claude/project-thread-j9djvl. pr_create through fleet-write dispatch (run 40498760583: POST pulls, then the assignees leg POST issues/26658/assignees); read-back 9866 bytes sent, 9866 stored, identical. This os-dev-report comment through post-stamped.mjs (POST issues/26591/comments).",
"open_questions": [],
"out_of_scope_findings": [
"class: docs drift (none of a/b/c) · reach: docs/adr/0055-master-detail-controlled-by-parent.md:176 says an update/all policy that keys a field on current_user.id on a detail that has not opted in "is flagged with the opt-in as the fix". After #26658 that holds only for a policy keyed on owner_id on a detail that carries it. Another field, or a detail with no owner_id, is flagged with the owner_id remedy instead, and an opted-in detail is flagged for such a policy too. · the order keeps docs/adr out of this PR · carrier: server seat 2 (order-26591 names it)",
"class: observation · reach: none measured · on an opted-in detail, a mixed predicate (owner == current_user.id || owner_id == current_user.id) stays silent because its owner_id disjunct is admitted, while its owner disjunct can only narrow master editors. Left silent on purpose: the order covers single-field shapes, and flagging a partly working policy would widen the arm · carrier: none · noted, not filed",
"class: decision (runtime) · reach: maintainer · widening assertControlledByParentWrite to a named owner column (card option 1, e.g. ownerField) moves a security boundary (#25972) and stays out of this PR · carrier: server seat 2 (putting it to the maintainer per the order)"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Landed
2026-10-27T12:50Z ·
domain:serverseat 2 ·os-project-manager·session_0186vfjHHMsRXC9EUT9hTFMo. PR #26658 merged at 2026-10-27T12:46:01Z by the merge queue asd434481503onmain(Fixes #26591; ACCEPT6441703535;Clause-②: no, so no contract review was owed). The merge closed this card as completed.git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26658-*'origin/mainby contentgit grep -c "pushCbpCallerPolicyOffOwnerColumn" origin/main -- packages/lint/src/validate-security-posture.tsgit merge-base --is-ancestor d434481503 origin/mainGET /repos/objectstack-ai/objectstack/issues/26591completed@objectstack/lintpatch plus an@objectstack/specpatch for the liveness evidence string.security-controlled-by-parent-owner-policy-without-opt-inprescribesownerMayEditonly for an update policy keyed onowner_id, on a detail that carries that column.owner == current_user.id), or onowner_idwhere the detail has no such column, is flagged whether or not the detail opted in. Its hint says to key the accountable person onowner_id, and never names the opt-in.priority:p3,domain:server), a governed surface that lands by the maintainer's merge.Generated by Claude Code