What bit
A fork serves one of its agents through a surface of its own: a route that runs the fork's checks around every turn (a crisis check, a per-user spend ceiling, a turn record). That surface needs the agent to be visibility: 'public'. Setting it also opens the agent to Sunrise's general consumer chat routes:
POST /api/v1/chat/stream streams a turn with it for any signed-in user, by agentSlug.
GET /api/v1/chat/agents lists it.
A turn through chat/stream skips everything the fork's surface does. The fork has no way to close that door without editing the route.
Why the existing seams cannot express it
The shape carried downstream (reference implementation)
A generic registry, with no fork vocabulary in the platform files:
lib/orchestration/chat/consumer-exclusions.ts (new): excludeFromConsumerChat(slug), isExcludedFromConsumerChat(slug), and a test reset. It is stored on globalThis under a Symbol.for key, because the route and the boot seam can land in different module graphs (the same reason lib/framework/modules/registry.ts does this).
app/api/v1/chat/stream/route.ts: one import, and if (!agent) becomes if (!agent || isExcludedFromConsumerChat(agent.slug)). A refused slug gets the same 404, code and message as an unknown agent. The DB lookup still runs, so nothing about the agent's existence leaks. The refusal happens before the per-agent limiter and before streamChat.
app/api/v1/chat/agents/route.ts: one import. The query result is filtered with the same predicate, so the query itself is unchanged.
- The fork registers from its boot seam.
Behaviour-neutral at rest: with nothing registered, both routes behave exactly as before, and a test asserts it. The downstream tests live in tests/unit/app/api/v1/chat/consumer-exclusions.test.ts (Lelañea): at rest vs registered, response-body equality with the unknown-agent answer, the listing omission, and the globalThis sharing across a module reset.
Reference implementation: human-centric-engineering/lelanea — PR link to follow in a comment.
Behaviour change we could not avoid
None at rest. With a registration, a registered agent is unreachable through the consumer routes by design. Admin chat and embed are untouched: they don't consult the registry.
Notes
- A fork could reasonably also want the same exclusion on the embed route. We did not need that (embed tokens are admin-minted per agent), so we did not carry it.
- The route file has moved on
main since 0.12.0 (the invite-token refactor). Our edit touches only the if (!agent) line and the imports, so it should re-apply cleanly on top of that.
We are reporting the gap with our shape as a reference. We are not proposing to write the platform's fix.
What bit
A fork serves one of its agents through a surface of its own: a route that runs the fork's checks around every turn (a crisis check, a per-user spend ceiling, a turn record). That surface needs the agent to be
visibility: 'public'. Setting it also opens the agent to Sunrise's general consumer chat routes:POST /api/v1/chat/streamstreams a turn with it for any signed-in user, byagentSlug.GET /api/v1/chat/agentslists it.A turn through
chat/streamskips everything the fork's surface does. The fork has no way to close that door without editing the route.Why the existing seams cannot express it
lib/app/authorization.ts): both routes arewithAuthwithownership.decidedBy: 'self'/'nothing'. The policy is never asked about an agent, so there is nothing for it to refuse.internalhides it from the fork's own surface as well.invite_onlychanges the fork's surface into a token flow it doesn't want.The shape carried downstream (reference implementation)
A generic registry, with no fork vocabulary in the platform files:
lib/orchestration/chat/consumer-exclusions.ts(new):excludeFromConsumerChat(slug),isExcludedFromConsumerChat(slug), and a test reset. It is stored onglobalThisunder aSymbol.forkey, because the route and the boot seam can land in different module graphs (the same reasonlib/framework/modules/registry.tsdoes this).app/api/v1/chat/stream/route.ts: one import, andif (!agent)becomesif (!agent || isExcludedFromConsumerChat(agent.slug)). A refused slug gets the same 404, code and message as an unknown agent. The DB lookup still runs, so nothing about the agent's existence leaks. The refusal happens before the per-agent limiter and beforestreamChat.app/api/v1/chat/agents/route.ts: one import. The query result is filtered with the same predicate, so the query itself is unchanged.Behaviour-neutral at rest: with nothing registered, both routes behave exactly as before, and a test asserts it. The downstream tests live in
tests/unit/app/api/v1/chat/consumer-exclusions.test.ts(Lelañea): at rest vs registered, response-body equality with the unknown-agent answer, the listing omission, and theglobalThissharing across a module reset.Reference implementation: human-centric-engineering/lelanea — PR link to follow in a comment.
Behaviour change we could not avoid
None at rest. With a registration, a registered agent is unreachable through the consumer routes by design. Admin chat and embed are untouched: they don't consult the registry.
Notes
mainsince 0.12.0 (the invite-token refactor). Our edit touches only theif (!agent)line and the imports, so it should re-apply cleanly on top of that.We are reporting the gap with our shape as a reference. We are not proposing to write the platform's fix.