fix(gateway): publish validation + streaming + batch semantics (GW-01, GW-06, GW-08..GW-11, HY-13)

- GW-01: /publish validates every NDJSON chunk against the op's
  publish_schema (incl. the first-line chunk) via NdjsonChunkStream —
  terminal Err(INVALID_INPUT)/422 on violation, matching the wire
  dispatcher's per-chunk contract. Route-level fix; the alkcall spine
  was explored and rejected (wire validation is pump-side by design).
- GW-06: the body is streamed, not buffered — Body::into_data_stream()
  -> newline-framed BufferedLines -> lazily parsed chunk stream.
  ADR-068 documents the streamed semantics and the 2 MiB per-line cap.
- GW-08: /batch capped at 100 operations (INVALID_INPUT 400).
- GW-09: internal-op batch entries now carry generated UUID request ids.
- GW-10: first publish line missing `chunk` is rejected INVALID_INPUT.
- GW-11: redundant /publish pre-checks removed; enforcement rides on
  invoke_sink via the shared dispatch spine.
- HY-13: the vacuous stub test was replaced by a body-cut-short test.
- Adjacent: INVALID_OPERATION_TYPE now maps 422 (with identity) / 401
  (without) in error.rs — the route relies on the shared mapper since
  the pre-checks are gone (GW-03's finding; was a 500 fall-through).

Verification: cargo test 211 passed; cargo clippy --all-targets -- -D
warnings clean; cargo fmt --check clean.
This commit is contained in:
2026-08-29 08:25:11 +00:00
parent 42a2fa0ee3
commit d7ee302046
6 changed files with 613 additions and 151 deletions
@@ -67,6 +67,23 @@ stream; the operation's final `ResponseEnvelope` is the HTTP response.
([ADR-023](023-operation-error-schemas.md), the gateway's
`HTTP_<status>` fidelity rules).
### Body handling (streamed, not buffered)
The NDJSON body is **streamed, never fully buffered** (GW-06): axum's
`Body` is framed into lines as bytes arrive, and each line is parsed and
pushed into the sink lazily — memory is bounded by the per-line cap (2
MiB, matching axum's default whole-body limit), not by the unbounded
chunk count. `publish_schema` validation is applied per chunk inside the
sink-feeding stream (GW-01), so a Pub op registered with a
`publish_schema` enforces the same per-chunk contract over HTTP as over
the call protocol; a violation terminates the chunk stream with
`INVALID_INPUT` (→ 422), the exact item shape an initiator-side
`call.error` produces on the wire. A first line missing `chunk` (GW-10)
is rejected `INVALID_INPUT` before dispatch — indistinguishable from a
missing `operation`. The client disconnect abort path is unchanged: the
dropped body stream propagates EOF through the framed reader into the
sink's `PublishStream`.
### Wire-shape note
On the call protocol, the initiator's chunks are `call.published`
@@ -105,6 +122,10 @@ the doc does not preload operations.
- 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.
- The body is streamed line-by-line (memory bounded by the per-line
cap; backpressure inherited from the HTTP body), so a client
disconnect mid-stream cancels the sink exactly like a dropped
write-half on the call protocol.
**Negative:**
@@ -118,6 +139,11 @@ the doc does not preload operations.
the `/openapi.json` version bumps~~ — settled: first-line
`{operation, chunk}` convention; terminal errors are plain HTTP
status + JSON body (not an NDJSON line).
- The 2 MiB per-line cap (not a whole-body cap) bounds a single chunk;
the total number of chunks is unbounded. Handlers that would receive
unbounded streams over the wire get the same behavior over HTTP —
operators front the endpoint with the same body/timeout controls used
for any other streaming surface.
## References