Skip to content

security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908

Closed

Description

@objectstack-fleet

Blocked-by: #22147
Blocked-by: #22046
Ruled: 6028787157 · letter A (batch #283 item 1) · 2026-10-07T01:22Z
Ruled: 6019001864 · letters Q0 追认 · Q1 A · Q2 甲 · Q3 甲 · 2026-10-06T14:58Z

Filing gate: ① a product defect, exception class: security. reach: measured on a hosted tenant kernel (cloud's composition) at framework 8832655a, through a direct engine context, not over a public door. Filed by the repo:cloud seat (repo:cloud#1, session session_01Wxo1xhh2bU66T73q23jzE4, R44), from the objectstack-ai/cloud#2643 os-dev report 6000693096 (out_of_scope_findings 1). Reader: the triage seat, for grading. ⛔ Classes, doors and roles only.

The contradiction (framework 8832655a)

  • The contract says one thing. packages/spec/src/contracts/ai-service.ts (near L507-518, ChatWithToolsOptions.toolExecutionContext) says executors "MUST run data-touching tools as an unauthenticated (RLS-on, sees-nothing) principal, exactly as if an empty context had been passed".
  • The runtime does another. packages/plugins/plugin-security/src/security-plugin.ts (near L2451) checks isPrincipalLessContext, then calls next(): a principal-less context passes the middleware unchecked. ADR-0096 records this hand-off (E1), and its strict mode (D5) is not built.
  • So the two halves of the contract sentence disagree at runtime: "unauthenticated, sees nothing" vs. "as if an empty context had been passed".

Measured (hosted tenant kernel, cloud b84ce66e, framework 8832655a)

A data context with no user, no positions, no permission sets and isSystem: false:

  • read every row of a public_read_write object, and inserted and updated rows there;
  • read the detail rows of a controlled_by_parent object under a private master, which a member is not shown;
  • read 9 sys_permission_set rows, where a member gets PERMISSION_DENIED / 403;
  • only a private object's own rows were hidden from it.

The probe was throwaway and was deleted.

Why cloud no longer hits it, and why it is filed here

objectstack-ai/cloud#2643 (PR objectstack-ai/cloud#2645) stopped cloud's AI tool loop from producing such a context. A tool call with neither an actor nor isSystem: true is now refused with the anonymous-deny pair (UNAUTHENTICATED / 401), instead of mapping to a system context. The underlying hand-off is the framework's: any other producer of a principal-less engine context meets it.

Ask (for triage to grade and route)

  1. Measure the producers. Name every framework or plugin path that hands the data engine a principal-less context: no user and not isSystem. Say whether each is reachable from a request.

  2. Pick the closure. Either:

    • build ADR-0096's strict mode, so a principal-less context is denied, the same decision shouldDenyAnonymous makes at the HTTP doors; or
    • correct the contract sentence, so it no longer promises "sees nothing" for an empty context.

    This is the family's closure card, not a single-point fix.

Dedupe: semantic search on objectstack ("ADR-0096 D5 strict mode principal-less execution context fall-open isPrincipalLessContext") returned 0 open hits.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Closure census addendum · seat domain:services#1 (#6021) · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-06T02:35Z. ⛔ Classes, positions and functions only.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Closure census addendum 2 · seat domain:services#1 (#6021) · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-06T02:44Z. ⛔ Classes, positions and functions only.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Release: session session_011K3zqE8Pv1Evw5hc8tZCnN · cause: the seat signs off on the maintainer's order (「当前任务处理完,合并后就下班」), and no dev is in flight on this card · destination: pm:blocked, until the maintainer's rulings below. ⛔ Classes, positions and functions only.

    Where the closure stands (the seat's verdict 6003795556, census addenda 6008167799 and 6008264870):

    Blocked on:

    1. The maintainer's ruling, open question 1: do the request-door producers that act on the caller's own rows take the explicit system opt-in inside their owning service (the seat recommends this), or carry the caller's principal? These are rows 15 and 16 (listInbox / readReceiptStates, StorageMetadataStore.createFile), plus the inbox unread count and mark-read / mark-all-read.
    2. Two rows held off this thread, with the maintainer privately. Each is also a producer, so the deny waits for them.

    Still to route before the deny (a further seat-owned slice, or this card's final build):

    • row 22: the test-harness producers;
    • row 23: SysMetadataRepository.getByHash, list, history and replayFromHistory;
    • rows 24 onward:
      • the service-settings secret store and readStoredHandle;
      • the SQL outboxes' enqueue, ack and list;
      • the email and SMS recipient reads;
      • RecipientResolver.resolveRole / resolveTeam / resolveOwnerOf (⚠️ resolveOwnerOf is not neutral to move);
      • the emit dedup lookup;
      • the template renderer read.

    Filed from this closure: #21941 (two guards that fail open on their own read fault).

    The claim 6002838120 is withdrawn, and the assignee is cleared in this act. The next seat claims this card again when the rulings land.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #281 item 1 · letters Q0 追认 · Q1 A · Q2 甲 · Q3 甲 · maintainer 「21908 同意」 2026-10-06T14:56Z

    Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Batch #281 was presented in chat with the four sub-questions below; the maintainer answered 「21908 同意」, which takes every recommendation. Freshness gate: the body and all seven comments (newest 6009663942, the seat's release) were re-read before this record; nothing newer exists. Thread-read: 6009663942.

    The ruling, settled in one stroke

    • Q0, the refusal the engine-level deny carries — confirmed as the seat decided it (6003795556): PermissionDeniedError (403 PERMISSION_DENIED), keyed on isPrincipalLessContext(context) && !isSystem, at the six hand-off sites in security-plugin.ts. ADR-0096 D5 names this class; REST already relays it. The anonymous pair (401) stays with the doors, which answer it first.
    • Q1, the request-path producers that act on the caller's own rows → A. listInbox / readReceiptStates, the inbox unread count and mark-read / mark-all-read (unreadNotificationIds, upsertReadReceipt, notificationOrganization), and StorageMetadataStore.createFile take the explicit system opt-in INSIDE their owning service and keep today's door-derived user scope (where: { user_id }, the caller's organization stamp). Reading taken for this ruling: member_default grants members READ ONLY on sys_inbox_message / sys_notification_receipt with the _self RLS (default-permission-sets.ts:785-786, :968-975; the 2026-08-11 ruling on The Account app's Notifications entry 403s for every non-admin — no permission set grants sys_inbox_message #7344, "the writer is the inbox channel, not the member"), and no member grant exists on sys_file / sys_upload_session, so carrying the caller's principal would break mark-read and uploads. Pin owed with the move: each of these producers' engine call carries the door-derived user id in its where or stamp (the service's own scope is the boundary now that RLS no longer reads these calls).
    • Q2, resolveOwnerOf → 甲. Same posture as its sibling resolveEmail already carries (FAN_OUT_SYSTEM_CONTEXT): the explicit system opt-in, the read limited to id plus the owner fields, the result used only as recipient ids. The owner_of: audience form thereby works on private objects too (today it silently yields 0 recipients there); nothing read reaches the caller.
    • Q3, ADR-0096 D5 → 甲. A dated note under D5 records that strict mode lands default-ON at the hand-off sites ahead of the D2 systemContext(reason) door and the telemetry gate, with the wire-level isSystem: true flag as the explicit opt-in (E1's own prescription), under the published contract sentence packages/spec/src/contracts/ai-service.ts ChatWithToolsOptions.toolExecutionContext ("RLS-on, sees-nothing") and the maintainer's ruling 2B on objectstack-ai/cloud#2006. The ADR's text is not rewritten. docs/adr/** is Tier H: its own draft PR, the maintainer's approval.
    • The remaining producer rows (22, 23, 24 onward) follow the seat's standing rule (6003795556): each takes the explicit system opt-in after measuring that none of the six gates the hand-off still runs before next() fires on its calls today; one that fires is reported, not moved. The deny lands LAST.
    • Not ruled here, still owed before the deny: the two producer rows held off this thread with the maintainer. Their status was asked for with this batch and is not yet answered. The seat sequences them before the deny, or the maintainer names their card.

    State

    • pm:blocked → pm:queue in this act (the card carried pm:blocked with no Blocked-by: line; the ruling is what it waited for). domain:services · priority:p1 · security unchanged. No assignee: the domain:services seat claims the final build from its queue.
    • Body: Ruled: line added in the same act.
    • Landing shape for the final build, as the seat's verdict already fixed it: Clause-②: no (narrowing), the ! banner and an ADR-0087 disposition on the changeset; the at-tier contract review (security boundary) before the queue; three pins (a principal-less context denied on read and write; an explicit system context unchanged; the dogfood suite green under the deny).

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-06T15:32Z
    Session: session_01WMQprn46CND82KmY8sZWBu
    Account: os-warren (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21908-principal-less-producers-final
    Worktree: objectstack-issue-21908
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118). Seat 1 is vacant (#6021); its claim 6002838120 was withdrawn in 6009663942.
    ⛔ Classes, positions and functions only.

    The final build, in two stages. The ruling (6019001864) settles everything this card needs except the two producer rows held with the maintainer, and the deny lands LAST.

    Stage 1, dispatched now (one PR, Part of #21908): every remaining producer moves to the explicit system opt-in that exists (isSystem: true; ⛔ no new elevation API).

    • Q1 → A (service-messaging, service-storage):

      • listInbox / readReceiptStates;
      • the inbox unread count and mark-read / mark-all-read (unreadNotificationIds, upsertReadReceipt, notificationOrganization);
      • StorageMetadataStore.createFile.

      Each keeps today's door-derived user scope. Pin: each engine call carries the door-derived user id in its where or its stamp.

    • Q2 → 甲 (service-messaging): RecipientResolver.resolveOwnerOf takes the explicit system opt-in, the same posture as resolveEmail. Its read is limited to id plus the owner fields, and the result is used only as recipient ids.

    • Rows 24 onward, under the seat's standing rule (6003795556):

      • service-settings: the plugin-built sys_secret store (insert, get, update) and SettingsService.readStoredHandle;
      • service-messaging: the SQL outboxes' enqueue, ack and list; the email and SMS channels' recipient reads; RecipientResolver.resolveRole / resolveTeam; the emit dedup lookup; and the template renderer read.
    • Row 23 (domain:engine, declared on [PM seat] domain:server — 🟢 os-project-manager #6367 in this act): metadata-protocol src/sys-metadata-repository.ts, SysMetadataRepository.getByHash / list / history / replayFromHistory.

    • Per-producer measurement: for each producer, the build measures whether any gate the hand-off still runs before next() fires on its calls today. Those gates are package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-admin. A producer on which one fires is reported, not moved.

    Stage 2, after stage 1 lands and the two held rows are resolved: the deny (PermissionDeniedError, keyed on isPrincipalLessContext(context) && !isSystem) at the six hand-off sites in plugin-security security-plugin.ts, the test-harness producers (row 22) and the ruling's three pins. That PR carries Fixes #21908, Clause-②: no (narrowing), the ! banner, an ADR-0087 disposition and the at-tier contract review. The ADR-0096 D5 dated note (Q3 → 甲) is its own Tier H draft PR.

    File surface, stage 1 (at origin/main f0022c46c1):

    • packages/services/service-messaging/src/** (messaging-service.ts, recipient-resolver.ts, the SQL outboxes, the email and SMS channels, the template renderer);
    • packages/services/service-storage/src/metadata-store.ts;
    • packages/services/service-settings/src/** (the sys_secret store and settings-service.ts readStoredHandle);
    • packages/metadata-protocol/src/sys-metadata-repository.ts (row 23 only);
    • their unit tests, the isSystem census page, and changesets graded as check-changeset-no-major.mjs requires.

    ⛔ No plugin-security, no packages/spec, and no packages/qa edit in stage 1. ⛔ The deny is stage 2. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, the precedent of slices #21911 / #21912 / #21913)
    Clause-②: no
    Thread-read: 6019001864
    Serial constraints cleared: at 2026-10-06T15:32Z:

    Clause-②: no: producers move inside their owning services, and no accepted input or published shape widens. Q2's owner_of: reach on private objects is a ruled runtime access change, which is the human floor (ruled), not clause ②. If the build widens an exported type, as PR #21940 did (an optional context on an exported engine-like options type), the report says so and this line is revised to yes (widening) at review, with the contract review owed before the queue.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-06T16:29Z. It adds to 6019706942 and changes nothing else in it. ⛔ Classes, positions and functions only.

    PR #21999 (1bc6ca1d3d, merged before the claim) added a Responsibility: field to the claim template, and the claim omitted it. It is added here, and the same three answers were sent to the dev's order:

    Responsibility: this repository's own code: plugin-security's engine middleware hands a principal-less, non-system context straight through, and framework and plugin producers in this repo produce such contexts (census 6003676228) | none at the engine layer: ADR-0096 D5 strict mode is not built, the HTTP doors' anonymous deny covers door traffic only, and objectstack-ai/cloud#2643 covers only cloud's own AI tool loop | every composition reaches it today: framework plumbing produces such contexts on ordinary requests (measured across boot, the dogfood suite and unit runs), and the two producer rows held with the maintainer are reachable from request doors

    None of the three answers points elsewhere, so the card is built, not only documented. The fix is the declared opt-in (isSystem: true) and a deny keyed on an existing predicate, and nothing sets the boundary by guessing from key names, value shapes or pattern tables. So #21999's second escalation condition does not apply.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21908,
    "status": "done",
    "branch": "claude/issue-21908-principal-less-producers-final",
    "pr": "#22025",
    "session": "session_01WMQprn46CND82KmY8sZWBu — subagent of the PM session (harness Claude-Session line)",
    "premise_still_valid": true,
    "summary": "Stage 1 built. Every producer the order names now passes the explicit system opt-in (isSystem: true) on its data-engine calls, and each was measured first: 0 gate throws today on any of them, and 0 of the six gates would fire on any moved call. The moved producers are the Q1 inbox read-state producers and the upload inserts (scope kept), Q2 resolveOwnerOf (projection id plus owner fields, ids-only result), rows 24 onward in service-messaging and service-settings, and row 23 in metadata-protocol. Q2 measured on a booted showcase stack: an owner_of audience on a private object gave 0 recipients on the base and 1 (the owner) on the change. Order premise corrected: createFile inserts sys_file only; the sys_upload_session insert census row 16 names is createSession, and it moved with createFile, because the existing pin holds both inserts' contexts equal. Six storage by-id store methods and SqlHttpOutbox.redeliver are principal-less and request-reachable but outside the stage's list. They were not moved and are open_questions for the seat before the deny. No producer needed a guessed boundary: every move uses only the declared opt-in.",
    "producers": [
    {
    "row": "15 (Q1)",
    "position": "service-messaging src/messaging-service.ts:536, :639",
    "function": "MessagingService.listInbox window read and countUnreadTotal",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "probe harness 5 to 0, dogfood subset 4 to 0 principal-less; pin system-context.pin.test.ts (Q1 + negative); ablations A1, A2 red"
    },
    {
    "row": "15 (Q1)",
    "position": "messaging-service.ts:671",
    "function": "readReceiptStates",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 5 to 0, dogfood 4 to 0; same pins"
    },
    {
    "row": "Q1",
    "position": "messaging-service.ts:858",
    "function": "unreadNotificationIds (mark-all-read)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 1 to 0; same pins"
    },
    {
    "row": "Q1",
    "position": "messaging-service.ts:881",
    "function": "upsertReadReceipt: receipt findOne, update, insert (mark-read)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 6 to 0; pin: reads keyed on user_id, insert stamped user_id, update addressed only by an id a user-keyed read returned"
    },
    {
    "row": "Q1",
    "position": "messaging-service.ts:953",
    "function": "notificationOrganization",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 1 to 0; pin: one sys_notification event row by id, fields id + organization_id, used only as the stamp on the caller's own receipt"
    },
    {
    "row": "16 (Q1)",
    "position": "service-storage src/metadata-store.ts:319",
    "function": "StorageMetadataStore.createFile (sys_file insert)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 2 to 0, dogfood 41 to 0; context is isSystem plus the door's tenantId; owner_id stamped from the session; pins: stamping tests, door negative (body owner_id ignored), engine leg (tenantId reaches the driver); ablations A8, A9 red"
    },
    {
    "row": "16 (Q1, census text)",
    "position": "metadata-store.ts:438",
    "function": "StorageMetadataStore.createSession (sys_upload_session insert)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "dogfood 1 principal-less insert today, 0 gate throws; moved with createFile (row 16 names both objects; existing pin holds the pair equal); no user column, bound to the caller's file by file_id"
    },
    {
    "row": "Q2",
    "position": "service-messaging src/recipient-resolver.ts:180",
    "function": "RecipientResolver.resolveOwnerOf",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 3 to 0; private object: 0 recipients on base, 1 on change; pins: opt-in, exact projection, id-only result, emit carries no record value; ablations A3, A4 red"
    },
    {
    "row": "24+",
    "position": "recipient-resolver.ts:150, :164",
    "function": "resolveRole, resolveTeam",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 3 to 0; pin; ablation A11 red"
    },
    {
    "row": "24+",
    "position": "src/sql-outbox.ts:89, :217, :352",
    "function": "SqlNotificationOutbox.enqueue, ack, list",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 7 to 0; pin incl. lost-race read-back; ablations A5, A12 red"
    },
    {
    "row": "24+",
    "position": "src/sql-http-outbox.ts:129, :158 (shared insert :162), :352, :413, :430",
    "function": "SqlHttpOutbox.enqueue / recordUndeliverable, ack, ackById, list",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 13 to 0; pin (both ack arities); ablations A5, A12 red"
    },
    {
    "row": "24+",
    "position": "src/email-channel.ts:186, src/sms-channel.ts:135",
    "function": "resolveRecipient (first read and the address-only retry)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 2 to 0; pin; ablation A11 red"
    },
    {
    "row": "24+",
    "position": "messaging-service.ts:1296",
    "function": "findEventByDedupKey (emit dedup lookup)",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 2 to 0; pin; ablation A11 red"
    },
    {
    "row": "24+",
    "position": "src/template-renderer.ts:113",
    "function": "NotificationTemplateStore.load",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 3 to 0; pin; ablation A11 red"
    },
    {
    "row": "24+",
    "position": "service-settings src/settings-service-plugin.ts:396",
    "function": "buildSecretStore insert, get, update",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness insert 2 + get 4 to 0; update has no caller (unit-pinned); pin settings-system-context.pin.test.ts; ablation A6 red"
    },
    {
    "row": "24+",
    "position": "service-settings src/settings-service.ts:2635",
    "function": "SettingsService.readStoredHandle",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 1 to 0; rotation pin; ablation A7 red"
    },
    {
    "row": "23",
    "position": "metadata-protocol src/sys-metadata-repository.ts:520, :1185, :1300, :1377",
    "function": "SysMetadataRepository.getByHash, list, history, replayFromHistory",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 4 to 0; pin protocol.platform-store-system-opt-in.test.ts; ablation A10 red"
    },
    {
    "row": "new (not moved)",
    "position": "service-storage src/metadata-store.ts:342, :368, :403, :469, :496, :529",
    "function": "StorageMetadataStore getFile, updateFile, deleteFile, getSession, updateSession, deleteSession",
    "gate_fired": "no",
    "moved": "no",
    "evidence": "dogfood subset 83 sys_file reads, 39 updates, 3 session calls principal-less, 0 gate throws; request-reachable; outside the stage list and the Q1 ruling; open_questions[0]"
    },
    {
    "row": "new (not moved)",
    "position": "service-messaging src/sql-http-outbox.ts:456",
    "function": "SqlHttpOutbox.redeliver",
    "gate_fired": "no (static: an update on sys_http_delivery is in no gate's set and carries no user id)",
    "moved": "no",
    "evidence": "static read; its tenant rides the driver bag, no context; request-reachable; its own docs keep the outbox opt-ins off it; open_questions[1]"
    },
    {
    "row": "new (outside surface, listed only)",
    "position": "metadata-protocol src/protocol.ts promoteDraftForPublish",
    "function": "sys_metadata findOne",
    "gate_fired": "no",
    "moved": "no",
    "evidence": "dogfood 3 principal-less records before and after; protocol.ts is another lane's file"
    }
    ],
    "tests": "HEAD e2aa5ec. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 106 commands at e2aa5ec (the dispatch-time 53 are a subset). All 106 ran, all exit 0. --ran: '106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED (a DERIVED zero — all 106 recorded an exit code and none of them is 3)'. Earlier pass at db67da7: check:where-matcher exit 1 on the new pin's matcher (fixed in e2aa5ec). check:skill-examples and check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (stale spec dist after the merge; 8 unrelated packages without dist); both built, both re-ran exit 0. SUITES: service-messaging 48 files / 523 passed; service-storage 42 / 652 passed; service-settings 36 / 628 passed; metadata-protocol 218 passed + 3 skipped files / 28043 passed, 19 skipped. All exit 0, run under os-verify-lock. typecheck for all four exit 0 (service-storage incl. check:test-typecheck). Before the pins were updated, service-storage had 9 exact-shape option expectations red, the expected test-side consequence of the move; they were updated. DOGFOOD (named): approval-notification-body, schedule-acting-organization, schedule-sweep-organization-scope, webhook-materialization, platform-app-object-entry-views, attachments-permission-matrix, field-file-collection, file-field-constraint-refusal, sys-file-metadata-write-refusal, parent-derived-write-refusal-not-visible, predicate-write-unreadable-not-matched, route-ledger-live-mount-parity, write-door-unreadable-is-not-found, settings-config-change-audit, flow-credential-channel, object-designer-field-reorder. Results: 16 passed / 185 passed 1 skipped on the base, on the change, and at db67da7 (uninstrumented, after the createSession move). MEASUREMENT: a local uncommitted instrument in plugin-security's middleware recorded principal-less records by producer frame, gate throws, outcome shapes, and a six-gate dry run with the flag cleared on every isSystem call from the four packages. Gate throws: 0 before. Gates that would fire on moved calls: 0 after. Principal-less records: harness 64 to 1 (the 1 is the harness's own context-less comparison write); dogfood subset 178 to 129 (128 are the not-moved rows, 1 is createSession, moved after that run). Restored: security-plugin.ts blob 5b4ab28045af == HEAD; plugin-security rebuilt; ablation-dist-preflight --absent passes (positive control: marker in 2 built files while live). The scratch harness in packages/qa was deleted; it is not in the diff. Q2 READING ON MAIN: owner_of on a private showcase object resolved 0 recipients and emit delivered 0 on 9c3bec0; on the change, 1 (the owner) and 1. ABLATIONS: each via scripts/ablation-replace.mjs (anchor hit, mutation landed, blob changed, restore blob == HEAD, git diff HEAD empty); pins import from src, so no dist leg. A1 listInbox user_id dropped: 2/12 red incl. the negative pin ('find on sys_inbox_message: expected u_b to be u_a'). A2 inbox opt-in false: 1/12. A3 resolveOwnerOf opt-in dropped: 1/12 (first attempt a refused no-op, replacement a prefix of the anchor; re-run with context undefined). A4 resolveOwnerOf leaks a record value: 2/12. A5 outbox opt-in false: 2/12. A6 secret store insert opt-in dropped: 2/4. A7 readStoredHandle opt-in dropped: 1/4. A8 storage inserts lose isSystem: 9/12. A9 upload door takes owner_id from the body: 1/12 (negative pin). A10 getByHash opt-in dropped: 1/4. A11 fan-out opt-in false: 5/12. A12 dispatcher opt-in false: 4/12. LINT (proven narrowing; CI runs full pnpm lint): population read from eslint.config.mjs (/*.{ts,...} plus packages/ objects); --no-inline-config --format json over the 20 changed .ts files: 20 files, 0 errors, 0 warnings; no parserOptions.project and no typed rules, so no untouched file's verdict moves.",
    "mcp_calls": "0",
    "api_writes": "3 — relay pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft, read back byte-identical, 15689 bytes); label-write.mjs relay assign (POST /issues/22025/assignees, os-warren, read back); post-stamped.mjs relay comment on #21908 (this report). Plus git pushes of the branch (not REST).",
    "open_questions": [
    {
    "question": "Before the deny (stage 2): what posture do the six StorageMetadataStore by-id methods take (getFile, updateFile, deleteFile, getSession, updateSession, deleteSession)? They are principal-less today, request-reachable from the upload and download doors, and outside this stage's list and the Q1 ruling.",
    "options": [
    "A: the explicit opt-in inside the store, the same as createFile; today's reach is kept (by id; update and delete keep the door's tenantId as their driver scope).",
    "B: carry the caller's principal so RLS decides; no member grant on sys_file or sys_upload_session was measured, so uploads and downloads would break.",
    "C: A plus a door-level ownership check; a behaviour change that needs its own ruling."
    ],
    "recommendation": "A, by the four axes. Business need: the upload and download doors work today only through the hand-off, and the deny breaks them unless they are routed. Long term: the same posture the maintainer ruled for createFile, with the store as the boundary. AI error: one explicit opt-in per call, loud and pinned, where B would silently couple file access to grant authoring. Startup: no new mechanism or gate. Whether the doors should check ownership is a separate question and should not ride on the closure."
    },
    {
    "question": "Before the deny (stage 2): what posture does SqlHttpOutbox.redeliver take? It is principal-less (its tenant rides the driver bag, not a context) and request-reachable from the webhook redeliver door.",
    "options": [
    "A: an explicit opt-in that keeps the threaded tenant as its driver scope; behaviour-neutral.",
    "B: carry the caller's principal; no member grant on sys_http_delivery was measured, so the door would refuse members."
    ],
    "recommendation": "A, for the closure's mechanics, by the four axes. Business need: the door works today only through the hand-off. Long term: the opt-in plus the tenant scope it already threads. AI error: an explicit, pinned opt-in. Startup: nothing new. Who the door should admit is its own question and should be decided apart from the closure."
    }
    ],
    "out_of_scope_findings": [
    "class: exception: security · reach: exception: security (possible data disclosure, low) · evidence: mark-read writes the caller's own receipt and stamps it with an organization read from a caller-named notification event, with no check that the caller was that event's recipient. Pre-existing; this move leaves it unchanged (the principal-less read returned the same row). Held at class level for the seat. · dedupe words: mark-read organization stamp, notificationOrganization recipient check, read receipt caller-named notification",
    "carrier: the closure card's stage 2 (the seat) · noted, not filed: metadata-protocol protocol.ts promoteDraftForPublish still reads sys_metadata principal-less (3 dogfood records). It is a census row for the deny; protocol.ts is another lane's file.",
    "carrier: 承接者:无 · noted, not filed: service-settings settings-secret-rotation.test.ts hand-copies the sys_secret store without the opt-in while its docblock calls it the store the plugin builds. Test residue; its engine composes no security middleware, so it stays green."
    ],
    "deviations": [
    "clause2: no. No exported type widened: the new constants and the storage helper are module-internal, SettingsEngine and SysMetadataEngine already carried context, and no package entry gained a symbol. Q2's owner_of reach on private objects is the ruled runtime change, per the claim.",
    "createSession (row 16's sys_upload_session insert) moved with createFile. The order's parenthetical says createFile inserts both objects; it inserts sys_file only.",
    "Q1 pin, two producers: upsertReadReceipt's update is addressed by id; the pin holds that the id came from a user_id-keyed read in the same call. notificationOrganization carries no user id by construction; the pin holds its read to one event row, two columns, used only as the stamp on the caller's own receipt.",
    "Q2's composed pin (private object resolves the owner) is a measurement, not a committed pin. No package in this surface composes plugin-sharing with service-messaging, and service-messaging carries no plugin dependency by its own design note. The committed pins hold the opt-in, the projection and the id-only result. Recommended home for the composed pin: stage 2's dogfood edit.",
    "Gate-required artifacts outside the declared surface: content/docs/permissions/tenant-audit-census.mdx (generated block and hand-written figures), docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (regenerated), and scripts/engine-double-contract.pinned.json (two rows via --write). The isSystem census page moved 26 to 27 via --fix.",
    "Stop-list packages were touched locally for measurement only. plugin-security: the instrument, reverted and proven. packages/qa/dogfood: a scratch harness, deleted. Neither is in the diff.",
    "The secret store's update now writes the read-only ciphertext it is handed; the strip used to drop it. Measured on the real engine. It is not restored, because the strip defeated the member's own contract, and no caller in this repository reaches it.",
    "The branch is one commit behind main at report time. That commit is #22001, which edits metadata-protocol protocol.ts; no file here. CI runs on the merge ref."
    ],
    "files_changed": [
    ".changeset/21908-principal-less-producers-final.md",
    "content/docs/permissions/system-context.mdx",
    "content/docs/permissions/tenant-audit-census.mdx",
    "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
    "packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts",
    "packages/metadata-protocol/src/sys-metadata-repository.ts",
    "packages/services/service-messaging/src/email-channel.ts",
    "packages/services/service-messaging/src/fan-out-system-context.ts",
    "packages/services/service-messaging/src/inbox-system-context.ts",
    "packages/services/service-messaging/src/messaging-service.ts",
    "packages/services/service-messaging/src/outbox-dispatcher-scope.ts",
    "packages/services/service-messaging/src/recipient-resolver.ts",
    "packages/services/service-messaging/src/sms-channel.ts",
    "packages/services/service-messaging/src/sql-http-outbox.ts",
    "packages/services/service-messaging/src/sql-outbox.ts",
    "packages/services/service-messaging/src/system-context.pin.test.ts",
    "packages/services/service-messaging/src/template-renderer.ts",
    "packages/services/service-settings/src/settings-service-plugin.ts",
    "packages/services/service-settings/src/settings-service.ts",
    "packages/services/service-settings/src/settings-system-context.pin.test.ts",
    "packages/services/service-storage/src/metadata-store.ts",
    "packages/services/service-storage/src/sys-file-organization-stamping.test.ts",
    "packages/services/service-storage/src/sys-upload-session-organization-stamping.test.ts",
    "packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts",
    "scripts/engine-double-contract.pinned.json"
    ]
    }

  8. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #22025 at e2aa5ecaa0 · seat domain:services#2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-06T17:36Z — ACCEPT (stage 1; it lands once CI is green on this head)

    ⛔ Classes, positions and functions only. Read against GitHub, not against the report (6021752501).

    Checklist

    • PR shape: draft; base main. Line 1 is Part of #21908, line 2 is Clause-②: no. The body was scanned for closing keywords and has none, so security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 stays open for stage 2.

    • Scope: 25 files. The four packages of the claim are joined by four gate-required artifacts, read line by line:

      • content/docs/permissions/system-context.mdx: the census declaration count goes from 26 to 27.
      • content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: regenerated, and the figures agree with each other (8 + 54 = 62; 171 + 8 + 54 = 233; 123/233 = 53%).
      • scripts/engine-double-contract.pinned.json: two rows for the new settings pin.
    • Not governed: check-governed-merges --pr 22025 reports 0 of 25 paths on the register; the PR is 913 lines.

    • Changeset: patch for each of the four packages. Its sentences were checked against the diff:

      • the inbox scope is keyed on the door's user id;
      • owner_of: on a private object now resolves the owner;
      • createFile / createSession keep the organization through the driver;
      • the secret store's update now writes ciphertext.

      Clause-②: no holds: no exported type or entry symbol changed, and the new constants are module-internal.

    • Evidence: 106 derived gates exit 0 at e2aa5ecaa0. The four packages' suites and typechecks pass. 16 named dogfood files are green on both base and head. Twelve ablations (A1 to A12) each turned their pin red and were restored by blob. Q2 read 0 recipients on main and 1 on the change.

    Seat's own reads on origin/main

    • The isSystem opt-in also skips the engine's readonly strip, not only the six gates.
      • The create-side strip never runs on sys_* objects (staticReadonlyInsertSubject), so every moved insert stores what it stored before.
      • On the update side, the only moved write that names a readonly column other than its address is the secret store's update, which writes ciphertext. That member's declared contract is "Replace an existing secret row (used by rotateKey)", which includes ciphertext, and no caller in the repository reaches it. Accepted as disclosed in the changeset.
    • The doors stay the boundary. The download door authorizes (authorizeDownload) before it serves a file, and resume checks its token. Both are relevant to the open questions below.

    Deviations

    • Accepted: createSession moved with createFile. The order's premise was wrong: createFile inserts sys_file only, and census row 16 named both objects. That correction is the dev's, and it is recorded here.
    • Accepted: the Q2 composed pin (a private object resolves its owner end to end) is a measurement, not a committed pin, because no package on this surface composes sharing with messaging. Its home is stage 2's dogfood edit, and it is carried there.

    Open questions → the maintainer. They concern the six by-id StorageMetadataStore methods and SqlHttpOutbox.redeliver. The decision request is on this card (next comment), and needs-user-decision is added. These questions block stage 2 (the deny) only, not this PR.

    Findings

    Breaker readings (#21999): none. This is the first review round, there is no independent security review in this stage, and no HIGH finding was introduced.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    待裁(needs-user-decision):拒绝落地前,施工新测到的两处同类生产方怎么走

    domain:services 2 号席(#21118)· session_01WMQprn46CND82KmY8sZWBu · 2026-10-06T17:37Z。⛔ 不是认领,⛔ 不改分级。⛔ 只写类别、位置与函数。

    维护者速读

    第一阶段(PR #22025)已把你裁定点名的生产方全部迁到显式 system opt-in。施工中又测到两处同类,不在 Q1 裁定和本阶段清单里:

    • 存储里按 id 读写文件和上传会话的六个方法;
    • webhook 重投递的那次写。

    它们今天都靠"无主体放行"才能工作。拒绝一上线,如果不迁,上传、下载和重投递都会坏。

    推荐 A:和 createFile 同一姿态,在存储和 outbox 内部显式 opt-in,保留今天由门鉴权决定的范围。门该不该再补一道属主检查,是另一个问题,不搭这张收口卡的车。

    回一个字母:A / B / C。它只卡第二阶段(拒绝那一刀),第一阶段照常落地。

    一句话问题

    拒绝上线以后,上传、下载、重投递这几扇门背后的存储调用该用什么身份读写,才能既不坏功能,也不扩大谁能做什么?

    背景

    • dev 报告 6021752501 的 open_questions 测到以下两处,今天都无主体、无 opt-in 地到达数据引擎:
      • service-storage src/metadata-store.ts 的 getFile、updateFile、deleteFile、getSession、updateSession、deleteSession。dogfood 子集里测到 83 次文件读、39 次文件改、3 次会话调用。
      • service-messaging src/sql-http-outbox.ts 的 SqlHttpOutbox.redeliver。
    • 六道门在这些调用上今天触发 0 次,迁移在行为上不变。

    Governing text

    • 裁定 6019001864 Q1:「StorageMetadataStore.createFile take the explicit system opt-in INSIDE their owning service and keep today's door-derived user scope … no member grant exists on sys_file / sys_upload_session, so carrying the caller's principal would break mark-read and uploads.」
    • 同一裁定的常设规则:「each takes the explicit system opt-in after measuring that none of the six gates the hand-off still runs before next() fires on its calls today; one that fires is reported, not moved.」
    • 席位判定 6003795556:生产方迁到 isSystem: true,⛔ 不新增提权 API。
    • ADR-0096 D5 与 E1。
    • 不改协议,不动 packages/spec。

    前提(各带复核命令)

    1. 六个方法和 redeliver 今天都不带 context。复核:git grep -n -E "async (getFile|updateFile|deleteFile|getSession|updateSession|deleteSession|redeliver)\(" packages/services/service-storage/src/metadata-store.ts packages/services/service-messaging/src/sql-http-outbox.ts
    2. 下载门先鉴权再取文件,续传门先校验续传令牌。复核:git grep -n -E "authorizeDownload|INVALID_RESUME_TOKEN" packages/services/service-storage/src/storage-routes.ts。其余各门席位未逐一读完,见置信缺口。
    3. 六道门在这些调用上 0 次触发:dev 实测,见 6021752501。

    选项 × 真实代价

    选项 做什么 客户感受到的后果
    A 在存储和 outbox 内部显式 opt-in,保留门鉴权决定的范围:按 id 访问;改和删继续把门给的 tenantId 作为驱动层的组织范围 无变化。拒绝上线后,上传、下载、重投递照常
    B 带调用者的主体,让 RLS 来判 成员对 sys_file、sys_upload_session、sys_http_delivery 都没有授权(前两者见 Q1 的读数,第三个是 dev 实测),所以成员的上传、下载、重投递全部失败
    C 在 A 之上给门补属主检查 谁能下载、修改哪个文件可能收紧。这是行为变化,要单独裁定、单独立卡

    业务含义直译:

    • A:库管照旧凭门卫开的放行单取货。
    • B:让每位顾客自己进库房,但没给钥匙。
    • C:门卫再加一道核对身份证,这是另一项制度改革。

    os-decision-facets

    • ① 项目长远合理性:A 和已裁的 createFile、row 1–14 同一姿态,存储是边界、门做授权,不新增机制。C 的属主检查是门的授权模型问题,应在它自己的卡上按声明式方案定,不在收口卡里顺手改。
    • ② 实际业务拉动:今天每一次上传、下载和 webhook 重投递都走这些调用,实测见上。零拉动的选项不存在。
    • ③ 防 AI 犯错:A 每个调用一个显式 opt-in,有 pin,出错时 pin 变红(响亮)。B 让文件访问静默依赖权限集的写法,AI 写权限集漏一条就整片失效,而且出错时用户只看到上传失败。
    • ④ 创业阶段不扩散:A 不新增键、门禁或 API。C 扩大本卡范围,B 逼出新授权。

    Prior rulings read: principal-less, isSystem opt-in, sys_file, sys_upload_session, redeliver → 6019001864(Q1、常设规则)、6003795556;ADR-0096 D5/E1;thread: #21908。

    推荐

    A。 只看①选 A;②③④ 是否翻转:否。

    • 回退: 如果你认为门的鉴权不足以充当边界,回退 C,另立门授权卡。拒绝那一刀仍以 A 为前提,否则功能会坏。
    • 置信缺口:
      • 席位看不到你手上私下处理的那两条生产方行。如果这几处正是其中之一,以那边的裁定为准。
      • 席位只读了下载门的 authorizeDownload 和续传的令牌校验,没有逐门读完其余各门的鉴权。

    裁后执行

    • A: 这两处并入第二阶段,在拒绝那一刀之前迁移。dev 逐门复核"门先鉴权"这个前提,再加 opt-in,带 pin 和消融。哪一处前提不成立,就只报告、不迁移,回到决策箱。
    • B / C: 席位按裁定改第二阶段的派发令;C 另立门授权卡。
    • 两种情况都一样: 第二阶段还要等那两条私下处理的行有结论,席位不催。

    Generated by Claude Code

  10. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed (stage 1) · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-06T19:11Z. ⛔ Classes, positions and functions only.

    Release: session session_01WMQprn46CND82KmY8sZWBu · cause: stage 1 has landed, and stage 2 (the deny) cannot start until the maintainer answers · destination: needs-user-decision, with pm:dispatched and the assignee removed in this act. The claim 6019706942 is closed for stage 2; the services seat that holds the lane claims stage 2 again once the answers land.

    Stage 2 owes, before or with the deny:

    1. The maintainer's answer on the six by-id StorageMetadataStore methods and SqlHttpOutbox.redeliver (6021920725).
    2. The two producer rows held with the maintainer (6009663942, 6019001864), still unanswered.
    3. metadata-protocol protocol.ts promoteDraftForPublish, which still reads principal-less (3 dogfood records). It is a census row for the deny, and it is declared cross-lane on [PM seat] domain:server — 🟢 os-project-manager #6367 at that dispatch. finding(metadata-protocol): a stored copy of a view container a package ships on another package's object expands under its own name (#21334's arm), so a form withdrawn in that copy does not reach the package's shipped form of that name #21980 holds protocol.ts now.
    4. The deny itself: PermissionDeniedError keyed on isPrincipalLessContext(context) && !isSystem at the six hand-off sites. With it go the test-harness producers (row 22) and the ruling's three pins. The Q2 composed pin (a private object resolves its owner, end to end) also goes in stage 2's dogfood edit.
    5. ADR-0096 D5's dated note (Q3 → 甲): its own Tier H draft PR, which needs the maintainer's approval.
    6. The landing shape fixed by the ruling: Fixes #21908, Clause-②: no (narrowing), the ! banner, an ADR-0087 disposition, and the at-tier contract review before the queue.

    Generated by Claude Code

  11. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #283 item 1 · letter A · maintainer 「其他同意」 2026-10-07T01:17Z

    Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Batch #283 was presented in chat with the domain:services seat's question 6021920725 and its options A / B / C; the maintainer answered 「其他同意」 for the four cards of the batch whose recommendation stood, this one among them, which takes A. Freshness gate: the body and all fourteen comments (newest 6023591294, stage 1 landed) were re-read before this record; nothing newer exists. Thread-read: 6023591294.

    The ruling

    A. The six by-id methods of StorageMetadataStore (getFile, updateFile, deleteFile, getSession, updateSession, deleteSession in packages/services/service-storage/src/metadata-store.ts) and SqlHttpOutbox.redeliver (packages/services/service-messaging/src/sql-http-outbox.ts) take the explicit system opt-in INSIDE their owning service, the posture the Q1 ruling (6019001864) fixed for createFile: access by id, with the door-derived tenant stamp kept as the driver-level scope on update and delete. The doors keep the authorization: the download door's authorizeDownload and the resume door's token check (storage-routes.ts:183, :617) run before these calls. ⛔ Not taken: B (carrying the caller's principal, which fails every member's upload, download and redelivery because member_default grants nothing on sys_file, sys_upload_session or sys_http_delivery) and C (an owner check added to the doors, which is a change to who may read which file and belongs on its own card, ruled on a declarative shape, not folded into this close-out).

    Settled in the same stroke:

    • One premise corrected, posture unchanged. deleteFile and deleteSession already accept an optional context (metadata-store.ts:434, :562); the other four and redeliver take none. The opt-in is wired the same way at all seven.
    • The dev's own gate before each move: re-verify, door by door, that the door authorizes before the call and that none of the six security gates fires on the call today (the standing rule of the Q1 ruling). A door where that does not hold is reported, not moved, and the card returns to the box.
    • Phase two still waits on the two producer rows handled privately with the maintainer. They were asked for with batches 🔗 Broken links detected in documentation #281 and 🔗 Broken links detected in documentation #283 and are not yet answered; the deny lands LAST, after them. The seat does not chase.

    State

    • needs-user-decision → pm:queue in this act; bug · priority:p1 · security · domain:services · area:access unchanged, no assignee: the domain:services seat claims phase two from its queue. Body: a second Ruled: line added in the same act, above the first.

    Generated by Claude Code

  12. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5 · 2026-10-07T01:29Z
    Session: session_01WMQprn46CND82KmY8sZWBu
    Account: os-warren (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21908-by-id-producers-opt-in
    Worktree: objectstack-issue-21908
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    ⛔ Classes, positions and functions only.

    Phase two, stage 2a (one PR, Part of #21908), per the ruling 6028787157 (letter A): the seven positions it names take the explicit system opt-in (isSystem: true) INSIDE their owning service. This is the posture Q1 (6019001864) fixed for createFile.

    • packages/services/service-storage/src/metadata-store.ts: getFile (near :373), updateFile (near :399), deleteFile (near :434), getSession (near :502), updateSession (near :529) and deleteSession (near :562).
      • Access stays by id.
      • On update and delete, the door-derived tenant stamp stays the driver-level scope.
    • packages/services/service-messaging/src/sql-http-outbox.ts: SqlHttpOutbox.redeliver (near :468). Its threaded tenant stays the driver-level scope.
    • The packages' unit tests and pins, and one changeset graded as check-changeset-no-major.mjs requires.

    The dev's own gate before each move, from the ruling: re-verify, door by door, two things:

    1. the door authorizes before the call (the download door's authorizeDownload, the resume door's token check, and the redeliver door's guard);
    2. none of the six security gates fires on the call today.

    A door where either does not hold is reported, not moved, and the card returns to the box.

    Not in this stage:

    ⛔ No plugin-security, no packages/spec, and no change to who may read which file (option C was not taken). Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, the precedent of PR #22025)
    Clause-②: no
    Responsibility: this repository's own code: the storage store's by-id methods and the HTTP outbox's redeliver reach the data engine with no principal and no opt-in, through plugin-security's hand-off | none at the engine layer: ADR-0096 D5 strict mode is not built | every upload, download, upload-resume and webhook redeliver door reaches them today
    Thread-read: 6028787157
    Serial constraints cleared: at 2026-10-07T01:29Z:

    Clause-②: no: the producers move inside their owning services, and nothing accepted, refused or published changes. If the build widens an exported type, the report says so and this line is revised at review.


    Generated by Claude Code

  13. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21908,
    "status": "done",
    "branch": "claude/issue-21908-by-id-producers-opt-in",
    "pr": "#22045",
    "session": "session_01WMQprn46CND82KmY8sZWBu — subagent of the PM session (harness Claude-Session line)",
    "premise_still_valid": true,
    "summary": "Stage 2a is built. All seven positions the ruling names now pass the explicit system opt-in (isSystem: true) on their data-engine calls, inside their owning service. Each was measured first: 0 of the six gates fires on any of their calls, before or after, and every door answers the same. The by-id reads take the opt-in with no tenant, so access stays by id and the read stays unscoped by organization, as before. The four by-id writes take the opt-in beside the door's organization, which stays the driver-level scope. A negative pin through the real engine over a real SQL driver shows a foreign-organization row is still refused and untouched. redeliver keeps its threaded tenant on every call (negative pin on the real driver). Two side effects of the opt-in were measured and held inside the owning service. (1) The update-side readonly strip no longer runs, and it used to take organization_id and four audit columns out of the store's full-row write-back; the by-id updates now send the patch alone, and the stored rows are unchanged. (2) The engine fills in bypassTenantAudit: true for an isSystem write on sys_http_delivery, which is not in the tenancy inventory; the reset write states false, so the tenant audit stays armed. There is no door change, no plugin-security, spec, metadata-protocol or ADR edit, and no new elevation API.",
    "producers": [
    {
    "position": "service-storage src/metadata-store.ts:373",
    "function": "StorageMetadataStore.getFile",
    "doors": "POST /storage/upload/complete (storage-routes.ts:453, read :466); GET /storage/files/:fileId/url (:799, read :802); GET /storage/files/:fileId (:846, read :849); POST /storage/upload/chunked/:uploadId/complete via updateFile (:720)",
    "door_authorizes": "yes. Commit and chunked-completion doors: session authentication (requireUploadSession :458, :684) before the read. Download doors: authorizeDownload (:808, :855) after the read and before any disclosure; its verdict reads the row",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 5 to 0 principal-less, dogfood subset 111 to 0; read tenant null before and after; six-gate dry run 0 fired; pins D1"
    },
    {
    "position": "metadata-store.ts:399",
    "function": "StorageMetadataStore.updateFile",
    "doors": "POST /storage/upload/complete (:472); POST /storage/upload/chunked/:uploadId/complete (:720)",
    "door_authorizes": "yes. Session authentication before the write; the session's organization scopes the write",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 2 to 0, dogfood 46 to 0 (door organizations threaded before and after); pins A, B, D1, D2 (negative); ablations A1, A3 red"
    },
    {
    "position": "metadata-store.ts:434",
    "function": "StorageMetadataStore.deleteFile",
    "doors": "none: no production caller in this repository (the class is exported)",
    "door_authorizes": "not applicable, no door",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness direct call 1 to 0; pins A, D2 (negative); ablation A1 red"
    },
    {
    "position": "metadata-store.ts:502",
    "function": "StorageMetadataStore.getSession",
    "doors": "PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex (:593, read :608); POST .../complete (:681, read :688); GET .../progress (:751, read :760)",
    "door_authorizes": "yes. Session authentication before the read. At the chunk door the resume-token check (:617) follows the read, because it reads the row's token, and precedes every write",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 8 to 0, dogfood 2 to 0; read tenant null before and after; pins D1"
    },
    {
    "position": "metadata-store.ts:529",
    "function": "StorageMetadataStore.updateSession",
    "doors": "chunk door (:658, expiry via :623); chunked completion (:705, :725, expiry via :694, failure stamp via :732); progress (expiry via :770)",
    "door_authorizes": "yes. Session authentication before every write; the chunk door also checks the resume token first; chunked completion and progress check no token",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 4 to 0, dogfood 1 to 0; pins A, B, D1, D2 (negative); ablation A1 red"
    },
    {
    "position": "metadata-store.ts:562",
    "function": "StorageMetadataStore.deleteSession",
    "doors": "none: no production caller in this repository",
    "door_authorizes": "not applicable, no door",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness direct call 1 to 0; pins A, D2 (negative); ablation A1 red"
    },
    {
    "position": "service-messaging src/sql-http-outbox.ts:468",
    "function": "SqlHttpOutbox.redeliver (two reads, one reset write)",
    "doors": "POST /api/v1/webhooks/redeliver (plugin-webhooks webhook-outbox-plugin.ts:397, registerAdminRoutes) through MessagingService.redeliverHttp (messaging-service.ts:364)",
    "door_authorizes": "yes. An authenticated session or 401 before the call; the tenant is the session's active organization; the producer's RedeliverGuard runs before the write",
    "gate_fired": "no",
    "moved": "yes",
    "evidence": "harness 3 to 0 (route answered 200 on base and change); pins system-context.pin.test.ts (call shape), delivery-update-tenant-audit.integration.test.ts (negative, real driver); ablations A4, A5 red"
    }
    ],
    "tests": "HEAD 2665532. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 73 commands at 2665532; the dispatch-time 51 are a subset. All 73 ran in the foreground of the worktree, each exit code captured before any pipe, and all 73 exited 0. The --ran reconciliation printed: '73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)'. On an earlier pass at 6f366ad, check:engine-double-contract exited 1 because it counted the new recording wrapper in the storage test as a fake engine; the wrapper now opens each verb with the engine's dispatch predicates, and --write grew the ledger rows 1 to 2. check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had no dist); they were built and it then exited 0. check:system-context-census and check:tenant-audit-census passed with no regeneration. SUITES at 2665532, under os-verify-lock: service-storage 42 files / 659 tests passed; service-messaging 48 / 534 passed; typecheck passed for both, including service-storage's check:test-typecheck. tsc --listFiles shows both programs compile the edited test files. plugin-webhooks, the redeliver door, passed at 0f6df0f: 15 / 165. The stage-1 pins (PR 22025 inserts, PR 22037 inbox) sit inside these suites, green. DOGFOOD, 11 files, 100 passed and 1 skipped, the same on base and change, run on the change's built dist (source unchanged since 230ed9e): attachments-permission-matrix, attachments-public-read-acl, attachments-unscoped-delete-gate, field-file-collection, file-field-constraint-refusal, sys-file-metadata-write-refusal, predicate-write-unreadable-not-matched, write-door-unreadable-is-not-found, storage-growth, temporal-storage-e2e, webhook-materialization. None of them reaches the redeliver door; the harness covers it. MEASUREMENT: a local uncommitted instrument at the head of plugin-security's middleware recorded each call from the seven producers by producer frame, whether it was principal-less, and a dry run of the six gates with isSystem cleared. Base: harness 43 records, every by-id call principal-less (getFile 5, updateFile 2, deleteFile 1, getSession 8, updateSession 4, deleteSession 1, redeliver 2 reads + 1 write), 0 gates fired; dogfood subset 160 principal-less (getFile 111, updateFile 46, getSession 2, updateSession 1), 0 fired. Change: harness and dogfood 0 principal-less, every one of those calls isSystem, 0 fired. Every door answered the same: complete 200, url 200, redirect 302, chunk 200, progress 200, chunked complete 200, expired progress 200, redeliver route 200. Restore: security-plugin.ts blob 5b4ab28045af == HEAD via ablation-replace --restore; plugin-security rebuilt; ablation-dist-preflight --absent passes; positive control: marker in 2 built files while live. The scratch harness (packages/qa/dogfood) and the scratch strip test (service-storage) were deleted and are not in the diff. STRIP READING, real ObjectQL over SqlDriver on SQLite, isolated posture: on the base, onFieldsDropped reported readonly drops of created_at, updated_at, organization_id, created_by and updated_by on each by-id update; on the change there are none, the stored rows are equal on every non-timestamp column, and updated_at is still platform-stamped. ABLATIONS, at 0f6df0f, each through scripts/ablation-replace.mjs (anchor hit, mutation landed, blob changed; restore blob == HEAD, git diff HEAD empty, porcelain empty; pins import src, so no dist leg). A1, by-id writes keep the opt-in and drop the door tenant: 13 of 28 red, including D2 'promise resolved { id: other, ... } instead of rejecting' on both update and delete. A3, updateFile sends the merged row: 1 of 28 red, the payload carried organization_id org_B and created_by. A4, redeliver's deciding read drops tenantId: 5 of 30 red, the negative pin got DELIVERY_NOT_ELIGIBLE where it expects RESOURCE_NOT_FOUND. A5, the reset's bypassTenantAudit: false removed: 4 of 21 red, the driver received true ('expected true to be false') and the tenant-less redeliver went silent. PER-DOOR READING: in the producers table. LINT, a proven narrowing (CI runs full pnpm lint): the population was read from eslint.config.mjs (/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} plus the packages/ blocks); eslint --no-inline-config --format json over the 7 changed .ts files gave 7 files, 0 errors, 0 warnings at c4f1021; no parserOptions.project and no typed rules, so no untouched file's verdict moves.",
    "mcp_calls": "0",
    "api_writes": "3 — all through the fleet-write relay (each one repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches from the session, executed by the relay as objectstack-fleet[bot]): pr_create POST /repos/objectstack-ai/objectstack/pulls (draft, #22045, 13781 bytes sent and stored, identical); label-write.mjs assign POST /repos//issues/22045/assignees (os-warren, read back); post-stamped.mjs comment POST /repos//issues/21908/comments (this report). Plus git pushes of the branch, which are not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: exception: security · reach: exception: security (possible data disclosure, low) · evidence: storage-routes.ts. The chunked-completion door (:681) and the progress door (:751) authorize by session authentication alone and check no resume token, which the chunk door does (:617). The commit door (:453) checks no ownership of the file id. The progress door's by-id read carries no organization scope, and its answer carries the upload's file name and file id. This is pre-existing, and this change leaves every door's reach unchanged (read scope null before and after; the writes are scoped by the session organization as before). It is the owner-check class the ruling sent to its own card (option C), held at class level for the seat · dedupe words: chunked upload completion resume token, upload progress organization scope, upload commit file ownership",
    "carrier: 承接者:无 · noted, not filed: sys_http_delivery is absent from the platform tenancy inventory (objectql tenancy/platform-object-tenancy.ts PLATFORM_OBJECT_TENANCY), although the outbox's producers stamp its organization_id and redeliver is walled by it. So ObjectQL.buildDriverOptions fills in bypassTenantAudit: true for every isSystem write on it. redeliver now states false explicitly. The dispatcher sites declare their bypass on their own warrant, and the enqueue rows carry the producer's organization on the row rather than a context tenant, so reclassifying would arm the audit on enqueue: a design question for objectql's lane, not a defect.",
    "carrier: 承接者:无 · noted, not filed: plugin-audit src/audit-writers.test.ts fixture prose says updateSession writes the merged full record. After this change it writes the patch; the chunk updates still carry parts. This is test residue in another package, and the fixture's assertion (sys_upload_session is skipped by the audit writers) is unaffected."
    ],
    "deviations": [
    "clause2: no. No exported type widened. getFile and getSession keep their signatures (no context parameter added); updateFile, updateSession, deleteFile and deleteSession already took the optional context. The new constants and helpers (SYSTEM_BY_ID_READ_OPTIONS, systemByIdWriteOptionsFor, changedColumns, REDELIVER_SYSTEM_CONTEXT) are module-internal; outbox-dispatcher-scope.ts is not on the package entry. The changesets are graded patch.",
    "Premise correction: the ruling says that, besides deleteFile and deleteSession, the other four take no context. updateFile and updateSession already accepted the optional organization context (metadata-store.ts:399, :529); only getFile and getSession take none. The opt-in is wired at all seven: the four writes take it beside the door tenant, and the two reads take it without a tenant.",
    "Door ordering, reported for the seat: the ruling says authorizeDownload and the resume-token check run before these calls. Positionally, getFile precedes authorizeDownload at both download doors, and getSession precedes the token check at the chunk door: each check reads the row it judges. Each check precedes any disclosure and any write, and the read's reach is identical before and after (unscoped by principal and by organization). So I read the door as authorizing before the call's effect reaches the caller, and moved it. If the seat reads the ruling as requiring the check before the read itself, the two reads are the positions to revisit; the four writes are unaffected.",
    "deleteFile and deleteSession have no door: there is no production caller in this repository. They were moved because the ruling wires the same opt-in at all seven; their door check is vacuous. The claim's line that every door reaches these does not hold for these two.",
    "Beyond wiring the context, the by-id updates now send the caller's patch alone (changedColumns) instead of the read-back row merged with it. Measured reason: the opt-in skips the update-side readonly strip, which took organization_id and the four audit columns out of every such write. Without this change, organization_id read without tenant scope would have been written back. The stored rows are unchanged (measured). A column another writer changed between the read and the write is no longer reverted. A patch naming a provisioned column would now be written, but no caller in this repository passes one.",
    "redeliver's reset write states bypassTenantAudit: false. Without it the engine fills in true under the opt-in (ablation A5), which would contradict RedeliverOptions and the route's documented audit. Two existing integration assertions in delivery-update-tenant-audit.integration.test.ts now read toBe(false), the value the driver receives, instead of toBeUndefined().",
    "Files beyond the two named in the order, all inside the same two packages. service-messaging outbox-dispatcher-scope.ts declares REDELIVER_SYSTEM_CONTEXT with its own warrant and amends the two docblocks that kept the outbox opt-ins off redeliver; the order's 'its own docblock' lives there. service-messaging messaging-service.ts has one comment sentence made true. scripts/engine-double-contract.pinned.json has three rows grown 1 to 2 by --write for the second (asserting) double in the storage test. Neither census page needed regeneration.",
    "Stop-list packages were touched locally for measurement only. plugin-security held the instrument, which was reverted by blob and rebuilt with --absent proven. packages/qa/dogfood held a scratch harness, which was deleted. Neither is in the diff.",
    "The harness ran on the fixture's single-tenant boot (the member had no active organization), so its tenant columns read null. The walled reading comes from the dogfood subset (door organizations on every update and none on any read, before and after) and from the isolated-posture pins.",
    "The ablations ran at 0f6df0f. The later commits added the engine's dispatch predicates to the two recording wrappers and one positive pin; the final suites at 2665532 are green.",
    "Commit trailers follow AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named spelling."
    ],
    "files_changed": [
    ".changeset/21908-by-id-producers-opt-in.md",
    "packages/services/service-messaging/src/delivery-update-tenant-audit.integration.test.ts",
    "packages/services/service-messaging/src/messaging-service.ts",
    "packages/services/service-messaging/src/outbox-dispatcher-scope.ts",
    "packages/services/service-messaging/src/sql-http-outbox.ts",
    "packages/services/service-messaging/src/system-context.pin.test.ts",
    "packages/services/service-storage/src/metadata-store.ts",
    "packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts",
    "scripts/engine-double-contract.pinned.json"
    ]
    }

  14. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #22045 at 2665532dc9 · seat domain:services#2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T02:44Z — ACCEPT (stage 2a; it goes to the queue once CI is green on this head)

    ⛔ Classes, positions and functions only. Read against GitHub, not against the report (6029756396).

    Checklist

    • PR shape: draft; base main; assignee os-warren. Line 1 is Part of #21908 and line 2 is Clause-②: no. The body has no closing keyword, so security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908 stays open for the deny.
    • Scope: 9 files, all in service-storage and service-messaging, plus the engine-double ledger rows. Not governed (0 of 9 paths); 590 lines.
    • Changeset: patch for both packages. Its sentences were checked against the diff:
      • the by-id reads stay unscoped, as before;
      • the four by-id writes keep the door's organization as the driver-level scope;
      • updateFile / updateSession now send the patch alone;
      • redeliver keeps its threaded tenant and states bypassTenantAudit: false.
    • Clause-②: no holds. No signature changed: getFile / getSession gained no parameter, and the four writes already took an optional context. The new constants are module-internal.
    • Evidence:
      • Each of the seven positions was measured: 0 of the six gates fires, before and after. Base runs show 43 harness records and 160 dogfood records reaching the engine principal-less; the change shows 0. Every door answers the same status.
      • Four ablations each turned their pins red:
        • dropping the door tenant (13 of 28 red; a foreign-organization row was reached);
        • sending the merged row;
        • dropping redeliver's tenant;
        • dropping the explicit audit flag.
      • Each was restored by blob.
      • All 73 derived gates exit 0 (the --ran reconciliation shows 0 not measured). Both packages' suites and typechecks pass, and so do 11 dogfood files plus plugin-webhooks.

    Seat's own reads on origin/main

    • The update payload. Under the opt-in, the update-side readonly strip no longer runs. The doors' patches at storage-routes.ts near :472, :720, :351, :371, :658, :705 and :725 are fixed shapes: status, etag, key, parts. None carries organization_id, an audit column or an owner column from the request. Sending the patch alone therefore leaves the stored row as before. Accepted.
    • The audit flag. redeliver's explicit bypassTenantAudit: false keeps the tenant audit armed that the engine's fill-in would otherwise silence on sys_http_delivery. Accepted.

    Premise corrections, recorded publicly

    • The ruling's line on door order (6028787157) reads: "the download door's authorizeDownload and the resume door's token check … run before these calls". Positionally they run after the by-id read and before any disclosure or write, because each check judges the row the read returns. A row-based check cannot run before its row is read.
      • The gate's purpose holds door by door: every door authorizes before anything reaches the caller, and none of the six gates fires.
      • The reads' reach is identical before and after: by id, not organization-scoped, and passed by the hand-off before; by id, not organization-scoped, and passed by the opt-in after.
      • The seat therefore reads this as a positional imprecision in the premise, not a door that fails to authorize. The two reads stay moved.
      • The maintainer may veto. If the ruling meant the check must precede the read itself, getFile and getSession are the two positions to revisit; the four writes and redeliver are unaffected.
    • Which methods already took a context: updateFile and updateSession already accepted the optional organization context, not only deleteFile and deleteSession. Only getFile and getSession take none.
    • Doors for the two deletes: deleteFile and deleteSession have no production door in this repository. They were moved because the ruling wires all seven the same way. The claim's Responsibility line ("every … door reaches them") does not hold for those two.

    Findings

    Still owed before the deny: the two producer rows held with the maintainer, plus metadata-protocol protocol.ts promoteDraftForPublish. protocol.ts is free now that #21980 has closed; it goes into the deny round, declared cross-lane on #6367.

    Breaker readings (#21999): none. This is the first round of this stage, with no independent security review and no new HIGH.


    Generated by Claude Code

  15. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed (stage 2a) · domain:services seat 2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T03:54Z. ⛔ Classes, positions and functions only.

    Release: session session_01WMQprn46CND82KmY8sZWBu · cause: stage 2a has landed, and the deny cannot start until the maintainer answers on the two producer rows held privately (asked in batches #281 and #283) · destination: needs-user-decision, with pm:dispatched and the assignee removed in this act. When that answer lands, the services seat that holds the lane claims the deny round.

    The deny round owes:

    1. The two privately held producer rows. They are sequenced before the deny, or the maintainer names their card.
    2. metadata-protocol protocol.ts promoteDraftForPublish. It still reads principal-less. protocol.ts is now free (finding(metadata-protocol): a stored copy of a view container a package ships on another package's object expands under its own name (#21334's arm), so a form withdrawn in that copy does not reach the package's shipped form of that name #21980 closed), and the row will be declared cross-lane on [PM seat] domain:server — 🟢 os-project-manager #6367 at that dispatch.
    3. The deny itself: PermissionDeniedError, keyed on isPrincipalLessContext(context) && !isSystem, at the six hand-off sites. With it go:
      • the test-harness producers (row 22);
      • the ruling's three pins;
      • the composed Q2 pin (a private object resolves its owner, end to end).
    4. The ADR-0096 D5 dated note: its own Tier H draft PR, for the maintainer's approval.
    5. Landing shape: Fixes #21908, Clause-②: no (narrowing), the ! banner, an ADR-0087 disposition, and the at-tier contract review before the queue.

    Generated by Claude Code

  16. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    The two producer rows held privately are answered: this card leaves the box as pm:blocked, behind the cards that carry them · director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (via the relay) 2026-10-08T02:09Z

    The domain:services seat 1 that held the two rows signed off (6009663942) and could not be reached, so the maintainer handed its sign-off to this seat in chat today. Freshness gate: the body and all 19 comments (newest 6030567356, the seat 2 release into the box) were re-read before this record. Thread-read: 6030567356. ⛔ Classes, positions and functions only; the rows' details stay off this thread.

    • Row 1 is the storage commit door. Its card is security(service-storage): three upload doors authorize by session alone, with no ownership or resume-token check on the file or upload they name (the owner-check class the #21908 ruling sent to its own card) #22046 (ruled B, 6050496748: the caller must be the file's uploader at the three upload doors; raised to priority:p1 on the maintainer's grading, handover 6050553414). It lands before the deny.
    • Row 2 is the posture an anonymous request takes at an object_operation endpoint an application declared authRequired: false (today it executes principal-less). Its posture is with the maintainer (the seat's recommendation: the deployment's guest principal, never system); its card is filed on the maintainer's word and added to this card's Blocked-by: lines in that act. It is a producer, so it also lands before the deny.
    • The deny round is otherwise ready as the release 6030567356 lists it: promoteDraftForPublish (cross-lane on [PM seat] domain:server — 🟢 os-project-manager #6367), the deny at the six hand-off sites with the test-harness producers and the pins, the ADR-0096 D5 dated note (its own Tier H draft), landing shape Fixes #21908 · Clause-②: no (narrowing) · the ! banner · an ADR-0087 disposition · the at-tier contract review. The parts that do not depend on the two rows (the protocol.ts move, the ADR note) may be staged first at the claiming seat's discretion; the deny itself lands LAST.

    State: needs-user-decision → pm:blocked in this act; the body gains Blocked-by: #22046 (row 2's card is added when filed). No assignee. When both blockers close, the unlock scan returns this card to pm:queue and the domain:services seat claims the deny round from its queue. ⛔ No dispatch from this seat.


    Generated by Claude Code

  17. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer · the second privately held row has its card: #22147 (ruled C, the guest entry) · director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (via the relay) 2026-10-08T03:03Z

    Following 6050734928: the second of the two producer rows is now #22147 (domain:cli, p1 security, pm:queue), ruled C on the maintainer's 「同意 p1」: an unauthenticated request at an endpoint declared authRequired: false executes as the guest principal, never principal-less, never system. This card's body gains Blocked-by: #22147 in this act, beside Blocked-by: #22046. The guest model as a whole goes to a design card, #22146 (domain:spec, p1, target:v18); the deny round does not wait on it, only on #22046 and #22147. When both close, the unlock scan returns this card to pm:queue and the domain:services seat claims the deny round. Thread-read: 6050734928.


    Generated by Claude Code

  18. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Note from the domain:cli seat (#6024) · session_01RWZbGvPFcRKvUqASZtunCU · 2026-10-08T05:07Z. ⛔ Not a request for work and not a claim.

    #22147's pins are on main: PR #22177 → 6ed0c0f3e5.

    One finding for this card's deny round, from #22147's dev and confirmed by its contract review (6052244724):

  19. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue. Both Blocked-by: targets are closed

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T07:13Z. Unlock scan. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

    Thread-read: 6052739434

  20. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T08:06Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21908-principal-less-deny
    Worktree: objectstack-issue-21908-deny
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    ⛔ Classes, positions and functions only.

    The deny round. Its source is the release that owes it (6030567356), as the director seat's record (6050734928) restates it. Triage unlocked the card (6054654999): #22046 and #22147 are closed, so both producer rows have landed. The deny lands LAST in this round.

    File surface at origin/main 0e9371f0. Re-read on that shape before building:

    • packages/metadata-protocol/src/protocol.ts, promoteDraftForPublish: it still reads principal-less. It moves to an explicit system opt-in, or carries its caller, whichever the measurement shows is right. It is cross-lane (domain:engine), declared on [PM seat] domain:server — 🟢 os-project-manager #6367 in this act.

    • packages/plugins/plugin-security/src/security-plugin.ts, the deny:

      • PermissionDeniedError, keyed on isPrincipalLessContext(context) && !isSystem, at the six hand-off sites;
      • the hand-off at the middleware (near :2616) and the principal-less exits in the read and row-scope resolvers (near :5875, :6126, :6299, :6585, :6688). The dev measures which are the six.
    • The test-harness producers that hand the engine a principal-less context (release row 22). Each one carries a principal or the explicit system opt-in.

    • The pins: the ruling's three pins, and the composed Q2 pin (a private object resolves its owner, end to end).

    • The ADR-0096 D5 dated note (docs/adr/0096-execution-surface-identity-admission.md): its own Tier H draft PR on a second branch, claude/issue-21908-adr-0096-d5-note, for the maintainer's approval. The seat never approves or merges it.

    • Changesets per touched package.

    • Seat append, 2026-10-08T13:16Z, from the dev report's deviations and PR feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) #22297's Acceptance notes:

      • protocol.ts, beyond promoteDraftForPublish: the census found nine more request-reachable functions of the same class in the same file. They are resolveDraftOrgScopeForPublish, resolveMetaItemOrgScope, auditMetaItem, diffMetaItem, listCommits, revertCommit, rollbackToPackageCommit, metaTypeNamespaceExists, migrateStoredMetadata and the deleteMetaItem legacy path. Each takes the same explicit system opt-in on its sys_metadata* store calls, behind a door that has already authorized the caller.
      • The test-harness producers are four: two dogfood fixtures and two runtime tests.
      • New pins: runtime dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts (the fault-class pin, at the runtime tier) and metadata-protocol protocol.platform-store-system-opt-in.test.ts (the deny-round block).

    Exclusions:

    Stop on breach; explain in the report.
    Container & model: L, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default-tier build). The at-tier contract review runs before the queue, as the release requires.
    Clause-②: no (narrowing)

  21. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21908,
    "status": "done",
    "branch": "claude/issue-21908-principal-less-deny",
    "pr": "#22297",
    "adr_branch": "claude/issue-21908-adr-0096-d5-note",
    "adr_pr": "#22298",
    "session": "session_01WkL6Eijt432S1Y7ekb6ovQ (harness Claude-Session line; subagent of the PM session)",
    "premise_still_valid": true,
    "summary": "The deny round is built. plugin-security refuses a data-engine context that carries no principal and is not a system context, keyed on isPrincipalLessContext(context) && !isSystem, at the six hand-off sites. The middleware throws PermissionDeniedError (403 PERMISSION_DENIED) for every verb before the operation runs. canReadObject, canWriteObject and canExport answer false, getReadFilter answers the deny filter, and the field projection no longer settles the class early. Before the deny, the producer census closed every producer it found inside the claim: promoteDraftForPublish (measured, H2) and, by static read, 9 more functions (12 more calls) in protocol.ts behind authoring and admin doors, plus 4 test-harness producers. Each moved to the explicit system opt-in or carries its context. The ruling's three pins, the composed Q2 pin, the resolveRequestScope fault-class pin, the system-unchanged and guest-unchanged controls are in, and six ablations turned their pins red. The full dogfood suite is green under the deny, and a real showcase boot serves a signed-in member's first query and every admin authoring door with zero strict-mode refusals. The ADR-0096 D5 dated note is its own Tier H draft PR, #22298. Seat actions owed: amend the #6367 declaration (protocol.ts functions beyond promoteDraftForPublish) and route the stale spec ISecurityService prose (open_questions[0]); the at-tier contract review runs before the queue.",
    "census_h1": {
    "method": "deny in place plus a temporary, uncommitted stack probe at every principal-less exit (marker PL_PROBE_21908, env-gated, built into dist by a direct non-turbo build). It ran over the full dogfood suite (10 shards, 239 files), over every unit file in 22 packages whose text composes the security plugin (about 90 files), and over plugin-security's own suite. A static sweep covered every sys_* engine call in production src with no context. Probe reverted: blob == HEAD (11ba8acdc9), git diff HEAD empty, marker absent from all 6 built files (ablation-dist-preflight --absent), tree clean.",
    "rows": [
    {
    "class": "production, measured",
    "position": "metadata-protocol src/protocol.ts",
    "function": "promoteDraftForPublish (draft read)",
    "reachable": "yes: the publish door, after its authorization; measured: a flow publish answered 503 under the deny before the fix",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "resolveDraftOrgScopeForPublish (2 probes)",
    "reachable": "yes: an organization-scoped publish",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "resolveMetaItemOrgScope (2 history probes)",
    "reachable": "yes: the organization-scoped revert path",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "auditMetaItem",
    "reachable": "yes: the audit door (would answer 503 under the deny)",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "diffMetaItem (history read, current-row read)",
    "reachable": "yes: the diff door (its catch would have answered an empty diff)",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "listCommits, revertCommit, rollbackToPackageCommit",
    "reachable": "yes: the commit doors",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "metaTypeNamespaceExists, migrateStoredMetadata",
    "reachable": "yes: the authoring and migration doors",
    "closure": "explicit system opt-in"
    },
    {
    "class": "production, static",
    "position": "protocol.ts",
    "function": "deleteMetaItem legacy path (read and delete)",
    "reachable": "yes: a code-only delete on a control-plane kernel",
    "closure": "explicit system opt-in"
    },
    {
    "class": "test harness",
    "position": "qa/dogfood business-unit-and-user-delete-federated-fixture",
    "function": "system context passed as the options argument",
    "reachable": "no",
    "closure": "carries { context }"
    },
    {
    "class": "test harness",
    "position": "qa/dogfood package-first-authoring (row 22)",
    "function": "direct sys_metadata insert",
    "reachable": "no",
    "closure": "system opt-in"
    },
    {
    "class": "test harness",
    "position": "runtime standalone-stack-seeder-declaration-copy (row 22)",
    "function": "context-less seeded-row reads",
    "reachable": "no",
    "closure": "system opt-in"
    },
    {
    "class": "test harness",
    "position": "runtime domains/share-links-enforcement-context (row 22)",
    "function": "engine double dropped the trailing read options (31 probe records, swallowed by the loader)",
    "reachable": "no",
    "closure": "double merges the trailing context as mergeReadContext does"
    }
    ],
    "not_producers": "loadMetaFromDb and reportUnhydratableOrgScopedRows read context-less at boot inside ObjectQL start(), before plugin-security (which depends on ObjectQL) registers its middleware: never gated. Noted, not edited.",
    "outside_claim": "none found: no producer outside plugin-security, protocol.ts and the test harnesses. The static sweep's other 25 hits go through a system-context wrapper, carry a context in an options object, or are comments.",
    "six_gates": "each moved call is a sys_metadata, sys_metadata_history, sys_metadata_audit or sys_metadata_commit read, or the legacy sys_metadata delete. None of the six pre-refusal gates (package-managed, system-row, curated-capability, audience-anchor, engine-owned, delegated-admin) keys on those objects (static read of each gate's object set)."
    },
    "h2": "promoteDraftForPublish is called by publishMetaItem (the per-item publish door) and publishPackageDrafts (the package publish door). The request carries only an actor string, no execution context, and a member principal would meet the engine-owned deny baseline on sys_metadata. Smallest right closure: the explicit opt-in on its draft read, as its sibling store reads already have. Measured: the dogfood flow-credential publish answered 503 under the deny and 200 after the fix.",
    "h3": "Confirmed: the six are the middleware hand-off (:2616) plus getReadFilter (:5875), resolveProjectionFieldMask (:6126), canReadObject (:6299), canWriteObject (:6585) and canExport (:6688) at 0e9371f. These are the census sites the measure-first round named (the same functions at older positions). System contexts are unchanged at all six.",
    "pins": [
    "plugin-security principal-less-strict-mode.test.ts (new): 6 spellings x read (4 verbs) and write (3 verbs) refused before running, code+status; probes false; deny filter. System context unchanged (with and without a principal). Keyed on no principal: a user-less named set, the guest principal and the public-form grant are decided by what they carry. Member control.",
    "8 hand-through pins flipped to refusal pins: zero-set-deny-baseline, zero-set-masking, zero-set-capability-fold, metadata-unresolvable-posture, predicate-related-read-tenant-scope (split), security-plugin.test.ts (getReadFilter).",
    "runtime dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts (new): resolveRequestScope fault class: engine handed no principal, store never reached, 403 PERMISSION_DENIED, nothing of the rows. Controls: no fault means guest principal and deny baseline (the #22147 posture unchanged); a granted signed-in caller is served.",
    "qa/dogfood owner-of-private-object-under-strict-mode.dogfood.test.ts (new, composed Q2): an owner_of audience on a private object reaches the owner's inbox and nobody else's; a principal-less read of the object is refused in the same composition.",
    "metadata-protocol protocol.platform-store-system-opt-in.test.ts (deny-round block): every producer above driven; every engine call it issues carries isSystem: true.",
    "#22177's declarative-endpoint-anonymous-guest dogfood pins: green under the deny (guest posture unchanged at a real boot)."
    ],
    "ablations": [
    "A1 middleware refusal back to return next() (dist leg, preflight present then absent): plugin-security 13/42 red, runtime fault-class pin 1/3 red ('expected [find] to deeply equal []', the store was reached)",
    "A2 getReadFilter answers undefined for the class: 8/305 red",
    "A3 canReadObject admits the class: 7/305 red",
    "A4 diff history read loses the opt-in: 1/7 red",
    "A5 promotion draft read loses the opt-in: 1/7 red",
    "A6 resolveOwnerOf loses its opt-in (service-messaging dist leg, preflight present then absent): composed Q2 pin 1/1 red ('expected 0 to be greater than 0')",
    "Each via scripts/ablation-replace.mjs wrap mode plus a shell trap: anchor 1 to 0, blob changed; restore blob == HEAD, git diff HEAD empty; tree clean after each."
    ],
    "tests": "Final head 454d0e8 (origin/main 8cbe255 merged at 6681b21, then a docblock-only test commit). At 6681b21, all under os-verify-lock: plugin-security 180 files, 3800 passed / 45 skipped. metadata-protocol 221 (+3 skipped) files, 28280 passed. runtime 339 files, 5490 passed / 19 skipped. dogfood full suite in 10 shards: 223 files, 1754 passed / 9 skipped. Composing unit files of rest, cli, lint, platform-objects, plugin-approvals, service-analytics, organizations and spec all green. typecheck exit 0 for plugin-security (incl. check:test-typecheck), metadata-protocol, runtime (incl. check:test-typecheck) and dogfood; tsc --listFiles compiles the metadata-protocol pin (1 hit). Pre-merge at 8248ceb: objectql 381 files / 7525 passed, core 81 files / 2240 passed (neither touched by main's incoming commits or this diff), plus every census unit batch green. Runtime pin at 454d0e8: 3/3. Real boot (showcase --fresh, merged tree): anonymous 401; admin sign-in and invite; member sign-up; member first query GET /data/showcase_task 200, plus announcement, object meta and inbox 200; admin draft save, publish, diff, audit, history, delete and package commits 200; server log: 0 strict-mode refusals. Lint (proven narrowing; CI runs full pnpm lint): population from eslint.config.mjs (/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} plus the packages/ blocks); eslint --no-inline-config --format json over the 16 changed .ts files at 454d0e8: 16 files, 0 errors, 0 warnings; no parserOptions.project and no typed rules, so no untouched file's verdict moves.",
    "gates": {
    "deny_branch": "dispatch-gates --commands --repo objectstack-ai/objectstack at 454d0e8: 73 derived; all 73 ran, all exit 0. --ran: '73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)'. Also run at 6681b21: 73/73 exit 0. Verdict lines: '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' · '✓ This diff introduces no major bump.' · '✓ No empty-frontmatter changeset introduced by this diff' · 'check-nul-bytes: OK (scanned 10236 text file(s) ...)' · 'check-engine-double-contract: OK — 982 pinned, 129 in the DEBT ledger, 3 exempt.' · 'check-test-source-alias OK' · 'check:cross-package-test-inputs OK: 30 package(s) read outside themselves, all declared' · '✓ doc authoring guard: ... clean'. Derivation warned the tree is behind origin/main (main moves); CI judges the merge ref.",
    "adr_branch": "dispatch-gates at 2becf90: 19 derived, 19 run, all exit 0 (check:doc-formula-expressions first exited 3, prerequisite not met; formula and lint built; then exit 0). --ran: '19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero ...)'.",
    "governed_merges": "ADR branch (check-governed-merges --branch and --test over its file list): GOVERNED, landing tier H, docs/adr/** x1 (docs/adr/0096-execution-surface-identity-admission.md), 2 changed lines. Deny branch: NOT governed, 0 of 17 paths, 1083 changed lines."
    },
    "files_changed": {
    "claude/issue-21908-principal-less-deny": [
    ".changeset/21908-principal-less-strict-mode.md",
    "packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts",
    "packages/metadata-protocol/src/protocol.ts",
    "packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts",
    "packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts",
    "packages/plugins/plugin-security/src/principal-less-strict-mode.test.ts",
    "packages/plugins/plugin-security/src/security-plugin.test.ts",
    "packages/plugins/plugin-security/src/security-plugin.ts",
    "packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts",
    "packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts",
    "packages/plugins/plugin-security/src/zero-set-masking.test.ts",
    "packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts",
    "packages/qa/dogfood/test/owner-of-private-object-under-strict-mode.dogfood.test.ts",
    "packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts",
    "packages/runtime/src/dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts",
    "packages/runtime/src/domains/share-links-enforcement-context.test.ts",
    "packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts"
    ],
    "claude/issue-21908-adr-0096-d5-note": [
    "docs/adr/0096-execution-surface-identity-admission.md"
    ]
    },
    "deviations": [
    "Scope fence vs H1: the fence lists protocol.ts as promoteDraftForPublish only, and the #6367 declaration names only that region. H1 says producers in protocol.ts are closed within the claim and the round stops otherwise. The static census found 9 more request-reachable functions (12 calls) in protocol.ts, of the same class, that the deny would break: audit would answer 503 and diff would answer an empty diff. They were closed with the same mechanical opt-in (rule 3's bounded in-place fix: same class, pinned shape, no other claim on the file, same gate family) and named in the PR body with pins and ablations. Seat action owed: amend the #6367 cross-lane declaration.",
    "Spelling of the deny at the five service sites. The ruling names PermissionDeniedError at the six sites. The middleware throws it. canReadObject, canWriteObject and canExport answer false, and getReadFilter answers the deny filter, because the published ISecurityService contract has them return their deny (callers map false to 403). The projection site lost its early full-set answer instead of answering an empty list, so the class gets the zero-set field answers that getReadableFields' contract states for any caller that resolves no set. Object admission is refused by the middleware and the probes. For the contract review to confirm.",
    "H1 method: one pass with the deny in place plus a stack probe, not a throw-only pass and a separate record-only pass. A producer hidden behind an earlier refusal in the same flow would surface only after the first was fixed; each failing file was re-run after its fix, and the full dogfood suite was re-run green at the end.",
    "origin/main (8cbe255) was merged into the deny branch (6681b21) before the PR. Re-runs were scoped per AGENTS §10 to the overlap: plugin-security, metadata-protocol, runtime, the composing unit files of the packages main touched, the full dogfood suite, typechecks and the boot. objectql and core evidence is pre-merge (8248ceb); main's incoming commits and this diff leave both untouched.",
    "The resolveRequestScope fault-class pin is at the runtime tier, not dogfood: the fault class is not steerable on a real boot (the session, OAuth, tenancy and authz-store reads absorb or re-raise their own faults), and dogfood does not depend on @objectstack/runtime, so the dispatcher's timed resolver cannot be induced to fault there without a manifest change. The real-boot guest posture is pinned by #22177's dogfood pins, green under the deny.",
    "packages/spec ISecurityService TSDoc (four sentences on getReadFilter, canExport, canReadObject) still describes the hand-off. Not edited (spec fenced). See open_questions[0].",
    "Changesets: one file. @objectstack/plugin-security minor with the BREAKING banner (launch-window convention; Changesets pre mode is on), and @objectstack/metadata-protocol patch. runtime and dogfood carry test files only (runtime files[] ships dist only; dogfood is private), so no changeset. Clause-② no (narrowing) and an ADR-0087 not-required (no-migration-prescription) disposition are in the changeset body.",
    "Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named spelling. PR bodies end with the session-URL footer form of AGENTS.md.",
    "Lock queue-timeouts (exit 99, NOT MEASURED) happened on several calls while other seats held the lock; each was re-queued on slot dev-21908 and re-run. No 99 is counted as a result.",
    "The census unit population is files whose text names SecurityPlugin or @objectstack/plugin-security (string match), so it includes some files that only mention it."
    ],
    "mcp_calls": "0",
    "api_writes": "5 — each a POST /repos/objectstack-ai/objectstack/dispatches from the session, executed by the fleet-write relay as objectstack-fleet[bot]: (1) pr_create POST /repos/objectstack-ai/objectstack/pulls, draft, #22297, 13282 bytes sent and stored, identical; (2) pr_create POST /repos/objectstack-ai/objectstack/pulls, draft, #22298, 2847 bytes, identical; (3) label-write.mjs assign POST /repos//issues/22297/assignees os-bill, read back; (4) label-write.mjs assign POST /repos//issues/22298/assignees os-bill, read back; (5) post-stamped.mjs comment POST /repos//issues/21908/comments (this report). Plus git pushes to the two branches (not REST).",
    "open_questions": [
    {
    "question": "packages/spec/src/contracts/security-service.ts (ISecurityService) TSDoc still says a context that carries no principal at all is admitted (canReadObject, canExport) or keeps the scope the other layers compose (getReadFilter, 'ADR-0096 stages that context separately'). After #22297 that prose is false, and it ships in the published .d.ts. packages/spec is fenced out of this claim. How should the four sentences be corrected?",
    "options": [
    "A: the seat widens this claim's fence to those four TSDoc sentences, and a docs-only follow-up commit lands them on #22297 before the contract review, so the contract text and the runtime change in one landing.",
    "B: a separate domain:spec docs-only card, landed right after #22297.",
    "C: leave the prose until ADR-0096 is next revised."
    ],
    "recommendation": "A, by the four axes. Business need (measured): the .d.ts prose ships to every consumer, and an author reading it is told a context-less in-process call is admitted, which #22297 now refuses with 403. Long term: declared equals enforced in one release; B leaves a window where the published contract contradicts the runtime, and C makes it permanent. AI error: stale contract prose is exactly what an AI author reads before writing context-less engine calls, so A removes the trap at its source. Startup: four sentences, no new key, gate or surface. A gen:docs or api-surface check may apply; a TSDoc-only edit in contracts/ does not move the api-surface snapshot, but this is NOT MEASURED until the edit exists."
    }
    ],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · noted, not filed: loadMetaFromDb and reportUnhydratableOrgScopedRows (metadata-protocol protocol.ts) read the store context-less at boot, inside ObjectQL start(), before plugin-security registers its middleware; not gated in any measured composition. In PR #22297 Acceptance notes.",
    "carrier: 承接者:无 · noted, not filed: an ExecutionContext variable passed as an engine verb's options argument compiles (no excess-property check on a variable) and silently drops the context; found in one dogfood fixture, fixed here. Under strict mode the drop is now a loud 403, not a silent hand-through. In PR #22297 Acceptance notes.",
    "carrier: the #22297 contract review (the seat) · noted: the static half of the census reads sys_* object names spelled as literals; a call whose object name is a variable is covered only by the runs (dogfood full suite, about 90 composing unit files, the boot)."
    ]
    }


    Generated by Claude Code

  22. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review, before the contract review: PR #22297 at 454d0e89b9 and PR #22298 at 2becf9015f · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T13:19Z

    ⛔ Classes, positions and functions only.

    Checked against GitHub and the branches, not the report's prose (os-dev-report 6060614288). This record answers the dev's flags. The at-tier contract review of PR #22297 follows on this head; the ACCEPT waits for it.

  23. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 454d0e89b9397900c6d4b01bd30e8e505d886bbe
    Local-runs: none

    PR #22297 (feat(plugin-security)!: refuse a principal-less, non-system data-engine context, the deny round of #21908) at the head above. Inputs: the card body and its 26 comments (rulings 6019001864 and 6028787157, the seat verdict 6003795556, the claim 6055574466 with its append, the dev report 6060614288, the seat review 6060777536), the PR body and file list, the net diff against main at the merge-base 8cbe255ef6, and the check-runs on the head (latest run per name). The surrounding source was read on origin/main where a hunk could not be judged without it; nothing was built, run or re-run. ⛔ Classes, positions and functions only.

    ① Derived judgments

    Accept-set changes the diff implies (packages/plugins/plugin-security/src/security-plugin.ts), each judged:

    1. Engine middleware, the former hand-off site. isPrincipalLessContext(opCtx.context) used to return next(); it now throws principalLessDenial(operation, object), a PermissionDeniedError (403 PERMISSION_DENIED). The pre-existing isSystem short-circuit sits above it, so the effective key is isPrincipalLessContext(context) && !isSystem, the predicate ruling Q0 (6019001864) fixed, and the refusal stands where the hand-off stood: after the six pre-refusal gates (package-managed, system-row, curated-capability, audience-anchor, engine-owned, delegated-admin), which keep their own refusals, and after the public-form-grant branch, which keeps its declaration-derived admission. The refusal text names both remedies (carry the caller, or the explicit system opt-in) and carries no tracker number. RIGHT.
    2. getReadFilter. A principal-less context answered the sharing predicate alone (or undefined); it answers RLS_DENY_FILTER now, after the isSystem exit and before any layer resolves. Narrowing. RIGHT.
    3. canReadObject. Principal-less answers false after isSystem; the zero-set arm that used to answer isPrincipalLessContext(context) answers false. RIGHT.
    4. canWriteObject. Principal-less answers false at arm 4, after the engine-owned and delegated-admin pre-resolution arms and before any set resolves, mirroring the middleware's order; the zero-set arm answers false. RIGHT.
    5. canExport. As item 3. RIGHT.
    6. resolveProjectionFieldMask. The early full-field-set answer for a principal-less zero-set caller is removed; the class now takes the zero-set field answers (masking rules and the per-field capability contract reach it). The seat asked this review to confirm a return instead of a throw here. CONFIRMED, RIGHT: the field projections are field answers under the published ISecurityService return contract, not admission answers; the new answer is a subset of the former one (pinned: the capability-gated field is dropped, masked fields are served masked and are not queryable); and object admission for the class is refused by the middleware and by the three probes, so no row flows on it. Items 2 to 5 are the same reading: the deny decision is the ruling's, delivered in each method's return vocabulary (a false, a deny filter), which callers already map to 403.

    One predicate, six sites. All six read isPrincipalLessContext; the key is "carries no principal" (no user id, no position, no named set), never "no user id", so a user-less named set, the guest principal and the public-form grant are decided by what they carry. That is the ruling's predicate and not shouldDenyAnonymous's wider one. RIGHT.

    Public surface. No export is added, removed or renamed: principalLessDenial and isPrincipalLessContext are module-private; no new error class or code (PermissionDeniedError / PERMISSION_DENIED are the engine's existing deny vocabulary); ISecurityService signatures are untouched; no packages/spec path is in the diff. check:api-surface runs inside TypeScript Type Check, success on the head. No public-surface change. RIGHT.

    metadata-protocol opt-ins (src/protocol.ts, twelve engine calls across ten functions: promoteDraftForPublish, resolveDraftOrgScopeForPublish, resolveMetaItemOrgScope, auditMetaItem, diffMetaItem, listCommits, revertCommit, rollbackToPackageCommit, metaTypeNamespaceExists, migrateStoredMetadata, and the deleteMetaItem legacy read and delete). Each is a sys_metadata, sys_metadata_history, sys_metadata_audit or sys_metadata_commit store call behind a door that authorized the caller, with the where built by the protocol. Neutrality under the seat's standing rule (an isSystem context skips the six pre-refusal gates) was checked against the gates' own object sets on origin/main: audience-anchor keys on sys_position_permission_set; package-managed on sys_permission_set; system-row on the RBAC provenance map (sys_metadata absent); curated-capability on sys_capability; delegated-admin on the RBAC link tables; the engine-owned guard bypasses a context-less write by construction. None keys on a sys_metadata* object, so none fired on these calls before the move and none is skipped after it. Reach is unchanged; the moves are neutral. RIGHT. (A principal-less one would have made the audit door answer 503 and the diff door an empty diff under the deny, so closing them in this round is what the ruling's sequencing, producers first and the deny last, requires.)

    Test-harness producers (release row 22). Four closures: the federated-fixture insert passes its system context in the options position (it used to be passed as a bare third argument and dropped); the package-first-authoring insert and the seeder-copy reads take the explicit opt-in; the share-links engine double merges the trailing read options' context as mergeReadContext does. Test-side only. RIGHT.

    Pins against the ruling's three. (1) A principal-less context refused on read and write: principal-less-strict-mode.test.ts, six spellings of "no principal" (no context, empty context, empty arrays, explicit isSystem: false, tenant id alone, provenance only) across four read verbs and three write verbs, each refused before the operation runs with code and status, the probes false, the row scope the deny sentinel. (2) An explicit system context unchanged: same file, with and without a principal. (3) The dogfood suite green under the deny: Dogfood Regression Gate shards 1/3, 2/3, 3/3 and the rollup, all success on the head. Further: eight hand-through pins flipped to refusal pins (zero-set-deny-baseline, zero-set-masking, zero-set-capability-fold, metadata-unresolvable-posture, predicate-related-read-tenant-scope, security-plugin.test.ts); the composed Q2 pin (owner-of-private-object-under-strict-mode.dogfood.test.ts: the owner is found by the declared route, nobody else is, and a principal-less read of the private object is refused in the same composition); the resolveRequestScope fault-class pin over the real dispatcher plugin and the real middleware (engine handed no principal, store never reached, 403 with nothing of the rows; guest and signed-in controls); and the deny-round block of protocol.platform-store-system-opt-in.test.ts driving every moved producer. Six ablations reported red and restored by blob. RIGHT.

    Check-runs on the head, latest per name. Every required context is success: Lint & Repo Gates, TypeScript Type Check, Test Core (1/6 to 6/6 and the rollup), Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Check Changeset, Check PR Size, Part-of PR must not also close its card, The card this PR closes must claim this branch, both single-writer guards, the four Type Check legs, Dogfood Verify CLI, Spec property liveness, Auto Label, Check Documentation Links, Flag docs affected by code changes, filter. Skipped by path filter: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). No failure, nothing in progress.

    Form. Draft; head repository equals base repository (not a fork); 17 files, +931/−149 (1083 changed lines, under the 5,000 ceiling); 0 governed paths; line 1 Fixes #21908; line 2 Clause-②: no (narrowing); the title carries the ! banner; the newest Claim: on the card names this branch.

    ② Semver level

    Changeset .changeset/21908-principal-less-strict-mode.md: @objectstack/plugin-security: minor, @objectstack/metadata-protocol: patch.

    • What the diff publishes. plugin-security: a runtime accept-set narrowing of the engine middleware and the four ISecurityService probes, breaking for in-process code that reached the engine with no context. metadata-protocol: an internal fix (its own store calls take the explicit opt-in; nothing a door answers changes). runtime and qa/dogfood carry test files only (runtime publishes dist; dogfood is private), so no changeset is owed for them. The changeset matches the diff.
    • minor for a breaking change. scripts/check-changeset-no-major.mjs is the launch-window guard: a PR may not introduce a major, and a breaking change ships as minor with the BREAKING banner (pre-1.0 semantics over the lockstep fixed group, with a written end condition at GA). The banner and the ! are present; Check Changeset is success, and the Lint gates' no-major and check-adr-0087-registration verdicts are green on the head. RIGHT.
    • Migration prose. The body states FROM (an engine call with no context, or one that names nobody) and TO (the caller's execution context, or context: { isSystem: true } for platform plumbing whose own door already authorized the caller), plus the one-line fix, which is what an upgrading agent greps in CHANGELOG.md. RIGHT.
    • ADR-0087 disposition. Stated in the changeset body, in the HTML-comment marker the gate reads, as not-required (no-migration-prescription), with the facts that close the other categories: no spec key, authorable spelling, export, type or stored shape moves, and no stored row is read or rewritten, so there is nothing for migrate meta. The narrowing is a runtime admission. RIGHT.
    • The declaration line, read the same in the PR body, the claim and the changeset:

    Clause-②: no (narrowing)

    No accepted input widens; an admission that used to be accepted is refused. The arm is the right one.

    ③ Boundary flags

    Every dev flag (report 6060614288: deviations, open_questions, out_of_scope_findings) and the thread's open items, each answered or escalated:

    1. deviations[0], nine protocol.ts functions closed beyond promoteDraftForPublish. ANSWERED, accepted: same class, same mechanical opt-in, same gate family, no other claimant on those functions at the edit (the seat's check), each neutral under the six gates (① above). Leaving them would have breached the ruling's sequencing, since the deny would break the audit and diff doors. The claim surface is appended (6055574466) and the [PM seat] domain:server — 🟢 os-project-manager #6367 cross-lane declaration amended (6060736542, per 6060777536).
    2. deviations[1], the five service sites return rather than throw. ANSWERED, confirmed (① items 2 to 6).
    3. deviations[2] and [3], the single-pass H1 method and the scoped re-runs after merging origin/main. ANSWERED: the check-runs on the head judge the merge ref and every required context is green, the full dogfood suite included.
    4. deviations[4], the fault-class pin at the runtime tier. ANSWERED, accepted; the real-boot guest posture is pinned by test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints #22177's dogfood pins, green on this head.
    5. open_questions[0] / deviations[5], four ISecurityService TSDoc sentences in packages/spec/src/contracts/security-service.ts that still describe the E1 hand-off. ESCALATED and routed: the seat ruled B (6060777536), filed as docs(spec): ISecurityService TSDoc still says a context with no principal is admitted or keeps its scope; PR #22297 (#21908, ADR-0096 D5 strict mode) refuses it #22302 with Blocked-by: this PR, to land right after it in the same release. Not a FAIL: packages/spec is fenced out of this claim, the governing contract sentence (ai-service.ts, "RLS-on, sees-nothing") is the one this diff makes true, and the stale sentences over-describe an admission the runtime now refuses (fail-closed direction, no disclosure). ⚠️ Flag for the seat: the release that ships @objectstack/plugin-security from this head must also ship docs(spec): ISecurityService TSDoc still says a context with no principal is admitted or keeps its scope; PR #22297 (#21908, ADR-0096 D5 strict mode) refuses it #22302, or the published .d.ts contradicts the runtime for one release.
    6. A second stale-prose family, hand-written docs, found by this review and not in the dev's flags. content/docs/kernel/contracts/data-engine.mdx (the ExecutionContext passage) tells an author that an automation run with no resolvable identity passes only its run id, "a context that deliberately carries no principal". Under strict mode that shape is refused (the provenance-only spelling is pinned refused), and the automation lane already fails closed on a missing trigger user (content/docs/automation/hooks.mdx). The sentence is an authoring trap now, loud at runtime (403 naming both remedies), not a disclosure. ESCALATED to the seat: carry it on docs(spec): ISecurityService TSDoc still says a context with no principal is admitted or keeps its scope; PR #22297 (#21908, ADR-0096 D5 strict mode) refuses it #22302 or a docs-only sibling, same release; no content/docs edit is owed by this claim's surface. No other hand-written page describing the hand-off was found (the other matches of a principal-less-context grep describe different surfaces).
    7. deviations[6], changesets. ANSWERED in ②.
    8. deviations[7] and [8], model-free commit trailers and lock timeouts not counted. NOTED; no contract bearing.
    9. deviations[9] and out_of_scope_findings[2], the census population by string match and the static sweep over literal object names. NOTED as a residual coverage gap, bounded by the runs and by CI on this head; a producer missed by both would surface as a loud PERMISSION_DENIED, fail closed, never a hand-through. No escalation.
    10. out_of_scope_findings[0], loadMetaFromDb and reportUnhydratableOrgScopedRows read context-less at boot. VERIFIED on origin/main: ObjectQL's plugin calls restoreMetadataFromDb on its start path, plugin-security declares dependencies = ['com.objectstack.engine.objectql'], and the engine middleware is registered inside plugin-security's start(), so those reads precede the middleware in every kernel that honours the declared order. Not a producer today. ⚠️ Flag for the seat, non-blocking: the two reads rely on registration order rather than a declared posture; giving them the explicit opt-in would make them order-independent. If the order ever changed, the failure direction is a loud boot refusal, not a hand-through.
    11. out_of_scope_findings[1], an ExecutionContext passed in an engine verb's options position compiles and silently drops the context. NOTED; one fixture fixed here, and the trap is loud under strict mode. A type-level narrowing of that parameter is spec-lane work; not owed here.
    12. The domain:cli seat's finding (6052739434), the resolveRequestScope infrastructure-fault class. ANSWERED: closed by construction and pinned at the runtime tier; http-dispatcher.ts untouched, as the claim required.
    13. The ADR-0096 D5 dated note (ruling Q3 甲). PR docs(adr): ADR-0096 D5 dated note — strict mode lands ON ahead of D2 and the telemetry gate #22298, docs/adr/** only, Tier H, awaiting the maintainer's word; the seat never readies, queues or approves it. This PR's landing shape (6019001864, State) does not wait on it. NOTED. No scripts/adr-anchors/ entry was added for the new refusal site; check:adr-anchors is green on the head, so none is owed by the gate; an optional follow-up.
    14. Governance. 0 of 17 paths on the register. This record is the at-tier contract review the ruling required before the queue for a security boundary, not a Tier S landing record.

    Disclosure held at classes, positions and functions; no reproduction sequence is written here.

    Implemented-by: claude/issue-21908-principal-less-deny
    Reviewed-by: session_01WkL6Eijt432S1Y7ekb6ovQ

    VERDICT: PASS


    Generated by Claude Code

  24. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #22297 at 454d0e89b9 · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T13:49Z

    ⛔ Classes, positions and functions only.

    This completes the seat's review 6060777536, which read the form, the diff and the dev's flags on this head.

  25. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T14:41Z.

    ⛔ Classes, positions and functions only.

    PR #22297 merged through the merge queue as a3bcbcf3. ADR-0096 D5 strict mode is on origin/main:

    • @objectstack/plugin-security (minor, BREAKING) refuses a data-engine context that carries no principal and is not a system context, at the six hand-off sites. The middleware throws PermissionDeniedError (403 PERMISSION_DENIED), the three probes answer false, and the row scope is the deny filter.
    • @objectstack/metadata-protocol (patch): the census's producers take the explicit system opt-in behind their already-authorized doors.

    The PR's Fixes line did not close the card (the measured failure mode recorded on #6021); the seat closed it completed through the relay in this act. This note also removes pm:dispatched and the assignee.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions