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

380 lines
16 KiB
Markdown

# 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:
```json
{
"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:
```json
{
"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`:
```rust
// 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:
```rust
// 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.