Skip to content

feat(config): derive and publish the batten.toml JSON Schema, gate min_batten_version (CLOUD-33) - #152

Merged
wenzowski merged 1 commit into
mainfrom
claude/cloud-33-config-schema-version-gate
Aug 8, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/cloud-33-config-schema-version-gate

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-33.

Why

Two halves of one promise: the config surface should be machine-validatable, and a binary too old for a config should say so rather than guess.

The schema is derived, never hand-authored

batten generate schema emits schema_for!(Config) — generated from the very types parse deserializes into, so it cannot describe a batten.toml the binary would refuse, nor miss a key it accepts (DoR §1).

Rather than trust the generator, the agreement is asserted directly. The schema is checked to accept the committed batten.example.toml and this repository's own batten.toml, and to reject the unknown key and the missing severity that deny_unknown_fields and the parser already reject. That last pair is the property that makes publishing safe: a schema an editor accepts but batten check refuses is the worst of both.

Published, not merely committed

A schema only a validator can reach is published; one that sits in the tree is a build artifact.

Both halves ship:

  • .taplo.toml binds batten.toml / batten.example.toml / batten.local.toml to the schema. taplo lint picks it up through auto-config, and so does any editor with the taplo LSP. Verified against a fixture with a bogus key:
    error: Additional properties are not allowed ('not_a_real_key' was unexpected)
      ┌─ batten.toml:2:18
    
  • release-artifacts.yml gains a schema job uploading batten.schema.json as a release asset, so a consumer writes the same rule against .../releases/latest/download/batten.schema.json. It is its own job, not a matrix step — the schema is platform-independent, so uploading it seven times would race seven writers onto one asset name.

path rather than that URL in this repo because this repo is the source: pointing at a release would validate the working tree against the last tag, so a key added in an unreleased commit would read as invalid.

The drift gate

mise run schema-check regenerates and diffs the committed copy byte-for-byte (DoR §4), glob-scoped in hk.pkl to the config types plus schema/. It carries more weight than a usual drift gate: nothing in the Rust build reads the committed file, and the artifact is published, so a stale one does not merely go unused — it tells a consumer their valid config is invalid, or waves through one batten check will refuse.

The min_batten_version gate

Enforced at parse time, so no verb can route around it by picking a different command (asserted across check, enforce, and config show).

Situation Exit
binary older than min_batten_version 1
equal or newer 0
key absent 0
unparseable version 1

Exit 1, not 2 — and note this differs from the issue's Ready block, which predates CLOUD-226. "This binary is too old" is a statement about the invocation, never a verdict about the repository; a harness reading 2 would report a policy denial that never happened (§7). I've patched the stale numbers in the issue body, preserving the semantic class (Usage) it actually specified.

An unparseable version is refused, not skipped: "cannot compare" is not "compatible", and skipping on a typo would silently disable the gate.

The override layer parses ungated

An existing test — resolve::the_local_file_may_not_restate_an_authority_only_key — caught a real design error. Putting the gate in parse meant it also fired on batten.local.toml, where min_batten_version is an authority-only key that resolve already refuses outright. The version message fired first and replaced the useful one: an author who set the key in the wrong file was told their binary was too old. Split into config::load_override / parse_override so the override layer parses ungated and the specific refusal speaks.

Dependencies

schemars 1 and semver 1 (both permissive, MSRV well under our 1.85). jsonschema 0.49 is a dev-dependency with default-features = false — its defaults pull reqwest + rustls + aws-lc-rs, which would bloat the tree and risk deny.toml's license allow-list, and a validator has no business fetching over the network. mise run deny is green: advisories, bans, licenses, sources all ok.

Tests

  • crates/batten/tests/config_schema.rs (new file — tests/cli.rs is the exit-code suite and other work appends to it): 13 E2E tests over the compiled binary covering everything above, plus byte-stability across runs and generate schema writing no file when run from a scratch directory.
  • tests/schema-check.bats (new): the gate's decision table, asserting it red as well as green — drift, missing schema, pointer-only output, and that it leaves the tree it judges unmodified (a gate that rewrites what it judges cannot fail twice).

mise run verify green on this SHA, rebased on current origin/main.


Generated by Claude Code

…n_batten_version (CLOUD-33)

Two halves of the same promise: the config surface is machine-validatable,
and a binary too old for a config says so instead of guessing.

The schema is DERIVED from the Rust config types, never hand-authored
(DoR §1). `batten generate schema` emits `schema_for!(Config)`, so the
schema cannot describe a `batten.toml` the binary would refuse, nor miss
a key it accepts. Three tests assert that agreement directly rather than
trusting the generator: the schema accepts the committed example and this
repository's own config, and rejects the unknown key and the missing
`severity` that `deny_unknown_fields` and the parser already reject.

Published, not merely committed. A schema only sitting in the tree is a
build artifact; one a validator can reach is published. Both halves ship:
a `.taplo.toml` rule binds `batten.toml` to the local copy — verified to
reject an unknown key with a `path:line` pointer — and a new
`release-artifacts` job uploads the schema as a release asset, so a
consumer binds the same rule to the released URL and is validated in the
editor rather than first at `batten check` time. `path` locally rather
than that URL because this repo IS the source: pointing at a release
would validate the working tree against the last tag.

`mise run schema-check` regenerates and diffs the committed copy
byte-for-byte, glob-scoped in hk to the config types plus `schema/`. It
matters more than a usual drift gate because nothing in the Rust build
reads the committed file, and the artifact is published: a stale schema
does not go unused, it tells a consumer their valid config is invalid.

`min_batten_version` is enforced at parse time, so no verb can route
around it. Below it is exit 1, not 2: "this binary is too old" is a
statement about the invocation, never a verdict about the repository, and
a harness reading 2 would report a policy denial that never happened
(§7). Equal-or-newer runs. An unparseable version is refused rather than
skipped — "cannot compare" is not "compatible", and skipping on a typo
would silently disable the gate.

The override layer parses ungated (`load_override`). `min_batten_version`
is authority-only and `resolve` already refuses a `batten.local.toml`
that sets it; gating there fired first and replaced that refusal with
"this build is too old" — true of the value, useless about the mistake.
An existing resolve test caught exactly this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119C7XbeQLWR5TTaW2ca6Gp
@linear-code

linear-code Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
CLOUD-33 Publish a JSON schema for `batten.toml` and enforce `min_batten_version` behavior

Why
The config surface should be machine-validatable, and version gating should distinguish between too-new and acceptable binaries.

Acceptance

  • The schema validates the example config
  • A binary older than min_batten_version hard-errors
  • An equal or newer binary runs

Refinement — Ready (schema is generated + drift-gated; version gate is an exit-code predicate)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The one authoritative artifact is the Rust config types behind batten.toml. The JSON Schema is derived, never hand-authored: batten generate schema (aliased as mise run schema) emits schema/batten.schema.json from those types. min_batten_version is read from batten.toml; nothing is re-typed.
  • Computable predicate (§2). mise run schema-check regenerates the schema and git diff --exit-codes it against the committed copy — drift is a non-zero exit. Wired into the hk gate step (glob-scoped to the config types + schema/) and run by mise run ci.
  • Effect (§3). Schema emission is read (inspection-only, no writes to the repo under check). Declared in the single effect table; the read-only allowlist picks it up by derivation.
  • Published, not merely committed (§1). A schema only a validator can reach is published; one that sits in the tree is a build artifact. The generated schema must be resolvable by an editor/CI TOML validator so a consumer's batten.toml is checked where it is edited, not first at batten check time — a released URL, and a .taplo.toml schema.path/schema.url rule in this repo binding batten.toml to it. This repo is consumer ci: check in the main-branch protection ruleset #1, so its own batten.toml is validated by the schema it ships.
  • Output & exit (§5). Schema-validation failures are pointer-only: path:line + failing key + a count, never a dump of the offending config. A binary older than min_batten_version hard-errors Usage (1) — an incompatible config this binary cannot honor; an equal-or-newer binary runs (Success 0). A too-new config is the same class as an unreadable one (Usage 1), not a Violation (2).
    • Renumbered 2026-08-08: this clause was written before CLOUD-226 harmonized the table. The semantic classes are unchanged — Usage for an incompatible or unreadable config — but Usage is now 1 and Violation is 2*, per house-style §7 and DoR §5 ("*2 is the policy verdict wherever it appears").
  • Commit / bump (§6). feat → patch until 0.1.0 (DoR §6: below 0.1.0 release-plz bumps the patch whatever the type says).
  • Test obligation (§7). E2E over the compiled binary (a new tests/config_schema.rs, not tests/cli.rs — other bundles append to that file): (a) the schema validates the committed example batten.toml; (b) a fixture whose min_batten_version exceeds the build's version exits 1; (c) an equal/newer one exits 0; (d) the drift gate fails on a stale committed schema.
  • Blockers (§8). blockedBy CLOUD-29 (the loader supplies the parsed batten.toml this validates and the min_batten_version field this compares). Refinable now; implement after CLOUD-29 lands.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 8, 2026 05:14
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit b837ac4 into main Aug 8, 2026
11 checks passed
@wenzowski
wenzowski deleted the claude/cloud-33-config-schema-version-gate branch August 8, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants