# schema-camelcase-fix — Design Spec **Date:** 2026-05-21 **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude **Closes:** Gitea #30 ## Goal Replace the two camelCase JSON tags `paramTypes` and `retType` on `Term::Lam` with the kebab-case `param-types` and `ret-type`. These are the only camelCase outliers in the entire AST schema; every other compound-key tag (`reuse-as`, `drop-iterative`, etc.) is kebab-case and every atomic tag (`type`, `fn`, `else`, `in`, `as`) is a single lowercase word. The fix is a pure schema-tag rename in the canonical JSON layer. ## Architecture This is a schema-only milestone. Four layers are affected: 1. **Canonical JSON tag layer.** Two `#[serde(rename = "...")]` attributes on `Term::Lam` in `crates/ailang-core/src/ast.rs:492,494` change literal value: `"paramTypes" → "param-types"`, `"retType" → "ret-type"`. The Rust field names (`param_tys`, `ret_ty`) are unaffected. 2. **Surface (Form-A) layer.** **Unchanged.** Form-A prints lambdas as `(lam (params (typed NAME TYPE)*) (ret TYPE) (effects ...) (body TERM))`. There is no Form-A keyword `paramTypes` or `retType` to migrate — the print/parse code at `crates/ailang-surface/src/print.rs:502-528` and `crates/ailang-surface/src/parse.rs:1430-1468` uses positional sub-forms `(typed ...)` and `(ret ...)`, both of which already match the kebab/single-word convention. 3. **JSON source files.** Two `.ail.json` fixtures and one inline JSON literal in a Rust unit test embed the old tags directly and must be updated: - `examples/test_loop_binder_captured_by_lambda.ail.json` - `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` - `crates/ailang-core/src/workspace.rs:1925-1926` (Verified via `grep -rn '"paramTypes"\|"retType"' --include="*.rs" --include="*.json"` — exhaustive list. Each fixture carries exactly two occurrences of the two tags combined, one per line.) 4. **Canonical-schema contract + rustdoc layer.** Three rustdoc strings in production source describe the present-state schema (rather than implementation history); per the Honesty Rule (`design/contracts/0007-honesty-rule.md`) they must read truthfully after the rename: - `crates/ailang-core/src/ast.rs:8` — module-level `//!` enumerating example field renames. - `crates/ailang-surface/src/parse.rs:81` — module-level `//!` mentioning `lam`'s `paramTypes` carry. - `crates/ailang-check/src/lib.rs:1707` — rustdoc on the InstanceMethod routing helper. Plus the canonical data-model contract document itself, which ships fenced JSON blocks describing the schema verbatim: - `design/contracts/0002-data-model.md:142-147` — the `Term::Lam` fenced-JSON schema block. The data-model contract IS the canonical schema documentation (linked from `design/INDEX.md` row `data-model`, with ratifying test `crates/ailang-core/tests/design_schema_drift.rs`). Leaving it on the old tags after `ast.rs` ships kebab would be a direct Honesty-Rule violation. The experiment-tree under `experiments/2026-05-12-cross-model-authoring/` (master spec, renderings, run transcripts) carries the old tags in multiple places. These are frozen historical artefacts of the 2026-05-12 cross-model-authoring experiment — analogous to `docs/plans/*.md` under the Honesty Rule — and are NOT migrated by this milestone. The experiment's `master/examples/fn_with_lambda.ail.json` IS migrated (it is a checked-in JSON fixture the workspace loader could deserialise); the surrounding prose documents and run transcripts are not. **Hash blast radius is zero on pinned modules.** None of the `.ail` modules pinned in `crates/ailang-core/tests/hash_pin.rs` (`sum`, `list`, `ordering_match`, `test_22b1_dup_a`, `test_22b1_dup_classmod`) contains a `(lam ...)` term. Verified via `grep -c "(lam " examples/.ail` — all zero. The two `.ail.json` fixtures listed above are not hash-pinned. Therefore the milestone does not refresh a single hash pin and no Honesty-Rule hash-comment migration is required. ## Concrete code shapes ### The AILang program (Form-A — unchanged) The Form-A author surface is unchanged by this milestone. Authors write lambdas exactly as before. Worked example, lifted from `examples/closure.ail`: ``` (fn main (doc "captures `n` from the enclosing let, returns 42") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (let n 3 (app print (app apply (lam (params (typed x (con Int))) (ret (con Int)) (body (app + x n))) 39))))) ``` The lambda sub-form `(lam (params (typed x (con Int))) (ret (con Int)) (body (app + x n)))` reads consistently with the surrounding `(fn-type (params ...) (ret ...))` — Form-A was never the locus of the inconsistency. The Form-A round-trip (`crates/ailang-surface/tests/round_trip.rs`) must stay GREEN with zero diff to the printer/parser. ### The delivered code shape (canonical JSON — before → after) The same lambda's canonical JSON before this milestone: ```json { "t": "lam", "params": ["x"], "paramTypes": [{ "k": "con", "name": "Int" }], "retType": { "k": "con", "name": "Int" }, "effects": [], "body": { "t": "app", "callee": { "t": "var", "name": "+" }, "args": [{ "t": "var", "name": "x" }, { "t": "var", "name": "n" }] } } ``` After: ```json { "t": "lam", "params": ["x"], "param-types": [{ "k": "con", "name": "Int" }], "ret-type": { "k": "con", "name": "Int" }, "effects": [], "body": { "t": "app", "callee": { "t": "var", "name": "+" }, "args": [{ "t": "var", "name": "x" }, { "t": "var", "name": "n" }] } } ``` That is the entirety of the observable change at the canonical-JSON layer. After this milestone the JSON schema is regular: kebab-case for every multi-word tag, single-word for every atomic tag, with no exceptions. ### The Rust implementation shape (secondary) In `crates/ailang-core/src/ast.rs`: ```rust // before Lam { params: Vec, #[serde(rename = "paramTypes")] param_tys: Vec, #[serde(rename = "retType")] ret_ty: Box, #[serde(default)] effects: Vec, body: Box, } // after Lam { params: Vec, #[serde(rename = "param-types")] param_tys: Vec, #[serde(rename = "ret-type")] ret_ty: Box, #[serde(default)] effects: Vec, body: Box, } ``` `crates/ailang-core/src/workspace.rs:1925-1926` — JSON-literal in a test that synthesises a Lam to drive the bare-cross-module-type-ref rejection path — updates the embedded keys verbatim: ```rust // before "body": { "t": "lam", "params": ["x"], "paramTypes": [{ "k": "con", "name": "Ordering" }], "retType": { "k": "con", "name": "Unit" }, ... // after "body": { "t": "lam", "params": ["x"], "param-types": [{ "k": "con", "name": "Ordering" }], "ret-type": { "k": "con", "name": "Unit" }, ... ``` The two `.ail.json` fixtures get the same key-rename, byte-for-byte; the surrounding fixture content is untouched. ## Components | Component | Change | |-----------|--------| | `crates/ailang-core/src/ast.rs:492,494` | Two `#[serde(rename)]` strings: `"paramTypes" → "param-types"`, `"retType" → "ret-type"`. | | `crates/ailang-core/src/ast.rs:8` | Module-level `//!` rustdoc — substitute the new tag spellings in the example list. | | `crates/ailang-surface/src/parse.rs:81` | Module-level `//!` rustdoc — substitute the new tag spelling. | | `crates/ailang-check/src/lib.rs:1707` | `///` rustdoc — substitute the new tag spellings. | | `crates/ailang-core/src/workspace.rs:1925-1926` | In-source JSON-literal in `ct1_validator_walks_lam_embedded_types` (function at L1911): same two key renames. | | `design/contracts/0002-data-model.md:142-147` | Fenced JSON-block in the canonical data-model contract: same two key renames. | | `examples/test_loop_binder_captured_by_lambda.ail.json` | Same two key renames (2 occurrences total of the two tags, one per line). | | `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` | Same two key renames (2 occurrences total). | | `crates/ailang-surface/src/{print,parse}.rs` (code) | **No change.** Form-A never used these tag names — only `parse.rs:81` rustdoc above. | | `crates/ailang-core/tests/hash_pin.rs` | **No change.** No pinned `.ail` module contains `(lam ...)` (verified per-file). | Historic / frozen documents NOT migrated (per Honesty Rule analogue: these describe state at the time of writing, not the present state): - `docs/plans/2026-05-10-23.3-...md`, `docs/plans/0003-22b3-monomorphisation.md`, `docs/plans/0005-22c-user-class-e2e.md`, `docs/plans/0056-24.2.md` — embed old tag names in example JSON blocks. - `experiments/2026-05-12-cross-model-authoring/master/spec.md`, `experiments/2026-05-12-cross-model-authoring/rendered/*.md`, and any `experiments/2026-05-12-cross-model-authoring/runs/**` transcripts — frozen artefacts of the 2026-05-12 experiment. ## Data flow The canonical-JSON ↔ AST bridge is `serde_json` with the `#[serde(tag = "t")]` discriminator on `Term`. The `rename` attributes are the only point where the JSON key differs from the Rust field name. Changing the rename string changes (a) what `serde_json::to_string` emits for the field, and (b) what `serde_json::from_str` accepts for the field. Both directions move together; there is no two-key transitional window — a single commit ships the rename plus all three call-sites (workspace test + two fixtures) so no `.ail.json` is observably inconsistent at any intermediate point. The Form-A path (`.ail` author surface → `parse_lam` → AST → canonical serialisation → hash) does not touch the renamed tags as text: the parser maps the Form-A keywords `params`/`typed`/`ret` directly to the Rust fields by struct-position, and `serde_json` then re-emits them with the new rename. The same module hashes the same way before and after at the AST level; only the *canonical JSON byte stream* differs, which is what the renaming targets. ## Error handling Three failure modes possible during the migration: 1. **A `.ail.json` fixture is missed.** Surfaces as a deserialisation error from `ailang-core` when the workspace loader hits an unknown field. Caught by `cargo test --workspace` (every fixture participates in at least one test). Resolution: the grep used to build the Components table was exhaustive across `examples/`, `experiments/`, and `crates/`; if a future fixture is added with the old tag, the deserialiser rejects it. 2. **The `workspace.rs` JSON literal is missed.** Same as above, caught by the same test crate — specifically the `validate_canonical_type_names` test that owns the synthesised module. 3. **A historic `docs/plans/*.md` is "helpfully" updated.** Not a failure mode, but a rule-violation against the Honesty Rule. Historic plans are frozen documents; updating them retroactively would falsify the recorded design intent at the iter close. The spec explicitly forbids this (see Components above) and the ratifying test (`crates/ailang-core/tests/docs_honesty_pin.rs`) does not police plan files anyway — the rule is editorial discipline. There is no compatibility shim and no transitional accept-both behaviour. The JSON tag rename is atomic in one commit; old fixtures not migrated by this commit cannot deserialise after this commit. Any future encounter with a stray `"paramTypes"` is a stale fixture, not a backward-compat concern. ## Testing strategy The milestone is GREEN if: - `cargo test --workspace` passes (this hits every `.ail.json` fixture transitively and the workspace.rs test directly). - `cargo test -p ailang-surface --test round_trip` passes — confirms Form-A is untouched. - A single targeted assertion is added: a fresh test in `crates/ailang-core/tests/design_schema_drift.rs` verifies that `serde_json::to_value(&Term::Lam{...})` emits the keys `"param-types"` and `"ret-type"` (and NOT the old camelCase), AND extends the existing fenced-block anchor sweep to assert that `design/contracts/0002-data-model.md` contains the new tag spellings inside the `Term::Lam` fenced JSON block. The home is `design_schema_drift.rs` (not `schema_coverage.rs`, which is a corpus-coverage walker) because that file already operates as the data-model contract's ratifying test, already builds `Term::Lam` exemplars (L121-129), and already pins the `data-model.md` ↔ schema pairing via the `anchor_in_jsonc_block` helper. The two assertions pin the schema layer and the contract document together so a future regression cannot silently flip the rename string back. - `cargo build --workspace` produces no new clippy warnings. No bench-regression check is required: the rename is a string literal swap at compile time with no runtime cost path. ## Acceptance criteria 1. Tag-rename change in `crates/ailang-core/src/ast.rs:492,494` is landed and observable in serialisation output. 2. All four code call-sites (`workspace.rs` JSON-literal + two `.ail.json` fixtures + `design/contracts/0002-data-model.md` fenced JSON block) carry the new tag names verbatim. 3. All three rustdoc strings (`ast.rs:8`, `parse.rs:81`, `check/lib.rs:1707`) describe the new tag spellings. 4. `cargo test --workspace` is GREEN. 5. `cargo test -p ailang-surface --test round_trip` is GREEN (Form-A unchanged invariant). 6. The new `design_schema_drift.rs` assertion(s) fire GREEN with the new tag names and would fire RED with the old ones. 7. The commit body references `closes #30`. No `docs/plans/*.md`, `experiments/.../master/spec.md`, `experiments/.../rendered/*`, or `experiments/.../runs/*` files are mutated. ## Feature-acceptance gate Per `design/contracts/0004-feature-acceptance.md`, three clauses must hold. This milestone's gate evaluation: - **Clause 1 — an LLM author naturally produces code that uses the feature.** Direct empirical evidence for *kebab-case preference inside this schema* is absent — the 2026-05-21 naming-A/B run measured a different naming axis (apply vs call vs classic head tag, not field-key case). The argument here is indirect but concrete: a future LLM author derives the lambda's JSON form by *generalising from the rest of the schema* it has just read. Every other compound tag in the AST is kebab-case (verified by exhaustive `grep -n "serde(rename" crates/ailang-core/src/ast.rs` — 17 sites, only the two on `Term::Lam` use camelCase). The generalisation an LLM forms reads "compound keys are kebab" — and it will get `paramTypes` wrong on first attempt with non-trivial probability. The fix eliminates the one site where the generalisation diverges from reality. Clause 1 holds **weakly but honestly**: the gain is consistency-induced lower error probability, not a measured preference for kebab. - **Clause 2 — measurably improves correctness or removes redundancy.** Redundancy reduction is direct: one less special case in the "JSON tag spelling convention" the LLM author has to memorise. The "measurable" side is the LLM-mistake probability reduction sketched in Clause 1; even without a number it is a monotonic improvement (the new rule is strictly simpler than the old). Holds. - **Clause 3 — reintroduces no bug class the core constraint exists to eliminate.** Pure tag rename; no semantic, type-system, uniqueness-mode, or codegen surface touched. The Form-A round-trip invariant is structurally untouched (parser doesn't see these tags). Holds vacuously. The gate passes. The honesty here is to name Clause 1 as the weakest of the three, supported by *schema-internal consistency* rather than by an experiment. A future schema-touching milestone (#27 arithmetic-rename) might collect more direct evidence; this fix does not block on that and does not pretend to.