- 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
6.8 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
| review-001-publish-schema-validation-robust | Fix /publish schema validation fail-open + per-request recompilation (post-remediation) | completed | narrow | high | component | implementation |
|
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 atracing::warnas 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
- Compiled validator (or error) cached per registration; no per-request recompile (test: compile count / perf shape not asserted, but code path is registration-keyed)
- Un-compilable
publish_schema→ request fails loudly (500/INTERNAL, error logged), chunks never flow unvalidated (test) - Schema re-registration (hot reload) picks up the new schema (test)
- A
publish_schema-registered Pub op still rejects an invalid chunk (existing GW-01 gate stays green) cargo testandcargo clippy --all-targets -- -D warningspass
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_eqcache identity; failure stays failed).src/gateway/routes.rs:publish_handleruses the cache;PublishSchemaState(Unresolved → Unvalidated/Validated/Failed) resolves lazily on first chunk poll, afterinvoke_sink's pre-checks;NdjsonChunkStreamis terminal-on-first-Err(wire parity). Route tests: uncompilable schema → 500INTERNALwith non-echoing message; spy handler receives 0Okchunks and exactly one terminalINTERNALerror; 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 throughRouterState/GatewayStateat 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.