# stapel-realtime 0.1.2

Realtime delivery substrate: the L1 library behind the Signal primitive (stapel_core.comm.signal). Ships the Channels/Redis transport for the core's signal-delivery seam, the two consumers every browser socket in the fleet is built from (EphemeralStreamConsumer for at-most-once Signal fan-out; ResumableStreamConsumer for hello/welcome/replay/live journals with seq dedup and a bounded replay window), the versioned v1 wire envelope, the canonical <mod>:<scope_type>:<scope_id>[:<topic>] stream key, a fail-closed per-stream authorize seam with the workspace-capability authorizer, revoke-to-kick, heartbeat with JWT-exp re-check, disconnect-on-overflow backpressure, the fleet close-code canon, build_websocket_application() host assembly with a port-aware origin guard, and five system checks. No models, migrations, views, urls or comm surface of its own; it is installed as a Django app only so its checks are registered.

Contract: axes 8 · surface 17 · extension points 6.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- ALLOWED_ORIGINS [list, default []] — Which web origins may open a realtime socket
  Exact origins WITH port that may open a socket. Empty disables the guard (realtime.W002); an entry that is not a scheme://host[:port] origin is realtime.E003, because a guard that can never match is worse than no guard. Configured-but-all-malformed refuses everything rather than falling open.
- AUTHORIZE_CACHE_S [int, default 30] — How long a subscription verdict is trusted before it is re-asked
  Seconds an authorize() verdict is reused for the same (user, stream) within one socket, matching the capability cache the HTTP path already accepts. This is the acknowledged ceiling on the residual leak window; the way to shorten it for a specific stream is revoke(), not a lower TTL.
- HEARTBEAT_S [int, default 25] — How often a live socket is proven alive and still credentialed
  Seconds between server-initiated ping frames. Each tick ALSO re-checks the JWT's exp and closes 4401 if it has passed, so setting this to 0 disables two things, not one (realtime.W003 says so at check time).
- HEARTBEAT_TIMEOUT_S [int, default 10] — Grace period before a silent socket is reaped
  Seconds to wait for the client's pong before closing 4408.
- LAYER_SOCKET_TIMEOUT_MIN [int|null, default null] — Floor for the channel layer's redis socket timeout
  Minimum acceptable redis socket_timeout for the channel layer; null derives it from the layer's expiry + 10s. redis-py >= 8 defaults socket_timeout to 5s, which tears down a consumer parked in BZPOPMIN on an idle stream — realtime.E002.
- MAX_REPLAY [int, default 500] — How far back a reconnecting client is caught up over the socket
  Widest resume gap replayed inline on a resumable stream, and the LIMIT passed to the module's get_replay_rows hook. A wider gap answers error{code=resync} and the client re-hydrates over HTTP pagination — there is no infinite rewind by design.
- SEND_QUEUE_SIZE [int, default 100] — How far a slow browser may fall behind before it is dropped
  Outbound frames buffered per socket before the substrate closes 4413. The producer never waits on a slow client; the client resyncs on reconnect.
- URL_PREFIX [str, default "ws"] — URL prefix for socket routes
  The edge convention every module's socket routes live under (/ws/<mod>/...), asserted by realtime.W004 so the generated route table stays a projection of the manifests.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- deny — stapel_realtime.authorize.deny
  The default authorize hook: refuse, and log which stream a consumer forgot to gate. Fail-closed is the whole design — an open default is a leak that looks like working code in every test a module writes for itself. Do not call it; know that it is what you get by not overriding authorize().
- revoke — stapel_realtime.delivery.revoke
  instead of: waiting for the client to reconnect before re-checking access
  Kick one user (or everyone) off a stream immediately when their access ends — call it from the module's @on_action subscriber on the membership Action. The subscriber gets a `kick` frame and close 4410. This is the answer to the main leak vector: a socket that keeps streaming after the user was removed. Without it the residual window is the 30-second capability cache.
### predicate
- close_code_name — stapel_realtime.close_codes.close_code_name
  Machine name for a close code (4403 -> 'forbidden'). Use it in logs and in a client's reconnect switch instead of comparing bare numbers; TERMINAL_CLOSE_CODES next to it names the ones a client must not retry with the same credentials.
- user_id_from_scope — stapel_realtime.authorize.user_id_from_scope
  The connecting user's pk, or None for an anonymous scope — the one place an authorizer should read identity from, so 'authenticated' means the same thing in every module's hook.
### factory
- build_websocket_application — stapel_realtime.asgi.build_websocket_application
  instead of: a hand-written ProtocolTypeRouter/URLRouter/AuthMiddlewareStack in a host's asgi.py
  Assemble the host's whole ProtocolTypeRouter in one call: origin guard (compared WITH the port) over core's G14 JWT stack over a URLRouter of every installed module's routing manifest. This is what a host's asgi.py should contain instead of a hand-written stack — the three existing hand-written ones are why the fleet has three different auth and close-code conventions.
- collect_websocket_urlpatterns — stapel_realtime.asgi.collect_websocket_urlpatterns
  instead of: a hand-kept list of module routing imports in the host
  Discover every installed app's `<package>.routing.websocket_urlpatterns`. Libraries carry the manifest and the assembly reads it, so a scaffold can emit an ASGI file without knowing any module by name — and a routing module that fails to import raises instead of silently contributing no routes.
- deliver — stapel_realtime.delivery.deliver
  instead of: a hand-rolled async_to_sync(get_channel_layer().group_send) in a module
  The transport callable the core's STAPEL_COMM['SIGNAL_TRANSPORT'] = 'channels' resolves to: transport(stream_key, frame), called after commit with the complete envelope stapel_core.comm.signal() built, forwarded to subscribers verbatim. At-most-once and best-effort — no layer, no subscriber or a dead redis all mean the frame is dropped and nothing raises. Emit with comm.signal(); call this directly only when wiring a transport by hand.
- deliver_frame — stapel_realtime.delivery.deliver_frame
  instead of: a module's own broadcast_message() over the channel layer
  Fan out one JOURNAL frame (carrying the persisted row's seq) to a resumable stream, after the row is committed. Use this — not deliver() — whenever the frame corresponds to a row a client could replay: the seq is what lets ResumableStreamConsumer deduplicate the replay/live overlap.
- error_frame — stapel_realtime.envelope.error_frame
  Build the `error` envelope — a machine `code` (bad_envelope / bad_type / unauthorized) plus a human `message`. Refusals only; the resume-gap verdict is a `resync` FRAME, not an error, because re-hydrating is a normal instruction and the socket stays open.
- frame — stapel_realtime.envelope.frame
  instead of: a hand-written dict literal sent with send_json
  Build a v1 wire envelope {v, type, payload, seq?, stream?}. Every frame on a Stapel socket goes through this, so the version and the optional-field discipline (no seq on ephemeral frames) hold everywhere.
- group_name — stapel_realtime.streams.group_name
  Translate a stream key into its Channels group name (':' is not a legal group character). Long keys fold into a digest rather than being truncated, because truncation would make two streams share one group. Only a host writing its own transport needs this; deliver()/revoke() call it for you.
- normalize_origin — stapel_realtime.asgi.normalize_origin
  Canonicalize an Origin header or allowlist entry to scheme://host[:port], lower-cased with the scheme's default port elided. The port is identity: an allowlist entry of 'studio.localhost' never matching 'http://studio.localhost:8600' is the incident the origin guard and realtime.E003 exist for.
- open_stream — stapel_realtime.testing.open_stream
  instead of: a hand-wired channels.testing.WebsocketCommunicator with a hand-built scope
  Connect a test socket to a consumer with an already-authenticated scope, returning a StreamClient that speaks envelopes and swallows heartbeat noise. A module testing its consumer should use this instead of wiring a WebsocketCommunicator by hand — with expect_accept=False it is also how a fail-closed authorize is asserted.
- parse_frame — stapel_realtime.envelope.parse_frame
  Validate an inbound envelope into a Frame dataclass, raising InvalidEnvelope rather than returning a half-trusted dict. Note the asymmetry it encodes: an unknown frame TYPE parses (the consumer answers bad_type), an unknown envelope VERSION does not (the shape underneath is unknown).
- parse_stream_key — stapel_realtime.streams.parse_stream_key
  Parse a canonical key into its module/scope_type/scope_id/topic parts, raising InvalidStreamKey on anything else. This is how an authorizer learns which workspace it is being asked about — a consumer that cannot parse its own stream key must close the socket, not guess a scope.
- register_transport — stapel_realtime.delivery.register_transport
  Register deliver() into the core's signal seam under the name 'channels'. Called from this package's AppConfig.ready(); a host that composes app config by hand and wants the axis to resolve calls it itself. Registration is not activation — the host still selects the transport, and the default stays 'none'.
- workspace_stream — stapel_realtime.streams.workspace_stream
  Shorthand for the common shape, <module>:ws:<workspace_id>[:<topic>] — the key form the WorkspaceCapability authorizer understands.

## Extension points — what a product replaces, fork-free
- ChannelsSignalTransport [transport]
  The v1 signal-delivery backend the core's STAPEL_COMM['SIGNAL_TRANSPORT'] axis names. Callable and .send()-able, so either resolution convention in the core works. Swap it for a dotted path to your own object to move Signal onto a different bus.
- EphemeralStreamConsumer [base_class]
  Subclass for Signal delivery: set module/scope_type/stream_key_kwarg (or override get_stream_key) plus an authorizer, and the socket is done. No hooks required — there is no journal.
- OriginGuard [middleware]
  The ASGI origin allowlist build_websocket_application() installs. Instantiable directly with explicit `allowed_origins` when a host composes its own stack.
- ResumableStreamConsumer [base_class]
  Subclass for a journal with catch-up. Two async hooks: get_server_seq() and get_replay_rows(after_seq, limit) -> Sequence[JournalRow]. The welcome, the bounded replay, the resync verdict and the replay/live dedup are the base class's.
- authorize() [required_hook]
  The per-stream authorization seam on every consumer. FAIL-CLOSED: a subclass that does not override it (or set `authorizer`) subscribes nobody. Set `authorizer = WorkspaceCapability('<mod>.read')` for a ws-scoped stream, or write an async (scope, stream_key) -> bool of your own (MODULE.md 'Extension points').
- stapel_realtime.testing [test_harness]
  open_stream()/StreamClient — the envelope-aware Channels test client a module uses to test its own consumer, instead of the fourth hand-wired WebsocketCommunicator.

## Fits with — fleet dependencies
- stapel-core (required) — AppSettings config layer (conf.py), the G14 Channels JWT middleware reused by build_websocket_application and its close-code constant (asgi.py, close_codes.py), and require_capability for the workspace authorizer (authorize.py). The non-fleet dependencies are extras: `channels` to SERVE a socket (the envelope, stream keys and the delivery seam work without it — delivery degrades to a silent no-op, which is the contract) and `channels-redis` for cross-replica fan-out (more than one worker without it is realtime.E001).
