cli: publish JSON Schemas for the machine surface #180

Merged
natsukium merged 4 commits from feat/cli-bridge-schemas-29 into main 2026-09-07 15:58:30 +09:00
Owner

Fixes #29.

The CLI's --format json / --format jsonl objects and the felis bridge protocol had only serde structs and a reference page, while the protobuf wire and config.toml both shipped machine-readable schemas. A consumer could not validate its requests or the bridge's outputs on its own.

  • crates/felis-cli/schemas/felis-cli-v1.schema.json and felis-bridge-v1.schema.json are rendered from the serde types (cli_schema.rs), so a field's schema and its serialization stay one edit apart and staleness is a failing test. Only the envelope is composed by hand — the CLI and the bridge wrap the same bodies differently, and serde has no type for "which body under which key".
  • error.kind is published as an open string, not an enum, so a validator built from an older bundle still accepts a token minted later.
  • The end-to-end suites now validate every object the binary really writes against the bundle; negative fixtures under tests/fixtures/schema-invalid/ carry what a live run cannot produce, and the request half is also fed to a live bridge so the schema and the binary draw one line.
  • Golden JSONL conversations under tests/fixtures/bridge/ pin what JSON Schema cannot state: one terminal per request id, and nothing after it. They are diffed per request id with session ids, timestamps and prose masked.
  • Integer fields now carry the width's own minimum/maximum (schemars emits an explicit range only up to 16 bits, and format is non-normative in draft 2020-12), and the spawn-geometry pairing rule is stated as a two-state oneOf rather than dependentRequired, which mismatched the bridge's null-as-absence reading.

Doc cascade: docs/reference/cli.md (schema files and their $defs), docs/reference/testing.md (the validation layer, fixture layout, regeneration recipes), docs/reference/ipc.md, docs/explanation/architecture/control-surfaces.md (why the bundle is generated rather than written, and why error.kind is open), CHANGELOG.md, justfile, skills/felis/SKILL.md, .agents/skills/extend-ipc/SKILL.md.

Verified: just check green (fmt, clippy, nextest, deny, proto-compat); reviewed by pi luna + pi sol (round 1) + pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high

Follow-ups

  • #166 [v0.1.x/CLI] Cover the bridge's stdout-loss path beside the golden conversations
  • #178 Request schema accepts source and attachment strings the runtime rejects, so schema-valid requests can still fail as malformed
  • #179 test: u64/u32 upper-bound schema tests never pin the actual boundary because boon compares numbers as f64

Deferred

  • Replace the bridge's hand-rolled Params accessors with per-op #[derive(Deserialize)] structs so the runtime and the published request schema share one type — a pure refactor: the binary already refuses unknown fields and parameters, and the guard test pins the published property sets to the runtime table.
  • The bridge's cancel result ({"canceled": bool}) is declared inline in the bridge document rather than derived from a type — it is the one body no CLI verb produces and it has no serde struct in the codebase.
  • No golden JSONL conversation pins the stdout-loss terminal path — issue #29's own Labels section permits deferring the stdout-loss (and reordered-reply) golden conversation to v0.1.x without reopening the contract, provided #21's stdout-loss behavior has its own coverage, which it does (cli_bridge.rs:549 asserts the exit code). Tracked by #166; the lower-severity duplicate finding on the same gap is merged into it.
  • The null-as-absence spawn-geometry regression test omits the two singleton-null cases ({"rows": null} / {"cols": null}) — minor extra edge-case coverage beyond any numbered acceptance criterion; the existing cases already cover the documented oneOf shape.
Fixes #29. The CLI's `--format json` / `--format jsonl` objects and the `felis bridge` protocol had only serde structs and a reference page, while the protobuf wire and `config.toml` both shipped machine-readable schemas. A consumer could not validate its requests or the bridge's outputs on its own. - `crates/felis-cli/schemas/felis-cli-v1.schema.json` and `felis-bridge-v1.schema.json` are rendered from the serde types (`cli_schema.rs`), so a field's schema and its serialization stay one edit apart and staleness is a failing test. Only the envelope is composed by hand — the CLI and the bridge wrap the same bodies differently, and serde has no type for "which body under which key". - `error.kind` is published as an open string, not an enum, so a validator built from an older bundle still accepts a token minted later. - The end-to-end suites now validate every object the binary really writes against the bundle; negative fixtures under `tests/fixtures/schema-invalid/` carry what a live run cannot produce, and the request half is also fed to a live bridge so the schema and the binary draw one line. - Golden JSONL conversations under `tests/fixtures/bridge/` pin what JSON Schema cannot state: one terminal per request id, and nothing after it. They are diffed per request id with session ids, timestamps and prose masked. - Integer fields now carry the width's own `minimum`/`maximum` (schemars emits an explicit range only up to 16 bits, and `format` is non-normative in draft 2020-12), and the spawn-geometry pairing rule is stated as a two-state `oneOf` rather than `dependentRequired`, which mismatched the bridge's null-as-absence reading. Doc cascade: `docs/reference/cli.md` (schema files and their `$defs`), `docs/reference/testing.md` (the validation layer, fixture layout, regeneration recipes), `docs/reference/ipc.md`, `docs/explanation/architecture/control-surfaces.md` (why the bundle is generated rather than written, and why `error.kind` is open), `CHANGELOG.md`, `justfile`, `skills/felis/SKILL.md`, `.agents/skills/extend-ipc/SKILL.md`. Verified: just check green (fmt, clippy, nextest, deny, proto-compat); reviewed by pi luna + pi sol (round 1) + pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high ## Follow-ups - #166 [v0.1.x/CLI] Cover the bridge's stdout-loss path beside the golden conversations - #178 Request schema accepts `source` and `attachment` strings the runtime rejects, so schema-valid requests can still fail as malformed - #179 test: u64/u32 upper-bound schema tests never pin the actual boundary because boon compares numbers as f64 ## Deferred - Replace the bridge's hand-rolled `Params` accessors with per-op `#[derive(Deserialize)]` structs so the runtime and the published request schema share one type — a pure refactor: the binary already refuses unknown fields and parameters, and the guard test pins the published property sets to the runtime table. - The bridge's `cancel` result (`{"canceled": bool}`) is declared inline in the bridge document rather than derived from a type — it is the one body no CLI verb produces and it has no serde struct in the codebase. - No golden JSONL conversation pins the stdout-loss terminal path — issue #29's own Labels section permits deferring the stdout-loss (and reordered-reply) golden conversation to v0.1.x without reopening the contract, provided #21's stdout-loss behavior has its own coverage, which it does (`cli_bridge.rs:549` asserts the exit code). Tracked by #166; the lower-severity duplicate finding on the same gap is merged into it. - The null-as-absence spawn-geometry regression test omits the two singleton-null cases (`{"rows": null}` / `{"cols": null}`) — minor extra edge-case coverage beyond any numbered acceptance criterion; the existing cases already cover the documented `oneOf` shape.
The protobuf wire and config.toml both had machine-readable schemas;
the CLI's `--format json` / `jsonl` objects and the `felis bridge`
protocol had only serde structs and a reference page, so a consumer
could not validate requests or outputs on its own, and the prose
drifted from the structs it described.

Rendering the bundle from the serde types keeps a field's schema and
its serialization one edit apart and makes staleness a failing test.
Only the envelope is composed by hand, because that is where "which
body under which key" and "`v` is the literal 1" live and serde has no
type for it: the CLI and the bridge wrap the same bodies differently.

`error.kind` is published as an open string rather than an enum so a
validator built from an older bundle still accepts a token minted
later, which is the fallback the additive vocabulary exists to allow.

Refs #29

Assisted-by: Claude Code
A committed schema no test reads is a second contract that drifts from
the first. Every object the end-to-end suites already parse is now
validated against the bundle, so the objects the binary really writes
are the positive fixtures; the negative fixtures carry what a live run
cannot produce, and the request half of them is fed to a live bridge
too, so the schema and the binary draw one line.

An output fixture names the class it claims rather than validating
against the root union: payload objects stay open for additive growth,
so a malformed object can otherwise satisfy a laxer sibling.

The golden conversations pin what JSON Schema cannot state: one
terminal per id, and nothing after it. They are diffed per request id
with session ids, timestamps and prose masked, because a byte-exact
transcript would flake on interleaving the bridge is free to choose.

Refs #29

Assisted-by: Claude Code
A consumer that wants to validate rather than trust needs to be told
the files exist and what their `$defs` mean, and the reference page is
where a script author already reads the envelope table. The testing
page gains the layer, since the fixture layout and the regeneration
recipes are the part a contributor has to find before touching an
output type.

The explanation twin records the one decision a contributor would
plausibly re-litigate: generating the bundle rather than writing it,
and publishing `error.kind` open rather than as an enum.

Refs #29

Assisted-by: Claude Code
cli: bound the published schemas to what the surface accepts
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m8s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m9s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 3m4s
pr / nix flake check (pull_request) Successful in 1m55s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m1s
windows / frontend smoke (Windows) (pull_request) Successful in 2m1s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (push) Has been skipped
pr / wire schema is compatible with the base (pull_request) Successful in 10s
windows / cargo nextest (Windows) (pull_request) Successful in 9m23s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m44s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 25s
pr / wire schema is compatible with the base (push) Successful in 9s
windows / cargo clippy (Windows cross) (push) Successful in 12s
pr / publish felis (x86_64-linux) (push) Successful in 11s
windows / frontend smoke (Windows) (push) Successful in 1m37s
darwin / build felis (aarch64-darwin) (push) Successful in 14s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m11s
pr / nix flake check (push) Successful in 7s
pr / cargo build / clippy / test / deny (push) Successful in 1m43s
pr / frontend smoke (x86_64-linux) (push) Successful in 5s
windows / cargo nextest (Windows) (push) Successful in 7m57s
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 1m47s
105b089979
Two constraints the bundle claimed to state were unenforceable by a
consumer's validator.

`format` is a non-normative annotation in draft 2020-12 and schemars
emits an explicit range only up to 16 bits, so every `u32`, `u64` and
`i64` field published a lower bound and no upper one: a `sessions` count
of 2^32 or a capture `lines` of 2^64 validated against a document that
describes a Rust integer which cannot hold them. The rendered document
now carries the width's own `minimum`/`maximum`.

`dependentRequired` keys on a property being present, while the bridge
reads an explicit `null` as absence. The grammar therefore refused
`{"rows": null}`, which the bridge serves on the default grid, and
admitted `{"rows": null, "cols": 80}`, which it answers
`invalid_request`. The two-state pairing rule is stated directly
instead, and the reference no longer promises `malformed_request` for
the one cross-field rule the bridge answers otherwise.

Refs #29
natsukium deleted branch feat/cli-bridge-schemas-29 2026-09-07 15:58:31 +09:00
Sign in to join this conversation.
No description provided.