Skip to content

fix(runtime)!: the notification inbox, API-key mint and share-link management domains deny an anonymous caller as their first statement (ADR-0138 D2 slice 1, #23225) - #23286

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23225-s1-closed-door-list
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23225-s1-closed-door-list

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #23225
Clause-②: no (narrowing)

ADR-0138 card E2, slice 1: D2 only — the closed list of doors recorded in the authorization conformance matrix, and denial by domain. D2b (the publish refusal of an anonymous flow door whose target runs as system) is slice 2 of the same card and is not in this PR; the card stays open for it.

What lands

1. The six door classes, recorded in AUTHZ_CONFORMANCE

packages/qa/dogfood/test/authz-conformance.matrix.ts gains one enforced row per D2 class, each naming its enforcing site as D2's table does:

row class enforcing site (D2's table) ledger key
anonymous-door-public-form 1, the public form doors RestServer.registerFormEndpoints; the publicFormGrant branch of SecurityPlugin rest-family:…:forms (both routes of the family are the class)
anonymous-door-share-link 2, share links registerShareLinkRoutes (plugin-sharing) none (the plugin mount is in neither ledger the matrix reads)
anonymous-door-public-book 3, the public book and doc metadata reads isPublicAudienceRead (rest meta-item-read-gate) none (rides the metadata floor's keys)
anonymous-door-endpoint-object-operation 4, authRequired: false object_operation endpoints resolveExecutionContext (the guest envelope, D9); the CRUD gate none (declared endpoints sit in no route table)
anonymous-door-endpoint-flow 5, authRequired: false flow endpoints the same, then @objectstack/service-automation none
anonymous-door-public-read-file 6, the acl: 'public_read' file download registerStorageRoutes, at authorizeDownload none (service-storage's own mount)

Each class's admission is pinned by the existing proving file, anonymous-door-sweep.dogfood.test.ts (the record's criterion-2 sweep), which now claims the six rows (authz-row: header lines) and which every row cites. That file already asserts each class on a real subject, admitted and refused: the admitted leg is the row's pin, the refused leg (an undeclared form, a revoked token, an org-audience book, a session-gated endpoint of the same type, a private file) its standing negative control. All six are HIGH_RISK, so the proof is mandatory. The new ADR-0138 D2 block in authz-conformance.test.ts holds the list closed: exactly six door-class rows, each enforced, each naming D2's enforcing site, each high-risk and proven by the sweep, and the sweep claims exactly those six. Its negative controls: a seventh door-class row is red, a missing one is red (and orphans the sweep's claim), a row that stops citing its proof or naming its site is red.

2. "A new route in a governed domain answers 401"

Pinned through the matrix's own two mechanisms, and stated at the granularity each delivers:

  • A new route family or dispatcher domain mints a ledger key and is UNCLASSIFIED until a row covers it. The matrix already enforced this; its negative controls are the existing population-block cases (a new REST family, a new dispatcher domain), and the scratch ablation below re-measures it on a real ledger edit.
  • A route added inside an existing domain mints no key (the matrix header's own measured bound, unchanged). It is held by the domain's floor being its first statement: the new negative control deletes each of the eight gate-pinned domain floors in turn and asserts the covering row goes STALE; a second case asserts every domain-floor gate pin the table mints is one of those eight. Each moved domain's unit suite asserts that a path or verb no face serves yet is refused 401 before route selection. On a booted deployment, the sweep's everything-else class turns red for any route that serves an anonymous caller without a door class.

3. Denial by domain: the census, and three moves

Every dispatcher domain's anonymous decision was measured. Domain-level (the decision is the domain's first statement): /actions, /automation (with the ADR-0069 allow-list row for the signed inbound hook), /analytics, and, in files the in-flight #23214 slice 1 holds, /packages, /i18n, /security and /meta (with class 3's carve-out). Three domains decided it per face, below another answer, and move to the domain-level deny here, each with the matrix shape the /analytics and /i18n moves used (a floor row, a gate pin that goes STALE when the floor is deleted, the domain's ledger key classified):

  • /notifications (handleNotificationRequest): the decision sat below the inbox capability probe.
  • /keys (handleKeysRequest): below the method check.
  • /share-links (handleShareLinksRequest, the dispatcher twin of the plugin-sharing mount): inside the management faces, below the service probe. The two token faces stay admitted — the one exception D2 names for this domain (class 2), selected past the floor by the same predicate that marks them public.

Each floor is asked about the user id alone: these domains serve a user and have no system-context arm, so passing the system flag would add an elevation read site with no behaviour.

Not moved here, and why (each raised for the seat in the report):

  • Two domain files held by hono adapter: createHonoApp's catch-all flattens a repeated query name to its last value, so every dispatcher arity rule is unreachable on a @objectstack/hono host #23214 slice 1 carry no domain-level decision of their own; their move is listed for after that slice lands.
  • /ai (handleAIRequest) takes the decision once at entry but applies it per route: a registered AI route declaring auth: false is admitted to an anonymous caller. No route in this repository declares it; moving the floor would retire a route-level opt-out the runtime pins today, and an auth: false route is a door D2 does not list. That is a decision, not a move, so it is raised as needs_decision.
  • /webhooks/redeliver is decided by its member (plugin-webhooks), by design: its refusals answer the same bytes at the dispatcher and at the self-hosted mount. Raised, not moved.
  • The REST server's families decide per route by construction (each handler opens with enforceAuth); only the metadata family has a family-wide guarded registrar. D2's "domain" is read here as the dispatcher domain the runtime face's guest envelope reaches; the REST reading is raised, not acted on.

The population baseline shrinks 30 to 26 (forms, /notifications, /keys, /share-links leave classified); the probe census is re-measured (23 probes over 18 files, 29 covers keys).

4. The module doc of assemble-execution-context.ts

Cites ADR-0138 D9 as the record of the 2026-08-08 ruling, and the D2 list as the closure of its adoption rule. Doc only; no code line moves (cross-lane, declared on #6367).

Changeset

.changeset/23225-domain-floor-anonymous-deny.md, @objectstack/runtime: minor, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription). The three moves change what an anonymous caller is answered on compositions where another answer used to come first (a 501 capability answer with no inbox or no share-link service; a 405 for a wrong verb on the key mint) to the 401 D2 states; nothing a signed-in caller is served changes. Per the claim, a move that changes an answer takes the narrowing grade, and one contract-tier review is owed before the queue. @objectstack/core moves a comment only (fast track: no changeset); packages/qa/dogfood is private. The gates that decide: check:adr-0087-registration, check:changeset-no-major, check:empty-changeset.

Ablations (one-time proofs; no permanent test file)

All four through scripts/ablation-replace.mjs (anchor must hit; blob hash checked before and after; restore proven as blob == HEAD and an empty git diff HEAD), under os-verify-lock.sh, each wrapped in a restore trap, from the committed tree 2d84837006:

  1. Remove one class's admission (class 3). isPublicAudienceRead made to answer false for every request (marker ABLATION_23225_CLASS3); anchor 1 to 0, blob 41952c07b37a to 41c05ff602de; @objectstack/rest rebuilt, and ablation-dist-preflight found the marker in 2 built files. The sweep: 2 failed / 18 passed — exactly class 3's two admitted legs (the public book and its tree; a doc the public book claims); its refused leg and every other class stayed green. Restore leg: blob back to HEAD, rest rebuilt, preflight --absent (marker in 0 of 6 built files, tree clean), sweep 20 / 20.
  2. Remove a moved domain-level deny (/notifications). The floor's condition replaced by false (marker count 1, the gate's call spelling count 0). Runtime unit suite: 14 failed / 33 passed — every /notifications A, B and C case, under both anonymous shapes. Matrix: STALE covers — notifications:domains/notifications.ts:anonymous-gate, a DEAD PROBE on notifications.ts, the census control vanished — 9 failed / 95 passed. Restore leg: blob back to HEAD; 47 / 47 and 104 / 104.
  3. A new dispatcher domain, unclassified (scratch). One row for an unknown domain added to the runtime route ledger: UNCLASSIFIED surface — dispatcher-domain:route-ledger.ts:/anon-scratch (and the docblock figure pins: 88 to 89 rows, 23 to 24 domains) — 8 failed / 53 passed.
  4. A new route inside the governed /notifications domain (scratch). One row added under the existing domain: zero UNCLASSIFIED — only the docblock row-count pin moved (2 failed). This is the matrix header's measured bound, re-measured: a route inside an existing domain mints no key; ablation 2 is what holds it.

Expected directions were declared first and observed as declared: red on the admitted leg only (1), red on the floor's pins (2), UNCLASSIFIED (3), no UNCLASSIFIED (4).

Gates

At 8f838b7460 (the final tree, origin/main merged):

  • Derived set: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths: 69 commands; each run with its exit code captured before any pipe: 69 / 69 exit 0 (check:type-check-debt: "OK — 26 raw tsc error(s) total, none above its recorded number"). Reconciled with --ran: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN".
  • Added: dogfood authz-conformance.test.ts + authz-probe-blind-spot.test.ts + anonymous-door-sweep.dogfood.test.ts after a closure build (64 / 64 tasks): 3 files, 124 / 124; @objectstack/dogfood typecheck green. Runtime: the 8 touched or ordering-sensitive files, 405 / 405; @objectstack/runtime typecheck (with check:test-typecheck OK) and @objectstack/core typecheck green. The full runtime --project local suite was 368 files, 5269 passed, 19 skipped at 2d84837006; the later merge touched no runtime, rest, core or dogfood file.
  • Lint, proven narrowing: eslint --no-inline-config --format json over the 16 changed .ts files: 16 files, 0 errors, 0 warnings, none ignored. The population is eslint's own (no file reported ignored), the count is read from the JSON output, and the config enables no type-aware linting (no parserOptions.project), so this diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes

  • The sweep and the matrix test live in @objectstack/dogfood, which runs on main's full battery, not per PR.
  • The /share-links dispatcher domain is unobservable on the standalone showcase boot (plugin-sharing mounts its own routes); its pins are the runtime unit suite.
  • A system-context caller without a user id is refused by the three moved domains exactly as before.

Generated by Claude Code

…ide anonymity once, as their first statement (ADR-0138 D2)

WIP: the domain-level anonymous baseline moves ahead of each domain's
capability probe / method check; the share-link token faces (class 2) stay
admitted by the capability token.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…tion conformance matrix, and gate-pin the three domain floors

WIP: six door-class rows proven by the anonymous door sweep, three
domain-floor rows with gate pins, the forms family and three dispatcher
domains burnt out of the population baseline (30 to 26), the census
re-measured, and the module doc of assemble-execution-context.ts points at
ADR-0138 D9 and the D2 list.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…at the domain refuses an anonymous caller first; re-measure the census notes

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
…hey serve a user and have no system-context arm

Passing isSystem added three elevation read sites the system-context page
does not carry, for no behaviour: each domain already refused a system
context with no user.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/runtime, touching 6 documentable anchor(s).

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

  • content/docs/api/declarative-endpoints.mdx (via object_operation (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/objects.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/getting-started/common-patterns.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/getting-started/quick-reference.mdx (via object_operation (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/attachments-access.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/authorization.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/permissions-matrix.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/rls.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/sharing-rules.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via object_operation (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/security.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/types.mdx (via public_read (literal, a string literal in a comment on a changed line))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v15.mdx (via public_read (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v17/17-2.mdx (via public_read (literal, a string literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 44 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 99e944daf26ac1d2d50e90c5b631349ab45f664f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d6de8e609febc89ec06ab2246fca297ab14a0a8 — the merge of head 8f838b7460d216f7d8a18c9bdff2e11995368fed into base 99e944daf26ac1d2d50e90c5b631349ab45f664f, 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 3d6de8e609febc89ec06ab2246fca297ab14a0a8 && git checkout 3d6de8e609febc89ec06ab2246fca297ab14a0a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99e944daf26ac1d2d50e90c5b631349ab45f664f 8f838b7460d216f7d8a18c9bdff2e11995368fed && git checkout -B drift-repro 99e944daf26ac1d2d50e90c5b631349ab45f664f && git merge --no-ff 8f838b7460d216f7d8a18c9bdff2e11995368fed

node scripts/docs-audit/affected-docs.mjs --json 99e944daf26ac1d2d50e90c5b631349ab45f664f

⚠️ 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 99e944daf26ac1d2d50e90c5b631349ab45f664f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet objectstack-fleet Bot changed the title feat(runtime,dogfood): ADR-0138 D2 slice 1 — six door classes recorded in the conformance matrix, and denial by domain (#23225) fix(runtime)!: the notification inbox, API-key mint and share-link management domains deny an anonymous caller as their first statement (ADR-0138 D2 slice 1, #23225) Oct 13, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8f838b7460d216f7d8a18c9bdff2e11995368fed
Local-runs: none

Review of PR #23286 (card #23225, ADR-0138 card E2 slice 1: D2 only) at the head above, rendered 2026-10-13T14:41Z inside the adopting seat's session by an isolated read-only reviewer. Inputs: the card's body and every comment (triage 6144040679, claim 6144558082, the cross-lane declaration 6144567590 on #6367, the dev's os-dev-report 6147723822, and the seat's ACCEPT 6147845189, read as a claim to test), ADR-0138 on origin/main, the PR's body, title, file list and net diff against the merge base 09ef77c7d8, the head's own files where the diff lacked context, and the head's check-runs. Not an input: the dispatch order or the seat's conclusions. The card is security, so this record stays at function level: function, file and decision names only.

Check-runs on the head, latest run per name, read 2026-10-13T14:40Z after four polls over ten minutes: every concluded run is success or skipped, with no failure; Test Core shards 1 to 4 of 6 were still in_progress (shards 5 and 6 concluded success), and this record judges on what has concluded. A green head is the queue's own separate condition, which the seat reads before landing. Check Changeset (three runs, the last on the corrected title), Governed Surface Queue Guard, Lint · ESLint, Lint · PM dispatch-gates, Lint · CI, release & engine gates, Spec property liveness, Type Check · workspace, TypeScript Type Check, Build Core and the four PR-shape checks are green. Two facts about what the check-runs do not cover: the Dogfood Regression Gate shards are skipped on every pull_request event by the workflow's own condition, and Test Core runs with --exclude @objectstack/dogfood, so no check-run on this head executes authz-conformance.test.ts, authz-probe-blind-spot.test.ts or anonymous-door-sweep.dogfood.test.ts. Their evidence on this head is the dev's local run (3 files, 124 tests) and the dev's four ablations; the first check-run verdict on the matrix block is main's battery after the merge, which is where ADR-0138 criterion 2 places the sweep by design. I read the test code itself and judge it below on that reading.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged.

The three floors: right. In keys.ts (handleKeysRequest), notifications.ts (handleNotificationRequest) and share-links.ts (handleShareLinksRequest), the first statement is now the shared decision shouldDenyAnonymous from @objectstack/core, before deps.resolveService, deps.getObjectQL or deps.getRequestKernelService is called and before any face or verb is selected; the new unit suite domain-floor-anonymous-deny.test.ts asserts, for both anonymous shapes the dispatcher produces (an unresolved context and the D9 guest envelope), that none of the three seams is called (expectNothingResolved). Each refusal is deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), character for character the spelling actions.ts, automation.ts, analytics.ts, i18n.ts, packages.ts and ai.ts use, so the wire body is the dispatcher's standard wrapper with the shared message. No second spelling survives: the three domain-local messages the base carried are gone; the || !ec || !ec.userId arm in keys.ts, the || !userId arm in notifications.ts and the body-level !ec?.userId arm in handleShareLinksRequestBody are tautologies of the floor (with no method, path or isSystem passed, shouldDenyAnonymous({ userId }) is exactly !userId), each spelled with the same three constants and each documented as a compiler narrowing rather than a decision. One deliberate difference from the six sibling floors, named rather than hidden: the siblings pass isSystem and method (and automation.ts the path); these three pass the user id alone. That is the base's own behaviour for these domains (judged under the census below), and it means the OPTIONS arm of the shared decision does not apply here, which changes nothing: none of the three domains ever admitted an anonymous OPTIONS before (no face took it), so that answer moves from the 405 or the capability answer to the 401, inside the disclosed change.

For /share-links, exactly the two class-2 token faces are exempt: isPublicShareLinkRoute admits a GET whose sub-path has exactly two segments ending in resolve or messages, and handleShareLinksRequestBody selects its two public arms with the identical predicate shape (the same leading-slash strip, split and filter, segment count and verb), so every request the exemption admits is served by a token face and judged by svc.resolveToken, and no management face (zero segments, or one segment under DELETE) and no path the domain does not serve can reach it: a two-segment path under any verb but GET, or a resolve/messages segment at any other depth, falls to the floor (case B of the suite pins a selection of these; case E pins both token faces admitted with the service asked about the token). A new public face would need an edit to that predicate, which the file's header and the matrix row both name as an amendment of ADR-0138, never an edit alone. A note for the seat, not a defect: ADR-0138 D2's table names registerShareLinkRoutes (plugin-sharing) as class 2's enforcer; the dispatcher twin's two token faces are the same door on the transport cloud's per-environment kernels serve, unobservable on the showcase boot, so their admission is pinned by the unit suite's case E alone, as the matrix row says.

No signed-in caller's answer changes: case D of the suite pins, for each domain, that a member reaches every face with the same service calls (listInbox with the member's id; the 405 for a wrong verb and the data-service probe on the mint; createLink, listLinks with createdBy the member, revokeLink) and still gets the capability answer where the slot is empty. The diff removes no signed-in branch and reorders none that reads the user: the inbox probe and the method check now sit after the floor, which a caller carrying a user id passes.

The system-context census: right. At the merge base all three domains already required a user id: keys.ts refused !ec || !ec.userId (after its method check), notifications.ts refused !userId (after its inbox probe), and the management faces of share-links.ts refused !ec?.userId (after the service probe); none had an isSystem arm. So a system or service caller without a user id never reached a face in any of the three before, and asking about the user id alone denies no caller that was admitted before; a caller with a user id passes the floor exactly as it passed the old check. Nothing is admitted that should not be: the floor admits only a truthy userId, the same predicate as before. The dev's record that passing the system flag turned check:system-context-census red (three new elevation read sites with no behaviour behind them) is the right reason to keep the flag out, and that gate is what reds if someone later adds it. One gap, stated: no case in the suite drives an isSystem: true context with no user id through a floor, so "refused exactly as before" for that shape rests on the code reading above rather than on a pin; it is held structurally (the floor, then each file's narrowing arm) and is not a reason to fail.

The six class rows: right. Each row names D2's enforcing site by the function D2's table names, and each symbol exists at this head: RestServer.registerFormEndpoints (rest-server.ts) and the publicFormGrant branch of SecurityPlugin (class 1); registerShareLinkRoutes (share-link-routes.ts, class 2); isPublicAudienceRead (meta-item-read-gate.ts, class 3); resolveExecutionContext (resolve-execution-context.ts) and the CRUD gate (class 4), then @objectstack/service-automation (class 5); registerStorageRoutes and authorizeDownload (storage-routes.ts, class 6). Each row is enforced, HIGH_RISK (the six ids are spread into HIGH_RISK), and cites anonymous-door-sweep.dogfood.test.ts, which now claims exactly the six by authz-row: lines, so checkLedger's mutual attribution holds in both directions. The sweep asserts each class on a real subject, admitted and refused, in its own describe per class: class 1 admits resolve and submit of a form declaring anonymous sharing and refuses an undeclared slug; class 2 admits a minted token to its one record and refuses it revoked; class 3 admits the public book, its tree and a doc it claims and refuses a non-public book and a write; class 4 asserts the engine is asked once with the guest envelope and answers D3's empty-state refusal, and refuses the session-gated sibling; class 5 asserts the flow runs once as the guest and serves its output only, and refuses the sibling; class 6 serves a public_read file's bytes at both download doors and refuses a private file at both. Removing a site's admission reds the admitted leg (the dev's ablation of isPublicAudienceRead turned exactly class 3's two admitted legs red, 2 of 20), and removing its refusal reds the refused leg of the same case. The closed-list block in authz-conformance.test.ts is a vitest block, not a gate script: checkDoorClassList reds a row carrying the anonymous-door- prefix outside the six (a seventh), a listed id with no row (a missing row, which also orphans the sweep's claim in checkAuthzLedger), a row that is not enforced, one that cites another proof or none (which the ledger also reds as a high-risk row without proof and as non-mutual attribution), one that is not HIGH_RISK, and one whose enforcement text stops naming its D2 site (the reworded control); a renamed id is caught twice, as a missing listed id and as an unlisted prefixed id, and a rename that drops the prefix is caught as missing. No new gate script: confirmed on the file list.

The new-route pin: right. The dispatcher-domain ledger key is minted by the probe over packages/runtime/src/route-ledger.ts, one key per domain prefix inside ROUTE_LEDGER, so a route added inside an existing domain mints no key (the dev's scratch ablation 4 re-measured it: zero UNCLASSIFIED, only the docblock row-count pin moved) while a new domain or REST family does (ablation 3: UNCLASSIFIED red). Inside a domain the only hold is the floor being the first statement, which the matrix now pins three ways: the GATE_PIN probes on the three domain files mint notifications:domains/notifications.ts:anonymous-gate, keys:domains/keys.ts:anonymous-gate and share-links:domains/share-links.ts:anonymous-gate only while the call spelling is in the file; the new negative control removes each of the eight DOMAIN_FLOOR_GATE_KEYS from the discovered set in turn and asserts STALE covers naming that gate (the same effect a deleted call has, which the dev's ablation 2 measured on notifications.ts: the covering row STALE, a DEAD PROBE, 9 matrix failures); and a second case asserts the set of minted :anonymous-gate keys equals exactly those eight, so a ninth floor cannot arrive unpinned and a pinned floor cannot vanish from the list. Case B of the unit suite closes the loop at the handler: a path or verb no face serves is refused by the floor before route selection, in each of the three domains. The claim is true at this head.

The census and the baseline: right. LEDGER_POPULATION_BASELINE_MAX 30 to 26, and the four keys that left are dispatcher-domain:route-ledger.ts:/notifications, /keys and /share-links (each covered by its new anonymous-deny-* floor row beside its gate-pin key) and rest-family:rest-route-ledger.ts:forms (covered by the class 1 row). The three domain keys are exactly what the floors classify. The forms key is not a floor's: it leaves because both routes of that family are class 1 doors by declaration (the ledger lists exactly two family: 'forms' rows, both disposition: 'public', after the lookup-picker row left earlier), admitted by registerFormEndpoints' own sharing-declaration rule and refused for an undeclared slug by the sweep; that is a declared door, not an unguarded surface, and the row's note says why a family key is the one thing a door class can classify. No key left unguarded. The probe census arithmetic holds: 20 to 23 entries (three GATE_PINs), 15 to 18 files, 22 to 29 keys (three gate keys, three domain keys, one family key), MATRIX_HEADER_PROBE_CLAIM 23 beside the header's "23 probes over 18 named source files", 65 rows of which 16 carry covers (12 before, plus three floors and class 1), 58 enforced of which 43 carry none, and 10 gate pins (7 plus three); the population block enumerates the four keys as classified. The census file's per-file controls (shouldDenyAnonymous( once; the handler name 2, 4 and 6 times) match the head's files.

The assemble-execution-context.ts doc change: right. The diff is nineteen lines inside the module's doc comment, no code line, no export: cross-lane as declared on #6367 (6144567590). Its text matches D9 (the two named entries, the 2026-08-08 Option A ruling recorded by the ADR) and D2's closure of the adoption rule (six classes in D2's order; the two endpoint classes take the guest envelope, the other four derive their authorization from a declaration, everything else answers 401 decided once per domain; a new door class is an amendment of the record, never a call site; the list is recorded in the conformance matrix). No @objectstack/core release is owed: Post-Task Checklist step 3 triggers a changeset for a feature, functional improvement or fix that publishes, and a doc comment is none of those even though the emitted dist/index.d.ts carries it; Check Changeset concluded success on the head. The dev's phrase "fast track" has no anchor in AGENTS.md; the authority is step 3's own trigger list.

The five existing tests moved to a signed-in caller: right, with one nuance. Each keeps its original assertion's meaning: domain-handler-registry.test.ts still pins the 405 for a wrong verb on the mint (now through handleKeys with a member) and the 501 with no share-link service (through handleShareLinks with a member), each with the anonymous 401 and UNAUTHENTICATED asserted beside it, and tightens the segment-boundary pin from "not 405" to the 404; share-links-public-cache-headers.test.ts still pins that an authenticated route propagates a throw unchanged; dispatcher-5xx-always-logged.test.ts still pins that the /notifications 501 costs no error line; the four boots in declared-capability-absence-warn-once.test.ts still pin the once-per-route warn, the dedupe key, the shared envelope predicate and the exact 501 bytes on every request (an instrument that was, and remains, runnable at the base for a signed-in caller); notification-schema-conformance.test.ts replaces the pinned message with the code and httpStatus, because the message is what this PR unifies, and adds the ordering pin (an empty slot is still 401 to an anonymous caller). The nuance: in three of those files the anonymous 401 is asserted not beside the moved case but in the sibling the comment names, domain-floor-anonymous-deny.test.ts, which pins it for every face and both anonymous shapes; that is not a weakened pin, since none of the moved cases pinned anonymity on purpose, and SIGNED_IN_AUTH is defined in both boot-based files.

② Semver level

@objectstack/runtime minor with fix(runtime)!: as the changeset's summary line and as the PR title (corrected by the seat from a feat(runtime,dogfood): spelling), Clause-②: no (narrowing) on PR body line 2 and in the changeset, one adr-0087 comment marker not-required (no-migration-prescription) stating its facts, and the launch-window BREAKING line: right against Post-Task Checklist step 3 and the two precedents. Step 3's closed pair makes (narrowing) BREAKING and demands exactly one ADR-0087 marker in the changeset body; the marker is present, one, and its facts hold (no spec key, authorable spelling, object definition, stored shape or export moves, so nothing for a migration to rewrite; the remedy is to sign in). The migration clause does not apply: nothing an author can write is removed or renamed. PR #23211 and PR #23263 carried yes (narrowing) because each also widened an entry (new @objectstack/rest exports) beside the runtime narrowing; this diff exports nothing new in any published package, so no is the truthful first axis, and no (narrowing) is the spelling the gates read: check-changeset-no-major and check-adr-0087-registration exit 0 in the dev's run, and Check Changeset is green on the head three times. minor for a BREAKING change is the launch-window convention both precedents used.

The tension the dev named: execution-duties.md says a runtime permission or security behaviour change is not Clause ② and that pulling back to a declared contract does not touch it, while the claim graded a move that changes an answer as no (narrowing) with a review owed. no (narrowing) is the right reading of both. Clause ② asks one question, whether the card widens an accept set or expands a public surface, and the answer here is no on either text; execution-duties.md's own line on narrowing says a card that narrows a published accept set is Clause-②: no, stays in its lane, and owes one contract-tier review before the queue, which is exactly the claim's rule and this record. The (narrowing) arm is the level axis, not a second Clause ② answer: it records that a published package's wire answer moves for a caller (the 501 and 405 an anonymous caller used to read, and the three messages, become the shared 401), which a client could observe, so BREAKING under the launch window is the conservative and correct grade; a bare no would ship an observable wire change as patch with no marker and no review. The measurement that made the arm necessary was the dev's, raised rather than chosen silently, as the claim required.

③ Boundary flags

Every dev flag and every open_questions entry, answered or escalated.

  • Open question 1, the /ai route-level auth: false opt-out (handleAIRequest, mountRouteOnServer). Right that this slice does not wait: no line of this diff reads or depends on the answer, /ai stays in the baseline, its floor is not among the eight gate pins, and the dev measured no in-repo producer. Nothing in the diff is wrong under either answer. Under A (retire the opt-out and move /ai to the domain floor), a later slice adds a ninth gate pin and a ninth entry to DOMAIN_FLOOR_GATE_KEYS, which the "exactly those eight" case reds until it is updated, the intended ratchet. Under B (a seventh class by amendment), ADR_0138_D2_DOOR_CLASS_ROWS grows with the amendment, and toHaveLength(6), the matrix header, the doc comment in assemble-execution-context.ts and the sweep's classification move together, which is what "the row follows the amendment, never the reverse" means. Escalation, stated: as of this reading no needs-user-decision card for this question is on the repository (the only decision card filed today, [Decision] 访客(guest)的行可见范围:沿用对象的内部共享模型(A,现状,公开对象全部行可见)、按 externalSharingModel 判(B)、还是只经分享链接(C) #23266, is E1's row-scope question), so the seat's "goes to the maintainer as asked" is an act still owed, not one done; it does not block this PR.

  • Open question 2, REST families. The seat's reading A holds on ADR-0138's text. D2 states the domain-level decision in terms of the runtime face ("the envelope the runtime face assembles for every sessionless request (D9) is refused in every domain"), the enforcement map says shouldDenyAnonymous "at each domain's entry", and the record uses "family" for the REST side throughout criterion 2 (the settings REST family, three families outside the list), naming the metadata family's guarded registrar as the REST shape where one exists. So "domain" in D2 is the dispatcher domain, and the REST families stay per route by construction (each handler opens with enforceAuth), held by the booted sweep's everything-else class and the probe census. The claim's file surface allowed packages/rest, so the dev measured it and raised it rather than moving it; a family-wide guarded registrar (B) is its own card with its own measurement, as the seat said.

  • The deferred handleUiRequest and handleDataRequest moves. Carrying them on ADR-0138 E2: the closed list of doors recorded in the conformance matrix, denial by domain, and D2b's publish refusal (D2, D2b) #23225 as the next slice, ahead of D2b, is right under Prime Directive chore: version packages #10. The E2 row's own words are "any face of a listed domain that still decides per face moved to the domain-level deny", so both moves are E2's deliverable, not scope expansion, and a separate card would split one record's execution card; the claim deferred the two files to hono adapter: createHonoApp's catch-all flattens a repeated query name to its last value, so every dispatcher arity rule is unreachable on a @objectstack/hono host #23214 slice 1, which landed as d45d0e938a, so nothing holds them now. The handleUiRequest reading is a possible disclosure, read at unit level and not reproduced, so noting it at function level in the report and the ACCEPT is the directive's second clause. The condition, stated: if the reach measurement the seat ordered reproduces it, that is a reproducible defect and the directive's first clause then requires a security card, at function level, beside the slice that fixes it.

  • The deviations. (a) The root pid file /build-closure.pid is outside the repository (not on the file list; the repository is identical to before minus the intended changes, Post-Task Checklist step 7), and its deletion is the seat's or a person's; recorded. (b) The five tests moved to a signed-in caller: judged right above. (c) The census, its test and the population baseline edited as the matrix's companions: required, since the three gate pins and the four classified keys move the census figures and the shrink-only baseline, and every figure reconciles (above); inside the claimed packages/qa/dogfood/test/ surface. (d) Zero label writes: consistent with the dispatch's write budget, and with a changeset added, so skip-changeset does not apply. (e) Two lock-queue timeouts re-run and a rebuild after a failed closure build: process, with both readings green at the final tree; the derived set reconciled 69 of 69 with --ran.

  • Two further flags from this read. (i) The dogfood evidence on this head is not a check-run (the shards skip on pull requests and Test Core excludes the package); the matrix block, the census and the sweep claims are accepted here on the dev's report, the dev's ablations and my reading of the test code, and main's battery after the merge is their first CI verdict. (ii) The class-2 row records the dispatcher twin's token faces as the same door as registerShareLinkRoutes; the record's table names one enforcer, and the twin's admission is pinned by the unit suite only. Neither is a defect in the diff.

Implemented-by: claude/issue-23225-s1-closed-door-list
Reviewed-by: session_01VoSxBQujKLZKPwK2u5ehQ6

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants