From 5bb3cb0a3a14527fd03a93842f4f294dc9965eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:50:44 +0000 Subject: [PATCH] docs(qa): record dispatcher-plugin.ts as deliberately outside the #2992 transport tripwire set (#9410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/runtime/src/dispatcher-plugin.ts mounts routes (/actions, /automation, /packages — the registration path separate from the @objectstack/rest one) and already writes SSE at two sites, yet none of the five #2992 / ADR-0096 D4 transport tripwires watch it. A subscribe/fan-out transport wired there mints no TRANSPORT-WIRED key and reds no build, and the protocol page stated that limitation only in general terms. This is a recording. No probe is added, no key is minted, and no matrix row is written for the two existing sites. The reason, recorded because a reader cannot re-derive it cheaply: both text/event-stream sites are per-request AI response streaming, not realtime subscription fan-out. Each drains one AsyncIterable returned by the route handler into that same request's response body and then calls res.end(); no subscriber is registered, no event reaches 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 mint a key on day one for a surface the tripwires are not about. The boundary is drawn on fan-out, not on the SSE content type, and both records say so. Promoting the file into the population with a fan-out-specific marker is written into #8347's acceptance as a precondition of the WebSocket/SSE transport landing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012WKSnqAaoqtW3QX7SSf1Vk --- ...thz-tripwire-dispatcher-plugin-boundary.md | 51 +++++++++++++++++++ .../protocol/kernel/realtime-protocol.mdx | 2 +- .../qa/dogfood/test/authz-conformance.test.ts | 34 +++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .changeset/authz-tripwire-dispatcher-plugin-boundary.md diff --git a/.changeset/authz-tripwire-dispatcher-plugin-boundary.md b/.changeset/authz-tripwire-dispatcher-plugin-boundary.md new file mode 100644 index 0000000000..c80e02cf86 --- /dev/null +++ b/.changeset/authz-tripwire-dispatcher-plugin-boundary.md @@ -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. diff --git a/content/docs/protocol/kernel/realtime-protocol.mdx b/content/docs/protocol/kernel/realtime-protocol.mdx index 720e5abca5..6da9dc7ff8 100644 --- a/content/docs/protocol/kernel/realtime-protocol.mdx +++ b/content/docs/protocol/kernel/realtime-protocol.mdx @@ -12,7 +12,7 @@ The **Real-Time Protocol** describes how live data synchronization is intended t **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. ## Why Real-Time Matters diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 8148714355..eb971673cb 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -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