Found while fixing #7653 (/ai/** anonymous-deny ordering, PR #7910). Filed rather than folded into that PR — different domain, and the card scoped that work to domains/ai.ts.
The defect
packages/runtime/src/domains/handleSecurityRequest (domains/security.ts) resolves the security service and returns a 503 when the slot is empty or the occupant does not duck-type — before it consults the anonymous-deny gate:
// domains/security.ts:71-74constservice=awaitdeps.resolveService(context,'security',context.environmentId);if(!service||typeofservice.listAudienceBindingSuggestions!=='function'){return{handled: true,response: deps.error('Security service not available',503)};}constec=context.executionContext;// …// domains/security.ts:92if(!ec||shouldDenyAnonymous({userId: ec.userId,isSystem: ec.isSystem})){So on any deployment where the security slot is empty or stubbed, an unauthenticated caller to /api/v1/security/suggested-bindings gets 503 Security service not available instead of 401 UNAUTHENTICATED — a capability answer served ahead of the gate. That is the same inversion #7653 records for /ai/**, on a surface the handler's own comment calls an "Admin surface — anonymous is denied UNCONDITIONALLY (#2567, #3963)".
Why it is worth a ticket even though the string is bland
Smaller disclosure than the AI one: the message names no remedy and no package, so what leaks is only whether this deployment serves the security slot. The value is the contract, not the secret — /security is meant to sit on the same anonymous-deny floor as /data, /meta, /actions and /automation (ADR-0056 D2 → #3963), and an anonymous caller should not be able to tell a 503-vs-401 apart on an admin surface. It is also the last of the six dispatcher domains still ordered this way.
Surveyed, for whoever picks this up
Every sibling in packages/runtime/src/domains/ was checked for the same shape while verifying #7653. Exactly one is inverted:
| domain | gate | first capability return | verdict |
|---|
automation.ts | :156 | capabilityUnavailable:174 | ✅ gate first — the reference, with a comment giving the reason |
meta.ts | :172 | — | ✅ gate first |
actions.ts | :129 | — | ✅ gate first |
packages.ts | :224 | — | ✅ gate first |
ai.ts | :128 (in-loop) | courtesy :68, 501 :79 | ❌ #7653 — fixed in #7910 |
security.ts | :92 | 503 :73 | ❌ this issue |
domains/automation.ts is the shape to copy: hoist the gate above the service probe.
Suggested fix
Move the !ec || shouldDenyAnonymous(...) block above the resolveService probe. Note the !ec arm is already documented as behaviour-preserving (#4127 batch 3), so hoisting does not change what it decides — only when. No route-level auth: false opt-out exists on this domain, so unlike /ai this is a straight hoist with a single consult site.
Verification the fix would need
Positive: anonymous GET /api/v1/security/suggested-bindings with an empty security slot → 401 with error.code === 'UNAUTHENTICATED' (assert code and status — ADR-0112). Negative: with a valid principal and an empty slot, the 503 Security service not available answer is unchanged; with a serveable slot the route still works for an authenticated caller and still denies anonymous.
Not assigned — recording, not claiming.
Found while fixing #7653 (
/ai/**anonymous-deny ordering, PR #7910). Filed rather than folded into that PR — different domain, and the card scoped that work todomains/ai.ts.The defect
packages/runtime/src/domains/handleSecurityRequest(domains/security.ts) resolves the security service and returns a 503 when the slot is empty or the occupant does not duck-type — before it consults the anonymous-deny gate:So on any deployment where the
securityslot is empty or stubbed, an unauthenticated caller to/api/v1/security/suggested-bindingsgets503 Security service not availableinstead of401 UNAUTHENTICATED— a capability answer served ahead of the gate. That is the same inversion #7653 records for/ai/**, on a surface the handler's own comment calls an "Admin surface — anonymous is denied UNCONDITIONALLY (#2567, #3963)".Why it is worth a ticket even though the string is bland
Smaller disclosure than the AI one: the message names no remedy and no package, so what leaks is only whether this deployment serves the security slot. The value is the contract, not the secret —
/securityis meant to sit on the same anonymous-deny floor as/data,/meta,/actionsand/automation(ADR-0056 D2 → #3963), and an anonymous caller should not be able to tell a 503-vs-401 apart on an admin surface. It is also the last of the six dispatcher domains still ordered this way.Surveyed, for whoever picks this up
Every sibling in
packages/runtime/src/domains/was checked for the same shape while verifying #7653. Exactly one is inverted:automation.ts:156capabilityUnavailable:174meta.ts:172actions.ts:129packages.ts:224ai.ts:128(in-loop):68, 501:79security.ts:92:73domains/automation.tsis the shape to copy: hoist the gate above the service probe.Suggested fix
Move the
!ec || shouldDenyAnonymous(...)block above theresolveServiceprobe. Note the!ecarm is already documented as behaviour-preserving (#4127 batch 3), so hoisting does not change what it decides — only when. No route-levelauth: falseopt-out exists on this domain, so unlike/aithis is a straight hoist with a single consult site.Verification the fix would need
Positive: anonymous
GET /api/v1/security/suggested-bindingswith an emptysecurityslot → 401 witherror.code === 'UNAUTHENTICATED'(assert code and status — ADR-0112). Negative: with a valid principal and an empty slot, the 503Security service not availableanswer is unchanged; with a serveable slot the route still works for an authenticated caller and still denies anonymous.Not assigned — recording, not claiming.