Files
AILang/docs/specs/0051-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 — 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:

  1. Canonical JSON tag layer. Two #[serde(rename = "...")] attributes on Term::Lam in crates/ailang-core/src/ast.rs:492,494 change literal value: "paramTypes" → "param-types", "retType" → "ret-type". The Rust field names (param_tys, ret_ty) are unaffected.

  2. 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 keyword paramTypes or retType to migrate — the print/parse code at crates/ailang-surface/src/print.rs:502-528 and crates/ailang-surface/src/parse.rs:1430-1468 uses positional sub-forms (typed ...) and (ret ...), both of which already match the kebab/single-word convention.

  3. JSON source files. Two .ail.json fixtures 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.json
    • experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json
    • crates/ailang-core/src/workspace.rs:1925-1926 (Verified via grep -rn '"paramTypes"\|"retType"' --include="*.rs" --include="*.json" — exhaustive list. Each fixture carries exactly two occurrences of the two tags combined, one per line.)
  4. 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 //! mentioning lam's paramTypes carry.
    • 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 — the Term::Lam fenced-JSON schema block.

    The data-model contract IS the canonical schema documentation (linked from design/INDEX.md row data-model, with ratifying test crates/ailang-core/tests/design_schema_drift.rs). Leaving it on the old tags after ast.rs ships 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 any experiments/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:

  1. A .ail.json fixture is missed. Surfaces as a deserialisation error from ailang-core when the workspace loader hits an unknown field. Caught by cargo test --workspace (every fixture participates in at least one test). Resolution: the grep used to build the Components table was exhaustive across examples/, experiments/, and crates/; if a future fixture is added with the old tag, the deserialiser rejects it.

  2. The workspace.rs JSON literal is missed. Same as above, caught by the same test crate — specifically the validate_canonical_type_names test that owns the synthesised module.

  3. A historic docs/plans/*.md is "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 --workspace passes (this hits every .ail.json fixture transitively and the workspace.rs test directly).
  • cargo test -p ailang-surface --test round_trip passes — confirms Form-A is untouched.
  • A single targeted assertion is added: a fresh test in crates/ailang-core/tests/design_schema_drift.rs verifies that serde_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 that design/contracts/0002-data-model.md contains the new tag spellings inside the Term::Lam fenced JSON block. The home is design_schema_drift.rs (not schema_coverage.rs, which is a corpus-coverage walker) because that file already operates as the data-model contract's ratifying test, already builds Term::Lam exemplars (L121-129), and already pins the data-model.md ↔ schema pairing via the anchor_in_jsonc_block helper. 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 --workspace produces 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

  1. Tag-rename change in crates/ailang-core/src/ast.rs:492,494 is landed and observable in serialisation output.
  2. All four code call-sites (workspace.rs JSON-literal + two .ail.json fixtures + design/contracts/0002-data-model.md fenced JSON block) carry the new tag names verbatim.
  3. All three rustdoc strings (ast.rs:8, parse.rs:81, check/lib.rs:1707) describe the new tag spellings.
  4. cargo test --workspace is GREEN.
  5. cargo test -p ailang-surface --test round_trip is GREEN (Form-A unchanged invariant).
  6. The new design_schema_drift.rs assertion(s) fire GREEN with the new tag names and would fire RED with the old ones.
  7. The commit body references closes #30. No docs/plans/*.md, experiments/.../master/spec.md, experiments/.../rendered/*, or experiments/.../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 on Term::Lam use camelCase). The generalisation an LLM forms reads "compound keys are kebab" — and it will get paramTypes wrong 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.