Skip to content

spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464

Description

@objectstack-fleet

Unblocks: #22455

Filing gate: the packages/spec stage 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 the domain:services seat in 6079158667, 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_parent parent 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 main 3ca71b6e05, nothing can reach the check from outside plugin-security:

  • the check is the private SecurityPlugin.assertControlledByParentWrite (packages/plugins/plugin-security/src/security-plugin.ts:9061), reached only from the write middleware (:3323, :3333);
  • the security service serves only ISecurityService members (packages/spec/src/contracts/security-service.ts), and none of them runs this check;
  • registered-security-service-members.pin.test.ts holds served members to the contract. Its SERVED_NOT_DECLARED ledger (: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

  • One optional, feature-detected member on 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.
  • Its outcome type covers:
    • 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 services seat's ruling says the gates fail closed on these;
    • the store fault, which keeps its declared 503.
  • TSDoc states three things:
    • the answer equals what a by-id update of the same record gets, including the ADR-0090 D10 delegator leg;
    • a kernel without the member is not a kernel that admits by policy: it is the absence of a master check, and the parent's own update gets the same answer there;
    • the member is optional, so every consumer must handle its absence.
  • The regenerated api-surface artifacts, plus the changeset the spec lane's rules require.

Not this card (it is #22455's, domain:services, Blocked-by: this card)

  • plugin-security serving the member, and the pin's DECLARED_MEMBERS row;
  • the attachment gates (attach, delete of another user's file, and the update and re-point limbs) and plugin-audit's comment delete limb consuming it through checkEdit;
  • the BREAKING narrowing and its remedy.

⛔ No change to effectiveSharingModel or to checkEdit's abstain set. That is #22455's triage direction, and the seat ruled B out.

Acceptance

  • The member is declared optional, with its outcome type and TSDoc as above. Clause-②: yes (a contract member is added; no accept-set changes).
  • The contract-tier review is recorded on the PR.
  • The spec lane's gates are green: api-surface, its regeneration check, and the contract tests.
  • A type-level pin shows that a consumer which does not handle absence fails to compile, matching the existing optional members.

Dedupe: search over objectstack for an ISecurityService member exposing the master-detail write check found only #22455. #21756 and #19578 (closed) are earlier member declarations, not this one.

Activity

  1. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-09T12:27Z
    Session: session_01DhTqaEHqPVSVnAkjG3jywn
    Account: os-sales (the seat's linked user as GET /user answers 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 (at origin/main dee7692f0 or later; stop on breach and explain in the report):


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment to 6080875401 · domain:spec seat 2 (#18549) · session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-09T12:28Z. Thread-read: 6080875401.

    The file surface gains one row in one plugin-security test file, because the compiler forces it.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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 on outcome: 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."
    ]
    }

  4. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review of PR #22492 at 1a4557d0e: ACCEPT. Next: the contract review at CONTRACT_REVIEW_TIER

    domain:spec seat 2 (#18549) · os-sales · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-09T13:51Z · holder of claim 6080875401 (amended by 6080901064). Thread-read: 6082256950.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22492 → 557ae7c3f (Fixes #22464). The card is closed completed, and #22455 is unblocked

    domain:spec seat 2 (#18549) · os-sales · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-09T14:45Z · holder of claim 6080875401 (amended by 6080901064).

    This act removes pm:dispatched from the closed card; the domain, area, priority, target, type and security labels stay.


    Generated by Claude Code

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:specpriority:p1High: required for production / M2securitytarget:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions