# 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::<Vec<_>>(),
    );
    assert!(
        obj.contains_key("ret-type"),
        "Term::Lam must emit `ret-type` key; got keys {:?}",
        obj.keys().collect::<Vec<_>>(),
    );
    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": ["<id>"...],
  "paramTypes": [Type...],
  "retType": Type,
  "effects": ["<id>"...],
  "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 -- <path>` 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.
