# schema-camelcase-fix — Implementation Plan > **Parent spec:** `docs/specs/0051-schema-camelcase-fix.md` > (commit `55ce6d0`, amended after plan-recon) > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Rename the two camelCase JSON tags `paramTypes` / `retType` on `Term::Lam` to the kebab-case `param-types` / `ret-type`, migrate every checked-in code/contract reference in the same iteration, and pin the new schema shape with a `design_schema_drift` assertion. After this iteration the AILang canonical-JSON schema has zero camelCase tags. Closes Gitea #30. **Architecture:** Single-iteration milestone. Four layers touched: (1) `#[serde(rename)]` strings on `Term::Lam` in `crates/ailang-core/src/ast.rs`; (2) one in-source test JSON-literal in `crates/ailang-core/src/workspace.rs`; (3) two `.ail.json` fixtures + one canonical-schema contract document (`design/contracts/0002-data-model.md`); (4) three rustdoc strings that describe present-state schema vocabulary. The Form-A surface (`crates/ailang-surface/`) is structurally untouched — Form-A uses `(params (typed ...)) (ret ...)`, not the renamed tags. No hash-pin refresh is required (no `.ail` module currently pinned in `hash_pin.rs` contains a `(lam ...)` term — verified per-file). **Tech Stack:** `serde_json` rename attributes, AILang `Term::Lam` schema, `design_schema_drift.rs` anchor walker, `anchor_in_jsonc_block` helper. --- ## Files this plan creates or modifies - Modify: `crates/ailang-core/tests/design_schema_drift.rs` — add one new `#[test] fn` (the schema-shape pin) and extend the existing `anchor_in_jsonc_block` walk to require the new tag spellings inside the `Term::Lam` fenced JSON block of `data-model.md`. - Modify: `crates/ailang-core/src/ast.rs:492,494` — two `#[serde(rename)]` string-literal swaps. - Modify: `crates/ailang-core/src/ast.rs:8` — module-level `//!` rustdoc enumerates rename targets. - Modify: `crates/ailang-core/src/workspace.rs:1925-1926` — in-source JSON literal inside `#[test] fn ct1_validator_walks_lam_embedded_types` (function at line 1911). - Modify: `crates/ailang-surface/src/parse.rs:81` — module-level `//!` rustdoc mentions `lam`'s `paramTypes` carry. - Modify: `crates/ailang-check/src/lib.rs:1707` — `///` rustdoc on the InstanceMethod routing helper. - Modify: `design/contracts/0002-data-model.md:142-147` — fenced JSON block describing the `Term::Lam` schema. - Modify: `examples/test_loop_binder_captured_by_lambda.ail.json` — two key renames, one per line (currently lines 32-33; verify by grep before editing). - Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` — two key renames, one per line (currently lines 25-26). **Forbidden touches** (per spec acceptance criterion 7): - `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` - `experiments/2026-05-12-cross-model-authoring/master/spec.md` - `experiments/2026-05-12-cross-model-authoring/rendered/*.md` - `experiments/2026-05-12-cross-model-authoring/runs/**` These embed the old tag names but are frozen historical artefacts. --- ## Task 1: RED — pin the new schema shape **Files:** - Modify: `crates/ailang-core/tests/design_schema_drift.rs` The pin lives in `design_schema_drift.rs` (not `schema_coverage.rs`) because that file already constructs `Term::Lam` exemplars at L121-129, already includes `data-model.md` via `include_str!` (L22, constant name `DATA_MODEL`), and already calls the `anchor_in_jsonc_block` helper (L34-47, called at L184) for fenced- block walks. The new pin and the data-model anchor extension belong in the same test file as the rest of the schema-drift family. - [ ] **Step 1: Read the existing pin family** Read `crates/ailang-core/tests/design_schema_drift.rs`. Note: - the `Term::Lam` exemplar around L121-129 - the `anchor_in_jsonc_block` helper at L34-47 - the existing `design_md_anchors_every_term_variant` test (where the new tag anchors will land) Step exists to ground the new test in the file's prevailing shape; no edit. - [ ] **Step 2: Add the schema-shape pin** Append at end of `crates/ailang-core/tests/design_schema_drift.rs` (or co-locate with the other `Term::Lam`-touching tests at L121-129 — pick the location with the closest sibling test): ```rust /// Schema-shape pin: the JSON serialisation of `Term::Lam` must /// emit kebab-case tags `"param-types"` and `"ret-type"`, and /// must NOT emit the old camelCase `"paramTypes"` / `"retType"`. /// Pinned because the closes-#30 milestone made the rename a /// schema-stability invariant: a future regression that flips /// the `#[serde(rename)]` string back to camelCase must fire RED /// here, not slip through. #[test] fn lam_serialises_with_kebab_keys() { use ailang_core::ast::*; let lam = Term::Lam { params: vec!["x".into()], param_tys: vec![Type::int()], ret_ty: Box::new(Type::int()), effects: vec![], body: Box::new(Term::Var { name: "x".into() }), }; let v = serde_json::to_value(&lam).expect("Term::Lam serialises"); let obj = v.as_object().expect("Term::Lam serialises as object"); assert!( obj.contains_key("param-types"), "Term::Lam must emit `param-types` key; got keys {:?}", obj.keys().collect::>(), ); assert!( obj.contains_key("ret-type"), "Term::Lam must emit `ret-type` key; got keys {:?}", obj.keys().collect::>(), ); assert!( !obj.contains_key("paramTypes"), "Term::Lam must NOT emit old camelCase `paramTypes` key", ); assert!( !obj.contains_key("retType"), "Term::Lam must NOT emit old camelCase `retType` key", ); } ``` - [ ] **Step 3: Extend the data-model.md anchor walk** Find the existing test that uses `anchor_in_jsonc_block` to assert fenced-block content (recon names `design_md_anchors_every_term_variant` at the test name level, and `anchor_in_jsonc_block` calls at L184). Read the existing anchor list, identify where the `lam` block anchors live, and add two new anchor assertions for the same `lam` fenced JSON block: ```rust assert!( anchor_in_jsonc_block(DATA_MODEL, "lam", "\"param-types\""), "data-model.md `lam` fenced block must list `param-types` key", ); assert!( anchor_in_jsonc_block(DATA_MODEL, "lam", "\"ret-type\""), "data-model.md `lam` fenced block must list `ret-type` key", ); ``` If the existing test asserts anchors via a `for (variant, anchor) in &[…]` table, add the two rows there instead of two standalone `assert!`s — match the local shape. The implementer reads the file to confirm the local shape before editing. - [ ] **Step 4: Run the new pin and verify it FAILS** Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -30` Expected: FAIL. The current `ast.rs:492,494` emit `paramTypes` / `retType`, so `obj.contains_key("param-types")` is false. The data-model anchor extension also FAILs because lines 142-147 still carry camelCase. Both RED conditions confirm we are pinning a real property the milestone is about to make true. If the test unexpectedly passes, STOP — the assumption "current state emits camelCase" is wrong; re-read `ast.rs:490-499` before proceeding. --- ## Task 2: GREEN-1 — atomic schema-coupled swap **Files:** - Modify: `crates/ailang-core/src/ast.rs:492` (line `#[serde(rename = "paramTypes")]`) - Modify: `crates/ailang-core/src/ast.rs:494` (line `#[serde(rename = "retType")]`) - Modify: `crates/ailang-core/src/workspace.rs:1925-1926` - Modify: `examples/test_loop_binder_captured_by_lambda.ail.json` - Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` - Modify: `design/contracts/0002-data-model.md:142-147` These files MUST move together. Changing only `ast.rs` makes any JSON literal containing the old tags undeserialisable: serde `Term::Lam` has no `#[serde(default)]` on `param_tys` / `ret_ty`, so an absent (renamed-away) key is a hard deserialise error. The workspace.rs test, the two `.ail.json` fixtures, and the data-model.md fenced JSON block all participate in the same serialiser/deserialiser contract — they migrate atomically, in a single task, with the full `cargo test` gate at the end. `data-model.md` is included by `data-model.md` consumer `design_schema_drift.rs` via `include_str!`; the constant is `DATA_MODEL`. After Task 1's anchor extension, leaving the fenced JSON block on camelCase would keep the test RED. - [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:492`** Locate line 492: ```rust #[serde(rename = "paramTypes")] ``` Replace with: ```rust #[serde(rename = "param-types")] ``` - [ ] **Step 2: Edit `crates/ailang-core/src/ast.rs:494`** Locate line 494: ```rust #[serde(rename = "retType")] ``` Replace with: ```rust #[serde(rename = "ret-type")] ``` - [ ] **Step 3: Edit `crates/ailang-core/src/workspace.rs:1925-1926`** The two lines live inside the test function `ct1_validator_walks_lam_embedded_types` (function attribute `#[test]` at L1911). Find the literal lines: ```rust "paramTypes": [{ "k": "con", "name": "Ordering" }], "retType": { "k": "con", "name": "Unit" }, ``` Replace with (preserve exact indentation and surrounding context): ```rust "param-types": [{ "k": "con", "name": "Ordering" }], "ret-type": { "k": "con", "name": "Unit" }, ``` - [ ] **Step 4: Edit `examples/test_loop_binder_captured_by_lambda.ail.json`** Grep first to confirm exact occurrences: Run: `grep -n '"paramTypes"\|"retType"' examples/test_loop_binder_captured_by_lambda.ail.json` Expected: 2 hits, one with `"paramTypes"`, one with `"retType"`, on consecutive lines (currently 32-33; verify). For each line, replace `"paramTypes"` → `"param-types"` and `"retType"` → `"ret-type"`. No other content changes. - [ ] **Step 5: Edit `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`** Grep first: Run: `grep -n '"paramTypes"\|"retType"' experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` Expected: 2 hits on consecutive lines (currently 25-26; verify). Same rename as Step 4. - [ ] **Step 6: Edit `design/contracts/0002-data-model.md:142-147`** The fenced JSON block currently reads (lines 142-147): ``` { "t": "lam", "params": [""...], "paramTypes": [Type...], "retType": Type, "effects": [""...], "body": Term } ``` Replace lines 144-145 with: ``` "param-types": [Type...], "ret-type": Type, ``` The surrounding `{ "t": "lam", ...` opener, `"params"` line, `"effects"` line, and `"body"` line are untouched. Verify the fenced-block boundaries (` ``` ` markers) above and below remain intact. - [ ] **Step 7: Build the workspace to catch any deserialisation regression** Run: `cargo build --workspace 2>&1 | tail -20` Expected: clean build, no new warnings. - [ ] **Step 8: Run the schema-shape pin (now expected GREEN)** Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -10` Expected: PASS (Task 1's assertion now holds). - [ ] **Step 9: Run the extended data-model anchor walk** Run: `cargo test -p ailang-core --test design_schema_drift design_md_anchors_every_term_variant 2>&1 | tail -10` Expected: PASS (the data-model.md fenced JSON block now carries the new tag anchors). - [ ] **Step 10: Run the workspace ct1 test** Run: `cargo test -p ailang-core ct1_validator_walks_lam_embedded_types 2>&1 | tail -10` Expected: PASS. This is the in-source test that owns the JSON literal edited in Step 3; if Step 3 missed a key, this test fails with a `serde_json::from_value` error mentioning the missing field. --- ## Task 3: GREEN-2 — rustdoc honesty pass **Files:** - Modify: `crates/ailang-core/src/ast.rs:8` - Modify: `crates/ailang-surface/src/parse.rs:81` - Modify: `crates/ailang-check/src/lib.rs:1707` These are pure prose edits — no compile or test consequence. They exist solely to keep the rustdoc honest after the rename. Per the Honesty Rule (`design/contracts/0007-honesty-rule.md`), production rustdoc describes present-state vocabulary, not history. - [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:8`** Locate line 8 in the module-level `//!` block. Current text: ```rust //! `fn`, `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`), ``` Replace with: ```rust //! `fn`, `param-types`, `ret-type`), enum tags (`kind`, `t`, `k`, `p`), ``` The surrounding `//!` lines are untouched. - [ ] **Step 2: Edit `crates/ailang-surface/src/parse.rs:81`** Locate line 81 in the module-level `//!` block. Current text: ```rust //! - The `lam` form carries `paramTypes`, a `ret` type, and an ``` Replace with: ```rust //! - The `lam` form carries `param-types`, a `ret` type, and an ``` If the line wraps differently in the local file (the carrier word `paramTypes` may sit on its own line or break across lines after the comma), edit the contiguous `paramTypes` occurrence; do NOT soft-wrap split the new `param-types` across two lines. - [ ] **Step 3: Edit `crates/ailang-check/src/lib.rs:1707`** Locate line 1707. Current text: ```rust /// convention a `Term::Lam` carrying its own `paramTypes` / `retType` ``` Replace with: ```rust /// convention a `Term::Lam` carrying its own `param-types` / `ret-type` ``` - [ ] **Step 4: Run a doc-build to catch broken intra-doc links** Run: `cargo doc --workspace --no-deps 2>&1 | grep -E "warning|error" | head -20` Expected: no NEW warnings (the workspace already carries a known-stable warning count; flag any new diagnostic that mentions `paramTypes` / `retType` / `param-types` / `ret-type`). --- ## Task 4: Final verification **Files:** none modified — verification only. - [ ] **Step 1: Exhaustive grep — nothing left on the old tags** Run: `grep -rn '"paramTypes"\|"retType"\|paramTypes\|retType' crates/ examples/ design/ runtime/ --include='*.rs' --include='*.json' --include='*.md' 2>/dev/null` Expected output (and only this output): - `docs/plans/*.md` matches in the historic plan files listed under "Forbidden touches" above. - `experiments/2026-05-12-cross-model-authoring/master/spec.md` matches. - `experiments/2026-05-12-cross-model-authoring/rendered/*.md` matches. - `experiments/2026-05-12-cross-model-authoring/runs/**` matches. - ZERO matches under `crates/`, `examples/`, `runtime/`, or `design/`. Any match outside the expected set is a missed touch-point; resolve it by adding the rename, then re-run the full test suite (Step 2). - [ ] **Step 2: Run the workspace test suite** Run: `cargo test --workspace 2>&1 | tail -25` Expected: every crate's test summary shows `0 failed`. The total test-group count must equal the pre-iter baseline of 90 (unchanged — Task 1 adds one new test inside an existing test crate, which does not change the test-group count). - [ ] **Step 3: Round-trip invariance check** Run: `cargo test -p ailang-surface --test round_trip 2>&1 | tail -10` Expected: PASS. This is the Form-A-unchanged gate; if it fails the spec's Layer-2 claim ("Form-A never used these tag names") was wrong and the iteration scope was mis-sized. - [ ] **Step 4: Confirm no forbidden files were touched** Run: `git diff --name-only HEAD 2>&1 | grep -E "docs/plans/|experiments/.*/(master/spec\.md|rendered/|runs/)" | head -20` Expected: empty output. If any line appears, the implementer mutated a forbidden file — `git checkout -- ` the change. - [ ] **Step 5: Sanity check the new pin would actually go RED** Mental check (no `git revert` — main is sacrosanct): the pin in Task 1 was confirmed RED in Task 1 Step 4 before the swap. Step 8 of Task 2 confirmed it GREEN after the swap. Therefore the pin is a real RED/GREEN bisector. No additional action — this step exists to mark the property as audited. - [ ] **Step 6: Status summary for Boss commit** Print a one-line summary of the resulting `git status`: Run: `git status --short` Expected: nine modified files (the eight listed in "Files this plan creates or modifies" plus the new test slot in `design_schema_drift.rs`), no untracked files of interest. The Boss will inspect the diff and assemble the iter-level commit.