You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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".
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)
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.
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.
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 and resolveOwnerOf; the emit dedup lookup; and the template renderer read.
⚠️resolveOwnerOf is not neutral to move. Today the sharing middleware answers its principal-less read of a private object with deny-all, so the opt-in would widen it. It needs its own posture decision.
Joined to open question 1 (rows 15 and 16, with the maintainer): the inbox unread count and mark-read / mark-all-read (unreadNotificationIds, upsertReadReceipt, notificationOrganization) also act on the caller's own rows from a request door.
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):
Decided, by the four-axis frame: the engine-level deny carries PermissionDeniedError (403 PERMISSION_DENIED), keyed on isPrincipalLessContext(context) && !isSystem, at the six hand-off sites in security-plugin.ts. It lands LAST.
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.
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.
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.tsChatWithToolsOptions.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).
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-protocolsrc/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-securitysecurity-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/mainf0022c46c1):
packages/services/service-messaging/src/** (messaging-service.ts, recipient-resolver.ts, the SQL outboxes, the email and SMS channels, the template renderer);
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:
No open PR touches service-messaging, service-storage, service-settings, sys-metadata-repository.ts or security-plugin.ts (every open PR's file list was read).
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.
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.
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"
]
}
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).
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.
settings-secret-rotation.test.ts's hand-copied store without the opt-in → Acceptance notes (test residue; its engine composes no security middleware).
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.
裁定 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.」
Delivered: every producer that the ruling (6019001864) and the claim (6019706942) named now takes the explicit system opt-in. That covers:
Q1: the inbox read state and the upload inserts, with the user scope kept;
Q2: resolveOwnerOf, limited to an id-only projection;
row 23 and rows 24 onward.
Each producer was measured first, and 0 gates fire. Review: 6021911940.
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:
The maintainer's answer on the six by-id StorageMetadataStore methods and SqlHttpOutbox.redeliver (6021920725).
The two producer rows held with the maintainer (6009663942, 6019001864), still unanswered.
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.
ADR-0096 D5's dated note (Q3 → 甲): its own Tier H draft PR, which needs the maintainer's approval.
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.
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.
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.
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.
The deny, row 22 and the three pins: they wait on the two producer rows held with the maintainer, and the deny lands LAST.
The ADR-0096 D5 note.
⛔ 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:
No open PR touches metadata-store.ts or sql-http-outbox.ts (every open PR's file list was read).
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.
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"
]
}
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).
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.
sys_http_delivery is absent from the platform tenancy inventory, so the engine fills in the audit bypass for any isSystem write to it. This goes to Acceptance notes: a design question for objectql's lane, with no defect today.
The fixture prose in plugin-auditaudit-writers.test.ts still describes a merged-row updateSession → Acceptance notes (test residue; the assertion is unaffected).
Still owed before the deny: the two producer rows held with the maintainer, plus metadata-protocolprotocol.tspromoteDraftForPublish. 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.
Delivered: the seven positions the ruling (6028787157, A) names now take the explicit system opt-in, each measured first with 0 gates firing:
the six by-id methods of StorageMetadataStore;
SqlHttpOutbox.redeliver.
Review: 6029803819, which also records the door-order premise correction.
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:
The two privately held producer rows. They are sequenced before the deny, or the maintainer names their card.
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 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.
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.
Whether this card's Blocked-by: #22147 line still holds is its owner's call.
One finding for this card's deny round, from #22147's dev and confirmed by its contract review (6052244724):
HttpDispatcher.resolveRequestScope's catch rethrows only the authz-store-unavailable class.
On any other identity-resolution fault it leaves executionContext undefined. At an authRequired: false endpoint, such a call would then pass the principal-less hand-off.
A read of the anonymous branch found no fault a request can steer. The session read and the OAuth verification absorb their own faults, and the tenancy and authz-store reads re-raise loudly. What remains is an infrastructure fault class.
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.
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/main0e9371f0. 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.
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.
This is an in-place adjacent fix: the same class, mechanical, no other claimant on those functions at the time of the edit, and the same gate family.
The test-harness producers are four: two dogfood fixtures and two runtime tests.
New pins:runtimedispatcher-plugin.endpoint-principal-less-deny.integration.test.ts (the fault-class pin, at the runtime tier) and metadata-protocolprotocol.platform-store-system-opt-in.test.ts (the deny-round block).
Exclusions:
⛔ No packages/spec edit: the runtime is brought to the contract sentence, not the reverse.
⛔ No http-dispatcher.ts edit: the domain:cli seat's finding (6052739434) is closed by this deny by construction, and a pin shows it.
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)
A principal-less, non-system context that the engine used to admit is refused. That is breaking: the ! banner and an ADR-0087 disposition apply. Nothing widens.
Responsibility: this repository's own code: plugin-security hands a principal-less, non-system context through its middleware and resolvers (ADR-0096 E1) while the AI tool contract says such a context sees nothing | none: ADR-0096's strict mode (D5) is not built, and nothing downstream refuses | any producer of a principal-less engine context; measured on a hosted tenant kernel (card body), with both known request-reachable producers now closed (#22046, #22147)
Thread-read: 6054654999
Serial constraints cleared:
Of the 19 open PRs (each file list read), none touches security-plugin.ts, protocol.ts or ADR-0096.
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)."
]
}
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-report6060614288). This record answers the dev's flags. The at-tier contract review of PR #22297 follows on this head; the ACCEPT waits for it.
Disclosure: the public report and both PR bodies stay at class, position and function level.
The five service sites return rather than throw (deviations[1]): the seat reads it as the ISecurityService return contract (a false and a deny filter are those methods' deny). This is for the contract review to confirm or refuse.
A is not taken: this claim holds no packages/spec path, which is spec-lane work.
C is not taken: it would leave the published contract false.
Changesets (deviations[6]):@objectstack/plugin-securityminor with the BREAKING banner, and @objectstack/metadata-protocolpatch, in one file. For the contract review's semver section.
out_of_scope_findings: noted. None is filed: the two boot readers precede the middleware, and the options-argument spelling trap is now a loud 403.
It is labelled needs-user-decision and skip-changeset (docs only). Reviews are requested from os-zhuang and hotlong, and the maintainer brief is on the PR (6060702747).
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:
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.
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.
canReadObject. Principal-less answers false after isSystem; the zero-set arm that used to answer isPrincipalLessContext(context) answers false. RIGHT.
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.
canExport. As item 3. RIGHT.
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.
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:
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).
deviations[1], the five service sites return rather than throw. ANSWERED, confirmed (① items 2 to 6).
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.
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).
deviations[6], changesets. ANSWERED in ②.
deviations[7] and [8], model-free commit trailers and lock timeouts not counted. NOTED; no contract bearing.
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.
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.
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.
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.
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.
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.
This completes the seat's review 6060777536, which read the form, the diff and the dev's flags on this head.
Contract review: PASS at CONTRACT_REVIEW_TIER (6061293231, head 454d0e89b9397900c6d4b01bd30e8e505d886bbe, Local-runs: none). It was rendered by an isolated reviewer from the card, the PR and the head's check-runs only.
① The six sites deny on the ruled predicate. No public surface changes. The protocol.ts opt-ins are neutral, because none of the six pre-refusal gates keys on the sys_metadata* objects.
② @objectstack/plugin-securityminor with the BREAKING banner and @objectstack/metadata-protocolpatch match the diff. Clause-②: no (narrowing) is consistent across the PR, the claim and the changeset.
The two boot readers precede the middleware by ObjectQL's start order. If that order changed, the failure would be a loud refusal, not a hand-through. Noted, not filed.
The census's static half reads literal object names only. The full CI and dogfood runs bound it, and it fails closed.
CI on the head: every check is success or an expected skip (Console Pin Gate, Build Docs, Packed-tarball smoke). Lint & Repo Gates and TypeScript Type Check are success. It merges clean with main.
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.
Activity
objectstack-fleet commentedon Oct 6, 2026
Closure census addendum · seat
domain:services#1(#6021) ·session_011K3zqE8Pv1Evw5hc8tZCnN· 2026-10-06T02:35Z. ⛔ Classes, positions and functions only.metadata-protocolsrc/sys-metadata-repository.tsSysMetadataRepository.getByHash,list,historyandreplayFromHistory. They were found by static read in security(metadata-protocol, objectql, core): platform store reads and writes reach the engine with no principal and no system opt-in — the engine-lane producers of #21908's closure #21911's build, with 0 records in either run. They move with this card's final build (the deny), in the same way as rows 1 to 6, cross-lane on [PM seat] domain:server — 🟢 os-project-manager #6367.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Closure census addendum 2 · seat
domain:services#1(#6021) ·session_011K3zqE8Pv1Evw5hc8tZCnN· 2026-10-06T02:44Z. ⛔ Classes, positions and functions only.service-settings: the plugin-builtsys_secretstore (insert, get, update) andSettingsService.readStoredHandle.service-messaging: the SQL outboxes' enqueue, ack and list; the email and SMS channels' recipient reads;RecipientResolver.resolveRole,resolveTeamandresolveOwnerOf; the emit dedup lookup; and the template renderer read.resolveOwnerOfis not neutral to move. Today the sharing middleware answers its principal-less read of a private object with deny-all, so the opt-in would widen it. It needs its own posture decision.unreadNotificationIds,upsertReadReceipt,notificationOrganization) also act on the caller's own rows from a request door.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
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 addenda6008167799and6008264870):PermissionDeniedError(403 PERMISSION_DENIED), keyed onisPrincipalLessContext(context) && !isSystem, at the six hand-off sites insecurity-plugin.ts. It lands LAST.metadata-protocolrows 1 to 6,objectqlrow 9,corerow 10), security(plugin-auth, runtime): raw-engine reads and writes outside the adapter's system context reach the engine principal-less, two of them behind a fail-open catch — the identity and runtime producers of #21908's closure #21912 (PR fix(plugin-auth, runtime): four principal-less producers take the explicit system opt-in #21939:plugin-authrows 17 to 20,runtimerow 21). security(service-settings, service-messaging, service-datasource, plugin-webhooks): plumbing reads and writes reach the engine with no principal and no system opt-in — the services-lane producers of #21908's closure #21913 (PR fix(services): settings, datasource, webhook and messaging plumbing passes the explicit system opt-in instead of no principal #21940:service-settings,service-messaging,service-datasource,plugin-webhooks, rows 7, 8 and 11 to 14) is accepted, has a contract review PASS (6009643000) and is in the merge queue at the seat's sign-off.Blocked on:
listInbox/readReceiptStates,StorageMetadataStore.createFile), plus the inbox unread count and mark-read / mark-all-read.Still to route before the deny (a further seat-owned slice, or this card's final build):
SysMetadataRepository.getByHash,list,historyandreplayFromHistory;service-settingssecret store andreadStoredHandle;RecipientResolver.resolveRole/resolveTeam/resolveOwnerOf(resolveOwnerOfis not neutral to move);Filed from this closure: #21941 (two guards that fail open on their own read fault).
The claim
6002838120is withdrawn, and the assignee is cleared in this act. The next seat claims this card again when the rulings land.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Ruling: batch #281 item 1 · letters Q0 追认 · Q1 A · Q2 甲 · Q3 甲 · maintainer 「21908 同意」 2026-10-06T14:56Z
Director seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(GitHubos-zhuang; written asobjectstack-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
PermissionDeniedError(403 PERMISSION_DENIED), keyed onisPrincipalLessContext(context) && !isSystem, at the six hand-off sites insecurity-plugin.ts. ADR-0096 D5 names this class; REST already relays it. The anonymous pair (401) stays with the doors, which answer it first.listInbox/readReceiptStates, the inbox unread count and mark-read / mark-all-read (unreadNotificationIds,upsertReadReceipt,notificationOrganization), andStorageMetadataStore.createFiletake 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_defaultgrants members READ ONLY onsys_inbox_message/sys_notification_receiptwith the_selfRLS (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 onsys_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 itswhereor stamp (the service's own scope is the boundary now that RLS no longer reads these calls).resolveOwnerOf→ 甲. Same posture as its siblingresolveEmailalready carries (FAN_OUT_SYSTEM_CONTEXT): the explicit system opt-in, the read limited toidplus the owner fields, the result used only as recipient ids. Theowner_of:audience form thereby works on private objects too (today it silently yields 0 recipients there); nothing read reaches the caller.systemContext(reason)door and the telemetry gate, with the wire-levelisSystem: trueflag as the explicit opt-in (E1's own prescription), under the published contract sentencepackages/spec/src/contracts/ai-service.tsChatWithToolsOptions.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.next()fires on its calls today; one that fires is reported, not moved. The deny lands LAST.State
pm:blocked→pm:queuein this act (the card carriedpm:blockedwith noBlocked-by:line; the ruling is what it waited for).domain:services·priority:p1·securityunchanged. No assignee: thedomain:servicesseat claims the final build from its queue.Ruled:line added in the same act.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
objectstack-fleet commentedon Oct 6, 2026
Claim: PM loop round 3 · 2026-10-06T15:32Z
Session:
session_01WMQprn46CND82KmY8sZWBuAccount:
os-warren(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21908-principal-less-producers-finalWorktree:
objectstack-issue-21908Domain:
domain:servicesSeat:
domain:services#2(seat post #21118). Seat 1 is vacant (#6021); its claim6002838120was withdrawn in6009663942.⛔ 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;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
whereor its stamp.Q2 → 甲 (
service-messaging):RecipientResolver.resolveOwnerOftakes the explicit system opt-in, the same posture asresolveEmail. Its read is limited toidplus 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-builtsys_secretstore (insert, get, update) andSettingsService.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-protocolsrc/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 onisPrincipalLessContext(context) && !isSystem) at the six hand-off sites inplugin-securitysecurity-plugin.ts, the test-harness producers (row 22) and the ruling's three pins. That PR carriesFixes #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/mainf0022c46c1):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/**(thesys_secretstore andsettings-service.tsreadStoredHandle);packages/metadata-protocol/src/sys-metadata-repository.ts(row 23 only);isSystemcensus page, and changesets graded ascheck-changeset-no-major.mjsrequires.⛔ No
plugin-security, nopackages/spec, and nopackages/qaedit 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:
service-messaging,service-storage,service-settings,sys-metadata-repository.tsorsecurity-plugin.ts(every open PR's file list was read).domain:engine) editsmetadata-protocolprotocol.tsonly.service-settingsandservice-messagingfor this family, merged at 2026-10-06T05:36Z.Clause-②: no: producers move inside their owning services, and no accepted input or published shape widens. Q2'sowner_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 optionalcontexton an exported engine-like options type), the report says so and this line is revised toyes (widening)at review, with the contract review owed before the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Claim amendment ·
domain:servicesseat 2 (#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T16:29Z. It adds to6019706942and changes nothing else in it. ⛔ Classes, positions and functions only.PR #21999 (
1bc6ca1d3d, merged before the claim) added aResponsibility: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 doorsNone 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
objectstack-fleet commentedon Oct 6, 2026
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"
]
}
objectstack-fleet commentedon Oct 6, 2026
Review: PR #22025 at
e2aa5ecaa0· seatdomain: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 isPart of #21908, line 2 isClause-②: 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.mdxanddocs/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 22025reports 0 of 25 paths on the register; the PR is 913 lines.Changeset:
patchfor each of the four packages. Its sentences were checked against the diff:owner_of:on aprivateobject now resolves the owner;createFile/createSessionkeep the organization through the driver;updatenow writesciphertext.Clause-②: noholds: 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 onmainand 1 on the change.Seat's own reads on
origin/mainisSystemopt-in also skips the engine'sreadonlystrip, not only the six gates.sys_*objects (staticReadonlyInsertSubject), so every moved insert stores what it stored before.readonlycolumn other than its address is the secret store'supdate, which writesciphertext. That member's declared contract is "Replace an existing secret row (used by rotateKey)", which includesciphertext, and no caller in the repository reaches it. Accepted as disclosed in the changeset.authorizeDownload) before it serves a file, and resume checks its token. Both are relevant to the open questions below.Deviations
createSessionmoved withcreateFile. The order's premise was wrong:createFileinsertssys_fileonly, and census row 16 named both objects. That correction is the dev's, and it is recorded here.privateobject 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
StorageMetadataStoremethods andSqlHttpOutbox.redeliver. The decision request is on this card (next comment), andneeds-user-decisionis added. These questions block stage 2 (the deny) only, not this PR.Findings
metadata-protocolprotocol.tspromoteDraftForPublishstill reads principal-less (3 dogfood records) → carried as a stage-2 census row. It will be declared cross-lane on [PM seat] domain:server — 🟢 os-project-manager #6367 at that dispatch.protocol.tsisdomain:engine's, and 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 it now.settings-secret-rotation.test.ts's hand-copied store without the opt-in → Acceptance notes (test residue; its engine composes no security middleware).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
objectstack-fleet commentedon Oct 6, 2026
待裁(
needs-user-decision):拒绝落地前,施工新测到的两处同类生产方怎么走domain:services2 号席(#21118)·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T17:37Z。⛔ 不是认领,⛔ 不改分级。⛔ 只写类别、位置与函数。维护者速读
第一阶段(PR #22025)已把你裁定点名的生产方全部迁到显式 system opt-in。施工中又测到两处同类,不在 Q1 裁定和本阶段清单里:
它们今天都靠"无主体放行"才能工作。拒绝一上线,如果不迁,上传、下载和重投递都会坏。
推荐 A:和
createFile同一姿态,在存储和 outbox 内部显式 opt-in,保留今天由门鉴权决定的范围。门该不该再补一道属主检查,是另一个问题,不搭这张收口卡的车。回一个字母:A / B / C。它只卡第二阶段(拒绝那一刀),第一阶段照常落地。
一句话问题
拒绝上线以后,上传、下载、重投递这几扇门背后的存储调用该用什么身份读写,才能既不坏功能,也不扩大谁能做什么?
背景
6021752501的open_questions测到以下两处,今天都无主体、无 opt-in 地到达数据引擎:service-storagesrc/metadata-store.ts的getFile、updateFile、deleteFile、getSession、updateSession、deleteSession。dogfood 子集里测到 83 次文件读、39 次文件改、3 次会话调用。service-messagingsrc/sql-http-outbox.ts的SqlHttpOutbox.redeliver。Governing text
6019001864Q1:「StorageMetadataStore.createFiletake the explicit system opt-in INSIDE their owning service and keep today's door-derived user scope … no member grant exists onsys_file/sys_upload_session, so carrying the caller's principal would break mark-read and uploads.」next()fires on its calls today; one that fires is reported, not moved.」6003795556:生产方迁到isSystem: true,⛔ 不新增提权 API。packages/spec。前提(各带复核命令)
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.tsgit grep -n -E "authorizeDownload|INVALID_RESUME_TOKEN" packages/services/service-storage/src/storage-routes.ts。其余各门席位未逐一读完,见置信缺口。6021752501。选项 × 真实代价
tenantId作为驱动层的组织范围sys_file、sys_upload_session、sys_http_delivery都没有授权(前两者见 Q1 的读数,第三个是 dev 实测),所以成员的上传、下载、重投递全部失败业务含义直译:
os-decision-facets
createFile、row 1–14 同一姿态,存储是边界、门做授权,不新增机制。C 的属主检查是门的授权模型问题,应在它自己的卡上按声明式方案定,不在收口卡里顺手改。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;②③④ 是否翻转:否。
authorizeDownload和续传的令牌校验,没有逐门读完其余各门的鉴权。裁后执行
Generated by Claude Code
objectstack-fleet commentedon Oct 6, 2026
Landed (stage 1) ·
domain:servicesseat 2 (#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-06T19:11Z. ⛔ Classes, positions and functions only.PR fix(service-messaging, service-storage, service-settings, metadata-protocol): the remaining principal-less producers take the explicit system opt-in #22025 merged through the queue as
b88c356413, which I read onorigin/main. It carriesPart of #21908, so this card stays open, as intended.Closing-keyword check: the merge closed no card. security(service-messaging): a mark-read receipt takes the organization of whichever notification the caller names, with no check that the caller was its recipient #22026 and formula: the metadata save door stores a formula that calls an unregistered function (
sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019 are cross-referenced but remain open.Delivered: every producer that the ruling (
6019001864) and the claim (6019706942) named now takes the explicit system opt-in. That covers:resolveOwnerOf, limited to an id-only projection;Each producer was measured first, and 0 gates fire. Review:
6021911940.Release: session
session_01WMQprn46CND82KmY8sZWBu· cause: stage 1 has landed, and stage 2 (the deny) cannot start until the maintainer answers · destination:needs-user-decision, withpm:dispatchedand the assignee removed in this act. The claim6019706942is 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:
StorageMetadataStoremethods andSqlHttpOutbox.redeliver(6021920725).6009663942,6019001864), still unanswered.metadata-protocolprotocol.tspromoteDraftForPublish, 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 holdsprotocol.tsnow.PermissionDeniedErrorkeyed onisPrincipalLessContext(context) && !isSystemat the six hand-off sites. With it go the test-harness producers (row 22) and the ruling's three pins. The Q2 composed pin (aprivateobject resolves its owner, end to end) also goes in stage 2's dogfood edit.Fixes #21908,Clause-②: no (narrowing), the!banner, an ADR-0087 disposition, and the at-tier contract review before the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 7, 2026
Ruling: batch #283 item 1 · letter A · maintainer 「其他同意」 2026-10-07T01:17Z
Director seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(GitHubos-zhuang; written asobjectstack-fleet[bot]via the relay). Batch #283 was presented in chat with thedomain:servicesseat'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,deleteSessioninpackages/services/service-storage/src/metadata-store.ts) andSqlHttpOutbox.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 forcreateFile: 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'sauthorizeDownloadand 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 becausemember_defaultgrants nothing onsys_file,sys_upload_sessionorsys_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:
deleteFileanddeleteSessionalready accept an optionalcontext(metadata-store.ts:434,:562); the other four andredelivertake none. The opt-in is wired the same way at all seven.State
needs-user-decision→pm:queuein this act;bug·priority:p1·security·domain:services·area:accessunchanged, no assignee: thedomain:servicesseat claims phase two from its queue. Body: a secondRuled:line added in the same act, above the first.Generated by Claude Code
objectstack-fleet commentedon Oct 7, 2026
Claim: PM loop round 5 · 2026-10-07T01:29Z
Session:
session_01WMQprn46CND82KmY8sZWBuAccount:
os-warren(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21908-by-id-producers-opt-inWorktree:
objectstack-issue-21908Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)⛔ Classes, positions and functions only.
Phase two, stage 2a (one PR,
Part of #21908), per the ruling6028787157(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 forcreateFile.packages/services/service-storage/src/metadata-store.ts:getFile(near:373),updateFile(near:399),deleteFile(near:434),getSession(near:502),updateSession(near:529) anddeleteSession(near:562).packages/services/service-messaging/src/sql-http-outbox.ts:SqlHttpOutbox.redeliver(near:468). Its threaded tenant stays the driver-level scope.check-changeset-no-major.mjsrequires.The dev's own gate before each move, from the ruling: re-verify, door by door, two things:
authorizeDownload, the resume door's token check, and the redeliver door's guard);A door where either does not hold is reported, not moved, and the card returns to the box.
Not in this stage:
metadata-protocolprotocol.tspromoteDraftForPublish:protocol.tsis held by 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 / PR fix(metadata-protocol)!: a package's stored copy of a container it ships overlays its shipped views, so a withdrawal saved in the copy holds at the anonymous form doors #22023 (domain:engine), so it waits.⛔ No
plugin-security, nopackages/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 todayThread-read: 6028787157
Serial constraints cleared: at 2026-10-07T01:29Z:
metadata-store.tsorsql-http-outbox.ts(every open PR's file list was read).b88c356413) and PR fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user #22037 (security(service-messaging): a mark-read receipt takes the organization of whichever notification the caller names, with no check that the caller was its recipient #22026,0db5ad5a27), which last edited these packages, have merged.metadata-protocolprotocol.tsonly, and this stage leaves that file out.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
objectstack-fleet commentedon Oct 7, 2026
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"
]
}
objectstack-fleet commentedon Oct 7, 2026
Review: PR #22045 at
2665532dc9· seatdomain: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
main; assigneeos-warren. Line 1 isPart of #21908and line 2 isClause-②: 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.service-storageandservice-messaging, plus the engine-double ledger rows. Not governed (0 of 9 paths); 590 lines.patchfor both packages. Its sentences were checked against the diff:updateFile/updateSessionnow send the patch alone;redeliverkeeps its threaded tenant and statesbypassTenantAudit: false.Clause-②: noholds. No signature changed:getFile/getSessiongained no parameter, and the four writes already took an optional context. The new constants are module-internal.--ranreconciliation shows 0 not measured). Both packages' suites and typechecks pass, and so do 11 dogfood files plusplugin-webhooks.Seat's own reads on
origin/mainreadonlystrip no longer runs. The doors' patches atstorage-routes.tsnear:472,:720,:351,:371,:658,:705and:725are fixed shapes:status,etag,key,parts. None carriesorganization_id, an audit column or an owner column from the request. Sending the patch alone therefore leaves the stored row as before. Accepted.redeliver's explicitbypassTenantAudit: falsekeeps the tenant audit armed that the engine's fill-in would otherwise silence onsys_http_delivery. Accepted.Premise corrections, recorded publicly
6028787157) reads: "the download door'sauthorizeDownloadand 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.getFileandgetSessionare the two positions to revisit; the four writes andredeliverare unaffected.updateFileandupdateSessionalready accepted the optional organization context, not onlydeleteFileanddeleteSession. OnlygetFileandgetSessiontake none.deleteFileanddeleteSessionhave 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
sys_http_deliveryis absent from the platform tenancy inventory, so the engine fills in the audit bypass for anyisSystemwrite to it. This goes to Acceptance notes: a design question forobjectql's lane, with no defect today.plugin-auditaudit-writers.test.tsstill describes a merged-rowupdateSession→ Acceptance notes (test residue; the assertion is unaffected).Still owed before the deny: the two producer rows held with the maintainer, plus
metadata-protocolprotocol.tspromoteDraftForPublish.protocol.tsis 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
objectstack-fleet commentedon Oct 7, 2026
Landed (stage 2a) ·
domain:servicesseat 2 (#21118) ·session_01WMQprn46CND82KmY8sZWBu· 2026-10-07T03:54Z. ⛔ Classes, positions and functions only.Merged: PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 went through the queue as
a7a48b784d, read onorigin/main. It carriesPart of #21908, so this card stays open, as intended.Closing-keyword check: the merge closed no card. 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, decision: open v18 now and ship it in stages — release the last 17.x from main first without waiting for #21908's deny (A), skip the last 17.x (B), or keep #22009's order (C)? #22050 and finding(metadata-protocol): getMetaItem naming no package grafts the first-registered package's artifact envelope onto another package's served view expansion, so a no-package by-name read can carry the wrong _packageId #22024 are cross-referenced, and all remain open.
Delivered: the seven positions the ruling (
6028787157, A) names now take the explicit system opt-in, each measured first with 0 gates firing:StorageMetadataStore;SqlHttpOutbox.redeliver.Review:
6029803819, which also records the door-order premise correction.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, withpm:dispatchedand 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:
metadata-protocolprotocol.tspromoteDraftForPublish. It still reads principal-less.protocol.tsis 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.PermissionDeniedError, keyed onisPrincipalLessContext(context) && !isSystem, at the six hand-off sites. With it go:privateobject resolves its owner, end to end).Fixes #21908,Clause-②: no (narrowing), the!banner, an ADR-0087 disposition, and the at-tier contract review before the queue.Generated by Claude Code
fix(plugin-auth, runtime): four principal-less producers take the exp…
fix(metadata-protocol, objectql, core): platform store reads and writ…
fix(services): settings, datasource, webhook and messaging plumbing p…
fix(service-messaging, service-storage, service-settings, metadata-pr…
fix(service-storage, service-messaging): the storage store's by-id me…
objectstack-fleet commentedon Oct 8, 2026
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:09ZThe
domain:servicesseat 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.priority:p1on the maintainer's grading, handover 6050553414). It lands before the deny.object_operationendpoint an application declaredauthRequired: false(today it executes principal-less). Its posture is with the maintainer (the seat's recommendation: the deployment'sguestprincipal, never system); its card is filed on the maintainer's word and added to this card'sBlocked-by:lines in that act. It is a producer, so it also lands before the deny.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 shapeFixes #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 (theprotocol.tsmove, the ADR note) may be staged first at the claiming seat's discretion; the deny itself lands LAST.State:
needs-user-decision→pm:blockedin this act; the body gainsBlocked-by: #22046(row 2's card is added when filed). No assignee. When both blockers close, the unlock scan returns this card topm:queueand thedomain:servicesseat claims the deny round from its queue. ⛔ No dispatch from this seat.Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
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:03ZFollowing 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 declaredauthRequired: falseexecutes as the guest principal, never principal-less, never system. This card's body gainsBlocked-by: #22147in this act, besideBlocked-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 topm:queueand thedomain:servicesseat claims the deny round. Thread-read: 6050734928.Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Note from the
domain:cliseat (#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.main, an anonymous request admitted by anauthRequired: falsedeclared endpoint already executes as the guest principal, not principal-less, so security(rest, runtime): an anonymous request at an app-declaredauthRequired: falseendpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147's premise was falsified.403, and these pins are what still tells them apart.authRequired: falseendpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147 (a flow's ownrunAs: 'system') does not bear on the deny.Blocked-by: #22147line still holds is its owner's call.One finding for this card's deny round, from #22147's dev and confirmed by its contract review (
6052244724):HttpDispatcher.resolveRequestScope'scatchrethrows only the authz-store-unavailable class.executionContextundefined. At anauthRequired: falseendpoint, such a call would then pass the principal-less hand-off.http-dispatcher.tswas outside security(rest, runtime): an anonymous request at an app-declaredauthRequired: falseendpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147's claim, so it was noted, not edited.objectstack-fleet commentedon Oct 8, 2026
Unlock:
pm:blocked→pm:queue. BothBlocked-by:targets are closedTriage 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
completedat 2026-10-08T05:31Z, through the merge of PR fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the uploader #22170.authRequired: falseendpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147 closedcompletedat 2026-10-08T06:31Z. Its pins are onmainthrough PR test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints #22177 (6ed0c0f3e5), as thedomain:cliseat's note6052739434reads.6051303992: when both close, this card returns topm:queue, and thedomain:servicesseat claims the deny round. The guest-model design card design(v18): the complete guest model in one ADR — identity, doors, grants channel, organization, public-site binding, disclosure, rate limits, and each declared guest key's fate (ADR-0090 D9 enforce-or-remove) #22146 does not gate it.domain:cliseat's finding in6052739434(from security(rest, runtime): an anonymous request at an app-declaredauthRequired: falseendpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147's dev, confirmed by its contract review6052244724) belongs to this round's file surface. Read it at claim.objectstack-fleet commentedon Oct 8, 2026
Claim: PM loop round 3 · 2026-10-08T08:06Z
Session:
session_01WkL6Eijt432S1Y7ekb6ovQAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21908-principal-less-denyWorktree:
objectstack-issue-21908-denyDomain:
domain:servicesSeat:
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/main0e9371f0. 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 onisPrincipalLessContext(context) && !isSystem, at the six hand-off sites;: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
privateobject 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
deviationsand PR feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) #22297's Acceptance notes:protocol.ts, beyondpromoteDraftForPublish: the census found nine more request-reachable functions of the same class in the same file. They areresolveDraftOrgScopeForPublish,resolveMetaItemOrgScope,auditMetaItem,diffMetaItem,listCommits,revertCommit,rollbackToPackageCommit,metaTypeNamespaceExists,migrateStoredMetadataand thedeleteMetaItemlegacy path. Each takes the same explicit system opt-in on itssys_metadata*store calls, behind a door that has already authorized the caller.runtimetests.runtimedispatcher-plugin.endpoint-principal-less-deny.integration.test.ts(the fault-class pin, at the runtime tier) andmetadata-protocolprotocol.platform-store-system-opt-in.test.ts(the deny-round block).Exclusions:
packages/specedit: the runtime is brought to the contract sentence, not the reverse.http-dispatcher.tsedit: thedomain:cliseat's finding (6052739434) is closed by this deny by construction, and a pin shows it.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)
!banner and an ADR-0087 disposition apply. Nothing widens.Responsibility:
this repository's own code: plugin-security hands a principal-less, non-system context through its middleware and resolvers (ADR-0096 E1) while the AI tool contract says such a context sees nothing | none: ADR-0096's strict mode (D5) is not built, and nothing downstream refuses | any producer of a principal-less engine context; measured on a hosted tenant kernel (card body), with both known request-reachable producers now closed (#22046, #22147)Thread-read: 6054654999
Serial constraints cleared:
security-plugin.ts,protocol.tsor ADR-0096.6055398588).delegated-admin-gate.tsand object files only.objectstack-fleet commentedon Oct 8, 2026
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
majorbump.' · '✓ 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
objectstack-fleet commentedon Oct 8, 2026
Seat review, before the contract review: PR #22297 at
454d0e89b9and PR #22298 at2becf9015f· seatdomain: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-report6060614288). This record answers the dev's flags. The at-tier contract review of PR #22297 follows on this head; the ACCEPT waits for it.Fixes #21908, with a line-startClause-②: no (narrowing). Its assignee isos-bill. It has 17 files (+931/−149) and is not governed (0 of 17 paths).Refs #21908. It changesdocs/adr/**only, 2 lines: Tier H.main(git merge-tree).security-plugin.ts):isPrincipalLessContext, answers at the six sites.PermissionDeniedErrorafter the six pre-refusal gates, and the system short-circuit precedes it.getReadFilterreturns the deny filter after itsisSystemexit.canReadObject,canWriteObjectandcanExportreturnfalse, and the projection's early full-set answer is removed.protocol.tsbeyondpromoteDraftForPublish(deviations[0]): accepted as an in-place adjacent fix. It is the same class, the mechanical opt-in, and the same gate family, and no other claimant held those functions at the edit. The claim's file surface is appended (6055574466), and the [PM seat] domain:server — 🟢 os-project-manager #6367 declaration is amended (6060736542). metadata-protocol/spec: the_draftsheader carries no label, so a draft-only item can only be shown by its machine name (ListDraftsResponseSchema drops the label the repository already reads) #22200'slistDraftsin the same file is a region-level overlap.ISecurityServicereturn contract (afalseand a deny filter are those methods' deny). This is for the contract review to confirm or refuse.main, and the fault-class pin at the runtime tier (deviations[2]–[4]): accepted. The full dogfood suite was re-run green at the end, and the guest posture at a real boot is pinned by test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints #22177's dogfood pins.open_questions[0](the specISecurityServiceTSDoc): B. The four sentences are 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, which isBlocked-by:this PR so that it lands right after it, in the same release.packages/specpath, which is spec-lane work.@objectstack/plugin-securityminorwith the BREAKING banner, and@objectstack/metadata-protocolpatch, in one file. For the contract review's semver section.out_of_scope_findings: noted. None is filed: the two boot readers precede the middleware, and the options-argument spelling trap is now a loud 403.6019001864).needs-user-decisionandskip-changeset(docs only). Reviews are requested fromos-zhuangandhotlong, and the maintainer brief is on the PR (6060702747).objectstack-fleet commentedon Oct 8, 2026
Contract review
Served-tier:
CONTRACT_REVIEW_TIERHead-sha:
454d0e89b9397900c6d4b01bd30e8e505d886bbeLocal-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 againstmainat the merge-base8cbe255ef6, and the check-runs on the head (latest run per name). The surrounding source was read onorigin/mainwhere 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:isPrincipalLessContext(opCtx.context)used toreturn next(); it now throwsprincipalLessDenial(operation, object), aPermissionDeniedError(403 PERMISSION_DENIED). The pre-existingisSystemshort-circuit sits above it, so the effective key isisPrincipalLessContext(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.getReadFilter. A principal-less context answered the sharing predicate alone (orundefined); it answersRLS_DENY_FILTERnow, after theisSystemexit and before any layer resolves. Narrowing. RIGHT.canReadObject. Principal-less answersfalseafterisSystem; the zero-set arm that used to answerisPrincipalLessContext(context)answersfalse. RIGHT.canWriteObject. Principal-less answersfalseat 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 answersfalse. RIGHT.canExport. As item 3. RIGHT.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 publishedISecurityServicereturn 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 (afalse, 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 notshouldDenyAnonymous's wider one. RIGHT.Public surface. No export is added, removed or renamed:
principalLessDenialandisPrincipalLessContextare module-private; no new error class or code (PermissionDeniedError/PERMISSION_DENIEDare the engine's existing deny vocabulary);ISecurityServicesignatures are untouched; nopackages/specpath is in the diff.check:api-surfaceruns insideTypeScript Type Check, success on the head. No public-surface change. RIGHT.metadata-protocolopt-ins (src/protocol.ts, twelve engine calls across ten functions:promoteDraftForPublish,resolveDraftOrgScopeForPublish,resolveMetaItemOrgScope,auditMetaItem,diffMetaItem,listCommits,revertCommit,rollbackToPackageCommit,metaTypeNamespaceExists,migrateStoredMetadata, and thedeleteMetaItemlegacy read and delete). Each is asys_metadata,sys_metadata_history,sys_metadata_auditorsys_metadata_commitstore call behind a door that authorized the caller, with thewherebuilt by the protocol. Neutrality under the seat's standing rule (anisSystemcontext skips the six pre-refusal gates) was checked against the gates' own object sets onorigin/main: audience-anchor keys onsys_position_permission_set; package-managed onsys_permission_set; system-row on the RBAC provenance map (sys_metadataabsent); curated-capability onsys_capability; delegated-admin on the RBAC link tables; the engine-owned guard bypasses a context-less write by construction. None keys on asys_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
mergeReadContextdoes. 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, explicitisSystem: 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 probesfalse, 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 Gateshards 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); theresolveRequestScopefault-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 ofprotocol.platform-store-system-opt-in.test.tsdriving 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 fourType Checklegs,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 2Clause-②: no (narrowing); the title carries the!banner; the newestClaim:on the card names this branch.② Semver level
Changeset
.changeset/21908-principal-less-strict-mode.md:@objectstack/plugin-security: minor,@objectstack/metadata-protocol: patch.plugin-security: a runtime accept-set narrowing of the engine middleware and the fourISecurityServiceprobes, 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).runtimeandqa/dogfoodcarry test files only (runtimepublishesdist;dogfoodis private), so no changeset is owed for them. The changeset matches the diff.minorfor a breaking change.scripts/check-changeset-no-major.mjsis the launch-window guard: a PR may not introduce amajor, and a breaking change ships asminorwith the BREAKING banner (pre-1.0 semantics over the lockstepfixedgroup, with a written end condition at GA). The banner and the!are present;Check Changesetis success, and the Lint gates' no-major andcheck-adr-0087-registrationverdicts are green on the head. RIGHT.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 inCHANGELOG.md. RIGHT.migrate meta. The narrowing is a runtime admission. RIGHT.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:deviations[0], nineprotocol.tsfunctions closed beyondpromoteDraftForPublish. 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).deviations[1], the five service sites return rather than throw. ANSWERED, confirmed (① items 2 to 6).deviations[2]and[3], the single-pass H1 method and the scoped re-runs after mergingorigin/main. ANSWERED: the check-runs on the head judge the merge ref and every required context is green, the full dogfood suite included.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.open_questions[0]/deviations[5], fourISecurityServiceTSDoc sentences inpackages/spec/src/contracts/security-service.tsthat 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 withBlocked-by:this PR, to land right after it in the same release. Not a FAIL:packages/specis 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).@objectstack/plugin-securityfrom 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.tscontradicts the runtime for one release.content/docs/kernel/contracts/data-engine.mdx(theExecutionContextpassage) 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; nocontent/docsedit 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).deviations[6], changesets. ANSWERED in ②.deviations[7]and[8], model-free commit trailers and lock timeouts not counted. NOTED; no contract bearing.deviations[9]andout_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 loudPERMISSION_DENIED, fail closed, never a hand-through. No escalation.out_of_scope_findings[0],loadMetaFromDbandreportUnhydratableOrgScopedRowsread context-less at boot. VERIFIED onorigin/main: ObjectQL's plugin callsrestoreMetadataFromDbon its start path, plugin-security declaresdependencies = ['com.objectstack.engine.objectql'], and the engine middleware is registered inside plugin-security'sstart(), so those reads precede the middleware in every kernel that honours the declared order. Not a producer today.out_of_scope_findings[1], anExecutionContextpassed 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.domain:cliseat's finding (6052739434), theresolveRequestScopeinfrastructure-fault class. ANSWERED: closed by construction and pinned at the runtime tier;http-dispatcher.tsuntouched, as the claim required.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. Noscripts/adr-anchors/entry was added for the new refusal site;check:adr-anchorsis green on the head, so none is owed by the gate; an optional follow-up.Disclosure held at classes, positions and functions; no reproduction sequence is written here.
Implemented-by:
claude/issue-21908-principal-less-denyReviewed-by:
session_01WkL6Eijt432S1Y7ekb6ovQVERDICT: PASS
Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Seat ACCEPT: PR #22297 at
454d0e89b9· seatdomain: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.CONTRACT_REVIEW_TIER(6061293231, head454d0e89b9397900c6d4b01bd30e8e505d886bbe,Local-runs: none). It was rendered by an isolated reviewer from the card, the PR and the head's check-runs only.protocol.tsopt-ins are neutral, because none of the six pre-refusal gates keys on thesys_metadata*objects.@objectstack/plugin-securityminorwith the BREAKING banner and@objectstack/metadata-protocolpatchmatch the diff.Clause-②: no (narrowing)is consistent across the PR, the claim and the changeset.Blocked-by:this PR.content/docs/kernel/contracts/data-engine.mdxtells authors that a{ flowRunId }-only context is fine, and strict mode now refuses it. It is appended to 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 for triage to route, in the same release.successor an expected skip (Console Pin Gate,Build Docs,Packed-tarball smoke).Lint & Repo GatesandTypeScript Type Checkare success. It merges clean withmain.main.objectstack-fleet commentedon Oct 8, 2026
Landed ·
domain:servicesseat 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 onorigin/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 throwsPermissionDeniedError(403 PERMISSION_DENIED), the three probes answerfalse, 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
Fixesline did not close the card (the measured failure mode recorded on #6021); the seat closed itcompletedthrough the relay in this act. This note also removespm:dispatchedand the assignee.os-zhuangand merged by the maintainer's hand.ISecurityServiceTSDoc, and thedata-engine.mdxpassage) is unblocked by this landing, for the same release.test(runtime): pin the guest principal an anonymous request executes …
docs(adr): ADR-0096 D5 dated note — strict mode lands ON ahead of D2 …
feat(plugin-security)!: refuse a principal-less, non-system data-engi…