gateway-publish:
- GatewayDispatch::invoke_sink (internal:false, forwarded_for:None)
- POST /publish: NDJSON body, first line {operation, chunk} (OQ-02
resolved: first-line convention; terminal errors = plain HTTP status
+ JSON body, not NDJSON lines); 404 internal/unknown, 401/403 ACL,
400 INVALID_OPERATION_TYPE for non-Pub
- ADR-068 + open-questions.md updated with the OQ-02 resolution
adapter-to-openapi:
- src/adapters/openapi_spec.rs: OpenAPISpec model (JSON/YAML/from_str
JSON-first per ADR-051, $ref resolution) shared by from/to_openapi
- src/adapters/to_openapi.rs: 6-endpoint projection, info.version
1.0.0 -> 1.1.0 (minor: /publish addition per ADR-045), /publish
NDJSON doc with 400 oneOf (INVALID_INPUT + INVALID_OPERATION_TYPE),
ADR-023 error fidelity (protocol statuses, HTTP_<status> passthrough,
internal-op exclusion)
- GET /openapi.json wired into HttpAdapter's router (bearer-auth layer)
Verified: cargo test (136 lib), test --all-features (136+10 WS),
clippy -D warnings (both), fmt. Doc validates against openapiv3.
138 lines
6.0 KiB
Markdown
138 lines
6.0 KiB
Markdown
# 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 <token>` — 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_<status>` 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 |