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.
24 KiB
Form-A as the Default Authoring Surface — Design Spec
Date: 2026-05-13 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Goal
Make Form A (.ail) the sole authoring surface for every program in
the working tree. After this milestone, the only .ail.json files
that remain in the repository are eight specific negative-test
fixtures whose subject matter cannot be expressed in Form A by
construction. Every other .ail.json is rendered to its .ail
sibling via ail render, and the now-redundant .ail.json is
deleted — not retained as a generated artefact, not stashed in a
build directory, not regenerated at test time. The working tree
contains exactly one representation per program: the .ail source.
The language doctrine does not change. The canonical, hashable,
content-addressed form remains the JSON-AST (Decision 6,
architectural pin, §"data structure is the source of truth"). What
changes is the authoring doctrine: authors write .ail; the
build derives the JSON-AST in-process when a consumer needs it,
gated by the round-trip invariant.
Driving evidence: the cross-model authoring-form study (audit-cma, audit-ms, 2026-05-12) measured that Form A is cheaper in tokens (~98k vs ~207k for the same four tasks) and produces strictly more first-shot green programs than JSON-AST authoring, across two distinct foreign LLMs (Qwen3-Coder-Next and CodeLlama-13b-Instruct). The internal authoring practice is now drift from the empirical "this is the form LLMs reach for unprompted" finding.
Architecture
Four architectural decisions land in this spec; each is the substantive answer to one of the open questions in the roadmap entry.
A1. In-process parse, no build-time artefacts
Tests and benches consume .ail fixtures by calling
ailang_surface::parse (library, in-process) — not by spawning
ail parse as a build prelude that emits .ail.json to a target
directory.
Why. A build-prelude approach reintroduces a second
representation: examples/foo.ail (source) plus
target/parsed/foo.ail.json (derived). Even with the derived form
gitignored, it would be a working-tree fact during tests and a
shadow corpus for downstream tools to drift against. The single-
representation discipline that motivates this milestone is broken
the moment a derived .ail.json exists on disk anywhere a tool
might read it.
The in-process path is also already plumbed: since iter ext-cli.1
(2026-05-12), every path-taking CLI subcommand accepts .ail and
parses through ailang_surface::parse in-line. The Python bench
drivers (bench/check.py, bench/compile_check.py,
bench/cross_lang.py, bench/mono_dispatch.py) call ail check
etc. via subprocess and inherit the .ail acceptance for free —
they need only a path-suffix change. The integration tests under
crates/ail/tests/*.rs and the workspace tests under
crates/ailang-check/tests/*.rs currently fs::read_to_string a
.ail.json and pass the bytes to a loader; they will switch to
reading the .ail and calling ailang_surface::parse to recover
the same Module value.
A2. prelude.ail ships as iter-0 pilot
Iteration 0 converts exactly one file: examples/prelude.ail.json
→ examples/prelude.ail. Nothing else moves. The roundtrip-CI gate
covers it (every .ail with a same-stem .ail.json counterpart is
already byte-compared by every_ail_fixture_matches_its_json_counterpart),
which proves the conversion path end-to-end before the bulk
migration commits to it.
Why. Prelude exercises the widest cross-section of language
features in a single file: typeclasses with superclasses, polymorphic
free functions with constraints, IO-effect signatures, primitive
ctor patterns, and the loader's auto-injection path. If prelude
round-trips identically through ail render → ail parse → canonical bytes, no other fixture in the corpus introduces a feature this
file does not. The pilot validates the migration mechanism on the
hardest single file before the bulk migration commits the test-
infrastructure refactor.
Iter 0 keeps prelude.ail.json on disk alongside prelude.ail —
the deletion of redundant JSON-AST starts in iter 1 once the
test-infrastructure refactor lands. This is the only iteration
where a fixture is dual-form; it is justified by the existing
every_ail_fixture_matches_its_json_counterpart gate, which is
exactly the byte-comparison this iteration wants to leverage as a
witness.
A3. Per-file deletion cadence with iteration-end checkpoints
.ail.json files are deleted on the same iteration that materialises
their .ail counterpart, not at milestone end. The bulk migration is
sequenced across ~3 iterations of ~30-40 files each, each iteration
fully reaping its own .ail.json files before close. There is no
intermediate state where the bulk of fixtures are dual-form.
The exception is iter 0 (the prelude pilot, A2 above), which is the singular dual-form window: the JSON counterpart is the witness the existing CI gate uses, and the gate is the safety net for the pilot itself.
Why. A milestone-end bulk delete would mean the working tree spends weeks in a "which form is the source?" ambiguity. Anyone editing a fixture in the interim — to repair an unrelated bug, add a regression test, fix a hash drift — has no canonical guide to which form to edit. The single-representation property must hold on a per-commit basis, not as a milestone-end snapshot.
The trade-off is iteration sequencing pressure: every iteration that
ships in this milestone must leave the tree in a coherent
single-representation state. This is enforced by the per-iteration
roundtrip-CI rewrite (see Testing strategy below): once the
infrastructure flips in iter 1, fixtures without .ail.json are
the new normal, and fixtures with .ail.json are visibly in-flight.
A4. CLAUDE.md and DESIGN.md rewording
Two documents need substantive rewording in this milestone. Both
edits land in the same commit as the iteration that retires the
last non-carve-out .ail.json.
CLAUDE.md §"AILang — a language for LLM authors" opening:
Currently:
Machine readability over human readability. Source of truth is structured data (
.ail.json), not text.
After this milestone:
Machine readability over human readability. The canonical, hashable, content-addressed form is structured data (
.ail.json); the authoring projection is Form A (.ail). Authors write.ail; the build derives the JSON-AST in-process viaailang_surface::parse, gated by the round-trip invariant. The two forms are byte-isomorphic — picking either does not change the identity of the module.
DESIGN.md §"What this Decision deliberately does not do" (line 465-466), currently:
It does not promote form (A) to the source of truth. Form (A) is one projection; the JSON-AST stays canonical.
The first sentence is replaced; the second stays. New text:
It does not change which form is canonical: the JSON-AST remains the hashable, content-addressed representation, and all hashing, content-addressing, and cross-module references flow through it unchanged. What did change in 2026-05-13 (this milestone) is which form is authored: authors now write Form A; the JSON-AST is materialised in-process by callers that need it. The two forms are byte-isomorphic by the round-trip invariant.
The "Form (A) is one projection; the JSON-AST stays canonical" phrasing is preserved verbatim in the second sentence because it remains true: Form A and the eventual visual / graphical projections are still parallel projections of the same AST. What the milestone changes is which projection is the entry point, not the projection's relation to the AST.
Components
C1. Migration script
A one-shot Bash script scripts/migrate-to-form-a.sh (new file)
walks examples/*.ail.json, skips the eight carve-outs (named
explicitly in the script body), invokes ail render to produce
the sibling .ail, and rms the JSON counterpart. The script is
idempotent (re-running on a partially migrated tree is a no-op for
already-migrated files). The script is retired at milestone close
— it has no second use; keeping it would invite drift the next
time someone wonders "where did the JSON-AST come from".
C2. Test infrastructure refactor
48 files under crates/ and bench/ reference .ail.json paths.
The refactor is mechanical but spans three classes of consumer:
- Roundtrip tests (
crates/ailang-surface/tests/round_trip.rs,crates/ail/tests/roundtrip_cli.rs). The five-test invariant restructures (see Testing strategy §T below). - Fixture-loading integration tests (
crates/ail/tests/*.rs, ~35 files). Each test currently readsexamples/<stem>.ail.jsonwithfs::read_to_stringand passes the bytes toModule::from_jsonor the workspace loader. The refactor readsexamples/<stem>.ailand parses viaailang_surface::parse_module_from_str. The eight carve-outs continue to use the.ail.jsonpath (they have no.ailform by construction). - Bench drivers (
bench/*.py,bench/run.sh). The drivers spawnail checketc. with fixture paths. Since ext-cli.1 the subcommands accept both extensions; the drivers' fixture walks change fromglob("examples/*.ail.json")toglob("examples/*.ail")plus an explicit allow-list for the carve-outs.
C3. Roundtrip invariant restatement
§"Roundtrip Invariant" at DESIGN.md:2027 currently anchors on a
two-way .ail.json ↔ .ail bijection. After this milestone the
JSON-AST counterpart no longer exists for the bulk of the corpus,
so the invariant restates as:
- Parse-determinism. For every well-formed
.ailtextt,parse(t)produces a unique AST (the loader is a pure function; no randomness, no time dependence, no environment leak). - Idempotency under print.
canonical(parse(t)) == canonical(parse(print(parse(t))))— printing and re-parsing is a no-op on the canonical bytes. - CLI-pipeline idempotency. The public CLI pipeline
ail render | ail parse(one round) is byte-identical to directail parseof the source.ail. Pins the user-facing CLI against drift internal tests cannot see. - Carve-out anchor. The eight
.ail.json-only fixtures are negative-tests (load-time / typecheck-time rejection); they participate only in the existing rejection-shape tests, not in the roundtrip gate. Their presence is the only surviving reason.ail.jsonremains a first-class file extension.
The "JSON → text → JSON" direction (Direction 1 in the current
spec) loses its corpus once the JSON files are deleted. The
property is replaced by parse-determinism plus idempotency — the
two together discharge the load-bearing concern (hashing is well-
defined for any .ail source).
C4. The carve-outs (immutable list)
Two structural reasons keep .ail.json alive post-milestone:
(a) subject-matter carve-outs (seven files; the canonical
list as drafted) and (b) compile-time-embed carve-outs (one
file as of 2026-05-13; subject to a separate milestone). Both
gain no .ail sibling.
(a) Subject-matter carve-outs (immutable, seven files):
examples/test_ct1_bare_xmod_rejected.ail.json
examples/test_ct1_qualified_class_rejected.ail.json
examples/test_ct1_bad_qualifier.ail.json
examples/broken_unbound.ail.json
examples/test_22b2_invalid_superclass_param.ail.json
examples/test_22b2_kind_mismatch.ail.json
examples/test_22b2_unbound_constraint_var.ail.json
Justification per file:
test_ct1_*(3): assert canonical-form rejection of malformed type references. The Form-A parser would either accept the reference (test loses its subject) or reject it at parse time with a different diagnostic (test loses its meaning). The fixture's purpose is to test the loader/checker rejection shape, which requires the input to bypass the parser.broken_unbound: asserts unbound-variable rejection at typecheck. The.ail.jsonform encodes a structurally well-formed but semantically broken module that the Form-A parser would also parse cleanly; the file is retained in JSON-form not because the surface cannot express it but because the test already pins the exact JSON bytes, and re-deriving from.ailwould add no value while adding a regeneration step.test_22b2_*(3): assert class-definition rejection shapes (invalid superclass param, kind mismatch, unbound constraint var) that the Form-A parser would either reject at parse time (changing the diagnostic) or refuse to encode at all (changing the test subject).
Status: RETIRED 2026-05-14 by milestone prelude-decouple. The §C4 (b) compile-time-embed carve-out is empty post that milestone; the prelude is now authored exclusively as
examples/prelude.ailand parsed at compile time viaailang_surface::parse_prelude. The original §C4 (b) framing below is preserved as historical context; it documents the doctrine inconsistency that prelude-decouple resolved. Seedocs/specs/0028-prelude-decouple.mdand the per-iter journals 2026-05-14-iter-pd.{1,2,3}.md for the resolution.
(b) Compile-time-embed carve-outs (one file as of 2026-05-13):
examples/prelude.ail.json
Justification:
-
prelude.ail.jsonisinclude_str!-embedded atcrates/ailang-core/src/workspace.rs:417andcrates/ail/src/main.rs:474, then parsed viaserde_json::from_strat compile-time-fixed sites in production code. Switching the embed toprelude.ailwould require parsing viaailang_surface::parse, butailang-corecannot importailang-surface(crate-cycle constraint documented atcrates/ailang-surface/src/loader.rs:9-13). The mechanism is a structurally distinct from the runtime-loaded-program corpus the rest of this milestone migrates:.ail.jsoncarve-outs in category (a) are kept because their content cannot be expressed in Form A;prelude.ail.jsonis kept because its embed mechanism requires the JSON-AST form.This is a real spec gap relative to §A4's authoring-doctrine ("authors write
.ail; the build derives the JSON-AST in-process viaailang_surface::parse"). The prelude is currently authored as.ail.jsonand embedded as bytes; the doctrine says it should be authored as.ailand parsed at compile time. Resolving the inconsistency requires either (i) splitting the workspace loader so prelude-init lives inailang-surface, (ii) generatingprelude.ail.jsonat build time fromprelude.ailvia abuild.rs, or (iii) some other refactor — all of which exceed the scope of this milestone.The resolution lives in a separate roadmap milestone: "Prelude embed: Form-A as compile-time source" (see
docs/roadmap.md). After that milestone closes, this carve-out category becomes empty and the spec text drops it.Note:
examples/prelude.ailwas rendered in iter form-a.0 and remains the authoring projection (it stays in the tree; both forms coexist for prelude specifically, mirroring the iter-0 pilot's dual-form witness arrangement, until the prelude-embed-refactor milestone resolves the inconsistency).
Future additions to either list require brainstorm-level justification on the same structural grounds — not "this test is annoying to migrate" or "the diff is large", but a named mechanism (canonical-form rejection / compile-time embed / etc.) that the surface cannot satisfy.
Data flow
Before this milestone:
author writes: examples/foo.ail.json
|
v
[loader / serde_json]
|
v
Module value -> typecheck, codegen, ...
After iter 0 (prelude pilot only):
author writes: examples/prelude.ail (new)
|
v
[ailang_surface::parse]
|
v
Module value -> typecheck, codegen, ...
[byte-witness]
examples/prelude.ail.json ----+ (retained
| | for iter 0,
v | deleted iter 1)
[loader / serde_json] |
| |
v |
Module value -----> canonical -------+
After milestone close (bulk migration done, JSON counterparts deleted):
author writes: examples/<name>.ail (every program)
|
v
[ailang_surface::parse]
|
v
Module value -> typecheck, codegen, ...
except for the eight carve-outs:
examples/<carve-out>.ail.json
|
v
[loader / serde_json] (negative-test only)
Cross-module imports continue to use module names, not file paths;
the workspace loader walks examples/* and accepts either extension
for any module it loads. Since the bulk of the corpus is single-
extension post-migration, the loader's extension-dispatch is exercised
only by the eight carve-outs (and by any test that explicitly mixes
the two forms for migration testing).
Error handling
The migration is mechanical and the round-trip invariant discharges correctness. There are three failure modes the spec must name explicitly because they could escape the existing CI net:
E1. A fixture round-trips before migration but the migrated .ail
fails to parse. This would be a bug in ail render or
ail parse; the round-trip CI gate
(every_ail_fixture_matches_its_json_counterpart) already covers
it for every fixture with a hand-written .ail sibling. The
migration script extends the same property to every fixture by
construction (the script's output is exactly the witness the gate
demands). If iter 0 (prelude pilot) fails this gate, the bug is
in render/parse; the milestone halts and the bug fix takes
precedence.
E2. A test that depends on the exact byte shape of .ail.json
breaks after migration. Some tests (e.g. snapshot tests) compare
byte-equal canonical JSON to a checked-in expected blob. These
tests do not break — the canonical bytes are derived from the AST,
and the AST is form-agnostic. The migration touches only the
fixture-loading path, not the canonical-encoding path.
E3. A bench driver receives a .ail path and emits stale
.ail.json paths in its report. The bench drivers' report format
echoes the fixture name; the suffix change from .ail.json to
.ail propagates into report bytes. The audit-* JSONs under
bench/orchestrator-stats/ will start carrying .ail paths after
the next milestone-close audit; this is intended and not a
regression.
A failure mode the spec explicitly does not worry about: parse non-determinism. The roundtrip-CI gate has been green across 9 consecutive audits (audit-cma onward); the parser is well- exercised.
Testing strategy
T1. Roundtrip-CI test restatement
crates/ailang-surface/tests/round_trip.rs currently runs three
tests against the .ail.json corpus. Post-migration, the three
restate:
| Pre-migration test | Post-migration test | Behaviour |
|---|---|---|
print_then_parse_round_trips_every_fixture (over .ail.json) |
replaced by parse_is_deterministic_over_every_ail_fixture (over .ail) |
Reads every .ail, parses twice, asserts canonical-byte equality between the two parses. Asserts pure-function-of-input. |
parse_then_print_then_parse_is_idempotent_on_every_ail_fixture |
unchanged | Reads every .ail, asserts parse == parse ∘ print ∘ parse. |
every_ail_fixture_matches_its_json_counterpart |
retired | Loses its corpus. The property it pinned (no drift between two hand-authored forms) is no longer expressible because only one form is hand-authored. |
crates/ail/tests/roundtrip_cli.rs::cli_render_then_parse_preserves_canonical_bytes_on_every_fixture
restates as cli_parse_then_render_then_parse_is_idempotent:
reads every .ail, runs ail parse (to canonical JSON tempfile),
runs ail render (back to .ail tempfile), runs ail parse
again, and asserts byte equality across the two parses.
crates/ailang-core/tests/schema_coverage.rs::every_ast_variant_is_observed_in_the_fixture_corpus
flips its corpus walk from .ail.json to .ail (load via
ailang_surface::parse_module_from_str); the assertion is
unchanged. The schema-coverage property is the strictest gate
that survives: a new AST variant cannot land without a fixture
that uses it.
T2. Per-iteration regression discipline
Every iteration in this milestone must leave cargo test --workspace green and bench/compile_check.py exit-0. The
migration is mechanical; non-green at iteration close is a defect
in the iteration's test-infrastructure refactor, not in the
language. The iteration does not close until the refactor is
green.
T3. Test-naming discipline for the migration
A few existing tests carry .ail.json in their test-function names
(e.g. mono_recursive_fn_loads_from_json_ast). These names are
renamed in the same iteration that migrates the corresponding
fixture: the new name should not lie about what file it reads.
This is mechanical, but it has to land in the migration commit, not
as a follow-up rename.
T4. Carve-out coverage check
A new test crates/ailang-core/tests/carve_out_inventory.rs
asserts that exactly the eight named carve-out files exist under
examples/*.ail.json after milestone close (seven from §C4 (a) +
one from §C4 (b)). The test fails if a new .ail.json is added
or a carve-out goes missing. The list is hardcoded in the test
body — this is intentional: the carve-out list is part of the
language's negative-test surface and any change to it is a
deliberate, brainstorm-level decision. The test's pass-count
drops to seven once the prelude-embed-refactor milestone retires
prelude.ail.json from category (b); the test body is then
re-published with the smaller list in that milestone's iter.
Acceptance criteria
The milestone closes when all of the following are true:
ls examples/*.ail.json | wc -lreturns8, and the eight files match the carve-out list verbatim (§C4 category (a) + category (b)).- Every program in
examples/other than the eight carve-outs exists as a.ailfile with no.ail.jsonsibling.examples/prelude.ailexists in addition toexamples/prelude.ail.jsonper §C4 (b) (dual-form for prelude only, pending the prelude-embed-refactor milestone). - The roundtrip-CI restatement (T1) is green: parse is deterministic, idempotent under print, CLI-pipeline idempotent, and the carve-out inventory test (T4) passes.
cargo test --workspaceis green at milestone close (no regressions introduced by the test-infrastructure refactor).bench/compile_check.pyandbench/cross_lang.pyexit 0 against the migrated corpus.bench/check.pyis either exit 0 or exit 1 with audit- ratifiable noise only (consistent with the 9-audit lineage since audit-cma); a new regression class attributable to the migration triggers brainstorm-level escalation.- CLAUDE.md and DESIGN.md carry the reworded paragraphs (§A4 above) verbatim.
- The migration script
scripts/migrate-to-form-a.shis deleted from the working tree (one-shot, retired). docs/WhatsNew.mdcarries a milestone-close entry framed for the user-as-reader: what changed in the authoring experience, not what changed in the internal representation.- The
[milestone] Form-A as the default authoring surfaceentry indocs/roadmap.mdis checked off and removed.
Iteration scope
The first iteration covers §A2 (prelude pilot) only:
- Render
examples/prelude.ail.json→examples/prelude.ailviaail render. - Do not delete
prelude.ail.jsonyet (iter 1 deletes it alongside the bulk migration of test infrastructure). - Verify the existing
every_ail_fixture_matches_its_json_counterparttest is green on the new.ail. - No test-infrastructure changes; no CLAUDE.md / DESIGN.md edits yet.
Iter 1 onward is out of scope for the first plan; the planner re-derives iter scope from this spec each iteration.