Repository navigation
test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints - #22177
Conversation
… 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
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 ① Derived judgmentsThe falsified premise — confirmed on Accept-set and public-surface changes implied by the diff: none. File by file:
The pins — do they hold the ruled posture, and would each go red if it broke? Card pin by card pin:
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 Check-runs on ② Semver level
③ Boundary flagsEvery dev flag and
No flag is unanswered or unrouted. No pin asserts a request a reader could reuse against a deployment. Implemented-by: VERDICT: PASS |
…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>
Part of #22147
Clause-②: no
Summary
The card's premise does not hold on
origin/mainfec87e7e. An anonymous request admitted by anauthRequired: falsedeclared 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.HttpDispatcher.resolveRequestScope→resolveExecutionContext) takes the explicit guest entry,assembleExecutionContextOrGuest, on the runtime face. It has done so since commitf586f1a89.endpoint-executor.tsthat said "undefinedfor anonymous".So this PR builds no behaviour change. It does two things:
403. After that, these pins are the only check that can tell them apart.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
fec87e7ewith a throwaway, uncommitted probe. The new pins assert the same facts at49546b0c.object_operationfind, get or create,authRequired: false403 PERMISSION_DENIED; no byte of the record; a create writes nothingauthRequired: true401 UNAUTHENTICATED, before the engine is asked anythingauthRequired: falseendpointprincipalKind: 'human')flowwith arunAs: 'user'target,authRequired: falsepositions: ['guest'], with no user, noisSystemand norunAs400 FLOW_FAILED: the automation service refuses a user-lessrunAs: 'user'run fail-closedflowwith arunAs: 'system'target,authRequired: falserunAsdeclarationThe 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/dataroute never evaluates a guest at all; on the REST face it answers an anonymous caller401.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 emptygit diff HEAD.object_operationreaches the engine as the guest, never principal-lessdist/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).authRequired: truekeeps answering 401authRequiredbranch disabledisSystemis never set on the anonymous pathisSystem: trueinjected into the step's contextisSystemflag injected into the door's automation contextauthRequired: falseruns as that userA4's first attempt was void.
ablation-replacerefused 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(thelocalproject): 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.--listFilesconfirms the new file is compiled.declarative-endpoint-anonymous-guest(6) anddeclarative-endpoint-policy(8), 14 passed.dispatch-gates --ran: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET: 8 packages had nodist/. After building them it exits 0.pnpm lint(full): exit 0.No changeset
@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 underpackages/runtime/dist/. The control does appear there: the documentation of the exportedcreateDispatcherPlugin. Tests do not ship.patchchangeset and forbadeskip-changeset, both on the assumption that the PR would change code. This PR therefore carries neither, andCheck Changesetwill read red until the seat decides.Acceptance notes
catchinHttpDispatcher.resolveRequestScopeleavesexecutionContextundefined. At anauthRequired: falseendpoint, that call would then run principal-less.503).AutomationContext, the spec contract, has noprincipalKindorisSystemmember, so the flow executor receives the guest as theguestposition alone. CarryingprincipalKindwould need apackages/specchange, which is out of this card's scope.Open question for the seat
Does ruling C's "never the system principal" reach a flow's declared
runAs? An anonymous request at anauthRequired: falseflowendpoint can target a flow that declaresrunAs: '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-byline on #22147 still holds is the seat's call.Generated by Claude Code