19 KiB
form_a.md scalar-parameter mode carve-out — Implementation Plan
Parent spec:
docs/specs/2026-05-18-fieldtest-embedding-abi-m1.md(findings [friction] "The published authoring rule steers an LLM away from the only spelling that works for scalar export params" + [spec_gap]#1 "form_a.md mandates a mode on every fn param; scalar params accept (and require) no mode"). Orchestrator design decision recorded indocs/roadmap.mdP1 entry "[todo] form_a.md scalar-parameter mode carve-out". This is a docs-honesty tidy in thedocs-honesty-lintclass, NOT a milestone — there is nodocs/specs/<milestone>.md; the fieldtest spec + roadmap entry are the design carrier.For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Reconcile crates/ailang-core/specs/form_a.md and
docs/DESIGN.md §"Embedding ABI (M1)" with the actual checker
behaviour — scalar (Int/Bool/Unit/Str) (fn …) parameters
take no mode and are written bare, mirroring the existing
return-type carve-out — and pin the corrected state against
regrowth.
Architecture: Pure documentation + a regression pin. Zero
language / checker / codegen change: the iteration only edits two
docs and adds one #[test]. The checker behaviour being documented
is already shipped and unchanged (recon Section D: the linearity
pass in crates/ailang-check/src/linearity.rs:324-329 skips entirely
unless all params carry an explicit mode; a mode on a scalar makes
the checker hold the primitive to linear discipline, so an ordinary
reuse trips use-after-consume / consume-while-borrowed pointed at
the body — crates/ailang-check/src/linearity.rs:757 / :1074). The
fix is RED-first: the pin is written first asserting the post-edit
state, fails (new carve-out absent + old unconditional phrasing still
present), then the doc edits turn it green.
Tech Stack: crates/ailang-core/specs/form_a.md,
docs/DESIGN.md, crates/ailang-core/tests/docs_honesty_pin.rs
(uses ailang_core::FORM_A_SPEC — the include_str!'d constant at
crates/ailang-core/src/lib.rs:120, the single canonical in-code
handle for that spec, also used by tests/spec_drift.rs; the
DESIGN.md half reuses the file's existing read("docs/DESIGN.md")
helper). spec_drift.rs is not a lockstep partner — recon
confirmed all its FORM_A_SPEC anchors are short keyword tokens
((own / (borrow survive because the carve-out adds, never
removes, the mode wrappers); no spec_drift.rs assertion pins any
altered text.
Scope: four form_a.md sites + one DESIGN.md insertion + one pin.
Recon found the unconditional rule restated in four places inside
form_a.md; all four are in scope because a docs-honesty tidy whose
thesis is "the doc mirrors only the true current state, consistently"
cannot fix some and leave others contradicting them (that ships the
exact internal inconsistency the pin class forbids). Site 5
(form_a.md:521-524, a few-shot annotation) already states the
correct rule using the canonical "heap-shaped / primitive value
type" vocabulary — it is left verbatim (no churn), and the new
carve-out text deliberately reuses its framing so the document is
internally consistent end to end.
Files this plan creates or modifies
- Modify:
crates/ailang-core/specs/form_a.md:96-98— site 1, the### Functionprose mode sentence. - Modify:
crates/ailang-core/specs/form_a.md:230— site 2, the grammar-blockimplicit modecomment. - Modify:
crates/ailang-core/specs/form_a.md:347-352— site 3, "Schema invariants" item 1. - Modify:
crates/ailang-core/specs/form_a.md:381-383— site 4, the "Forgetting mode annotations" Pitfalls bullet. - Modify:
docs/DESIGN.md:2285— insert the canonical bare-scalar export-param Form-A example after the §"Embedding ABI (M1)" scalar-constraint paragraph, before## Data model(2287). - Test:
crates/ailang-core/tests/docs_honesty_pin.rs:12/:26— adduse ailang_core::FORM_A_SPEC;and a new#[test] fn form_a_scalar_param_carveout_present_and_old_rule_absent.
Files that do NOT change (verified by recon, recorded so the
implementer does not "tidy" them): crates/ailang-core/specs/form_a.md:521-524
(already-correct few-shot annotation, left verbatim);
crates/ailang-core/tests/spec_drift.rs (not a lockstep partner);
crates/ailang-core/src/lib.rs (only defines the const);
any crates/ailang-check/** source (zero behaviour change).
Task 1: RED — write the regression pin asserting the post-edit state
Files:
-
Test:
crates/ailang-core/tests/docs_honesty_pin.rs -
Step 1: Add the
FORM_A_SPECimport
In crates/ailang-core/tests/docs_honesty_pin.rs, the current line 12 is:
use std::fs;
Replace it with:
use ailang_core::FORM_A_SPEC;
use std::fs;
(FORM_A_SPEC is pub const FORM_A_SPEC: &str at
crates/ailang-core/src/lib.rs:120; this integration test is in the
ailang-core crate's tests/ dir and may use the crate's public
API, exactly as tests/spec_drift.rs:17 does.)
- Step 2: Append the new pin test
Append this #[test] fn to the END of
crates/ailang-core/tests/docs_honesty_pin.rs (after the closing
} of prose_roundtrip_md_has_no_wunschdenken, which is the last
item, currently line 100):
#[test]
fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
// form_a.md is read via the canonical include_str! const, not a
// re-derived path: single source of truth, compile-checked, no
// stale-path bug. norm() collapses the docs' hard line-wrap so
// every asserted substring below is the single-spaced form
// (planner Step-5 item 6: line-wrap is structurally discharged,
// the substrings need not be contiguous in the .md source).
let f = norm(FORM_A_SPEC);
let d = norm(&read("docs/DESIGN.md"));
// --- ABSENT: the four old unconditional phrasings must be gone ---
assert!(!f.contains("Mode annotations on every `(fn ...)` parameter."),
"form_a.md item 1 must no longer head with the unconditional 'every (fn ...) parameter' rule (site 3)");
assert!(!f.contains("All parameters of a `(fn ...)` MUST carry a mode annotation"),
"form_a.md ### Function prose must no longer state the unconditional all-params rule (site 1)");
assert!(!f.contains("(DO NOT USE in new (fn ...) defs)"),
"form_a.md grammar block must no longer blanket-forbid the bare (implicit-mode) form (site 2)");
assert!(!f.contains("Wrap every `(fn ...)` parameter in `(own ...)` or `(borrow ...)`."),
"form_a.md Pitfalls bullet must no longer state the unconditional 'wrap every parameter' imperative (site 4)");
// --- PRESENT: the scalar-parameter carve-out, the four sites ---
assert!(f.contains("A scalar (non-heap-shaped) parameter or return takes **no** mode: write it bare, e.g. `(con Int)`."),
"form_a.md item 1 must state the scalar-parameter carve-out symmetrically with the return-type carve-out (site 3)");
assert!(f.contains("a scalar (non-heap-shaped: `(con Int)`, `(con Bool)`, `(con Unit)`, `(con Str)`) takes **no** mode and is written bare"),
"form_a.md ### Function prose must carry the scalar carve-out (site 1)");
assert!(f.contains("; no mode — REQUIRED for scalars (Int/Bool/Unit/Str), rejected for heap-shaped"),
"form_a.md grammar block comment must state the bare form is required for scalars (site 2)");
assert!(f.contains("Putting a mode on a scalar parameter."),
"form_a.md Pitfalls must carry the inverse pitfall — over-wrapping a scalar (site 4)");
// --- PRESENT: the DESIGN.md §\"Embedding ABI (M1)\" mirror ---
assert!(d.contains("Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode"),
"DESIGN.md §Embedding ABI (M1) must mirror the canonical bare-scalar export-param rule");
}
- Step 3: Run the pin — it MUST FAIL (this is the RED)
Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent
Expected: FAIL. The first failing assertion is the first ABSENT
check — form_a.md item 1 must no longer head with the unconditional 'every (fn ...) parameter' rule (site 3) — because the old phrasing
Mode annotations on every \(fn ...)` parameter.is still present inform_a.md` (the doc edits are Tasks 2–3). This proves the pin
discriminates the pre-edit state.
- Step 4: Confirm the rest of the file's pins are unaffected
Run: cargo test -p ailang-core --test docs_honesty_pin
Expected: the four pre-existing tests
(design_md_has_no_wunschdenken, design_md_has_no_doc_archaeology,
design_md_present_tense_anchors_present,
prose_roundtrip_md_has_no_wunschdenken) PASS; only
form_a_scalar_param_carveout_present_and_old_rule_absent FAILS.
("test result: FAILED. 4 passed; 1 failed".)
Task 2: form_a.md — the four carve-out sites
Files:
- Modify:
crates/ailang-core/specs/form_a.md
Each step Reads nothing extra — the verbatim current text is given
as old and the replacement as new. The implementer applies each
as an exact string replacement.
- Step 1: Site 1 —
### Functionprose (lines 96-98)
Replace, verbatim:
old:
`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for
polymorphic defs). All parameters of a `(fn ...)` MUST carry a mode
annotation — see *Modes* below.
new:
`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for
polymorphic defs). A *heap-shaped* parameter or return of a
`(fn ...)` MUST carry a mode annotation; a scalar (non-heap-shaped:
`(con Int)`, `(con Bool)`, `(con Unit)`, `(con Str)`) takes **no**
mode and is written bare — see *Modes* below.
- Step 2: Site 2 — grammar-block comment (line 230)
Replace, verbatim (single line):
old:
TYPE ; implicit mode (DO NOT USE in new (fn ...) defs)
new:
TYPE ; no mode — REQUIRED for scalars (Int/Bool/Unit/Str), rejected for heap-shaped
- Step 3: Site 3 — "Schema invariants" item 1 (lines 347-352)
Replace, verbatim:
old:
1. **Mode annotations on every `(fn ...)` parameter.** Every type in
the `(params ...)` clause of a `(fn ...)` definition's
`(fn-type ...)` MUST be wrapped in `(own T)` or `(borrow T)`. The
return type MUST also carry a mode whenever the type is heap-shaped
(i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`,
`(con Str)`). Implicit mode on a `(fn ...)` def is rejected.
new:
1. **Mode annotations on every heap-shaped `(fn ...)` parameter.** A
parameter type in the `(params ...)` clause of a `(fn ...)`
definition's `(fn-type ...)`, and the return type, MUST be wrapped
in `(own T)` or `(borrow T)` whenever the type is heap-shaped
(i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`,
`(con Str)`). A scalar (non-heap-shaped) parameter or return takes
**no** mode: write it bare, e.g. `(con Int)`. Scalars are
primitive value types, not linear resources; putting a mode on a
scalar makes the checker hold the primitive to linear discipline
(single use under `own`, no use while a `borrow` is live), so an
ordinary scalar reused in the body (`sample * sample`) trips a
body-pointing `use-after-consume` / `consume-while-borrowed`
rather than a mode error. Implicit (omitted) mode on a
*heap-shaped* `(fn ...)` parameter or return is rejected.
- Step 4: Site 4 — Pitfalls bullet (lines 381-383)
Replace, verbatim:
old:
- **Forgetting mode annotations.** `(fn-type (params (con List)) ...)`
is accepted by the parser but rejected by the checker. Wrap every
`(fn ...)` parameter in `(own ...)` or `(borrow ...)`.
new:
- **Forgetting mode annotations on a heap-shaped parameter.**
`(fn-type (params (con List)) ...)` is accepted by the parser but
rejected by the checker. Wrap every *heap-shaped* `(fn ...)`
parameter in `(own ...)` or `(borrow ...)`.
- **Putting a mode on a scalar parameter.** The inverse trap:
`(fn-type (params (own (con Int))) ...)` is wrong — scalars
(`Int`/`Bool`/`Unit`/`Str`) take no mode, write them bare
`(con Int)`. A mode on a scalar makes the checker treat the
primitive as a linear resource, so an ordinary reuse trips a
body-pointing `use-after-consume` / `consume-while-borrowed`
instead of pointing at the signature.
- Step 5: Run the pin — form_a.md half now GREEN, DESIGN half still RED
Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent
Expected: still FAIL, but now the only failing assertion is the
last one — DESIGN.md §Embedding ABI (M1) must mirror the canonical bare-scalar export-param rule (the four form_a.md ABSENT + four
form_a.md PRESENT assertions now all pass; DESIGN.md is edited in
Task 3). If any form_a.md assertion still fails, an exact-string
replacement above did not match — re-read the named line range and
fix the replacement, do not weaken the assertion.
- Step 6: Confirm spec_drift.rs is unaffected (it is not a lockstep partner)
Run: cargo test -p ailang-core --test spec_drift
Expected: PASS, all 8 tests
(spec_mentions_every_term_variant,
spec_mentions_every_pattern_variant,
spec_mentions_every_type_variant,
spec_mentions_every_literal_variant,
spec_mentions_every_def_kind, spec_mentions_mode_keywords,
spec_mentions_tail_variants, spec_mentions_suppress_clause).
spec_mentions_mode_keywords pins only the bare (own / (borrow
tokens, which the carve-out keeps; this run confirms the recon
finding that no spec_drift.rs assertion couples to the altered
text.
Task 3: DESIGN.md §"Embedding ABI (M1)" mirror
Files:
-
Modify:
docs/DESIGN.md:2285 -
Step 1: Insert the canonical bare-scalar export-param example
In docs/DESIGN.md, the §"Embedding ABI (M1)" scalar-constraint
paragraph ends at line 2285 (The value/record layout freeze is M3.), followed by a blank line (2286) and ## Data model (2287).
Replace, verbatim:
old:
change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.
## Data model
new:
change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.
Export parameters are written **bare**: a scalar type carries no
`own`/`borrow` mode (modes apply only to heap-shaped types, which the
scalar-only rule above forbids at an export boundary anyway). The
canonical M1 export shape:
(fn step (export "backtest_step") (type (fn-type (params (con Int) (con Int)) (ret (con Int)))) (params state sample) (body (app + state (app * sample sample))))
## Data model
(This Form-A snippet is the step fn of the public
examples/embed_backtest_step.ail corpus fixture — recon confirmed
lines 3-11 — so the example is corpus-grounded, not minted.)
- Step 2: Run the pin — now fully GREEN
Run: cargo test -p ailang-core --test docs_honesty_pin form_a_scalar_param_carveout_present_and_old_rule_absent
Expected: PASS. All ABSENT + PRESENT assertions hold.
- Step 3: Full docs_honesty_pin suite GREEN
Run: cargo test -p ailang-core --test docs_honesty_pin
Expected: PASS, "test result: ok. 5 passed; 0 failed" (the four pre-existing tests + the new carve-out pin).
Task 4: Regression gate — zero behavioural drift
Files: (none — verification only)
- Step 1: Full
ailang-coretest suite
Run: cargo test -p ailang-core
Expected: PASS, no test regressed. In particular spec_drift
(8) and docs_honesty_pin (5) green; all pre-existing ailang-core
tests green. Record the total ("N passed; 0 failed").
- Step 2: Workspace-wide no-behavioural-drift assertion
This iteration changes only two docs and adds one test — no language / checker / codegen byte changes. The full workspace suite must therefore not lose any passing test.
Run: cargo test --workspace 2>&1 | grep -E "test result:" | awk '{p+=$4} END {print "TOTAL_PASSED="p}'
Expected: TOTAL_PASSED= a number ≥ the count on main HEAD before
this iteration (the only delta is +1, the new
form_a_scalar_param_carveout_present_and_old_rule_absent). If the
total is lower than the pre-iteration baseline, a pre-existing test
broke — STOP and investigate; a docs+pin iteration cannot regress a
behavioural test, so any drop is a real problem, not an expected
cost. (Unfiltered suite + explicit pass-count per planner Step-5
item 8 — "nothing ran" cannot masquerade as "nothing regressed".)
- Step 3: Confirm zero source-code change
Run: git diff --stat -- crates/ailang-check crates/ailang-codegen crates/ailang-core/src crates/ailang-surface
Expected: empty output (no diff). The only modified paths are
crates/ailang-core/specs/form_a.md, docs/DESIGN.md, and
crates/ailang-core/tests/docs_honesty_pin.rs. Any source-code diff
under those crate src/ trees is out of scope for this tidy and a
defect — STOP.
Self-review (planner Step 5)
- Spec coverage: [friction] + [spec_gap]#1 both resolved — form_a.md carve-out (Task 2, four sites) + DESIGN.md mirror (Task 3) + anti-regression pin (Task 1). Roadmap entry's "symmetrically with the existing return-type carve-out" satisfied (item 1 new text scopes params to heap-shaped exactly as the pre-existing return-type clause does). ✔
- Placeholder scan: no "TBD/TODO/implement later/similar to/add
appropriate". The literal string
; no mode — REQUIRED for scalars …and the test phrases are content, not placeholders. ✔ - Type/name consistency: test fn
form_a_scalar_param_carveout_present_and_old_rule_absentnamed identically in Task 1 Step 2/3, Task 2 Step 5, Task 3 Step 2.FORM_A_SPEC,norm,readmatch the actual file. ✔ - Step granularity: every step is one exact replacement or one command, 2-5 min. ✔
- No commit steps: none. ✔
- Pin/replacement substring contiguity: the pin
norm()s both docs and every asserted substring is written single-spaced; thenorm()whitespace-collapse structurally discharges the line-wrap family (the docs-honesty-lint precedent'snorm()is reused verbatim, not a fresh keep-on-one-line constraint). Every PRESENT substring appears single-spaced-contiguously inside the exactnewreplacement bodies in Tasks 2–3 (verified: e.g. "A scalar (non-heap-shaped) parameter or return takes no mode: write it bare, e.g.(con Int)." is one clause in the Task 2 Step 3newblock; line-wrap between "takes" and "no" is collapsed bynorm()). Every ABSENT substring is verbatim contiguous in the correspondingoldblock and verbatim gone from thenewblock. ✔ - Compile-gate vs. deferred-caller ordering: N/A — no signature
change, no crate-wide compile break; the only Rust change is one
additive
#[test]+ oneuse. Task ordering is RED-pin → doc edits → green, the docs-honesty-lint pattern. ✔ - Verification-command filter strings resolve:
--test docs_honesty_pinand--test spec_driftare real test-target files (recon-confirmed); the filterform_a_scalar_param_carveout_present_and_old_rule_absentis the exact name of the test added in Task 1 (not guessed from a feature word). Task 4 Step 2 uses the unfiltered workspace suite + an explicitTOTAL_PASSEDcount assertion so "0 ran" cannot read as "0 regressed". ✔