Repository navigation
spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464
Description
Activity
- addedpriority:p1High: required for production / M2High: required for production / M2area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsand removed
on Oct 9, 2026 objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-09T12:27Z
Session:session_01DhTqaEHqPVSVnAkjG3jywn
Account:os-sales(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-22464-security-master-detail-member
Worktree:objectstack-issue-22464
Domain:domain:spec
Seat:domain:spec#2(seat post #18549)
File surface (atorigin/maindee7692f0or later; stop on breach and explain in the report):packages/spec/src/contracts/security-service.ts.- It gains one optional, feature-detected
ISecurityServicemember. The member answers the existing ADR-0055 master-detail write check for an update of(object, recordId)in the caller's context. - It also gains the member's outcome type:
- the check's authorization legs (deny);
- not-applicable (the object is not
controlled_by_parent); - the check's non-verdict outcomes (a broken declaration, a missing master row, a null master FK);
- the store fault, which keeps its declared
503.
- The TSDoc says three things, as the card asks:
- the answer equals what a by-id update of the same record gets, ADR-0090 D10 delegator leg included;
- a kernel without the member has no master check, and is not a kernel that admits by policy;
- every consumer must handle the member's absence.
- The member's name and the outcome type's shape follow the file's own precedents (
checkAuthoredRowWrite/AuthoredRowWriteVerdict, and PR feat(spec): ISecurityService declares discardPermissionSetOverlay and contributeOwnershipFloorAlternates as optional, feature-detected members #21781's two optional members). The report names both, because security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 consumes them.
- It gains one optional, feature-detected
packages/spec/src/contracts/security-service.test.ts. It gets the contract-test row, plus a type-level pin that a consumer which does not handle absence fails to compile, matching the existing optional members.- The generated spec artifacts this changes (
packages/spec/api-surface/**,api-surface-signatures.json, and any generated reference page), regenerated by the spec's own generators, never by hand. - Changeset:
.changeset/22464-*.md,@objectstack/specminor(a contract member is added; no accept-set changes). - ⛔ Not security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455's half: no
plugin-securityserving, noDECLARED_MEMBERSpin row, noservice-storageorplugin-auditgate, and no change toeffectiveSharingModelor tocheckEdit's abstain set.
Container & model:S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; the Clause-② suspect line namespackages/spec/src/**, so the contract review is owed atCONTRACT_REVIEW_TIERbefore enqueue).
Clause-②: yes (widening: one optional member and its outcome type join the publishedISecurityServicecontract)
Responsibility:service-storage's attachment gates andplugin-audit's comment delete limb admit on acontrolled_by_parentparent without the master-detail write check (security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455's measured reproduction6079124329) |SecurityPlugin.assertControlledByParentWritealready decides it for a by-id update, but nothing outsideplugin-securitycan reach it, and this card declares the seam | a member who can read a master but not edit it reaches it today, through attach and through deleting another user's file on its detail
Thread-read: none
Serial constraints cleared: the 9 open PRs' file lists were read at this stamp, and none touchescontracts/security-service.ts, its test orpackages/spec/api-surface/**. PR feat(core,cli,verify): bootStack composes what serve composes — item 1 stage 2 of #22301 (HELD at stop conditions) #22381 (this seat, in the merge queue) touches nopackages/specfile. security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 (domain:services) isBlocked-by:this card.
Generated by Claude Code
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsClaim amendment to
6080875401·domain:specseat 2 (#18549) ·session_01DhTqaEHqPVSVnAkjG3jywn· 2026-10-09T12:28Z. Thread-read: 6080875401.The file surface gains one row in one
plugin-securitytest file, because the compiler forces it.- What forces it. Measured on
origin/maindee7692f0:packages/plugins/plugin-security/src/registered-security-service-members.pin.test.tsholdsDECLARED_MEMBERSwithas const satisfies DeclaredOptionality. That type maps every key ofISecurityServicewith-?, so it is "held equal to the interface by the compiler" (its own header).- Declaring a new member on
ISecurityServicewithout a row there failsplugin-security's test typecheck on a missing key.
- Declaring a new member on
- The added row: exactly one,
NEW_MEMBER: 'optional', inDECLARED_MEMBERS. That is bookkeeping of the declaration, not serving.- The pin's served-side check (served ⊆ declared,
SERVED_NOT_DECLAREDempty) is unchanged. An optional member that is declared and not yet served passes it.
- The pin's served-side check (served ⊆ declared,
- Still security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455's:
plugin-securityserving the member fromassertControlledByParentWrite, and every gate. - Declared cross-lane on the
domain:servicesseat post [PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021 in this act.
Generated by Claude Code
- What forces it. Measured on
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22464,
"status": "done",
"branch": "claude/issue-22464-security-master-detail-member",
"pr": "#22492",
"session": "session_01DhTqaEHqPVSVnAkjG3jywn (subagent: the dispatching seat's session)",
"premise_still_valid": true,
"summary": "ISecurityService now declares the optional, feature-detected member checkControlledByParentWrite(object: string, recordId: string, context?: SecurityContext): Promise of ControlledByParentWriteOutcome, in packages/spec/src/contracts/security-service.ts. ControlledByParentWriteOutcome is a discriminated union onoutcome: allow; deny with leg (ControlledByParentWriteDenialLeg: object_permission, row_level_security, record_sharing, master_chain); not_applicable; and unresolvable with reason (ControlledByParentWriteUnresolvedReason: master_detail_relation_missing, record_not_found, master_reference_missing). A store fault is not an arm: the member rejects with the engine's own error, so it keeps its declared 503. The TSDoc states the three things the card asks for (the answer equals what a by-id update gets from this check, D10 delegator leg included; absence is no master check, not an admitting policy; optional, so every consumer handles absence). It also pins the edge contexts: system answers allow, an empty set list answers deny/object_permission, and the three context refusals (no principal, set resolution failure, dangling delegator) reject with the same 403. Three contract-test rows were added, plus the one compiler-forced DECLARED_MEMBERS row in plugin-security, regenerated api-surface/export-origins (+3 type-only exports each) and a minor changeset. No runtime source changes; the member is declared, not served (that is #22455's).",
"tests": "All at 1a4557d, under os-verify-lock. (1) pnpm --filter @objectstack/spec build: VERDICT command-exit 0. (2) check:generated: exit 1, exactly api-surface/ and export-origins/ stale; check:generated --fix regenerated both, and both now pass. api-surface-signatures.json (defineX factories only) and the reference pages (check:docs) are unchanged. (3) spec typecheck: exit 0; security-service.test.ts carries no debt entry, so its ts-expect-error lines are live. (4) spec vitest --project local: Test Files 630 passed (630), Tests 18796 passed | 1 todo. (5) spec vitest --project repo: Test Files 54 passed (54), Tests 915 passed (915). (6) turbo build --filter='@objectstack/plugin-security^...': 17/17. (7) plugin-security typecheck: exit 0; its test layer reports 0 file(s) / 0 error(s). (8) plugin-security vitest of the pin file: Tests 3 passed (3). Ablations ran from committed 1a4557d through scripts/ablation-replace.mjs wrap mode: each anchor hit exactly once, each landing was proven on disk, and each restore was proven as blob == HEAD with an empty git diff HEAD. Each leg ran the package's check-test-typecheck gate plus raw tsc -p tsconfig.test.json. Controls C-spec and C-psec were green with 0 diagnostics. A1 (member made required): red, (659,7) TS2578 at the unguarded-call pin, plus (70,3) TS2322 at makeService and (122,11) TS2322 at REQUIRED_MEMBERS. A2 (deny leg optional): red, (697,5) TS2578. A6 (unresolvable reason optional): red, (699,5) TS2578. A3 ('store_fault' added as a reason): red, (725,5) TS2578. A4 ('abstain' added to an arm): red, (705,5) TS2578 plus (740,17) TS2322 at the exhaustive switch's never. A5 (DECLARED_MEMBERS row deleted): plugin-security red, pin.test.ts(79,12) TS1360 at the satisfies DeclaredOptionality clause. R5 (restored): green. Every observation matched its written prediction. The first A4 attempt was a no-op: its replacement contained the anchor, and the tool refused before running. A4 was re-run with a corrected anchor. The plugin-security leg reads spec through dist/, and the unmutated row compiling green proves that program read the rebuilt .d.ts. The three runtime rows run against stubs, so mutating the contract cannot redden them, and no ablation applies to them. Lint, narrowed (a measurement): eslint --no-inline-config --format json over the 3 changed .ts files reports files 3 errors 0 warnings 0; all three resolve under eslint --print-config; no parserOptions.project or projectService is set, so no untouched file's verdict can move.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 1a4557d derived 90 commands from 6 paths, and all 90 were run with exit codes recorded before any pipe. On the first pass, 88 exited 0, and check:i18n and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET). check:dts-closure and check:sourcemap-no-sources-content exited 0, but over only the 17 packages built at that point. After a full workspace turbo build (72/72 tasks, 71 cached), all four were re-run and exited 0: check-i18n-bundles OK (9 packages); 107 require entry points across 66 packages load; dts-closure swept 72 packages; sourcemaps swept 68 packages. dispatch-gates --ran: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. CI at 1a4557d: in_progress (10 success, 3 skipped, 19 in progress at the one read taken; Governed Surface Queue Guard success).",
"mcp_calls": "0",
"api_writes": "3 — each relay stroke is one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls (draft), giving #22492, run 37939217105, body read back byte-identical (14117/14117); (2) assign, i.e. POST /repos//issues/22492/assignees (os-sales, the card's assignee) via label-write.mjs, run 37939295874, read back MATCHES; (3) this report, POST /repos//issues/22464/comments via post-stamped.mjs. Zero label writes: the dispatch named none, and skip-changeset does not apply (spec publishes). git push is not a REST write. Reads were single-card REST GETs (the card, the five comments named in the dispatch, the PR, and one check-runs read).",
"files_changed": [
"packages/spec/src/contracts/security-service.ts",
"packages/spec/src/contracts/security-service.test.ts",
"packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts",
"packages/spec/api-surface/contracts.json",
"packages/spec/export-origins/contracts.json",
".changeset/22464-security-service-controlled-by-parent-write.md"
],
"premise_readings": [
"P1 holds: git grep on origin/main dee7692 finds the step 2.8 calls at security-plugin.ts:3325 (principal) and :3335 (D10 delegator pass), and the private definition at :9113 (the card's :9061 was an older main). The legs are in the private assertMasterRowEditable (:9377).",
"P2 holds: DeclaredOptionality maps keyof ISecurityService with -? (:52); DECLARED_MEMBERS ... as const satisfies DeclaredOptionality (:78); SERVED_NOT_DECLARED is empty (:87).",
"P3 holds: read in full, ISecurityService declared 21 members and none runs the master-detail check; checkAuthoredRowWrite answers authored row-level security only."
],
"deviations": [
"The card's 'Not this card' places the DECLARED_MEMBERS row in #22455. I followed the claim amendment 6080901064, which moved it here because the compiler forces it, with the cross-lane declaration 6080912705. Nothing else in plugin-security changed.",
"The store fault is a rejection, not a union arm. The card says the outcome type 'covers' it; the type's docblock covers it, and a compile pin holds that store_fault is not a reason. This follows the check's own semantics (on a fault 'this gate answers nothing') and the dev's option A on #22455 ('a store fault thrown so it keeps its declared 503'). A value arm would keep the 503 only if every consumer rethrew it, and a consumer that forgot would fail open.",
"The card and ruling call one non-verdict 'a missing master row'. In the check, the 404 DetailRecordNotFoundError is the ADDRESSED record, so the reason is record_not_found and the TSDoc says so. A missing first-hop master is judged by the legs, which can admit it: resolveSharingCanEdit answers true when sharing abstains on a public master with no write RLS. Above the first hop it is a master_chain refusal. Calling it a refusal would have been a verdict the check does not make.",
"Not merged with origin/main: it is 2 commits ahead (8b713fa: CLI, core and dogfood tests, plus an unrelated changeset), with zero shared files or packages, and dispatch-gates reports that none of them touched what its answer derives from. The merge queue rebuilds on current main.",
"Narrowed: plugin-security's runtime suite ran only the pin file, the package's only change. Its full suite and the workspace consumer typecheck are CI's. The consumer sweep was a structural git grep: Required, keyof, implements or satisfies over ISecurityService finds only the pin updated here.",
"The verify-suite lock hold was 19m37s, mostly spec's repo tier (750s). The lock printed its long-hold warning. That is this suite's normal size, not a one-off.",
"The worktree was removed after the PR opened, then re-added at the same path on the same branch, with no install and no edits, to run the relay tools at the version that wrote the PR (the shared checkout's fleet-write is older than main)."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: #22455 (serves the member). Step 2.8's guard needs a non-empty resolved set list AND context.userId, so a non-system principal that carries positions and no userId is never master-checked on the write path. The live shape is the guest envelope (positions ['guest'], no userId), and it is dormant: guest anchor bindings refuse allowEdit (packages/spec/src/security/high-privilege.ts:218), so the CRUD gate refuses a guest update before step 2.8. The contract states the check's composition, not that guard; #22455 decides whether the served member mirrors it. This is a read-only inference, not measured. Noted, not filed.",
"carrier: #22455 (edits security-plugin.ts to serve the member). The registration log line at security-plugin.ts:2391 lists members by hand. Noted, not filed."
]
}objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsSeat review of PR #22492 at
1a4557d0e: ACCEPT. Next: the contract review atCONTRACT_REVIEW_TIERdomain:specseat 2 (#18549) ·os-sales· sessionsession_01DhTqaEHqPVSVnAkjG3jywn· 2026-10-09T13:51Z · holder of claim6080875401(amended by6080901064). Thread-read: 6082256950.-
The surface matches the claim and its amendment. The PR has 6 files and 320 lines added, and no runtime source changes:
contracts/security-service.tsand its test;- the one compiler-forced
DECLARED_MEMBERSrow inplugin-security, declared cross-lane in6080912705; - the regenerated
api-surfaceandexport-originsentries; - a
minorchangeset.
The body opens with
Fixes #22464andClause-②: yes (widening). No governed path is touched. -
The member, for security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 to consume:
checkControlledByParentWrite?(object, recordId, context?)returnsPromiseofControlledByParentWriteOutcome, a discriminated union onoutcome:allow;denywith aleg:object_permission,row_level_security,record_sharingormaster_chain;not_applicable;unresolvablewith areason:master_detail_relation_missing,record_not_foundormaster_reference_missing.
-
Deviation 1, accepted: the store fault is a rejection, not an arm.
- The ruling's shape detail asks that the store fault "keeps its declared
503". A rejection carries the engine's own error, so it keeps that status by construction. A value arm would keep it only if every consumer rethrew it, and one that forgot would admit. - A compile pin holds that
store_faultis not a reason.
- The ruling's shape detail asks that the store fault "keeps its declared
-
Deviation 2, accepted: "a missing master row" is named
record_not_found. In the check, the404is the addressed record. A missing first-hop master is judged by the legs, and above the first hop it is amaster_chainrefusal. Naming it otherwise would state a verdict the check does not make. -
The pins bite: ablations A1–A6 each turn red at the predicted line, and A5 deletes the forced row and turns
plugin-securityred. All are restored with blob == HEAD. -
Readings:
- spec local: 630 files / 18796 tests;
- spec repo: 54 / 915;
- spec and
plugin-securitytypecheck: exit 0; - 90 derived gates: all exit 0, with
--ranreading 0 NOT-MEASURED.
CI was still running at this stamp, and the review waits for it.
-
Out-of-scope findings, both carried to security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 (noted, not filed):
- Step 2.8's guard needs
context.userId, so a principal with positions and nouserId(the guest envelope) is never master-checked. That is dormant today, because guest bindings refuseallowEdit. security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 decides whether the served member mirrors that guard. - The registration log line at
security-plugin.ts:2391lists the served members by hand.
- Step 2.8's guard needs
Generated by Claude Code
-
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsLanded: PR #22492 →
557ae7c3f(Fixes #22464). The card is closedcompleted, and #22455 is unblockeddomain:specseat 2 (#18549) ·os-sales· sessionsession_01DhTqaEHqPVSVnAkjG3jywn· 2026-10-09T14:45Z · holder of claim6080875401(amended by6080901064).- Landed: PR feat(spec): ISecurityService declares checkControlledByParentWrite, an optional member answering the master-detail write check #22492 merged through the merge queue at 2026-10-09T14:44Z as
557ae7c3f. It has one parent,6212cc6cf, and is an ancestor oforigin/main. There was no queue ejection.Fixesclosed this card. - Content check: all 6 PR files on
557ae7c3fare blob-equal to the reviewed head1a4557d0e. That head carries the seat's ACCEPT6082295852and the contract review PASS6082677387. - What now holds (
@objectstack/specminor;Clause-②: yes (widening), with no accept-set change):ISecurityServicedeclares the optional, feature-detected membercheckControlledByParentWrite?(object, recordId, context?). It resolves withControlledByParentWriteOutcome, a discriminated union onoutcome:allow;deny, with aleg:object_permission,row_level_security,record_sharingormaster_chain;not_applicable;unresolvable, with areason:master_detail_relation_missing,record_not_foundormaster_reference_missing.
- A store fault rejects with the engine's own error, so it keeps its
503. - The member is declared, not served.
plugin-security's pin carries its one'optional'row.
- For security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455 (
domain:services), now unblocked: it serves the member fromassertControlledByParentWriteand wires the attachment gates andplugin-audit's comment delete limb to it. The review's two serving notes go with it:- Step 2.8's
userIdguard. A principal with positions or sets but nouserId(the guest envelope) never reaches the check on the write path. That is dormant today, because guest bindings refuseallowEdit. Whether the served member mirrors that guard is security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455's call. If it does, theallowTSDoc needs a second clause. - The registration log line in
security-plugin.ts(about:2391) lists the served members by hand.
- Step 2.8's
- Owed by this lane: one TSDoc correction, filed as a follow-up card in this act. The
master_chainsentence says the walk's refusals name "a master ABOVE the record's own master". On the code, the walk's first iteration resolves and reads the record's own master when that master is itselfcontrolled_by_parent. The arm and the vocabulary are right, and no consumer is affected.
This act removes
pm:dispatchedfrom the closed card; the domain, area, priority, target, type andsecuritylabels stay.
Generated by Claude Code
- Landed: PR feat(spec): ISecurityService declares checkControlledByParentWrite, an optional member answering the master-detail write check #22492 merged through the merge queue at 2026-10-09T14:44Z as
Unblocks: #22455
Filing gate: the
packages/specstage of a p1 security card, split out under 强制条款② (as #19578 split the spec half of #18783). Filed by the triage seat (seat post #6015,session_01AavokzJ5DndAwitDXvKy4U). The split was asked by thedomain:servicesseat in6079158667, on the dev's measured stop (6079124329). ⛔ Not a claim. ⛔ Classes, positions and functions only.Why this exists
#22455's fix makes the attachment and comment parent gates judge a
controlled_by_parentparent through the master-detail write check that a by-id update of that parent already runs (ADR-0055). The fix must reuse that check. Triage excluded a second copy.On
main3ca71b6e05, nothing can reach the check from outside plugin-security:SecurityPlugin.assertControlledByParentWrite(packages/plugins/plugin-security/src/security-plugin.ts:9061), reached only from the write middleware (:3323,:3333);ISecurityServicemembers (packages/spec/src/contracts/security-service.ts), and none of them runs this check;registered-security-service-members.pin.test.tsholds served members to the contract. ItsSERVED_NOT_DECLAREDledger (:87) is empty by design.So reuse needs one declared member. The precedent for this exact storage-to-security seam is PR #21781 (
045f764c26), which declared two optional, feature-detected members.What to declare
ISecurityService. It answers the existing master-detail write check for an update of(object, recordId)in the caller's context. ⛔ It adds no new verdict: it exposes the one a by-id update already gets.controlled_by_parent);503.Not this card (it is #22455's,
domain:services,Blocked-by:this card)plugin-securityserving the member, and the pin'sDECLARED_MEMBERSrow;plugin-audit's comment delete limb consuming it throughcheckEdit;⛔ No change to
effectiveSharingModelor tocheckEdit's abstain set. That is #22455's triage direction, and the seat ruled B out.Acceptance
Clause-②: yes(a contract member is added; no accept-set changes).Dedupe: search over objectstack for an
ISecurityServicemember exposing the master-detail write check found only #22455. #21756 and #19578 (closed) are earlier member declarations, not this one.