plan: schema-camelcase-fix — 4-task atomic schema-tag migration (refs #30)

Iteration plan derived from `docs/specs/2026-05-21-schema-camelcase-fix.md`
(commit 55ce6d0, amended). Single-iteration milestone, four tasks:

- **Task 1 (RED)**: add `lam_serialises_with_kebab_keys` pin to
  `design_schema_drift.rs` + extend `anchor_in_jsonc_block` walk
  to require the new tag spellings inside `data-model.md`'s `lam`
  fenced block. Verifies pin fires RED on current state.

- **Task 2 (GREEN atomic schema swap)**: 10-step coupled
  migration of `ast.rs:492,494` + `workspace.rs:1925-1926` +
  `examples/test_loop_binder_captured_by_lambda.ail.json` +
  `experiments/.../master/examples/fn_with_lambda.ail.json` +
  `design/contracts/data-model.md:142-147`. All five files MUST
  move together — changing only `ast.rs` makes the JSON literals
  undeserialisable (no `#[serde(default)]` on `param_tys`/`ret_ty`,
  so the renamed-away key is a hard error).

- **Task 3 (GREEN-2 rustdoc honesty)**: three pure-prose edits in
  `ast.rs:8`, `parse.rs:81`, `check/lib.rs:1707`. Decoupled from
  the schema swap — no compile or test consequence.

- **Task 4 (Verify)**: exhaustive negative grep, full workspace
  test suite, round-trip invariance, forbidden-files check.

Plan pre-recon by `ailang-plan-recon` (DONE_WITH_CONCERNS) caught
the data-model.md + rustdoc layer the brainstorm initially missed
— spec was amended in commit 55ce6d0 + re-grounding-check PASS
(9 claims ratified, 2 of them via exhaustive negative grep as
"no-test-pins-this" Boss-overrideable shape).

Self-review: 8/8 (spec coverage, no placeholders, name consistency,
step granularity 2-5 min, no commit steps, pin/replacement
contiguity, compile-gate threading complete within Task 2,
verification filters all resolve to named real tests).
This commit is contained in:
2026-05-21 12:48:59 +02:00
parent 55ce6d0d70
commit 90977663ff
@@ -0,0 +1,460 @@
# schema-camelcase-fix — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-21-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/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/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/2026-05-09-22b3-monomorphisation.md`,
`docs/plans/2026-05-09-22c-user-class-e2e.md`, `docs/plans/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/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/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/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.