Files
AILang/docs/plans/0100-schema-camelcase-fix.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

16 KiB

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):

/// 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:

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:

        #[serde(rename = "paramTypes")]

Replace with:

        #[serde(rename = "param-types")]
  • Step 2: Edit crates/ailang-core/src/ast.rs:494

Locate line 494:

        #[serde(rename = "retType")]

Replace with:

        #[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:

                    "paramTypes": [{ "k": "con", "name": "Ordering" }],
                    "retType": { "k": "con", "name": "Unit" },

Replace with (preserve exact indentation and surrounding context):

                    "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:

//! `fn`, `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`),

Replace with:

//! `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:

//! - The `lam` form carries `paramTypes`, a `ret` type, and an

Replace with:

//! - 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:

/// convention a `Term::Lam` carrying its own `paramTypes` / `retType`

Replace with:

/// 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.