Files
alkhttp/tasks/gateway/review-001-publish-schema-validation-robust.md
T
glm-5.3-flash 1572a9d2d0 fix(gateway): fail-closed publish_schema compile + compile-once cache (GW-01 follow-up)
- new gateway::schema_cache — PublishSchemaCache: compile the op's
  publish_schema once per registration (value-keyed invalidation for
  hot reload), cache compile failures (logged once at error level,
  never retried per request)
- /publish compile failure is now fail-closed: the chunk stream
  terminates with INTERNAL (500), the error text stays in the log
  (no schema internals on the wire) — the per-request warn-and-skip
  unvalidated ingest path is removed
- schema resolution is lazy (first chunk poll, after invoke_sink's
  404/403/422 pre-checks — GW-11 order preserved) and keyed by schema
  value, so re-registration/hot reload is picked up (test)
- NdjsonChunkStream: first Err item is terminal (done + stream end),
  mirroring the wire pump's send(Err) + break — Ok chunks can never
  follow an error on the HTTP path either (found by spy-handler test)

Verified: cargo test (308), cargo test --all-features, clippy
--all-targets -D warnings (default + all-features), fmt --check.

Tasks: review-001-publish-schema-validation-robust
2026-08-30 07:02:27 +00:00

132 lines
6.8 KiB
Markdown

---
id: review-001-publish-schema-validation-robust
name: Fix /publish schema validation fail-open + per-request recompilation (post-remediation)
status: completed
depends_on: []
scope: narrow
risk: high
impact: component
level: implementation
tags: [gateway, review-001, follow-up]
---
## Description
Follow-up to review-001-gateway-publish-semantics (GW-01): the landed
fix compiles the op's `publish_schema` **on every `/publish` request**
(`src/gateway/routes.rs:254-268`) and, when compilation fails, logs a
warning and proceeds with **no validation** — chunks flow to the handler
unvalidated. Two remediation-introduced problems:
- **Fail-open on compile failure** — this reintroduces, conditionally,
the exact transport-dependent invariant GW-01 closed: a handler that
registered a `publish_schema` (or a remote import whose schema is
un-compilable by jsonschema 0.46's dialect) silently receives
arbitrary JSON over the HTTP path that the wire path would have
aborted. A registry with one broken schema becomes an unvalidated
ingest path with only a `tracing::warn` as the tell.
- **Per-request compile cost** — `jsonschema::options().build()` on the
hot path is CPU + allocation per publish call; a hostile or merely
busy caller multiplies it.
Fix direction (implementer's choice on mechanism): compile **once per
registration** — cache the compiled `Validator` (or the compile error)
against the op in the gateway state, invalidated on re-registration —
and make compile failure **loud and closed**: the request path surfaces
a server-fault (500, `INTERNAL`-class with the compile error logged at
error level), never a silent skip. Consider whether adapters can reject
un-compilable `publish_schema` values at import/registration time so
the failure lands before any traffic (adapter-side validation where the
schema originates), with the gateway cache as defense in depth.
## Acceptance Criteria
- [x] Compiled validator (or error) cached per registration; no per-request recompile (test: compile count / perf shape not asserted, but code path is registration-keyed)
- [x] Un-compilable `publish_schema` → request fails loudly (500/INTERNAL, error logged), chunks never flow unvalidated (test)
- [x] Schema re-registration (hot reload) picks up the new schema (test)
- [x] A `publish_schema`-registered Pub op still rejects an invalid chunk (existing GW-01 gate stays green)
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
## References
- docs/reviews/001-initial-implementation-review.md (Part C, GW-01)
- tasks/gateway/review-001-gateway-publish-semantics.md (the landed fix + rationale)
- src/gateway/routes.rs:254-268 (the fail-open)
## Notes
Mechanism: new `src/gateway/schema_cache.rs`
`PublishSchemaCache`, a cloneable `Arc<RwLock<HashMap<String,
CacheEntry>>>` held on `RouterState` (built empty per
`HttpAdapter`/router construction; `GatewayState` extracts it via
`FromRef`). Each entry stores the compiled `Arc<jsonschema::Validator>`
(or a `Failed` marker) **plus the raw schema `Value`** that produced
it. Lookup compares the registry's current `publish_schema` value
against the cached one: equal → serve the cached validator/failure;
different → recompile. This gives hot-reload correctness by value
equality without lifetime-coupling to registry internals; under the
documented assemble-then-serve invariant (the same one
`CachedOpenAPIDoc` relies on) each schema compiles exactly once per
process, and the value check is defense in depth for re-assembly
paths.
Fail-closed semantics: a failed compile is **cached as a failure**
the error is logged once at `error` level (never retried or re-logged
per request), and the wire message is a generic INTERNAL naming the
compile failure without echoing schema internals (an untrusted
schema's error text must not reach the wire; same discipline as the
`/openapi.json` cache-miss 500).
Resolution is **lazy**, resolved against the cache inside the chunk
stream's first poll, not in the handler prologue: `invoke_sink` owns
the 404/403/422 pre-checks (GW-11), so an unknown/internal/wrong-type
op is rejected before any schema lookup or compile happens — no cache
population or error-log spam from ops a caller cannot reach.
Stream terminality fix (found by the spy-handler test): the old
`NdjsonChunkStream` yielded `Err` items but kept streaming — a handler
that drained the stream (instead of aborting on first `Err`) would
have kept receiving **unvalidated** chunks after a violation or a
compile failure. The wire pump (`alkcall dispatch.rs:599-600`) does
`send(Err)` + `break`, i.e. the error item is terminal and the channel
closes. `NdjsonChunkStream` now mirrors that exactly: the first `Err`
item (schema violation, bad JSON, read failure, or compile failure)
sets `done` and the stream ends — `Ok` chunks can never flow after an
error on this transport either.
Adapter-side registration-time rejection was considered and **not**
pursued here: alkcall's `OperationRegistry::register` does not observe
`publish_schema` compilability, and adding that would be an alkcall
surface change (this crate consumes `alkcall = "0.1.1"` from crates-
io) — the gateway cache is the defense in depth the task prescribed.
Worth noting for alkcall: the identical compile-fail-open pattern
exists on the wire path (`alkcall/src/protocol/dispatch.rs:356-366`,
warn-and-skip) — upstream fix candidate, out of scope for this crate.
## Summary
Implemented in three parts:
- **`src/gateway/schema_cache.rs`**: `PublishSchemaCache` — compile-
once, value-keyed invalidation, cached failures, `error!`-level compile
logging, generic non-leaking wire error (`CompileFailed::call_error`
`CallError::internal`). 5 unit tests (no-schema/unknown-op →
`Ok(None)`; compile+validate; `Arc::ptr_eq` cache identity; failure
stays failed).
- **`src/gateway/routes.rs`**: `publish_handler` uses the cache;
`PublishSchemaState` (Unresolved → Unvalidated/Validated/Failed)
resolves lazily on first chunk poll, after `invoke_sink`'s
pre-checks; `NdjsonChunkStream` is terminal-on-first-`Err` (wire
parity). Route tests: uncompilable schema → 500 `INTERNAL` with
non-echoing message; spy handler receives 0 `Ok` chunks and exactly
one terminal `INTERNAL` error; hot-reload re-registration picks up
the replacement schema (validator identity + semantics asserted);
all pre-existing GW-01 gates (reject-invalid, accept-valid,
first-chunk validation, no-schema passthrough) stay green.
- **`src/server/{state,adapter}.rs`** + `gateway/mod.rs`: cache plumbed
through `RouterState`/`GatewayState` at all construction sites.
Verification: `cargo test` 308 passed (default), `--all-features` all
suites green (379+), `cargo clippy --all-targets -- -D warnings`
clean (default + all-features), `cargo fmt --check` clean.
`--no-default-features` warnings are pre-existing on the base commit.