Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/authz-tripwire-dispatcher-plugin-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@objectstack/dogfood": patch
---

docs(qa): record `dispatcher-plugin.ts` as deliberately outside the #2992 transport tripwire set, with the reason (#9410)

`packages/runtime/src/dispatcher-plugin.ts` has both properties that make a file
a plausible landing site for a realtime transport, and neither the conformance
test nor the protocol page said anything about it. It **mounts routes** —
`/actions`, `/automation` and `/packages`, the registration path separate from
the `@objectstack/rest` one — and it **already writes SSE**: two
`text/event-stream` sites with `no-cache` and `keep-alive`, working plumbing an
agent could extend without writing any new transport mechanics. None of the five
`#2992` / ADR-0096 D4 transport tripwires watch it, so a subscribe/fan-out
transport wired there mints no `TRANSPORT-WIRED` key, produces no UNCLASSIFIED
surface and reds no build. The protocol page stated the general limitation ("a
transport wired outside the watched files produces no key and no failure")
without naming the specific already-SSE-capable file sitting inside it.

**This change is a recording. It changes no behaviour**: no probe is added, no
key is minted, and no matrix row is written for the two existing sites.

The reason is recorded because it is the part a reader cannot re-derive cheaply.
Those two `text/event-stream` sites are **per-request AI response streaming, not
realtime subscription fan-out**: each drains one `AsyncIterable` that the route
handler itself returned into that same request's response body and then calls
`res.end()` — the second site's own source comment names its producer as the AI
routes. No subscriber is registered, no event is delivered to a *set* of
recipients, and the file carries no upgrade handler, no subscribe registration
and no realtime-service call. Watching it with the existing mechanics pattern
would therefore mint a key on day one for a surface that is not the hazard
`#2992` is about, leaving only two exits: classify two non-realtime sites in the
matrix vocabulary, or weaken the pattern. Neither is acceptable, so the file
stays out and the boundary is written down instead.

It is written in the two places a reader actually lands. In
`authz-conformance.test.ts` the note closes the tripwire probe list, so a reader
who has just finished enumerating the watched set reads the set's boundary in
the same breath — beside, and explicitly distinguished from, the pre-existing
`#5519` mention of the same file, which is about anonymous gates on the mounted
routes and is a different point. In `realtime-protocol.mdx` it extends the
identity-admission callout at the exact sentence that states the general
limitation.

The exclusion is drawn on **fan-out, not on the SSE content type**, and both
records say so: wiring an upgrade handler, a subscribe registration or a
realtime-service call into that file puts it back inside the hazard while the
recorded boundary still claims otherwise. Promoting it into the tripwire
population with such a fan-out-specific marker is written into #8347's
acceptance as a precondition of the WebSocket/SSE transport landing, so the
design effort is spent when the hazard becomes real rather than now.
2 changes: 1 addition & 1 deletion content/docs/protocol/kernel/realtime-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The **Real-Time Protocol** describes how live data synchronization is intended t
<Callout type="warn">
**Implementation status (v1):** The shipping realtime service is an **in-memory pub/sub adapter** (`@objectstack/service-realtime`, `InMemoryRealtimeAdapter`) plus a **long-polling** client (`RealtimeAPI` in `@objectstack/client`). The `IRealtimeService` contract reserves an optional `handleUpgrade()` for a WebSocket handshake, but **no WebSocket (`/ws`) or SSE (`/api/v1/stream`) transport is wired up yet** — those sections below document the planned wire protocol, not a deployed endpoint. The in-memory adapter is **single-instance only** (v1 deployment contract); a Redis-backed adapter for multi-node HA is a post-GA fast-follow. Treat the WebSocket/SSE message formats, connection limits, and debug endpoints in this page as a forward-looking design spec until that transport lands.

**Identity admission (framework#2992, ADR-0096 D4):** today's delivery path is a trusted server-internal fan-out with **no per-recipient authorization** — subscriptions carry no principal and events carry the full record body. Before any client transport ships, delivery must re-check each subscriber's authority (RLS/FLS/tenant) per event — the subscribe-time permission check shown below is *not* sufficient — or switch to id-only payloads with client re-fetch. CI backs this with a **classification** ratchet rather than a proof of it: the `realtime-delivery-authz` row is `experimental` — it records the posture above rather than an enforcement site — and the transport tripwires watch a **curated list of realtime source files**, so wiring a WebSocket/SSE/subscribe transport in one of them turns it into an **unclassified surface** and reddens the build until the row is upgraded with its enforcement site. That puts the admission requirement in front of a reviewer; it does not check that the re-check was written — upgrading the row is a hand edit, and the build goes green on that edit alone. A transport wired outside the watched files produces no key and no failure.
**Identity admission (framework#2992, ADR-0096 D4):** today's delivery path is a trusted server-internal fan-out with **no per-recipient authorization** — subscriptions carry no principal and events carry the full record body. Before any client transport ships, delivery must re-check each subscriber's authority (RLS/FLS/tenant) per event — the subscribe-time permission check shown below is *not* sufficient — or switch to id-only payloads with client re-fetch. CI backs this with a **classification** ratchet rather than a proof of it: the `realtime-delivery-authz` row is `experimental` — it records the posture above rather than an enforcement site — and the transport tripwires watch a **curated list of realtime source files**, so wiring a WebSocket/SSE/subscribe transport in one of them turns it into an **unclassified surface** and reddens the build until the row is upgraded with its enforcement site. That puts the admission requirement in front of a reviewer; it does not check that the re-check was written — upgrading the row is a hand edit, and the build goes green on that edit alone. A transport wired outside the watched files produces no key and no failure. One file is on record as deliberately outside that set: `packages/runtime/src/dispatcher-plugin.ts` both mounts routes and already writes `text/event-stream`, but its two SSE sites are **per-request AI response streaming** — each drains one iterable the route handler returned into that same request's response body — and not **subscription fan-out**, so watching them would mint a transport key for a surface this admission requirement is not about. That exclusion is drawn on fan-out, not on the SSE content type: wiring an upgrade handler, a subscribe registration or a realtime-service call into that file puts it back inside the hazard, and promoting it into the watched set with such a fan-out-specific marker is a precondition on the WebSocket/SSE transport work rather than something the current tripwires would catch.
</Callout>

## Why Real-Time Matters
Expand Down
34 changes: 34 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,40 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
key: () => tripwireKey('realtime:rest-server.ts:route'),
},

// ── #9410 — the tripwire population's DELIBERATE boundary ─────────────
//
// `packages/runtime/src/dispatcher-plugin.ts` is NOT in the list above, and
// that is a decision on record rather than an oversight — read this before
// concluding it was simply missed. It has both properties that make a file a
// plausible landing site for a realtime transport: it mounts routes
// (`/actions`, `/automation`, `/packages` — the separate registration path
// named in the #5519 note above, which is a DIFFERENT point about anonymous
// gates, not this one), and it already writes SSE — content type,
// `no-cache`, `keep-alive` and all.
//
// Its two `text/event-stream` sites are nevertheless outside the #2992
// tripwire set, because they are per-request AI response streaming, not
// realtime subscription fan-out: each drains one `AsyncIterable` that the
// route handler itself returned into that same request's response body, then
// calls `res.end()`. No subscriber is registered, no event is delivered to a
// SET of recipients, and the file carries no upgrade handler, no subscribe
// registration and no realtime-service call. Watching them with the mechanics
// pattern would mint a TRANSPORT-WIRED key on day one for a surface that is
// not the hazard #2992 is about, and the only exits from that red would be to
// classify two non-realtime sites in the matrix vocabulary or to weaken the
// pattern — so this record adds no probe, no key and no matrix row.
//
// ⚠️ The boundary is drawn on FAN-OUT, not on the SSE content type. Wire an
// actual subscription transport into that file — an upgrade handler, a
// subscribe registration, a realtime-service call — and it is back inside the
// hazard while this paragraph still says otherwise: nothing here will fail
// for you. Promoting the file into the population with a fan-out-specific
// marker (NOT the bare content type, which is precisely what would over-match
// the two sites above) is written into #8347's acceptance as a precondition
// of the WS/SSE transport landing. The twin record, for a reader arriving
// from the docs side, is the identity-admission callout in
// `content/docs/protocol/kernel/realtime-protocol.mdx`.

// ── ADR-0096 / #3167 — MCP execution-surface identity pins ─────────────
// (1) The HTTP `/mcp` handler must stay classified (a new sibling MCP data
// handler → UNCLASSIFIED). (2) Its caller-identity threading: handleMcp must
Expand Down
Loading