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 — Design Spec
Date: 2026-05-21 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude Closes: Gitea #30
Goal
Replace the two camelCase JSON tags paramTypes and retType on
Term::Lam with the kebab-case param-types and ret-type. These
are the only camelCase outliers in the entire AST schema; every
other compound-key tag (reuse-as, drop-iterative, etc.) is
kebab-case and every atomic tag (type, fn, else, in, as)
is a single lowercase word. The fix is a pure schema-tag rename in
the canonical JSON layer.
Architecture
This is a schema-only milestone. Four layers are affected:
-
Canonical JSON tag layer. Two
#[serde(rename = "...")]attributes onTerm::Lamincrates/ailang-core/src/ast.rs:492,494change literal value:"paramTypes" → "param-types","retType" → "ret-type". The Rust field names (param_tys,ret_ty) are unaffected. -
Surface (Form-A) layer. Unchanged. Form-A prints lambdas as
(lam (params (typed NAME TYPE)*) (ret TYPE) (effects ...) (body TERM)). There is no Form-A keywordparamTypesorretTypeto migrate — the print/parse code atcrates/ailang-surface/src/print.rs:502-528andcrates/ailang-surface/src/parse.rs:1430-1468uses positional sub-forms(typed ...)and(ret ...), both of which already match the kebab/single-word convention. -
JSON source files. Two
.ail.jsonfixtures and one inline JSON literal in a Rust unit test embed the old tags directly and must be updated:examples/test_loop_binder_captured_by_lambda.ail.jsonexperiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.jsoncrates/ailang-core/src/workspace.rs:1925-1926(Verified viagrep -rn '"paramTypes"\|"retType"' --include="*.rs" --include="*.json"— exhaustive list. Each fixture carries exactly two occurrences of the two tags combined, one per line.)
-
Canonical-schema contract + rustdoc layer. Three rustdoc strings in production source describe the present-state schema (rather than implementation history); per the Honesty Rule (
design/contracts/0007-honesty-rule.md) they must read truthfully after the rename:crates/ailang-core/src/ast.rs:8— module-level//!enumerating example field renames.crates/ailang-surface/src/parse.rs:81— module-level//!mentioninglam'sparamTypescarry.crates/ailang-check/src/lib.rs:1707— rustdoc on the InstanceMethod routing helper.
Plus the canonical data-model contract document itself, which ships fenced JSON blocks describing the schema verbatim:
design/contracts/0002-data-model.md:142-147— theTerm::Lamfenced-JSON schema block.
The data-model contract IS the canonical schema documentation (linked from
design/INDEX.mdrowdata-model, with ratifying testcrates/ailang-core/tests/design_schema_drift.rs). Leaving it on the old tags afterast.rsships kebab would be a direct Honesty-Rule violation.
The experiment-tree under
experiments/2026-05-12-cross-model-authoring/ (master spec,
renderings, run transcripts) carries the old tags in multiple
places. These are frozen historical artefacts of the 2026-05-12
cross-model-authoring experiment — analogous to docs/plans/*.md
under the Honesty Rule — and are NOT migrated by this milestone.
The experiment's master/examples/fn_with_lambda.ail.json IS
migrated (it is a checked-in JSON fixture the workspace loader
could deserialise); the surrounding prose documents and run
transcripts are not.
Hash blast radius is zero on pinned modules. None of the
.ail modules pinned in crates/ailang-core/tests/hash_pin.rs
(sum, list, ordering_match, test_22b1_dup_a,
test_22b1_dup_classmod) contains a (lam ...) term. Verified
via grep -c "(lam " examples/<each>.ail — all zero. The two
.ail.json fixtures listed above are not hash-pinned. Therefore
the milestone does not refresh a single hash pin and no Honesty-Rule
hash-comment migration is required.
Concrete code shapes
The AILang program (Form-A — unchanged)
The Form-A author surface is unchanged by this milestone. Authors
write lambdas exactly as before. Worked example, lifted from
examples/closure.ail:
(fn main
(doc "captures `n` from the enclosing let, returns 42")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (let n 3
(app print
(app apply
(lam (params (typed x (con Int)))
(ret (con Int))
(body (app + x n)))
39)))))
The lambda sub-form (lam (params (typed x (con Int))) (ret (con Int)) (body (app + x n))) reads consistently with the surrounding
(fn-type (params ...) (ret ...)) — Form-A was never the locus of
the inconsistency. The Form-A round-trip
(crates/ailang-surface/tests/round_trip.rs) must stay GREEN with
zero diff to the printer/parser.
The delivered code shape (canonical JSON — before → after)
The same lambda's canonical JSON before this milestone:
{
"t": "lam",
"params": ["x"],
"paramTypes": [{ "k": "con", "name": "Int" }],
"retType": { "k": "con", "name": "Int" },
"effects": [],
"body": { "t": "app",
"callee": { "t": "var", "name": "+" },
"args": [{ "t": "var", "name": "x" },
{ "t": "var", "name": "n" }] }
}
After:
{
"t": "lam",
"params": ["x"],
"param-types": [{ "k": "con", "name": "Int" }],
"ret-type": { "k": "con", "name": "Int" },
"effects": [],
"body": { "t": "app",
"callee": { "t": "var", "name": "+" },
"args": [{ "t": "var", "name": "x" },
{ "t": "var", "name": "n" }] }
}
That is the entirety of the observable change at the canonical-JSON layer. After this milestone the JSON schema is regular: kebab-case for every multi-word tag, single-word for every atomic tag, with no exceptions.
The Rust implementation shape (secondary)
In crates/ailang-core/src/ast.rs:
// before
Lam {
params: Vec<String>,
#[serde(rename = "paramTypes")]
param_tys: Vec<Type>,
#[serde(rename = "retType")]
ret_ty: Box<Type>,
#[serde(default)]
effects: Vec<String>,
body: Box<Term>,
}
// after
Lam {
params: Vec<String>,
#[serde(rename = "param-types")]
param_tys: Vec<Type>,
#[serde(rename = "ret-type")]
ret_ty: Box<Type>,
#[serde(default)]
effects: Vec<String>,
body: Box<Term>,
}
crates/ailang-core/src/workspace.rs:1925-1926 — JSON-literal in a
test that synthesises a Lam to drive the bare-cross-module-type-ref
rejection path — updates the embedded keys verbatim:
// before
"body": {
"t": "lam",
"params": ["x"],
"paramTypes": [{ "k": "con", "name": "Ordering" }],
"retType": { "k": "con", "name": "Unit" },
...
// after
"body": {
"t": "lam",
"params": ["x"],
"param-types": [{ "k": "con", "name": "Ordering" }],
"ret-type": { "k": "con", "name": "Unit" },
...
The two .ail.json fixtures get the same key-rename, byte-for-byte;
the surrounding fixture content is untouched.
Components
| Component | Change |
|---|---|
crates/ailang-core/src/ast.rs:492,494 |
Two #[serde(rename)] strings: "paramTypes" → "param-types", "retType" → "ret-type". |
crates/ailang-core/src/ast.rs:8 |
Module-level //! rustdoc — substitute the new tag spellings in the example list. |
crates/ailang-surface/src/parse.rs:81 |
Module-level //! rustdoc — substitute the new tag spelling. |
crates/ailang-check/src/lib.rs:1707 |
/// rustdoc — substitute the new tag spellings. |
crates/ailang-core/src/workspace.rs:1925-1926 |
In-source JSON-literal in ct1_validator_walks_lam_embedded_types (function at L1911): same two key renames. |
design/contracts/0002-data-model.md:142-147 |
Fenced JSON-block in the canonical data-model contract: same two key renames. |
examples/test_loop_binder_captured_by_lambda.ail.json |
Same two key renames (2 occurrences total of the two tags, one per line). |
experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json |
Same two key renames (2 occurrences total). |
crates/ailang-surface/src/{print,parse}.rs (code) |
No change. Form-A never used these tag names — only parse.rs:81 rustdoc above. |
crates/ailang-core/tests/hash_pin.rs |
No change. No pinned .ail module contains (lam ...) (verified per-file). |
Historic / frozen documents NOT migrated (per Honesty Rule analogue: these describe state at the time of writing, not the present state):
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— embed old tag names in example JSON blocks.experiments/2026-05-12-cross-model-authoring/master/spec.md,experiments/2026-05-12-cross-model-authoring/rendered/*.md, and anyexperiments/2026-05-12-cross-model-authoring/runs/**transcripts — frozen artefacts of the 2026-05-12 experiment.
Data flow
The canonical-JSON ↔ AST bridge is serde_json with the
#[serde(tag = "t")] discriminator on Term. The rename
attributes are the only point where the JSON key differs from the
Rust field name. Changing the rename string changes (a) what
serde_json::to_string emits for the field, and (b) what
serde_json::from_str accepts for the field. Both directions move
together; there is no two-key transitional window — a single commit
ships the rename plus all three call-sites (workspace test +
two fixtures) so no .ail.json is observably inconsistent at any
intermediate point.
The Form-A path (.ail author surface → parse_lam → AST → canonical
serialisation → hash) does not touch the renamed tags as text: the
parser maps the Form-A keywords params/typed/ret directly to
the Rust fields by struct-position, and serde_json then re-emits
them with the new rename. The same module hashes the same way before
and after at the AST level; only the canonical JSON byte stream
differs, which is what the renaming targets.
Error handling
Three failure modes possible during the migration:
-
A
.ail.jsonfixture is missed. Surfaces as a deserialisation error fromailang-corewhen the workspace loader hits an unknown field. Caught bycargo test --workspace(every fixture participates in at least one test). Resolution: the grep used to build the Components table was exhaustive acrossexamples/,experiments/, andcrates/; if a future fixture is added with the old tag, the deserialiser rejects it. -
The
workspace.rsJSON literal is missed. Same as above, caught by the same test crate — specifically thevalidate_canonical_type_namestest that owns the synthesised module. -
A historic
docs/plans/*.mdis "helpfully" updated. Not a failure mode, but a rule-violation against the Honesty Rule. Historic plans are frozen documents; updating them retroactively would falsify the recorded design intent at the iter close. The spec explicitly forbids this (see Components above) and the ratifying test (crates/ailang-core/tests/docs_honesty_pin.rs) does not police plan files anyway — the rule is editorial discipline.
There is no compatibility shim and no transitional accept-both
behaviour. The JSON tag rename is atomic in one commit; old fixtures
not migrated by this commit cannot deserialise after this commit.
Any future encounter with a stray "paramTypes" is a stale fixture,
not a backward-compat concern.
Testing strategy
The milestone is GREEN if:
cargo test --workspacepasses (this hits every.ail.jsonfixture transitively and the workspace.rs test directly).cargo test -p ailang-surface --test round_trippasses — confirms Form-A is untouched.- A single targeted assertion is added: a fresh test in
crates/ailang-core/tests/design_schema_drift.rsverifies thatserde_json::to_value(&Term::Lam{...})emits the keys"param-types"and"ret-type"(and NOT the old camelCase), AND extends the existing fenced-block anchor sweep to assert thatdesign/contracts/0002-data-model.mdcontains the new tag spellings inside theTerm::Lamfenced JSON block. The home isdesign_schema_drift.rs(notschema_coverage.rs, which is a corpus-coverage walker) because that file already operates as the data-model contract's ratifying test, already buildsTerm::Lamexemplars (L121-129), and already pins thedata-model.md↔ schema pairing via theanchor_in_jsonc_blockhelper. The two assertions pin the schema layer and the contract document together so a future regression cannot silently flip the rename string back. cargo build --workspaceproduces no new clippy warnings.
No bench-regression check is required: the rename is a string literal swap at compile time with no runtime cost path.
Acceptance criteria
- Tag-rename change in
crates/ailang-core/src/ast.rs:492,494is landed and observable in serialisation output. - All four code call-sites (
workspace.rsJSON-literal + two.ail.jsonfixtures +design/contracts/0002-data-model.mdfenced JSON block) carry the new tag names verbatim. - All three rustdoc strings (
ast.rs:8,parse.rs:81,check/lib.rs:1707) describe the new tag spellings. cargo test --workspaceis GREEN.cargo test -p ailang-surface --test round_tripis GREEN (Form-A unchanged invariant).- The new
design_schema_drift.rsassertion(s) fire GREEN with the new tag names and would fire RED with the old ones. - The commit body references
closes #30. Nodocs/plans/*.md,experiments/.../master/spec.md,experiments/.../rendered/*, orexperiments/.../runs/*files are mutated.
Feature-acceptance gate
Per design/contracts/0004-feature-acceptance.md, three clauses must
hold. This milestone's gate evaluation:
-
Clause 1 — an LLM author naturally produces code that uses the feature. Direct empirical evidence for kebab-case preference inside this schema is absent — the 2026-05-21 naming-A/B run measured a different naming axis (apply vs call vs classic head tag, not field-key case). The argument here is indirect but concrete: a future LLM author derives the lambda's JSON form by generalising from the rest of the schema it has just read. Every other compound tag in the AST is kebab-case (verified by exhaustive
grep -n "serde(rename" crates/ailang-core/src/ast.rs— 17 sites, only the two onTerm::Lamuse camelCase). The generalisation an LLM forms reads "compound keys are kebab" — and it will getparamTypeswrong on first attempt with non-trivial probability. The fix eliminates the one site where the generalisation diverges from reality. Clause 1 holds weakly but honestly: the gain is consistency-induced lower error probability, not a measured preference for kebab. -
Clause 2 — measurably improves correctness or removes redundancy. Redundancy reduction is direct: one less special case in the "JSON tag spelling convention" the LLM author has to memorise. The "measurable" side is the LLM-mistake probability reduction sketched in Clause 1; even without a number it is a monotonic improvement (the new rule is strictly simpler than the old). Holds.
-
Clause 3 — reintroduces no bug class the core constraint exists to eliminate. Pure tag rename; no semantic, type-system, uniqueness-mode, or codegen surface touched. The Form-A round-trip invariant is structurally untouched (parser doesn't see these tags). Holds vacuously.
The gate passes. The honesty here is to name Clause 1 as the weakest of the three, supported by schema-internal consistency rather than by an experiment. A future schema-touching milestone (#27 arithmetic-rename) might collect more direct evidence; this fix does not block on that and does not pretend to.