Skip to content

fix(runtime): consult the anonymous-deny gate before /ai/** capability answers (#7653) - #7910

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-7653-ai-anon-deny-ordering
Aug 12, 2026
Merged

fix(runtime): consult the anonymous-deny gate before /ai/** capability answers (#7653)#7910
hotlong merged 1 commit into
mainfrom
claude/issue-7653-ai-anon-deny-ordering

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#7653

Premise verified on current origin/main (d91fad5), not taken on trust

The card's reading held exactly, with the line numbers drifted by 0:

  • ai.ts:68-70 — the /ai/agents empty-list courtesy returns at the top of handleAIRequest
  • ai.ts:79capabilityUnavailable(deps, 'ai') returns immediately after
  • ai.ts:128shouldDenyAnonymous sits inside the per-route loop, reachable only once the AI service is serveable

So serveability decided whether the gate ran at all. On an open-edition boot — where @objectstack/service-ai is absent by construction, it being a Cloud/Enterprise package — the whole /ai/** family answered unauthenticated callers before the gate was ever consulted.

domains/automation.ts is the counter-example in the same directory: gate at :156, capabilityUnavailable at :174, with a comment giving this exact reason ("an anonymous caller should not learn from a 501-vs-401 whether this deployment mounts automation at all"). /ai was the odd one out.

The fix

The decision is taken once, at the top of the handler, and consulted at the exits. There is no second copy of the rule — one shouldDenyAnonymous call, one refusal builder.

The route-level auth: false opt-out stays in the loop, and that placement is deliberate rather than a leftover: it is a property of a registered route, so it can only be honoured where a route table exists. With no serveable service there is no route to declare it, so the family default (auth required) stands. This is why the gate is not simply short-circuited at the top — a blanket 401 would pass every positive assertion below and quietly close routes a Cloud deployment legitimately opens.

One extra exit in the same handler took the same treatment: the !routes 503 (AI service routes not yet initialized) also preceded the gate, so a boot-race window disclosed "AI is mounted here" to anonymous callers. Same reasoning, same one-line consult. Flagged here because it is slightly beyond the two paths the issue measured.

Verification

All commands run in a dedicated worktree off origin/main @ d91fad5, full package closure built first (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) — stale dist/*.d.ts lie in both directions.

Reverse-verification — the load-bearing measurement

With only the reordering reverted (git show HEAD:packages/runtime/src/domains/ai.ts > the file, tests untouched):

 Test Files 1 failed (1)
Tests 5 failed | 8 passed (13)

Every red is an anonymous-deny assertion; every degradation assertion stayed green. Named breakdown from --reporter=verbose:

caserevertedfixed
AGET /ai/models → 401, not the 501 remedy sentence× expected 501 to be 401
AGET /ai/agents → 401, not the 200 empty-list courtesy× expected 200 to be 401
Adenies the resolved-but-sessionless anonymous shape identically×
Acovers the rest of the family (/conversations, /usage, /chat, /status, /tools/:t/execute)× expected 501 to be 401
B501s /ai/models, /conversations, /usage, /chat with the remedy sentence✓ green✓ green
B/ai/agents declared envelope, payload relocated under data.agents✓ green✓ green
B501 body string-identical to /discovery's services.ai message✓ green✓ green
Binternal SYSTEM context still reaches the same degradation answers✓ green✓ green
Cstill denies anonymous on an auth: true route (the control — the gate that always worked)✓ green✓ green
Cstill SERVES anonymous on a route declaring auth: false✓ green✓ green
Cserves an authenticated caller on an auth: true route✓ green✓ green
Danonymous gets 401, not "AI routes not yet initialized"× expected 503 to be 401
Dauthenticated caller still told routes are not initialized (503)✓ green✓ green

Explicit statement: the degradation assertions stayed green in both directions. Group B and Group C never moved — which is the point, since a fix that 401s everyone would satisfy all of Group A and be a regression.

Group C is the unit-level form of the issue's /data control (anonymous GET /api/v1/data/showcase_task → 401 on the same boot): with a serveable service this handler already denied anonymous callers before this PR, which is what makes the defect ordering rather than absence.

Refusals assert both halves of the ADR-0112 envelope — status === 401 === ANONYMOUS_DENY_STATUS and error.code === 'UNAUTHENTICATED' === ANONYMOUS_DENY_CODE, plus error.httpStatus. The test's deps.error is the real envelope builder (apiErrorResponse, as http-dispatcher.ts wires it), not a stub that drops the third argument — with a stub, details.code would never be promoted and every code assertion would be vacuous.

Gates

node scripts/pm/dispatch-gates.mjs packages/runtime/src/domains/ai.ts derived two local families; both run, plus the build closure, the package suite, and the convention-scoped gates:

✓ pnpm check:route-envelope — 9 route modules audited; dispatcher domains 16 audited, 0 newly ratcheted
✓ pnpm check:changeset-gate-self-tests — 118 + 153 + 117 assertions
✓ pnpm check:nul-bytes — 7232 files scanned
✓ node scripts/check-empty-changeset.mjs — 1 declaring changeset added
✓ node scripts/check-adr-0087-registration.mjs — no declared-breaking changeset
✓ node scripts/check-changeset-no-major.mjs — no major bump
✓ npx eslint <all 4 touched files> — exit 0
✓ pnpm --filter @objectstack/runtime exec vitest run
Test Files 138 passed (138)
Tests 2100 passed (2100)

pnpm check:type-check-debt (the CI ratchet, which compiles *.test.ts too) — run after building the closure, as the script itself demands:

check-type-check-coverage --re-measure: OK — 33 ledger entries re-measured, 1789 raw tsc errors total, none above its recorded number.

Measured per-file against @objectstack/runtime's TEST_DEBT ceiling of 227: total exactly 227, unchanged, contributed by the touched files as ai.ts 0, ai-anonymous-deny-ordering.test.ts 0, domain-handler-registry.test.ts 0. The 18 in http-dispatcher.test.ts are pre-existing stubbed() duck-typing and possibly-undefined errors; the one now reported at 3131 is the same one previously at 3124, shifted by the added lines. No debt ceiling was raised or lowered.

Existing tests that moved, and why that is not a silent scope grab

Three pre-existing degradation cases failed against the fix because their fixtures were incidentally anonymous — they were measuring the courtesy and the 501 through the very hole this closes. They now seed a principal, which is the convention the /notifications stub-slot case immediately above one of them already used (executionContext: { userId: 'usr_1' }):

  • http-dispatcher.test.ts/ai — a stub slot 501s per route and keeps the /ai/agents empty list
  • domain-handler-registry.test.ts/ai/agents returns an empty list (not 404) when no AI service is configured
  • domain-handler-registry.test.ts/ai routes 501 (service missing) for non-agents paths

The last two ran through dispatch(), which re-resolves identity off the auth-less mock kernel and so overwrites any seeded context (the same reason the /keys and /automation cases in that file call their delegate directly). They now call handleAI with a principal — and the end-to-end registry coverage they provided is kept, not dropped, as a new case asserting that /ai/** denies an anonymous caller through the full registry path with the UNAUTHENTICATED envelope.

⚠️Declared file surface note. The card scoped this to packages/runtime/src/domains/ai.ts and its tests. http-dispatcher.test.ts and domain-handler-registry.test.ts are test files for the handler being changed, and neither is on the held list (rest-server.ts, domains/meta.ts, domains/packages.ts, sandbox/**, packages/mcp/**) — but they are named separately, so flagging the touch rather than burying it. Changes there are fixture-only; no assertion was weakened or deleted.

Out of scope — filed separately, not fixed here

I checked every sibling in packages/runtime/src/domains/ for the same inversion. meta.ts, actions.ts, packages.ts and automation.ts all gate correctly ahead of any capability answer. security.ts is inverted the same way:72-74 returns 503 'Security service not available' before its gate at :92 — filed as its own unassigned finding issue rather than folded in.

Changeset

.changeset/ai-anonymous-deny-ordering.md@objectstack/runtime: patch. No content/docs/releases/ edit.


Generated by Claude Code

…y answers (#7653)
`handleAIRequest` held the `shouldDenyAnonymous` gate INSIDE its per-route
loop, reachable only once the AI service is serveable, while the
`!isServiceServeable` branch returned above it. On an open-edition boot —
where `@objectstack/service-ai` is absent by construction, it being a
Cloud/Enterprise package — the whole `/ai/**` family therefore answered
unauthenticated callers: `GET /api/v1/ai/agents` → 200 with the console's
empty-list courtesy, every other route → 501 carrying the Cloud/EE remedy
sentence. Serveability decided whether the gate ran at all, which inverts
the contract this item exists to pin.
The decision is now taken once at the top of the handler and consulted at
the exits; there is no second copy of the rule. The route-level `auth:
false` opt-out stays in the loop because it is a property of a REGISTERED
route and can only be honoured where a route table exists — with no
serveable service there is no route to declare it, so the family default
(auth required) stands. The unpublished-route-table exit ("AI service
routes not yet initialized", 503) takes the gate first for the same reason.
The honest degradation is deliberately unchanged for authenticated and
internal SYSTEM callers, and is pinned as hard as the fix: `/ai/models`,
`/ai/conversations`, `/ai/usage` and `/ai/chat` still answer 501 with
`serviceUnavailableMessage('ai')` verbatim (never 404, never 503),
`/ai/agents` still returns the declared envelope with the payload relocated
under `data.agents`, and the 501 body stays string-identical to what
`/discovery` reports for the `ai` slot.
Three existing degradation cases carried incidentally anonymous fixtures and
so measured the courtesy through the hole; they now seed a principal, the
same way the `/notifications` stub-slot case next door already did. The
registry-path coverage they provided is kept as its own case asserting the
401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUpcFD7LkpqUy42CYzck51
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 3:53am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/runtime)
  • content/docs/plugins/packages.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/runtime)
  • content/docs/releases/v17.mdx(via @objectstack/runtime)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

open-edition-honest-degradation c3: anonymous-deny ordering inverted on /ai/** (unauth /ai/models → 501, /ai/agents → 200 instead of 401)

2 participants

@hotlong@claude