Skip to content

test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints - #22177

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22147-anonymous-endpoint-guest
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22147-anonymous-endpoint-guest

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #22147
Clause-②: no

Summary

The card's premise does not hold on origin/main fec87e7e. An anonymous request admitted by an authRequired: false declared endpoint already executes as the guest principal that ruling C names: principalKind: 'guest', positions: ['guest'], isSystem: false. It is not principal-less. This was measured on a real boot, by class.

  • Why: the dispatcher's scope resolution (HttpDispatcher.resolveRequestScope → resolveExecutionContext) takes the explicit guest entry, assembleExecutionContextOrGuest, on the runtime face. It has done so since commit f586f1a89.
  • Where the misreading came from: a doc comment in endpoint-executor.ts that said "undefined for anonymous".

So this PR builds no behaviour change. It does two things:

  1. Pins the ruled posture, each pin with an ablation, at two tiers: over a socket (runtime) and on a real boot (dogfood). Once 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's engine-level deny lands, a guest and a principal-less call will both answer 403. After that, these pins are the only check that can tell them apart.
  2. Corrects the three comments that read as principal-less execution.

The diff publishes nothing (measured below), so it carries no changeset.

Who reaches what, before and after (by class)

Before = after. No runtime behaviour changes. The readings below were measured at fec87e7e with a throwaway, uncommitted probe. The new pins assert the same facts at 49546b0c.

Request (anonymous unless noted) What is handed down Security middleware Driver Answer
object_operation find, get or create, authRequired: false the guest envelope, no user Passes the gates that run before the principal-less hand-off (none fires). Is not handed through. Is refused at step 2, the CRUD check (ADR-0056 D2 deny baseline). never reached 403 PERMISSION_DENIED; no byte of the record; a create writes nothing
the same operation, authRequired: true n/a n/a n/a 401 UNAUTHENTICATED, before the engine is asked anything
a signed-in user at the authRequired: false endpoint that user (principalKind: 'human') normal evaluation reached when granted served as that user
flow with a runAs: 'user' target, authRequired: false the automation service receives positions: ['guest'], with no user, no isSystem and no runAs none: the run's data step never reaches the engine never reached 400 FLOW_FAILED: the automation service refuses a user-less runAs: 'user' run fail-closed
flow with a runAs: 'system' target, authRequired: false the same hand-off as the row above the flow's data steps run as the system principal, by the flow's own runAs declaration reached the flow's result (see the open question below)

The guest's refusal is the same as a member's. A signed-in member without a grant gets the same refusal at this door: the same throw site and the same 403 PERMISSION_DENIED, byte-identical. The built-in /data route never evaluates a guest at all; on the REST face it answers an anonymous caller 401.

Pins and their ablations

Each ablation was a one-shot, uncommitted mutation made with scripts/ablation-replace.mjs. Each was restored, and the restore was verified as blob equal to HEAD with an empty git diff HEAD.

Pin Where Ablation Result
An anonymous object_operation reaches the engine as the guest, never principal-less runtime socket test; dogfood find, get and create A1: the runtime face's guest entry returns no context for a sessionless request Socket: 2 of 21 red. Real boot: runtime rebuilt, marker proven in dist/ and then absent after the restore rebuild; 4 of 6 red. The find and the get were served the row (200), and the create wrote a row (201).
Denied, and nothing of the row disclosed dogfood A1 (above) red, as above
authRequired: true keeps answering 401 runtime socket test; dogfood A2: the policy's authRequired branch disabled Socket: 5 of 21 red (this pin and 4 existing ones)
isSystem is never set on the anonymous path runtime socket test A3: isSystem: true injected into the step's context 2 of 21 red
The flow executor is handed the guest, with no user and no elevation runtime socket test; dogfood A1 (the guest position disappears), and A4: an isSystem flag injected into the door's automation context A1: red at both tiers. A4: 1 of 21 red.
An authenticated request at authRequired: false runs as that user runtime socket test; dogfood A5: the user id dropped from the runtime face's input 2 of 21 red

A4's first attempt was void. ablation-replace refused it before any test ran, because the replacement text repeated the anchor. It was redone with a replacement that does not.

Evidence (at 49546b0c)

  • pnpm --filter @objectstack/runtime test (the local project): 333 files, 4714 passed, 19 skipped.
  • pnpm --filter @objectstack/runtime typecheck: exit 0. The test-layer debt ledger held.
  • pnpm --filter @objectstack/dogfood typecheck: exit 0. --listFiles confirms the new file is compiled.
  • Dogfood: declarative-endpoint-anonymous-guest (6) and declarative-endpoint-policy (8), 14 passed.
  • Gates: all 67 commands the dispatch named exit 0. dispatch-gates --ran: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET: 8 packages had no dist/. After building them it exits 0.
  • pnpm lint (full): exit 0.

No changeset

  • The diff publishes nothing from @objectstack/runtime. Two of the three corrected comments sit on interfaces the package entry does not export. The third sits inside a function body, which the bundler strips. Built, none of the three new texts appears anywhere under packages/runtime/dist/. The control does appear there: the documentation of the exported createDispatcherPlugin. Tests do not ship.
  • There is no release note to write, because nothing changed for anonymous endpoints.
  • This departs from the dispatch. It asked for a patch changeset and forbade skip-changeset, both on the assumption that the PR would change code. This PR therefore carries neither, and Check Changeset will read red until the seat decides.

Acceptance notes

Open question for the seat

Does ruling C's "never the system principal" reach a flow's declared runAs? An anonymous request at an authRequired: false flow endpoint can target a flow that declares runAs: 'system'. That flow's data steps run as the system principal: this is the flow's own explicit opt-in, and the request itself still arrives as the guest. The options, and a recommendation along the four axes, are in the dev report on #22147. This PR builds nothing for it.

#22147 remains open for the seat, because its premise is falsified and the open question above needs a ruling. #21908 remains open and is not addressed here. Whether its Blocked-by line on #22147 still holds is the seat's call.


Generated by Claude Code

claude added 2 commits October 8, 2026 03:45
… endpoints

An unauthenticated request admitted by an `authRequired: false` declared
endpoint executes as the guest principal (`principalKind: 'guest'`,
`positions: ['guest']`, `isSystem: false`): the dispatcher's scope resolution
takes the explicit guest entry on the runtime face. Measured on a real boot,
it is never principal-less. The `executionContext` docs on the step and the
executor said "`undefined` for anonymous", which read as principal-less
execution; they now say what arrives.

Pins: the socket-level fallback test asserts the context the engine and the
flow executor receive (anonymous, authenticated, and authRequired:true), and
a new dogfood file asserts it on a real boot, together with the deny the guest
earns (403 PERMISSION_DENIED, driver not reached, nothing of the row returned)
and a runAs:'user' flow's data step never reaching the engine.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ack/runtime

The three corrected comments sit on interfaces the package entry does not
export, or inside function bodies that the bundler strips. Built, the shipped
`dist/` carries none of the new texts, while an exported symbol's documentation
from the same module is present (the control). No behaviour changed for
anonymous endpoints: they already executed as the guest principal. So there is
nothing for a release note to say, and no version to bump.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 959c209d56a8b9ab6f7e561e87700721194e4678 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 15a24e056e376f827186a1ae6501f449bbec0aee — the merge of head 49546b0c061e7fd4a65d125b67a1bc32aea7181c into base 959c209d56a8b9ab6f7e561e87700721194e4678, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 15a24e056e376f827186a1ae6501f449bbec0aee && git checkout 15a24e056e376f827186a1ae6501f449bbec0aee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 959c209d56a8b9ab6f7e561e87700721194e4678 49546b0c061e7fd4a65d125b67a1bc32aea7181c && git checkout -B drift-repro 959c209d56a8b9ab6f7e561e87700721194e4678 && git merge --no-ff 49546b0c061e7fd4a65d125b67a1bc32aea7181c

node scripts/docs-audit/affected-docs.mjs --json 959c209d56a8b9ab6f7e561e87700721194e4678

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 959c209d56a8b9ab6f7e561e87700721194e4678 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 49546b0c061e7fd4a65d125b67a1bc32aea7181c
Local-runs: none

Read-only, on 2026-10-08T04:25Z: card #22147 (body and all four comments — the ruling 6051299672, the claim, the os-dev-report, the seat's ACCEPT), PR #22177 (body, the 5-file list, the net diff origin/main...49546b0c; merge base fec87e7e, origin/main at 959c209d), the 42 check-runs on the head, the #21908 and #22146 bodies, the code on origin/main, and the published @objectstack/runtime 17.7.0 tarball (sha1 matches the registry). The PR head had not moved. The seat's and the dev's conclusions were re-derived, not adopted.

① Derived judgments

The falsified premise — confirmed on origin/main, independently. The endpoint fallback (createDispatcherPlugin, setFallbackHandler) calls HttpDispatcher.resolveRequestScope, which assigns context.executionContext from timedResolveExecutionContext → resolveExecutionContext (packages/runtime/src/security/resolve-execution-context.ts), whose return type is a Promise of ExecutionContext (never undefined) and whose last statement is assembleExecutionContextOrGuest(...). In packages/core/src/security/assemble-execution-context.ts, entryFields(input, anonymous = true) emits positions: ['guest'], principalKind: 'guest', isSystem: false, and a userId of undefined that emit drops. The envelope then rides protocolContext.executionContext → runAppEndpointStep → buildEndpointExecutionContext → executeObjectOperation's deps.callData(..., ctx.executionContext) and executeFlow's buildAutomationContext(ctx.body, ctx.protocolContext), which copies positions and, when present, userId off the envelope and never writes isSystem. The card's static reading ("threads undefined for anonymous") came from the JSDoc on BuildEndpointExecutionContextInput.executionContext, not from the code. Right: the PR builds no behaviour change; ruling C already holds at this door on main.

Accept-set and public-surface changes implied by the diff: none. File by file:

  • endpoint-executor.ts, api-endpoint-step.ts: every changed line is inside the JSDoc of BuildEndpointExecutionContextInput.executionContext and AppEndpointExecutionInput.executionContext; the member declarations are byte-identical. Right. The new sentences are true of the normal path (③ names the one residual).
  • dispatcher-plugin.ts: five // lines above the resolveRequestScope call inside createDispatcherPlugin's body. Right, and "the ONE place the guest principal is built" is true of this door.
  • No packages/spec, packages/core, plugin-security, service-automation or docs edit; the docs sentence in content/docs/api/declarative-endpoints.mdx is already true of main and is not edited, as the card asked. Right. The Docs Drift Check lists two pages via the createDispatcherPlugin symbol; the diff changes a comment inside that function, so no page's accuracy moves. Advisory only.

The pins — do they hold the ruled posture, and would each go red if it broke? Card pin by card pin:

  1. An anonymous object_operation reaches the engine as the guest, never principal-less — held at two tiers. Socket (dispatcher-plugin.endpoint-fallback.integration.test.ts, 4 new cases): the fixture stubs auth, objectql and automation at the KERNEL boundary (identity by a test header through the stubbed session getter), so the production resolveRequestScope → resolveExecutionContext → assembleExecutionContextOrGuest chain runs for real; the pin asserts the context the engine stub received is defined, matches principalKind: 'guest', positions: ['guest'], isSystem: false, and carries no userId. Real boot (declarative-endpoint-anonymous-guest.dogfood.test.ts, 6 new cases; bootStack with automation: true and a MetadataPlugin): an engine spy on find / findOne / insert captures the handed context, and a middleware registered after boot (so it runs last) records driver reach. Principal-less → the defined-context assertion reddens at both tiers, and on the real boot the deny would become a serve (the dev's A1 reading: 200 on find and get, 201 on create — the one check that still tells a guest from a principal-less call once 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's deny makes both answer 403). System → isSystem: false reddens (A3). Right.
  2. With no permission set bound to the guest, denied and nothing disclosed — held (dogfood): 403 with error.code PERMISSION_DENIED, no data member, neither the seeded row's marker nor its id anywhere in the response text, the driver never reached; for create, an admin read-back shows the seeded row and not the anonymous one. Right.
  3. The same endpoint declared authRequired: true answers 401, unchanged — held at both tiers, with the engine never asked (the authRequired branch of applyEndpointPolicies in endpoint-policy.ts runs before delegation). A2 reddens it. Right.
  4. isSystem never set on the anonymous path — held for what this door hands down: the object path (isSystem: false asserted) and the flow hand-off (no isSystem property; A4 reddens it, and buildAutomationContext on main never writes one). A flow's OWN declared runAs: 'system' elevating its data steps is a separate fact; see ③.
  5. A flow endpoint's data calls carry the same guest context — not landed as written, and correctly not claimed. What is pinned: the flow EXECUTOR is handed positions: ['guest'] with no userId, no isSystem and no runAs, and a runAs: 'user' run with no user is refused fail-closed before its data node touches the engine (400 FLOW_FAILED; engine and driver untouched; no disclosure). The data calls themselves either do not happen (runAs: 'user') or run as the flow's declared system principal (runAs: 'system'), and AutomationContext (the spec contract) has no principalKind or isSystem member, so the guest reaches a flow as a position only. The PR says so, routes it (③), and is Part of, not Fixes, so merging does not close the card over an unlanded pin. Right.
    Also pinned: an authenticated request at authRequired: false runs as that user (principalKind: 'human', no guest position; A5 reddens it). Right — it guards the opposite failure, a guest everywhere.

Public text at class level. The PR body, the os-dev-report and the ACCEPT describe reach by class (door, gate, driver, status) with no request shape against a shipped endpoint; the ablation readings describe a mutated build, not main. The two test files exercise inline, throwaway fixtures (a test-only object and namespace in a stack the test itself declares), which is what a pin is; nothing in them reaches a real deployment's data. The throwaway probe is not in the diff (5 files, as listed). Right.

Check-runs on 49546b0c, read 2026-10-08T04:25Z: 42 runs — 28 success, 11 skipped, 1 failure, 2 in progress (Test Core (1/6), Lint & Repo Gates). Green and complete: Build Core; Test Core (2/6) through (6/6); Dogfood Regression Gate and its three shards; Dogfood Verify CLI; Temporal Conformance (live PG + MySQL); the four Type Check rows and TypeScript Type Check; Spec property liveness; Governed Surface Queue Guard; Part-of PR must not also close its card; The card this PR closes must claim this branch; both single-writer guards; Check Documentation Links; Flag docs affected by code changes. The one failure is Check Changeset at 04:12:24Z, before skip-changeset was applied (04:17Z); the two labeled runs of that job at 04:17:49Z and 04:17:50Z are skipped by the job's own if: (the payload carries the label), which the workflow documents as the exemption. Recorded as read; the two in-progress rows are the seat's to see complete before the queue.

② Semver level

skip-changeset is right, and Clause-②: no with no arm is right.

  • @objectstack/runtime publishes dist, README.md and CHANGELOG.md only (files); tsup bundles from src/index.ts, which exports nothing from api-endpoint-step.ts, endpoint-executor.ts or endpoint-policy.ts (read on origin/main). Verified against the published 17.7.0 tarball (sha1 8f079ab4… matches the registry; main is at 17.7.0): neither BuildEndpointExecutionContextInput nor AppEndpointExecutionInput appears anywhere under dist/; the pre-existing #4936 comment beside the resolveRequestScope call is absent from dist/index.js and dist/index.cjs (esbuild strips non-legal comments); the control, createDispatcherPlugin, is present in all four bundles. The one hit for the phrase "undefined for anonymous" in the d.ts belongs to resolveActiveOrganizationId on the domain-handler registry — a different declaration this diff does not touch — so the seat's "the old text of all three comments is absent" holds for the three comments in question. The diff adds no export, so the new text is unreachable the same way.
  • The two test files do not ship (files excludes src), and @objectstack/dogfood is private: true.
  • Nothing changed for anonymous endpoints, so there is no release note to write; a patch entry would describe a change that did not happen. AGENTS.md's criterion for the label ("a diff that publishes nothing from any released package") is met exactly. The dispatch's patch ask was premised on a code change that proved unnecessary, and the departure is on the record in the PR body, the os-dev-report (open question 2) and the ACCEPT.
  • Clause-②: no is PR line 2. Right: no accepted input or published shape moves. The card's anticipated (narrowing) arm is correctly dropped — nothing narrows, because the anonymous call was never handed through principal-less at this door.
  • .changeset/: none on the head (the second commit removed the one the first added). Consistent with the label.

③ Boundary flags

Every dev flag and open_questions entry, judged:

  1. Open question 1 — a flow declaring runAs: 'system' under an anonymous authRequired: false request. Measured by the dev and consistent with domains/automation.ts on main ("the engine elevates to a system principal only when the flow declares runAs: 'system'"): the request arrives as the guest; the elevation is the flow author's declaration. Whether ruling C's "never the system principal" reaches a flow's own runAs is a maintainer ruling, not a seat's; the seat escalates (card to needs-user-decision at landing with the dev's four options and recommendation A, and a pointer to design(v18): the complete guest model in one ADR — identity, doors, grants channel, organization, public-site binding, disclosure, rate limits, and each declared guest key's fate (ADR-0090 D9 enforce-or-remove) #22146 question 2). Right. The PR builds nothing for it and does not close the card. For the ruling: the docs' own anonymous example is a flow, and until a guest grants channel exists an elevated flow is the only way an anonymous flow can touch data, so option C or D taken now would be reshaped by design(v18): the complete guest model in one ADR — identity, doors, grants channel, organization, public-site binding, disclosure, rate limits, and each declared guest key's fate (ADR-0090 D9 enforce-or-remove) #22146.
  2. Open question 2 — changeset: answered in ②. Right.
  3. Open question 3 — 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's Blocked-by: #22147: B, keep 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 blocked until this PR merges. Right: once the engine-level deny lands, a guest and a principal-less call both answer 403 and only these pins tell them apart, so the pins must be on main first. 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's pointer comment already names security(rest, runtime): an anonymous request at an app-declared authRequired: false endpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147 as the gate; lifting the line is its owner's call, as the seat says.
  4. Dev flag — the identity-resolution fault path. Confirmed in HttpDispatcher.resolveRequestScope: the catch calls rethrowAuthzStoreUnavailable and otherwise leaves executionContext undefined, and the fallback branch then spreads nothing into execution.executionContext, so at an authRequired: false endpoint such a call would run principal-less through the ADR-0096 E1 hand-off. Independent read of the anonymous branch: the session getter and the OAuth verification each absorb their own faults; classifyAdmissionTenancyPosture re-raises unbranded faults as the loud class; the only throw in resolveAuthzContext is AuthzStoreUnavailableError; getQl guards its first lookup. No fault a request can steer was found; what remains is an infrastructure fault class. The pins cannot cover it (it needs a fault injection), so the ruled "never principal-less" is held on the request-steerable path rather than by construction, and the two corrected comments' "never undefined" is true of that path only. The seat routes it to 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 at landing, whose engine-level deny closes it by construction and which waits on this PR. Right, with one note for the seat: the dispatch's suggested consumer-side guard in the fallback branch (inside this claim's file surface) would have closed it at the door as well; the dev's reason for declining — a second guest construction for a value never undefined on the normal path — stands, and the routing keeps the closure where 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 owns it.
  5. Dev flag — AutomationContext has no principalKind or isSystem, so a guest-triggered flow sees only the guest position, and resolveRunDataContext refuses a user-less runAs: 'user' run rather than running it as the guest. A packages/spec change, out of this card's scope; routed to design(v18): the complete guest model in one ADR — identity, doors, grants channel, organization, public-site binding, disclosure, rate limits, and each declared guest key's fate (ADR-0090 D9 enforce-or-remove) #22146. Right.
  6. Deviations: Part of rather than Fixes — right (the card keeps an open ruling; the Part-of PR must not also close its card check is green). No production change and the suggested route not taken — right, verified in ①. No changeset — ②. Model-free commit trailers per AGENTS.md — not a contract matter. A4's void first attempt redone — reported, and the socket pin's missing-isSystem assertion is the one it reddens. Throwaway probe never committed — verified from the file list.
  7. The seat's own pending items — its dispatch-gates --self-test at the head, the mkdtempSync standing-lesson note, and the two in-progress check-runs — are the seat's to read before the queue; nothing in this verdict depends on them.

No flag is unanswered or unrouted. No pin asserts a request a reader could reuse against a deployment.

Implemented-by: claude/issue-22147-anonymous-endpoint-guest
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 04:33
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 04:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 6ed0c0f Oct 8, 2026
47 of 48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22147-anonymous-endpoint-guest branch October 8, 2026 05:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…ne context (ADR-0096 D5 strict mode) (objectstack-ai#22297)

Fixes objectstack-ai#21908
Clause-②: no (narrowing)

## Summary

ADR-0096 D5 strict mode, the deny round of objectstack-ai#21908. A data-engine context
that carries no principal (no user id, no position, no permission set)
and is not a system context is now **refused** at every place
`plugin-security` used to hand it through. The published contract
sentence on `ChatWithToolsOptions.toolExecutionContext`
("unauthenticated (RLS-on, sees-nothing)") now holds.

Before the deny, every producer of such a context that a run or a static
read found was closed inside this claim: it carries the caller's
principal or the explicit system opt-in (`isSystem: true`) inside its
owning code. Breaking for in-process code (the `!` banner); the
changeset carries the migration and an ADR-0087 disposition. The at-tier
contract review is owed before the queue.

The ADR-0096 D5 dated note is its own Tier H draft PR, on
`claude/issue-21908-adr-0096-d5-note`, for the maintainer's approval.

## What changes, by class

The predicate is `isPrincipalLessContext(context) && !isSystem`, the one
the hand-off already used. These are the six sites, at `origin/main`
`0e9371f0` positions:

| Site (`security-plugin.ts`) | Before | After |
|---|---|---|
| engine middleware (near `:2616`) | `return next()`: no CRUD gate, no
RLS, no field mask, no tenant wall | throws `PermissionDeniedError`
(`403 PERMISSION_DENIED`) for every verb, before the operation runs |
| `getReadFilter` (near `:5875`) | the sharing predicate alone | the
deny filter (zero rows) |
| `resolveProjectionFieldMask` (near `:6126`) | the full field set | no
early answer: the zero-set field answers, like any caller that resolves
no set |
| `canReadObject` (near `:6299`) | `true` | `false` |
| `canWriteObject` (near `:6585`) | `true` | `false` |
| `canExport` (near `:6688`) | `true` | `false` |

- **Unchanged:** a system context is admitted everywhere.
- **Unchanged:** a context that carries a principal without a user id is
decided by what it carries. That covers a named permission set, the
guest principal, and the public-form grant.
- **The gates that run before the refusal keep their own answers:**
package-managed, system-row, curated-capability, audience-anchor,
engine-owned and delegated-admin.

**Why the five service sites return rather than throw.** The ruling's
error class is the engine-level refusal, and the middleware throws it.
The published `ISecurityService` contract has `canReadObject`,
`canWriteObject` and `canExport` *return* `false`, and has
`getReadFilter` *return* the deny filter. Each of them now gives a
principal-less context the deny it already gives a caller that carries a
principal and resolves no set (ADR-0056 D2). The field projections are
field answers, not admission answers. The contract text for
`getReadableFields` names the answer for "a non-system caller who
resolves no permission set", and that answer now applies to this context
too. Object admission is refused by the middleware and the three probes.

## Producer census (H1), by class, position and function

**Method.**
- A temporary probe recorded the call stack at every principal-less exit
while the deny was in place. It was never committed; it was reverted,
and the blob was proven equal to HEAD.
- It ran over the full dogfood suite (10 shards, 239 files) and over
every unit file in 22 packages that composes the security plugin (about
90 files).
- A static sweep covered every `sys_*` engine call in production source
that passes no context.
- After the fixes, a real boot was driven, with a member's first query
and the admin authoring doors.

| Class | Position | Function | From a request | Closure |
|---|---|---|---|---|
| production, measured | `metadata-protocol` `protocol.ts` |
`promoteDraftForPublish` (the draft read) | yes: the publish door,
behind its authorization | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`resolveDraftOrgScopeForPublish` (both probes) | yes: an
organization-scoped publish | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`resolveMetaItemOrgScope` (both history probes) | yes: the
organization-scoped revert path | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`auditMetaItem` | yes: the audit door | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`diffMetaItem` (the history read and the current-row read) | yes: the
diff door | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`listCommits`, `revertCommit`, `rollbackToPackageCommit` | yes: the
commit doors | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`metaTypeNamespaceExists`, `migrateStoredMetadata` | yes: the authoring
and migration doors | explicit system opt-in |
| production, static | `metadata-protocol` `protocol.ts` |
`deleteMetaItem` legacy path (read and delete) | yes: the code-only
delete on a control-plane kernel | explicit system opt-in |
| test harness | `qa/dogfood` | the federated-fixture test (context
passed as the options argument) | no | carries `{ context }` |
| test harness | `qa/dogfood` | `package-first-authoring` (a direct
store insert) | no | system opt-in |
| test harness | `runtime` | `standalone-stack-seeder-declaration-copy`
(context-less reads) | no | system opt-in |
| test harness | `runtime` | the `share-links-enforcement-context`
engine double (dropped the trailing read options) | no | honours the
trailing context, as `mergeReadContext` does |

**Not producers today.** `loadMetaFromDb` and
`reportUnhydratableOrgScopedRows` read context-less at boot. They run
inside ObjectQL's `start()`, and plugin-security depends on ObjectQL, so
they run before the security middleware is registered. Noted, not
edited.

No producer was found outside `plugin-security`, `protocol.ts` and the
test harnesses. The static sweep's other 25 hits all go through a
system-context wrapper or a context-carrying options object, or are
comments. Every edit uses only the declared opt-in; nothing guesses a
boundary. Each moved call is a `sys_metadata*` store read, or the legacy
delete of a `sys_metadata` row. None of the six gates the middleware
runs before the refusal keys on those objects, so the opt-in changes
nothing they decide.

**The dispatcher's identity-resolution fault class** (the `domain:cli`
seat's note) is closed by this deny by construction. It is pinned below,
and `http-dispatcher.ts` is not edited.

## Pins

**The ruling's three pins:**
- **A principal-less context is refused on read and write:**
`plugin-security` `principal-less-strict-mode.test.ts`. It covers every
spelling: no context, an empty one, empty arrays, `isSystem: false`, a
tenant id alone, and provenance only. The middleware answers
`PERMISSION_DENIED` / 403 for all seven verbs before the operation runs,
the probes answer `false`, and the row scope is the deny filter.
- **An explicit system context is unchanged:** the same file, with and
without a principal.
- **The dogfood suite is green under the deny:** see Evidence.

**Further pins:**
- **The hand-through pins became refusal pins:**
`zero-set-deny-baseline`, `zero-set-masking`,
`zero-set-capability-fold`, `metadata-unresolvable-posture`,
`predicate-related-read-tenant-scope` and `security-plugin.test.ts`'s
row-scope case.
- **The deny is keyed on carrying no principal:** a user-less named set,
the guest principal and the public-form grant are each decided by what
they carry.
- **The `resolveRequestScope` fault class is now denied:** `runtime`
`dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts`,
over the real dispatcher plugin and the real security middleware. The
engine is handed no principal, the store is never reached, and the
caller gets 403 with nothing of the rows. Its two controls: an anonymous
request with no fault runs as the guest (the posture of objectstack-ai#22147,
unchanged), and a granted signed-in caller is served.
- **The guest posture is unchanged at a real boot:** objectstack-ai#22177's
`declarative-endpoint-anonymous-guest` pins stay green under the deny.
- **The composed Q2 pin:** `qa/dogfood`
`owner-of-private-object-under-strict-mode`. An `owner_of:` audience on
a `private` object reaches its owner's inbox, and nobody else's, end to
end. In the same composition, a principal-less read of that object is
refused.
- **The protocol producers:** `metadata-protocol`
`protocol.platform-store-system-opt-in.test.ts`, deny-round block. Each
producer is driven, and every engine call it issues must carry
`isSystem: true`.

## Ablations

Each ablation ran through `scripts/ablation-replace.mjs` (wrap mode,
plus a shell trap). Every mutation landed (anchor 1 → 0, blob changed),
and every restore was proven (blob == HEAD, `git diff HEAD` empty). The
dist legs were proven with `ablation-dist-preflight` (marker present
while live, `--absent` after the restore rebuild, tree clean).

| # | Mutation | Red |
|---|---|---|
| A1 | the middleware refusal replaced by the old `return next()` (dist
leg) | plugin-security 13 of 42; runtime fault-class pin 1 of 3 (the
store was reached) |
| A2 | `getReadFilter` answers no scope for the class | 8 of 305 |
| A3 | `canReadObject` admits the class | 7 of 305 |
| A4 | the diff door's history read loses the opt-in | 1 of 7 |
| A5 | the promotion's draft read loses the opt-in | 1 of 7 |
| A6 | `resolveOwnerOf` loses its opt-in (service-messaging, dist leg) |
the composed Q2 pin 1 of 1 (0 recipients) |

## Evidence

Final head `454d0e89b9`: `origin/main` `8cbe255ef6` merged at
`6681b216e1`, then a docblock-only test commit. All runs were made under
the shared verify lock, so wall times are shared-box readings.

**On the merged tree (`6681b216e1`):**
- plugin-security: 180 files, 3800 passed, 45 skipped.
- metadata-protocol: 221 files (+3 skipped), 28280 passed.
- runtime: 339 files, 5490 passed, 19 skipped.
- The composing unit files of the packages `main` touched (rest, cli,
lint, platform-objects, plugin-approvals, service-analytics,
organizations, spec): all green.
- **dogfood, the whole suite in 10 shards: 223 files, 1754 passed, 9
skipped.**
- typecheck exits 0 for plugin-security, metadata-protocol, runtime and
dogfood. `tsc --listFiles` shows metadata-protocol's program compiles
the edited pin.

**Before the merge (`8248ceb97f`):** objectql 381 files / 7525 passed,
and core 81 files / 2240 passed. Neither package is touched by `main`'s
incoming commits or by this diff.

**Real boot, showcase `--fresh`, on the merged tree:**
- An anonymous request answers 401.
- The admin signs in and invites a member. The member signs up, and the
member's first data query answers 200, as do the member's object read
and inbox.
- The admin's draft save, publish, diff, audit, history, delete and
package-commits doors all answer 200.
- The server log carries zero strict-mode refusals.

**Gates:** `dispatch-gates --commands` derived 73 families at
`454d0e89b9`. All 73 ran and exited 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)". Selected verdict lines:
- `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition.`
- `✓ This diff introduces no \`major\` bump.`
- `check-nul-bytes: OK`
- `check-engine-double-contract: OK`
- `check-test-source-alias OK`
- `check:cross-package-test-inputs OK`

**Lint, a proven narrowing (CI runs the full `pnpm lint`):**
- The population read from `eslint.config.mjs` is
`**/*.{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 `454d0e89b9` gives 16 files, 0 errors, 0 warnings.
- The config enables no type-aware linting (no `parserOptions.project`,
no typed rules), so no untouched file's verdict can move.

`check-governed-merges --branch`: 0 of 17 paths on the register; 1083
changed lines.

## Acceptance notes

- **The spec's `ISecurityService` prose still describes the hand-off.**
Four TSDoc sentences on `getReadFilter`, `canExport` and `canReadObject`
in `packages/spec/src/contracts/security-service.ts` say a context with
no principal "is admitted" or "keeps the scope". `packages/spec` is
outside this claim, so the sentences are not edited here. The report
asks the seat how to route them.
- **Two of the producers closed here are outside the cross-lane
declaration.** The `domain:engine` declaration on objectstack-ai#6367 names
`promoteDraftForPublish`. The census found the other `protocol.ts`
producers listed above in the same file: the same class, with the same
mechanical opt-in. They are closed here and named for the seat, so the
declaration can be amended.
- **A test-side spelling trap.** Passing an `ExecutionContext` variable
as the options argument of an engine verb compiles, and silently drops
the context. One fixture did this. Under strict mode it is now a loud
403, no longer a silent hand-through.
- **What the census cannot see.** The static sweep reads `sys_*` object
names spelled as literals; a call whose object name is a variable is
covered only by the runs.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants