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.
Path: permissions that actually hold — anonymous gets in nowhere | access-security.anonymous-deny-surfaces | P2
Triage: first grade — bug · security · priority:p1 · domain:services · area:access · pm:queue (needs-triage removed). Enforce the contract: build ADR-0096 D5's strict mode, after measuring the producers; the contract sentence is not loosened
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-05T18:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, doors and roles only.
Triage: lands in packages/plugins/plugin-security/src/security-plugin.ts (the principal-less hand-off, about :2451) ⇒ domain:services; rationale: the published contract (ai-service.ts about :507–:518) promises "RLS-on, sees-nothing", ADR-0096 D5 designed the strict mode that delivers it, and the middleware still hands the context through (E1).
Which closure, and why it is not a decision card.
Building D5's strict mode enforces a published contract and an accepted ADR. It moves no boundary outward.
Correcting the contract sentence instead would loosen a published security guarantee. That would be the maintainer's, and nothing measured calls for it.
The maintainer's ruling 2B on objectstack-ai/cloud#2006 (5995756827, cloud#2638) set the same principle for the apply gate: consent and privilege come only from an explicit system opt-in, never from a missing field. This card applies it at the data middleware.
Why p1. It is a fail-open in the core security middleware. On a hosted kernel, a principal-less context read and wrote beyond a member's reach, including permission-set rows a member is refused. Safety comes first under North Star priority rule 1. Cloud's producer is closed (cloud#2643), but any other producer meets the same hand-off.
The step.
Measure first: every framework and plugin path that hands the data engine a context with no user and no isSystem, and whether each is reachable from a request. This is the card's own ask 1.
Then: strict mode denies a principal-less context with the same decision the HTTP doors make (shouldDenyAnonymous). Each producer that legitimately needs system access sets the explicit opt-in.
⛔ No producer keeps working by relying on the missing field.
The closure card. This is the family's closure, as the filer says. The pin enumerates the measured producers: each either carries a principal, sets the explicit system opt-in, or is denied.
Pins: a principal-less context is denied on read and write, an explicit system context works as today, and each measured producer still does its job through one of the two routes.
Claim: PM loop round 3 · 2026-10-05T21:00Z
Session: session_011K3zqE8Pv1Evw5hc8tZCnN
Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-21908-principal-less-strict
Worktree: objectstack-issue-21908
Domain: domain:services
Seat: domain:services#1 (seat post #6021)
File surface (at origin/maincab63967), per triage's direction 6000979188:
Round 1 is measure-first. That is triage's step 1 and the card's own ask 1: name every framework and plugin path that hands the data engine a context with no principal and no isSystem, and whether each is reachable from a request. The dev stops with that table, without building, unless every edit the closure needs stays inside packages/plugins/plugin-security.
content/docs/** sentences this makes false, and a changeset.
⛔ The contract sentence in packages/spec/src/contracts/ai-service.ts is not loosened (triage). ⛔ No producer keeps working by relying on the missing field.
⛔ ADR-0096 is Proposed, and its D5 stages the flip behind telemetry. The seat reads triage's direction (enforce the published contract now, every legitimate producer setting the explicit system opt-in) as this card's binding line. That reading follows the maintainer's ruling 2B on objectstack-ai/cloud#2006 (5995756827) and the four-axis frame: no staged transition without named external evidence. Any producer whose legitimate posture is not decidable from that line goes to the maintainer.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier). The at-tier contract review is owed (security boundary) and runs before landing.
Clause-②: no (narrowing)
A non-system engine context with no principal, which the security middleware hands through unchecked today, is denied. What the data middleware accepts narrows. The changeset carries the ! banner and an ADR-0087 disposition.
Thread-read: 6000979188
Serial constraints cleared: at 2026-10-05T21:00Z:
os-dev-report
{
"issue": 21908,
"status": "blocked",
"branch": "claude/issue-21908-principal-less-strict",
"pr": null,
"session": "session_011K3zqE8Pv1Evw5hc8tZCnN — subagent of the PM session (harness Claude-Session line)",
"premise_still_valid": true,
"summary": "Measure-first round, STOP case. On origin/main cab6396 the principal-less hand-off grants at six plugin-security sites (H1). A local, uncommitted probe at those sites (now reverted, blob equal to HEAD) recorded 36452 principal-less, non-system contexts in three kinds of run: a booted showcase dev composition (boot, seed, migrations, admin and anonymous requests, 60s idle), the whole dogfood suite (206 files, 1590 tests green), and the 32 unit files that compose SecurityPlugin. Every hit was at the middleware site; the five service-method sites recorded none. Deduplicated by first frame outside the engine and the middleware, the hits come from about 40 producer functions, plus static reads for paths no run exercised. Almost all live outside plugin-security: metadata-protocol, service-settings, service-messaging, service-storage, objectql, core, plugin-webhooks, service-datasource, plugin-auth, runtime. Several sit on every request (the API-exposure gate's metadata read, the localization settings read), so a deny that lands before them breaks the platform. Two catch blocks would fail OPEN under a deny (rows 19 and 21), so the producers must move first. The closure needs edits outside plugin-security, so per the stop gate nothing was built and nothing was pushed beyond the empty-branch probe. 2 producers are held off this thread. Class: reachable from a request door (one from an authenticated door with a member role, one from an unauthenticated door), where the read or write exceeds what that caller may.",
"tests": "No code change. Measurement runs only, see gates. Probe: boot 385, dogfood 35245, unit 822 records. Every dogfood and unit run exited 0.",
"mcp_calls": "0",
"api_writes": "1 — POST issue comment on #21908 via scripts/pm/post-stamped.mjs (this report)",
"stop_or_build": "STOP — the closure needs producer edits in at least ten packages outside plugin-security (rows 1-21), plus test-file edits (row 22).",
"h1_sites": [
{
"site": "packages/plugins/plugin-security/src/security-plugin.ts:2525 (engine find/write middleware)",
"answers_today": "return next(): no CRUD gate, no RLS, no FLS mask, no tenant Layer 0 for any verb. The gates placed before it still run (package-managed, system-row, curated-capability, audience-anchor, ADR-0103 engine-owned, ADR-0090 D12 delegated-admin), and sibling middlewares (plugin-sharing OWD) still run.",
"measured_hits": 36452
},
{
"site": "security-plugin.ts:5742 getReadFilter",
"answers_today": "the sharing (OWD) predicate alone, or undefined (no row restriction)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:5993 resolveProjectionFieldMask (both field projections)",
"answers_today": "the full field set (no FLS)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6166 canReadObject",
"answers_today": "true",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6452 canWriteObject",
"answers_today": "true (after its engine-owned and D12 pre-resolution arms)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6555 canExport",
"answers_today": "true",
"measured_hits": 0
}
],
"h5_reading": "The doors answer the anonymous pair (UNAUTHENTICATED / 401, core anonymous-deny constants) before any engine call. ADR-0096 D5 names PermissionDeniedError (PERMISSION_DENIED / 403), which REST mapDataError and the dispatcher relay as 403 today. The readings disagree, so this is open_questions[0] and nothing is built on it.",
"h6_reading": "No systemContext(reason) and no SystemGrant exist. The explicit opt-in today is the wire-level isSystem: true flag, spelled as literals and as per-package constants (plugin-security SYSTEM_CTX and seedCtx, service-queue SYSTEM_CTX, service-automation resolveRunDataContext).",
"producers": [
{
"row": 1,
"package": "metadata-protocol",
"position": "src/protocol.ts findServedOverlayRow (findOne sys_metadata)",
"reachable": "yes: authenticated metadata read doors (any role) and authoring doors (admin/author); also boot, via plugin-security reconcilePermissionSetProjection → protocol.getMetaItemLayered (no context), and plugin-security's own permission-set projection on a write",
"today": "store read with no RLS and no tenant wall; the protocol scopes by organization in its own where; the door decides what reaches the caller",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 13891, boot 80, unit 277 records; scratchpad all-producers.txt"
},
{
"row": 2,
"package": "metadata-protocol",
"position": "src/protocol.ts overlayLockLayerAt (find sys_metadata)",
"reachable": "yes: same doors as row 1",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 14024 (shared anonymous-closure group), boot 80"
},
{
"row": 3,
"package": "metadata-protocol",
"position": "src/protocol.ts queryByOrg / readActiveOverlayRows / readFlattenedMetaItems (find sys_metadata)",
"reachable": "yes: the REST API-exposure gate on every data request (authenticated, any role) and the public-form door (unauthenticated, declared public forms); also boot (MCP prompt bridge at start, automation flow sync)",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 2037, boot 14, unit 14"
},
{
"row": 4,
"package": "metadata-protocol",
"position": "src/protocol.ts read within foldStoredCollection / assertRuntimeAuthoringRules (find sys_metadata)",
"reachable": "yes: authoring doors (flow save, metadata save; admin/author)",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 761, unit 64"
},
{
"row": 5,
"package": "metadata-protocol",
"position": "src/sys-metadata-repository.ts SysMetadataRepository get / put / delete / promoteDraft / restoreVersion / listDrafts / nextItemVersion / nextEventSeq (sys_metadata, sys_metadata_history; some under a transaction-only context)",
"reachable": "yes: authoring, publish and rollback doors (admin/author)",
"today": "store reads and writes with no RLS and no tenant wall",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood ~640 records across the eight functions"
},
{
"row": 6,
"package": "metadata-protocol",
"position": "src/protocol.ts recordMetadataAudit (insert sys_metadata_audit), persistPackageCommitRow (insert sys_metadata_commit), publishPackageDrafts, resolveOverlayPackageBinding, storedFlowBindingAgrees, deletePackage, duplicatePackage, reassignOrphanedMetadata",
"reachable": "yes: authoring and package doors (admin); also direct in-process protocol calls",
"today": "as row 5",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood ~140"
},
{
"row": 7,
"package": "service-settings",
"position": "src/settings-service.ts SettingsService.loadRows (find sys_setting)",
"reachable": "yes: the execution-context build of every REST request (localization; any caller) and the dispatcher's execution-context resolve; also boot (plugin-auth settings/brand/locale binding), plugin-audit write locale, objectql lifecycle governance",
"today": "platform settings read with no RLS and no tenant wall; only a locale or setting value is used",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 2090, boot 16, unit 13"
},
{
"row": 8,
"package": "service-settings",
"position": "src/settings-service.ts SettingsService.upsertRow + src/settings-service-plugin.ts audit write (find/insert sys_setting, insert sys_setting_audit)",
"reachable": "yes: the settings write door (admin)",
"today": "door-gated store write",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 11"
},
{
"row": 9,
"package": "objectql",
"position": "src/plugin.ts ObjectQLPlugin.readAuthoredActionRows / readAuthoredHookRows (find sys_metadata)",
"reachable": "no (boot and the detached metadata-change resync)",
"today": "platform metadata read",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 1306, boot 5, unit 56"
},
{
"row": 10,
"package": "core",
"position": "src/fallbacks/authored-translation-sync.ts readAuthoredTranslationLayer (find sys_metadata)",
"reachable": "no (boot and resync)",
"today": "platform metadata read",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 496, boot 2, unit 18"
},
{
"row": 11,
"package": "service-datasource",
"position": "loadDatasourceRows (boot, find sys_metadata) measured; src/datasource-admin-plugin.ts sys_metadata CRUD and src/datasource-secret-binder.ts sys_secret insert/delete/find (static)",
"reachable": "boot: no; admin datasource door: yes (static, admin)",
"today": "platform store reads/writes",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 1; static sweep"
},
{
"row": 12,
"package": "plugin-webhooks",
"position": "src/auto-enqueuer.ts AutoEnqueuer.doRefresh (find sys_webhook) measured; src/redeliver-guard.ts createWebhookRedeliverGuard (findOne sys_webhook) static",
"reachable": "auto-enqueuer: no (boot/refresh); redeliver guard: yes via the messaging redeliver door (static)",
"today": "platform subscription reads",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 3; static sweep"
},
{
"row": 13,
"package": "service-messaging",
"position": "src/sql-outbox.ts and src/sql-http-outbox.ts claim / claimDigest / reapExpired (find/update sys_notification_delivery, sys_http_delivery)",
"reachable": "no (background dispatcher ticks)",
"today": "outbox claim and reap",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 357, dogfood 34"
},
{
"row": 14,
"package": "service-messaging",
"position": "emit fan-out: src/messaging-service.ts writeEvent (insert sys_notification), src/inbox-channel.ts send (insert sys_inbox_message) and writeDeliveredReceipt (insert sys_notification_receipt), src/preference-resolver.ts loadRows (sys_notification_preference), src/recipient-resolver.ts resolveEmail (findOne sys_user)",
"reachable": "yes: authenticated member doors that notify (approval comment) and scheduled flows",
"today": "writes land on the recipients' rows and a recipient's address is read for delivery; nothing read is returned to the caller",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 41"
},
{
"row": 15,
"package": "service-messaging",
"position": "src/messaging-service.ts listInbox / readReceiptStates (find sys_inbox_message, sys_notification_receipt)",
"reachable": "yes: the authenticated notifications list door (any role)",
"today": "scoped to the door's authenticated user id in the where; nothing beyond the caller's own rows",
"posture": "UNDECIDED, see open_questions[1]",
"evidence": "probe: boot 2, dogfood 8"
},
{
"row": 16,
"package": "service-storage",
"position": "src/metadata-store.ts StorageMetadataStore.createFile (insert sys_file / sys_upload_session under a tenant-only context)",
"reachable": "yes: authenticated upload doors (member)",
"today": "inserts the caller's own pending file row, stamped with the caller's user and organization",
"posture": "UNDECIDED, see open_questions[1]",
"evidence": "probe: dogfood (StorageMetadataStore.engineOp group, 63 inserts)"
},
{
"row": 17,
"package": "plugin-auth",
"position": "src/auth-plugin.ts platform-admin OAuth client toggle route (findOne/update sys_oauth_application on the raw engine from getDataEngine)",
"reachable": "yes: platform-admin door (the shared platform-admin judge runs first)",
"today": "door-gated read and write",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); plugin-auth's own adapter already writes these tables through withSystemContext",
"evidence": "probe: dogfood 5"
},
{
"row": 18,
"package": "plugin-auth",
"position": "src/scim-connection-service.ts verifyScimBearerToken (findOne sys_scim_connection_credential on the raw engine)",
"reachable": "yes: SCIM bearer door (static)",
"today": "credential verification by token digest",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); under a deny its catch answers null, so SCIM fails closed",
"evidence": "static read, no run reached it"
},
{
"row": 19,
"package": "plugin-auth",
"position": "src/auth-manager.ts organization-update hook (find sys_organization, sys_environment on the raw engine)",
"reachable": "yes: org-admin organization update (static)",
"today": "reads the caller's own organization for a slug guard",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); ORDERING HAZARD: its catch returns early, so a deny that lands first skips the guard",
"evidence": "static read"
},
{
"row": 20,
"package": "plugin-auth",
"position": "other raw-engine sites outside the adapter's withSystemContext wrapper (src/adopt-membership.ts, src/membership-ended-session.ts, src/auth-manager.ts insert helper)",
"reachable": "not established (static, unexamined in depth)",
"today": "principal-less if the raw engine is used",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table) (presumed)",
"evidence": "static sweep heuristic"
},
{
"row": 21,
"package": "runtime",
"position": "src/http-dispatcher.ts environment-membership check (find sys_environment_member)",
"reachable": "yes: environment-scoped requests (authenticated; static)",
"today": "reads the caller's own membership row",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); ORDERING HAZARD: its catch fails open, so a deny that lands before this producer moves opens the membership gate",
"evidence": "static read; no run used an environment-scoped request"
},
{
"row": 22,
"package": "test harness (not runtime)",
"position": "packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts (direct sys_metadata insert and direct protocol calls); packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts (context-less reads); packages/runtime/src/domains/share-links-enforcement-context.test.ts (its engine double drops the engine's trailing options argument, so plugin-security's permission-set loader, which passes an isSystem context there, reads principal-less only under the double)",
"reachable": "no",
"today": "test-side producers",
"posture": "tests set isSystem; the double honours the trailing context (edits to existing packages/qa and runtime test files)",
"evidence": "probe: dogfood 1, unit 34"
}
],
"closed_since_adr_0096_e1": [
"sandbox body runner: the action body api is built over an explicit isSystem context (runtime action-execution buildActionExecutionContext / buildActionApi); no record",
"MCP record resource: registered only with a principal-bound reader (ADR-0101); no record",
"REST public-form submit: the context carries publicFormGrant and a guest_portal permission name, so the grant branch handles it and it is not principal-less",
"guest principal (core assembleExecutionContextOrGuest): carries positions guest, so it is not principal-less",
"objectql engine visibleWhen: no engine-internal producer in any run (36452 records); buildEvalUser reads nothing"
],
"private_producers": "2 rows held off GitHub. Class: reachable from a request door (one from an authenticated door with a member role, one from an unauthenticated door), where the read or write exceeds what that caller may.",
"open_questions": [
{
"question": "Which refusal does the engine-level principal-less deny carry, and on which predicate? (H5: the two readings disagree, so nothing is built on either.)",
"options": [
"A: PermissionDeniedError, 403 PERMISSION_DENIED, keyed on isPrincipalLessContext and not isSystem. ADR-0096 D5 names it; REST mapDataError and the dispatcher already relay it as 403; no new emitter.",
"B: the anonymous-deny pair, 401 UNAUTHENTICATED, as the card and triage cite. It needs a new plugin-security error class emitting a registered code (an error-code provenance row, not a new code). Reusing shouldDenyAnonymous's predicate (no user id and not system) would be wider than principal-less: it also denies the guest principal and any position- or permission-bearing context without a user id."
],
"recommendation": "A on the isPrincipalLessContext predicate. Business need (measured): no HTTP door hands a principal-less context in on an anonymous request, because every door answers 401 first (dev boot: all anonymous data reads 401). Every measured producer is in-process plumbing, for which 'authenticate' is a false remedy. Long-term: one engine deny vocabulary, the same as the fail-closed permission-resolution path. AI-error: a loud refusal whose text names the two remedies (carry the caller, or set isSystem). Startup: no new class and no new emitter. B's 401 belongs to the doors, which already have it."
},
{
"question": "Posture for the request-path producers that act on the caller's OWN rows: service-messaging listInbox / readReceiptStates (row 15) and service-storage createFile (row 16). Carry the caller's principal, or set the explicit system opt-in and keep the door's own user scope?",
"options": [
"A: explicit system opt-in inside the owning service, keeping today's door-derived user scope (the where clause / owner stamp).",
"B: carry the caller's principal so RLS decides. Whether members hold grants on these service-owned objects is NOT MEASURED, and without them the deny baseline breaks the inbox and uploads."
],
"recommendation": "A. Business need: the services own these objects and already scope by the door-authenticated user; no measured member grant exists. Long-term: the service is the boundary for its own objects (listInboxAsCaller already derives the recipient from the caller). AI-error: B silently couples a working inbox to grant authoring. Startup: A moves no grant model. The maintainer decides; not picked here."
}
],
"out_of_scope_findings": [
"carrier: the closure's producer slices (the seat) · noted, not filed: rows 19 and 21 swallow a read fault into a skipped guard or an open gate. That is pre-existing (row 21 is documented as deferred), and it matters for sequencing: a deny that lands first turns them into openings. Dedupe words: fail-open catch, membership check, slug guard, principal-less deny ordering"
],
"gates": [
{
"command": "git push -u origin claude/issue-21908-principal-less-strict (empty-branch write probe)",
"exit": 0
},
{
"command": "os-verify-lock: turbo run build --filter dogfood^... --filter example-showcase... --concurrency=1 (base build)",
"exit": 0
},
{
"command": "os-verify-lock: pnpm --filter @objectstack/plugin-security build (local probe in dist)",
"exit": 0
},
{
"command": "os-verify-lock: boot-probe.sh (showcase dev composition, seed-admin, admin + anonymous requests, 60s idle)",
"exit": 0
},
{
"command": "os-verify-lock: dogfood vitest chunks 00..06 --maxWorkers=2 (206 files; 205 passed + 1 skipped; 1590 tests passed, 9 skipped)",
"exit": "0 x7"
},
{
"command": "os-verify-lock: unit probe runs (rest 11 files/160 tests, runtime 11/162, service-automation 2/23, plugin-auth 2/34, organizations 1/21, verify 1/6, cli 4/43)",
"exit": "0 x7"
},
{
"command": "python3 static-sweep.py (heuristic context-less engine-call sweep; 239 raw hits, about 40 examined)",
"exit": 0
},
{
"command": "git checkout HEAD -- security-plugin.ts; hash-object == HEAD blob 5b4ab280; git diff HEAD empty",
"exit": 0
},
{
"command": "dispatch-gates --commands / --ran and the check:* battery",
"exit": "NOT MEASURED: stop case, no diff (build case only)"
}
],
"line_budget": "n/a",
"deviations": [
"Unit scope narrowed to the 32 test files that compose SecurityPlugin: a suite that never composes it cannot reach the hand-off.",
"start not booted separately: NOT MEASURED. The dev boot enters through the same serve command (Serve.run).",
"The probe dist was built with a direct pnpm build, not turbo, so the shared turbo cache never held the instrumented output.",
"The static sweep is a heuristic: 239 raw hits, many of them comments or non-engine receivers, about 40 examined."
],
"files_changed": []
}
Seat verdict on the measure-first round (6003676228) · seat domain:services#1 (#6021) · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T21:52Z. ⛔ Classes, positions and functions only.
ACCEPT the STOP. The closure needs producer edits in about ten packages outside plugin-security, so this card does not build in one round. The measurement is the map. The seat read the six hand-off sites and the producer rows against origin/maincab63967. Only the middleware site took live traffic. Rows 19 and 21 swallow a read fault into a skipped guard or an open gate, so the deny must land last.
Open question 0, which refusal the engine-level deny carries: decided A by the four-axis frame (the axes agree).PermissionDeniedError (403 PERMISSION_DENIED), keyed on isPrincipalLessContext(context) && !isSystem, the predicate the hand-off already uses.
Business need: no HTTP door hands a principal-less context in on an anonymous request, because every door answers 401 first (measured at boot). Every measured producer is in-process plumbing, for which "authenticate" is a false remedy.
Long term: one engine deny vocabulary, the same as the fail-closed permission-resolution path. ADR-0096 D5 names this class.
AI error: a loud refusal whose text names both remedies (carry the caller, or set the explicit system opt-in).
Startup: no new class, emitter or code.
Why not shouldDenyAnonymous's predicate: it would also deny the guest principal and any position- or permission-bearing context without a user id. Those are not principal-less. Triage's "the same decision the HTTP doors make" is read as the decision (deny), not the doors' status code.
Open question 1, the request-path producers that act on the caller's own rows (rows 15 and 16): escalated to the maintainer. The choice is between an explicit system opt-in inside the owning service, keeping today's door-derived user scope, and carrying the caller's principal. It decides how much reach a request path holds on a security boundary, and this lane's floor sends that to a human. The seat's recommendation is the dev's A (system opt-in in the owning service, scope kept). No measured member grant exists on those objects, so B would break the inbox and uploads. Rows 15 and 16 wait for the ruling; nothing else does.
Held off this thread: the report counts two producer rows as a live access-gap class. They went to the maintainer privately. The deny waits for their resolution, because each is also a producer.
The split (producers first, the deny last; each slice is a seat-owned sub-issue with this card's domain and priority):
This card, last: the deny at the six hand-off sites, the test-harness producers (row 22), and the pins. Those are: a principal-less context denied on read and write; an explicit system context unchanged; and the dogfood suite green under the deny, which is the proof that every measured producer took a route.
Each slice moves its producers to the explicit system opt-in (isSystem: true, the opt-in that exists; ⛔ no new elevation API). It must also prove that the move changes nothing today. ⚠️ An isSystem context short-circuits the gates the hand-off still runs before next(): package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-admin. So each slice measures, per producer, whether any of those gates fires on its calls today. If one does, that producer is reported, not moved.
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:engine — 🟢 sunhaolin #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:engine — 🟢 sunhaolin #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 5, 2026
Path: permissions that actually hold — anonymous gets in nowhere | access-security.anonymous-deny-surfaces | P2
Triage: first grade —
bug·security·priority:p1·domain:services·area:access·pm:queue(needs-triageremoved). Enforce the contract: build ADR-0096 D5's strict mode, after measuring the producers; the contract sentence is not loosenedTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T18:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, doors and roles only.Triage: lands in
packages/plugins/plugin-security/src/security-plugin.ts(the principal-less hand-off, about:2451) ⇒domain:services; rationale: the published contract (ai-service.tsabout:507–:518) promises "RLS-on, sees-nothing", ADR-0096 D5 designed the strict mode that delivers it, and the middleware still hands the context through (E1).5995756827, cloud#2638) set the same principle for the apply gate: consent and privilege come only from an explicit system opt-in, never from a missing field. This card applies it at the data middleware.isSystem, and whether each is reachable from a request. This is the card's own ask 1.shouldDenyAnonymous). Each producer that legitimately needs system access sets the explicit opt-in.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Claim: PM loop round 3 · 2026-10-05T21:00Z
Session:
session_011K3zqE8Pv1Evw5hc8tZCnNAccount:
os-steve(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-21908-principal-less-strictWorktree:
objectstack-issue-21908Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface (at
origin/maincab63967), per triage's direction6000979188:isSystem, and whether each is reachable from a request. The dev stops with that table, without building, unless every edit the closure needs stays insidepackages/plugins/plugin-security.security-plugin.ts(the principal-less hand-off in the write middleware, about:2525, and the read-filter twin thatisPrincipalLessContextserves),plugin-securitytests, and a door pin in a NEW file underpackages/qa/dogfood/test/(declared cross-lane on [PM seat] domain:cli — 🟢 os-sales · session_01BVm1pAXVXQ3PjC42j1kPFf · serial, p1/p2 + #24022 (maintainer) #6024 in this act).content/docs/**sentences this makes false, and a changeset.⛔ The contract sentence in
packages/spec/src/contracts/ai-service.tsis not loosened (triage). ⛔ No producer keeps working by relying on the missing field.⛔ ADR-0096 is
Proposed, and its D5 stages the flip behind telemetry. The seat reads triage's direction (enforce the published contract now, every legitimate producer setting the explicit system opt-in) as this card's binding line. That reading follows the maintainer's ruling 2B on objectstack-ai/cloud#2006 (5995756827) and the four-axis frame: no staged transition without named external evidence. Any producer whose legitimate posture is not decidable from that line goes to the maintainer.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier). The at-tier contract review is owed (security boundary) and runs before landing.Clause-②: no (narrowing)
!banner and an ADR-0087 disposition.Thread-read: 6000979188
Serial constraints cleared: at 2026-10-05T21:00Z:
security-plugin.tsorai-service.ts. This seat's PR fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage #21902 editspackaged-permission-set-lock.ts, which is disjoint.hotlong's in-flight security cards (security(forms): a public-form setting at one metadata layer can re-open intake that another layer withdrew — 17.7 regression, detail withheld pending maintainer #21835 PR fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake #21864, security(sharing): share-link password handling falls short of the platform's credential rules (response shape, hashing strength, transport) — detail withheld pending maintainer #21839 PR fix(plugin-sharing): share-link password never leaves the server, is stored with the platform slow hash, and is accepted in a header #21890, security(datasource): credential-shaped values in some datasource configurations are stored and served without the platform's secret handling — detail withheld pending maintainer #21840 PR fix(spec)!: credential-shaped datasource config values are refused at write and redacted on every read door for drivers with no shipped contract #21877, security(auth): implicit account linking on social / OIDC sign-in is broader than the platform's account-ownership rules allow — detail withheld pending maintainer #21846 PR fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured #21872) touch none of this card's files. A producer the measurement finds in their files is reported, not edited, in round 1.Selection: the lane, read fresh at this pick, holds 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 (p1
security, public, class-level detail only) and security(automation): a paused flow run's stored state keeps record-change values that #21830's mask removes elsewhere — detail withheld pending maintainer #21867 (p2security, withheld; by the maintainer's routing it is dispatched by the session holding its detail). This card takes the free slot, ahead of the contract review queued for PR fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage #21902.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
os-dev-report
{
"issue": 21908,
"status": "blocked",
"branch": "claude/issue-21908-principal-less-strict",
"pr": null,
"session": "session_011K3zqE8Pv1Evw5hc8tZCnN — subagent of the PM session (harness Claude-Session line)",
"premise_still_valid": true,
"summary": "Measure-first round, STOP case. On origin/main cab6396 the principal-less hand-off grants at six plugin-security sites (H1). A local, uncommitted probe at those sites (now reverted, blob equal to HEAD) recorded 36452 principal-less, non-system contexts in three kinds of run: a booted showcase dev composition (boot, seed, migrations, admin and anonymous requests, 60s idle), the whole dogfood suite (206 files, 1590 tests green), and the 32 unit files that compose SecurityPlugin. Every hit was at the middleware site; the five service-method sites recorded none. Deduplicated by first frame outside the engine and the middleware, the hits come from about 40 producer functions, plus static reads for paths no run exercised. Almost all live outside plugin-security: metadata-protocol, service-settings, service-messaging, service-storage, objectql, core, plugin-webhooks, service-datasource, plugin-auth, runtime. Several sit on every request (the API-exposure gate's metadata read, the localization settings read), so a deny that lands before them breaks the platform. Two catch blocks would fail OPEN under a deny (rows 19 and 21), so the producers must move first. The closure needs edits outside plugin-security, so per the stop gate nothing was built and nothing was pushed beyond the empty-branch probe. 2 producers are held off this thread. Class: reachable from a request door (one from an authenticated door with a member role, one from an unauthenticated door), where the read or write exceeds what that caller may.",
"tests": "No code change. Measurement runs only, see gates. Probe: boot 385, dogfood 35245, unit 822 records. Every dogfood and unit run exited 0.",
"mcp_calls": "0",
"api_writes": "1 — POST issue comment on #21908 via scripts/pm/post-stamped.mjs (this report)",
"stop_or_build": "STOP — the closure needs producer edits in at least ten packages outside plugin-security (rows 1-21), plus test-file edits (row 22).",
"h1_sites": [
{
"site": "packages/plugins/plugin-security/src/security-plugin.ts:2525 (engine find/write middleware)",
"answers_today": "return next(): no CRUD gate, no RLS, no FLS mask, no tenant Layer 0 for any verb. The gates placed before it still run (package-managed, system-row, curated-capability, audience-anchor, ADR-0103 engine-owned, ADR-0090 D12 delegated-admin), and sibling middlewares (plugin-sharing OWD) still run.",
"measured_hits": 36452
},
{
"site": "security-plugin.ts:5742 getReadFilter",
"answers_today": "the sharing (OWD) predicate alone, or undefined (no row restriction)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:5993 resolveProjectionFieldMask (both field projections)",
"answers_today": "the full field set (no FLS)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6166 canReadObject",
"answers_today": "true",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6452 canWriteObject",
"answers_today": "true (after its engine-owned and D12 pre-resolution arms)",
"measured_hits": 0
},
{
"site": "security-plugin.ts:6555 canExport",
"answers_today": "true",
"measured_hits": 0
}
],
"h5_reading": "The doors answer the anonymous pair (UNAUTHENTICATED / 401, core anonymous-deny constants) before any engine call. ADR-0096 D5 names PermissionDeniedError (PERMISSION_DENIED / 403), which REST mapDataError and the dispatcher relay as 403 today. The readings disagree, so this is open_questions[0] and nothing is built on it.",
"h6_reading": "No systemContext(reason) and no SystemGrant exist. The explicit opt-in today is the wire-level isSystem: true flag, spelled as literals and as per-package constants (plugin-security SYSTEM_CTX and seedCtx, service-queue SYSTEM_CTX, service-automation resolveRunDataContext).",
"producers": [
{
"row": 1,
"package": "metadata-protocol",
"position": "src/protocol.ts findServedOverlayRow (findOne sys_metadata)",
"reachable": "yes: authenticated metadata read doors (any role) and authoring doors (admin/author); also boot, via plugin-security reconcilePermissionSetProjection → protocol.getMetaItemLayered (no context), and plugin-security's own permission-set projection on a write",
"today": "store read with no RLS and no tenant wall; the protocol scopes by organization in its own where; the door decides what reaches the caller",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 13891, boot 80, unit 277 records; scratchpad all-producers.txt"
},
{
"row": 2,
"package": "metadata-protocol",
"position": "src/protocol.ts overlayLockLayerAt (find sys_metadata)",
"reachable": "yes: same doors as row 1",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 14024 (shared anonymous-closure group), boot 80"
},
{
"row": 3,
"package": "metadata-protocol",
"position": "src/protocol.ts queryByOrg / readActiveOverlayRows / readFlattenedMetaItems (find sys_metadata)",
"reachable": "yes: the REST API-exposure gate on every data request (authenticated, any role) and the public-form door (unauthenticated, declared public forms); also boot (MCP prompt bridge at start, automation flow sync)",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 2037, boot 14, unit 14"
},
{
"row": 4,
"package": "metadata-protocol",
"position": "src/protocol.ts read within foldStoredCollection / assertRuntimeAuthoringRules (find sys_metadata)",
"reachable": "yes: authoring doors (flow save, metadata save; admin/author)",
"today": "as row 1",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 761, unit 64"
},
{
"row": 5,
"package": "metadata-protocol",
"position": "src/sys-metadata-repository.ts SysMetadataRepository get / put / delete / promoteDraft / restoreVersion / listDrafts / nextItemVersion / nextEventSeq (sys_metadata, sys_metadata_history; some under a transaction-only context)",
"reachable": "yes: authoring, publish and rollback doors (admin/author)",
"today": "store reads and writes with no RLS and no tenant wall",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood ~640 records across the eight functions"
},
{
"row": 6,
"package": "metadata-protocol",
"position": "src/protocol.ts recordMetadataAudit (insert sys_metadata_audit), persistPackageCommitRow (insert sys_metadata_commit), publishPackageDrafts, resolveOverlayPackageBinding, storedFlowBindingAgrees, deletePackage, duplicatePackage, reassignOrphanedMetadata",
"reachable": "yes: authoring and package doors (admin); also direct in-process protocol calls",
"today": "as row 5",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood ~140"
},
{
"row": 7,
"package": "service-settings",
"position": "src/settings-service.ts SettingsService.loadRows (find sys_setting)",
"reachable": "yes: the execution-context build of every REST request (localization; any caller) and the dispatcher's execution-context resolve; also boot (plugin-auth settings/brand/locale binding), plugin-audit write locale, objectql lifecycle governance",
"today": "platform settings read with no RLS and no tenant wall; only a locale or setting value is used",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 2090, boot 16, unit 13"
},
{
"row": 8,
"package": "service-settings",
"position": "src/settings-service.ts SettingsService.upsertRow + src/settings-service-plugin.ts audit write (find/insert sys_setting, insert sys_setting_audit)",
"reachable": "yes: the settings write door (admin)",
"today": "door-gated store write",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 11"
},
{
"row": 9,
"package": "objectql",
"position": "src/plugin.ts ObjectQLPlugin.readAuthoredActionRows / readAuthoredHookRows (find sys_metadata)",
"reachable": "no (boot and the detached metadata-change resync)",
"today": "platform metadata read",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 1306, boot 5, unit 56"
},
{
"row": 10,
"package": "core",
"position": "src/fallbacks/authored-translation-sync.ts readAuthoredTranslationLayer (find sys_metadata)",
"reachable": "no (boot and resync)",
"today": "platform metadata read",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 496, boot 2, unit 18"
},
{
"row": 11,
"package": "service-datasource",
"position": "loadDatasourceRows (boot, find sys_metadata) measured; src/datasource-admin-plugin.ts sys_metadata CRUD and src/datasource-secret-binder.ts sys_secret insert/delete/find (static)",
"reachable": "boot: no; admin datasource door: yes (static, admin)",
"today": "platform store reads/writes",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 1; static sweep"
},
{
"row": 12,
"package": "plugin-webhooks",
"position": "src/auto-enqueuer.ts AutoEnqueuer.doRefresh (find sys_webhook) measured; src/redeliver-guard.ts createWebhookRedeliverGuard (findOne sys_webhook) static",
"reachable": "auto-enqueuer: no (boot/refresh); redeliver guard: yes via the messaging redeliver door (static)",
"today": "platform subscription reads",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 3; static sweep"
},
{
"row": 13,
"package": "service-messaging",
"position": "src/sql-outbox.ts and src/sql-http-outbox.ts claim / claimDigest / reapExpired (find/update sys_notification_delivery, sys_http_delivery)",
"reachable": "no (background dispatcher ticks)",
"today": "outbox claim and reap",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: boot 357, dogfood 34"
},
{
"row": 14,
"package": "service-messaging",
"position": "emit fan-out: src/messaging-service.ts writeEvent (insert sys_notification), src/inbox-channel.ts send (insert sys_inbox_message) and writeDeliveredReceipt (insert sys_notification_receipt), src/preference-resolver.ts loadRows (sys_notification_preference), src/recipient-resolver.ts resolveEmail (findOne sys_user)",
"reachable": "yes: authenticated member doors that notify (approval comment) and scheduled flows",
"today": "writes land on the recipients' rows and a recipient's address is read for delivery; nothing read is returned to the caller",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table)",
"evidence": "probe: dogfood 41"
},
{
"row": 15,
"package": "service-messaging",
"position": "src/messaging-service.ts listInbox / readReceiptStates (find sys_inbox_message, sys_notification_receipt)",
"reachable": "yes: the authenticated notifications list door (any role)",
"today": "scoped to the door's authenticated user id in the where; nothing beyond the caller's own rows",
"posture": "UNDECIDED, see open_questions[1]",
"evidence": "probe: boot 2, dogfood 8"
},
{
"row": 16,
"package": "service-storage",
"position": "src/metadata-store.ts StorageMetadataStore.createFile (insert sys_file / sys_upload_session under a tenant-only context)",
"reachable": "yes: authenticated upload doors (member)",
"today": "inserts the caller's own pending file row, stamped with the caller's user and organization",
"posture": "UNDECIDED, see open_questions[1]",
"evidence": "probe: dogfood (StorageMetadataStore.engineOp group, 63 inserts)"
},
{
"row": 17,
"package": "plugin-auth",
"position": "src/auth-plugin.ts platform-admin OAuth client toggle route (findOne/update sys_oauth_application on the raw engine from getDataEngine)",
"reachable": "yes: platform-admin door (the shared platform-admin judge runs first)",
"today": "door-gated read and write",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); plugin-auth's own adapter already writes these tables through withSystemContext",
"evidence": "probe: dogfood 5"
},
{
"row": 18,
"package": "plugin-auth",
"position": "src/scim-connection-service.ts verifyScimBearerToken (findOne sys_scim_connection_credential on the raw engine)",
"reachable": "yes: SCIM bearer door (static)",
"today": "credential verification by token digest",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); under a deny its catch answers null, so SCIM fails closed",
"evidence": "static read, no run reached it"
},
{
"row": 19,
"package": "plugin-auth",
"position": "src/auth-manager.ts organization-update hook (find sys_organization, sys_environment on the raw engine)",
"reachable": "yes: org-admin organization update (static)",
"today": "reads the caller's own organization for a slug guard",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); ORDERING HAZARD: its catch returns early, so a deny that lands first skips the guard",
"evidence": "static read"
},
{
"row": 20,
"package": "plugin-auth",
"position": "other raw-engine sites outside the adapter's withSystemContext wrapper (src/adopt-membership.ts, src/membership-ended-session.ts, src/auth-manager.ts insert helper)",
"reachable": "not established (static, unexamined in depth)",
"today": "principal-less if the raw engine is used",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table) (presumed)",
"evidence": "static sweep heuristic"
},
{
"row": 21,
"package": "runtime",
"position": "src/http-dispatcher.ts environment-membership check (find sys_environment_member)",
"reachable": "yes: environment-scoped requests (authenticated; static)",
"today": "reads the caller's own membership row",
"posture": "explicit system opt-in (isSystem: true): a platform store or plumbing read/write whose door, if any, already authorizes; decided from Zone 1 (legitimately needs system access; carrying a member principal would hit the deny baseline on an engine-owned table); ORDERING HAZARD: its catch fails open, so a deny that lands before this producer moves opens the membership gate",
"evidence": "static read; no run used an environment-scoped request"
},
{
"row": 22,
"package": "test harness (not runtime)",
"position": "packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts (direct sys_metadata insert and direct protocol calls); packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts (context-less reads); packages/runtime/src/domains/share-links-enforcement-context.test.ts (its engine double drops the engine's trailing options argument, so plugin-security's permission-set loader, which passes an isSystem context there, reads principal-less only under the double)",
"reachable": "no",
"today": "test-side producers",
"posture": "tests set isSystem; the double honours the trailing context (edits to existing packages/qa and runtime test files)",
"evidence": "probe: dogfood 1, unit 34"
}
],
"closed_since_adr_0096_e1": [
"sandbox body runner: the action body api is built over an explicit isSystem context (runtime action-execution buildActionExecutionContext / buildActionApi); no record",
"MCP record resource: registered only with a principal-bound reader (ADR-0101); no record",
"REST public-form submit: the context carries publicFormGrant and a guest_portal permission name, so the grant branch handles it and it is not principal-less",
"guest principal (core assembleExecutionContextOrGuest): carries positions guest, so it is not principal-less",
"objectql engine visibleWhen: no engine-internal producer in any run (36452 records); buildEvalUser reads nothing"
],
"private_producers": "2 rows held off GitHub. Class: reachable from a request door (one from an authenticated door with a member role, one from an unauthenticated door), where the read or write exceeds what that caller may.",
"open_questions": [
{
"question": "Which refusal does the engine-level principal-less deny carry, and on which predicate? (H5: the two readings disagree, so nothing is built on either.)",
"options": [
"A: PermissionDeniedError, 403 PERMISSION_DENIED, keyed on isPrincipalLessContext and not isSystem. ADR-0096 D5 names it; REST mapDataError and the dispatcher already relay it as 403; no new emitter.",
"B: the anonymous-deny pair, 401 UNAUTHENTICATED, as the card and triage cite. It needs a new plugin-security error class emitting a registered code (an error-code provenance row, not a new code). Reusing shouldDenyAnonymous's predicate (no user id and not system) would be wider than principal-less: it also denies the guest principal and any position- or permission-bearing context without a user id."
],
"recommendation": "A on the isPrincipalLessContext predicate. Business need (measured): no HTTP door hands a principal-less context in on an anonymous request, because every door answers 401 first (dev boot: all anonymous data reads 401). Every measured producer is in-process plumbing, for which 'authenticate' is a false remedy. Long-term: one engine deny vocabulary, the same as the fail-closed permission-resolution path. AI-error: a loud refusal whose text names the two remedies (carry the caller, or set isSystem). Startup: no new class and no new emitter. B's 401 belongs to the doors, which already have it."
},
{
"question": "Posture for the request-path producers that act on the caller's OWN rows: service-messaging listInbox / readReceiptStates (row 15) and service-storage createFile (row 16). Carry the caller's principal, or set the explicit system opt-in and keep the door's own user scope?",
"options": [
"A: explicit system opt-in inside the owning service, keeping today's door-derived user scope (the where clause / owner stamp).",
"B: carry the caller's principal so RLS decides. Whether members hold grants on these service-owned objects is NOT MEASURED, and without them the deny baseline breaks the inbox and uploads."
],
"recommendation": "A. Business need: the services own these objects and already scope by the door-authenticated user; no measured member grant exists. Long-term: the service is the boundary for its own objects (listInboxAsCaller already derives the recipient from the caller). AI-error: B silently couples a working inbox to grant authoring. Startup: A moves no grant model. The maintainer decides; not picked here."
}
],
"out_of_scope_findings": [
"carrier: the closure's producer slices (the seat) · noted, not filed: rows 19 and 21 swallow a read fault into a skipped guard or an open gate. That is pre-existing (row 21 is documented as deferred), and it matters for sequencing: a deny that lands first turns them into openings. Dedupe words: fail-open catch, membership check, slug guard, principal-less deny ordering"
],
"gates": [
{
"command": "git push -u origin claude/issue-21908-principal-less-strict (empty-branch write probe)",
"exit": 0
},
{
"command": "os-verify-lock: turbo run build --filter dogfood^... --filter example-showcase... --concurrency=1 (base build)",
"exit": 0
},
{
"command": "os-verify-lock: pnpm --filter @objectstack/plugin-security build (local probe in dist)",
"exit": 0
},
{
"command": "os-verify-lock: boot-probe.sh (showcase dev composition, seed-admin, admin + anonymous requests, 60s idle)",
"exit": 0
},
{
"command": "os-verify-lock: dogfood vitest chunks 00..06 --maxWorkers=2 (206 files; 205 passed + 1 skipped; 1590 tests passed, 9 skipped)",
"exit": "0 x7"
},
{
"command": "os-verify-lock: unit probe runs (rest 11 files/160 tests, runtime 11/162, service-automation 2/23, plugin-auth 2/34, organizations 1/21, verify 1/6, cli 4/43)",
"exit": "0 x7"
},
{
"command": "python3 static-sweep.py (heuristic context-less engine-call sweep; 239 raw hits, about 40 examined)",
"exit": 0
},
{
"command": "git checkout HEAD -- security-plugin.ts; hash-object == HEAD blob 5b4ab280; git diff HEAD empty",
"exit": 0
},
{
"command": "dispatch-gates --commands / --ran and the check:* battery",
"exit": "NOT MEASURED: stop case, no diff (build case only)"
}
],
"line_budget": "n/a",
"deviations": [
"Unit scope narrowed to the 32 test files that compose SecurityPlugin: a suite that never composes it cannot reach the hand-off.",
"start not booted separately: NOT MEASURED. The dev boot enters through the same serve command (Serve.run).",
"The probe dist was built with a direct pnpm build, not turbo, so the shared turbo cache never held the instrumented output.",
"The static sweep is a heuristic: 239 raw hits, many of them comments or non-engine receivers, about 40 examined."
],
"files_changed": []
}
Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Seat verdict on the measure-first round (
6003676228) · seatdomain:services#1(#6021) ·session_011K3zqE8Pv1Evw5hc8tZCnN· 2026-10-05T21:52Z. ⛔ Classes, positions and functions only.ACCEPT the STOP. The closure needs producer edits in about ten packages outside
plugin-security, so this card does not build in one round. The measurement is the map. The seat read the six hand-off sites and the producer rows againstorigin/maincab63967. Only the middleware site took live traffic. Rows 19 and 21 swallow a read fault into a skipped guard or an open gate, so the deny must land last.Open question 0, which refusal the engine-level deny carries: decided A by the four-axis frame (the axes agree).
PermissionDeniedError(403 PERMISSION_DENIED), keyed onisPrincipalLessContext(context) && !isSystem, the predicate the hand-off already uses.401first (measured at boot). Every measured producer is in-process plumbing, for which "authenticate" is a false remedy.shouldDenyAnonymous's predicate: it would also deny the guest principal and any position- or permission-bearing context without a user id. Those are not principal-less. Triage's "the same decision the HTTP doors make" is read as the decision (deny), not the doors' status code.Open question 1, the request-path producers that act on the caller's own rows (rows 15 and 16): escalated to the maintainer. The choice is between an explicit system opt-in inside the owning service, keeping today's door-derived user scope, and carrying the caller's principal. It decides how much reach a request path holds on a security boundary, and this lane's floor sends that to a human. The seat's recommendation is the dev's A (system opt-in in the owning service, scope kept). No measured member grant exists on those objects, so B would break the inbox and uploads. Rows 15 and 16 wait for the ruling; nothing else does.
Held off this thread: the report counts two producer rows as a live access-gap class. They went to the maintainer privately. The deny waits for their resolution, because each is also a producer.
The split (producers first, the deny last; each slice is a seat-owned sub-issue with this card's domain and priority):
metadata-protocol(rows 1 to 6),objectql(row 9) andcore(row 10). Cross-lane on [PM seat] domain:engine — 🟢 sunhaolin #6367.service-settings(rows 7 and 8),service-messaging(rows 13 and 14),service-datasource(row 11) andplugin-webhooks(row 12).plugin-auth(rows 17 to 20) and theruntimeenvironment-membership read (row 21). Cross-lane on [PM seat] domain:cli — 🟢 os-sales · session_01BVm1pAXVXQ3PjC42j1kPFf · serial, p1/p2 + #24022 (maintainer) #6024. Rows 19 and 21 are the ordering hazards.Each slice moves its producers to the explicit system opt-in (⚠️ An
isSystem: true, the opt-in that exists; ⛔ no new elevation API). It must also prove that the move changes nothing today.isSystemcontext short-circuits the gates the hand-off still runs beforenext(): package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-admin. So each slice measures, per producer, whether any of those gates fires on its calls today. If one does, that producer is reported, not moved.Generated by Claude Code
47 remaining items
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:engine — 🟢 sunhaolin #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:engine — 🟢 sunhaolin #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:engine — 🟢 sunhaolin #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…