# ADR-068: Gateway `/publish` Endpoint for Pub Operations ## Status Accepted ## Context alkcall added `OperationType::Pub` and `HandlerKind::Sink` (alkcall ADR-046): producer→consumer streaming, where the *initiator* streams chunks to the operation via `call.published` events and the handler consumes them as a `PublishStream`. This is the inverse of `Sub` (consumer→producer streaming of results) and completes the four operation types: `Query`, `Mutation`, `Sub`, `Pub`. The HTTP gateway ([ADR-042](042-openapi-gateway-pattern.md), [ADR-047](047-remove-direct-call-http-surface.md)) exposes five endpoints: `/search`, `/schema`, `/call`, `/batch`, `/subscribe`. The gateway's dispatch covers `Query`/`Mutation` (`/call`, `/batch`) and `Sub` (`/subscribe` via the SSE projection, [ADR-049](049-streaming-handler-for-subscriptions.md)). `Pub` operations have no HTTP expression: an HTTP client cannot feed a `Pub` operation's sink. Without a surface, `Pub` operations are call-protocol-only (WS channel 0 or a QUIC/`alk/channels` session). That leaves HTTP clients — curl, axios, a server-side script — unable to produce into `Pub` ops, which breaks parity: every operation type reachable over the call protocol should be reachable over HTTP, or the HTTP surface is not a faithful projection. ## Decision **The gateway gains a sixth endpoint: `POST /publish`.** It invokes a `Pub` operation; the HTTP request body is the initiator's publish stream; the operation's final `ResponseEnvelope` is the HTTP response. ### Request - Path: `POST /publish`. - Body: **newline-delimited JSON (NDJSON)** — each line is one published chunk, serialized as a JSON value; the stream of lines maps 1:1 to `call.published` events. Chunk boundaries are the line boundaries; a line's JSON value is the chunk payload. - Auth: `Authorization: Bearer ` — same as every gateway endpoint ([ADR-004](004-auth-as-shared-core.md)). - The target operation is named the same way as `/call` — the body's first line carries `{ "operation": "/{service}/{op}", "chunk": {...}` for the first chunk, with subsequent lines carrying `chunk` values only. (OQ-02 resolved: first-line convention, no query parameter, no header; a `?operation=` parameter was rejected because it duplicates the first-line field and complicates curl one-liners for no gain.) ### Dispatch 1. Resolve identity (Bearer → `resolve_from_token`). 2. Look up the operation; enforce `Visibility::External` (Internal → `404`, same as `/call`) and `AccessControl::check` (→ `403`). 3. Verify `op_type == Pub` — a non-`Pub` op is `INVALID_OPERATION_TYPE` → `400`. 4. Dispatch through `invoke_sink()` (alkcall ADR-046): stream each NDJSON line as one `call.published` chunk into the handler's `PublishStream`. 5. On end-of-body, deliver the handler's final `ResponseEnvelope`: - `Ok(output)` → `200` with the output as JSON. - `Err(call_error)` → mapped status per the standard error mapping ([ADR-023](023-operation-error-schemas.md), the gateway's `HTTP_` fidelity rules). ### Wire-shape note On the call protocol, the initiator's chunks are `call.published` events over channel 0 or a stream; abort is `call.aborted`. Over HTTP, the abort path is the request being cut short: the client closing the connection early drops the body stream — the dispatch cancels the sink (the handler's `PublishStream` sees EOF, matching write-half close semantics). ### `to_openapi` projection The published gateway doc ([ADR-045](045-to-openapi-gateway-spec-versioning.md)) describes `/publish` alongside the other five endpoints. The addition bumps the gateway contract's minor version. The per-caller operation surface remains discovered via `/search` (`Pub` ops are listed there); the doc does not preload operations. ### What does not change - `to_mcp` still exposes 4 tools and excludes both `Sub` and `Pub` ([ADR-041](041-mcp-tool-gateway-pattern.md); alkcall ADR-046) — MCP tool calls are request/response. - The WS path needs no `/publish` equivalent: a WS session's channel 0 carries native `call.published` events. - `from_openapi`/`from_jsonschema` produce no `Pub` operations in v1 (OpenAPI has no client-streaming representation). ## Consequences **Positive:** - The gateway is a faithful projection of the call protocol's four operation types; no operation type is HTTP-unreachable. - NDJSON is the natural HTTP encoding for a chunk stream (curl-able: `echo '{"chunk":1}' | curl --data-binary @- -X POST .../publish`). - The dispatch path is `invoke_sink()` — the same spine as `/call`'s `invoke()`, so the shared-dispatch invariants (identity, ACL, Internal filtering) hold by construction. **Negative:** - One more endpoint in the wire-stable gateway contract (version bump; one-way once published). - HTTP has no in-band abort message: a mid-stream failure is indistinguishable from a network error to the server side (the handler sees EOF either way). Callers needing explicit failure semantics use the call protocol (WS channel 0). - ~~OQ-02 (first-line operation-naming convention) must settle before the `/openapi.json` version bumps~~ — settled: first-line `{operation, chunk}` convention; terminal errors are plain HTTP status + JSON body (not an NDJSON line). ## References - [http-server.md](../http-server.md) — the gateway dispatch, `/publish` section - [http-adapters.md](../http-adapters.md) — the gateway endpoint table - [ADR-042](042-openapi-gateway-pattern.md) — the gateway pattern this extends - [ADR-045](045-to-openapi-gateway-spec-versioning.md) — version bump mechanics - [ADR-047](047-remove-direct-call-http-surface.md) — the gateway as sole invoke path (now 6 endpoints) - [ADR-023](023-operation-error-schemas.md) — error mapping - alkcall ADR-046 (Publish Operation Type and `HandlerKind::Sink`) — the `Pub`/`invoke_sink()`/`PublishStream` machinery this endpoint exposes - [open-questions.md](../open-questions.md) OQ-02 — first-line convention, error-envelope position