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.
16 KiB
schema-camelcase-fix — Implementation Plan
Parent spec:
docs/specs/0051-schema-camelcase-fix.md(commit55ce6d0, amended after plan-recon)For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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.ailmodule currently pinned inhash_pin.rscontains 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 existinganchor_in_jsonc_blockwalk to require the new tag spellings inside theTerm::Lamfenced JSON block ofdata-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 mentionslam'sparamTypescarry. - 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 theTerm::Lamschema. - 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.mdexperiments/2026-05-12-cross-model-authoring/master/spec.mdexperiments/2026-05-12-cross-model-authoring/rendered/*.mdexperiments/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::Lamexemplar around L121-129 - the
anchor_in_jsonc_blockhelper at L34-47 - the existing
design_md_anchors_every_term_varianttest (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/*.mdmatches in the historic plan files listed under "Forbidden touches" above.experiments/2026-05-12-cross-model-authoring/master/spec.mdmatches.experiments/2026-05-12-cross-model-authoring/rendered/*.mdmatches.experiments/2026-05-12-cross-model-authoring/runs/**matches.- ZERO matches under
crates/,examples/,runtime/, ordesign/.
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.