# design-md-consolidation iter 3 — sweep 3: schema SoT inversion + data-model hardening

> **Parent spec:** `docs/specs/2026-05-10-design-md-consolidation.md`
> §"Sweep 3 — Schema SoT inversion + Data-model hardening".
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.

**Goal:** Invert the schema source-of-truth relationship between
`docs/DESIGN.md` §"Data model" and `crates/ailang-core/src/ast.rs`
so DESIGN.md is canonical and `ast.rs` follows; remove the Rust
type definitions inside DESIGN.md (which made it look secondary);
add a drift test that fails fast when an `ast.rs` enum variant
loses its DESIGN.md anchor.

**Architecture:** Three-substance refactor + one test. (1) Two
inline Rust code blocks inside Decision 10 are replaced with
prose pointers to §Data-model; (2) the §Data-model opener loses
the "ast.rs is the source of truth" disclaimer and gains a
"DESIGN.md is canonical" sentence; (3) ast.rs's module doc-comment
is updated to name DESIGN.md §"Data model" as canonical and to
name the new drift test as the enforcement; (4) a new test file
`crates/ailang-core/tests/design_schema_drift.rs` (analogous to
the existing `spec_drift.rs`) walks exhaustive matches over
`Term` / `Pattern` / `Type` / `Def` / `Literal` / `ParamMode` and
asserts a JSON-schema anchor for each variant exists in DESIGN.md.

**Tech Stack:** Edit (`docs/DESIGN.md`,
`crates/ailang-core/src/ast.rs`); Write
(`crates/ailang-core/tests/design_schema_drift.rs`); cargo test.

**Files this plan creates or modifies:**
- Modify: `docs/DESIGN.md` — Decision 10 Rust code blocks removed
  (Task 1); §Data-model opener inverted (Task 2).
- Modify: `crates/ailang-core/src/ast.rs` — module-level doc-comment
  updated (Task 3).
- Create: `crates/ailang-core/tests/design_schema_drift.rs` —
  drift test with exhaustive match per enum (Task 4).
- Append: `docs/JOURNAL.md` — iter entry (Task 5).
- Modify: `docs/roadmap.md` — Sweep 3 `[ ]` → `[x]` (Task 5).

---

## Task 1: Remove Rust code blocks from Decision 10

**Files:** Modify `docs/DESIGN.md` (2 sites: Rust code block opening
at line ~1027 and at line ~1132 — line numbers may have shifted by
prior tasks; re-locate at Step 1).

The two Rust code blocks inside Decision 10 illustrate the schema
in Rust — but DESIGN.md's job is to specify JSON. The Rust form is
in `ast.rs` (sweep-3 inversion makes that explicit). Each code
block is replaced with a one-line pointer to §Data-model.

- [ ] **Step 1: Pre-test — locate the code blocks**

```bash
grep -n '^```rust' docs/DESIGN.md
```

Expected: 2 matches.

```bash
grep -nE '^\s*(struct |enum |pub (struct|enum|fn))' docs/DESIGN.md
```

Expected: 2 matches (the `enum ParamMode` and `struct Suppress`
inside the code blocks).

- [ ] **Step 2: Replace the first Rust code block (Type::Fn + ParamMode)**

The block is preceded by the prose `Internally, this is *not* a new
\`Type\` variant. Modes are metadata on \`Type::Fn\`:` and is
followed by the prose `The substantive reasons for per-position
metadata over a \`Type::Borrow\` / \`Type::Own\` variant approach:`.

Edit:
- old:
```
Internally, this is *not* a new `Type` variant. Modes are
metadata on `Type::Fn`:

```rust
Type::Fn {
    params: Vec<Type>,
    param_modes: Vec<ParamMode>,   // same length as params
    ret: Box<Type>,
    ret_mode: ParamMode,
    effects: Vec<String>,
}

enum ParamMode { Implicit, Own, Borrow }   // default: Implicit
```

The substantive reasons for per-position metadata over a
```
- new:
```
Internally, this is *not* a new `Type` variant. Modes are
metadata on `Type::Fn` — `paramModes` and `retMode` fields run
parallel to `params` and `ret` (see §"Data model" for the JSON
schema). The substantive reasons for per-position metadata over a
```

Verification: the surrounding paragraph reads coherently after the
substitution; the `ParamMode` mention now flows into the §"Data
model" pointer, and the next sentence (`Type::Borrow` / `Type::Own`
contrast) continues seamlessly.

- [ ] **Step 3: Replace the second Rust code block (Suppress)**

The block is preceded by the prose `**\`FnDef.suppress\`.**` (a bold
header on its own line) and is followed by the prose `Form-A
surface: \`(suppress (code "...") (because "..."))\` clause`.

Edit:
- old:
```
**`FnDef.suppress`.**

```rust
FnDef.suppress: Vec<Suppress>                        ; advisory diagnostic suppress list

struct Suppress {
    code: String,        // diagnostic code being suppressed
    because: String,     // mandatory non-empty reason
}
```

Form-A surface: `(suppress (code "...") (because "..."))` clause
```
- new:
```
**`FnDef.suppress`.** The `suppress` field on `FnDef` carries a
list of advisory-diagnostic suppress entries; each entry has a
`code` (the diagnostic being suppressed) and a `because` (a
mandatory non-empty reason). See §"Data model" for the canonical
schema.

Form-A surface: `(suppress (code "...") (because "..."))` clause
```

- [ ] **Step 4: Post-test**

```bash
grep -n '^```rust' docs/DESIGN.md
```

Expected: empty.

```bash
grep -nE '^\s*(struct |enum |pub (struct|enum|fn))' docs/DESIGN.md
```

Expected: empty.

- [ ] **Step 5: Commit**

```bash
git add docs/DESIGN.md
git commit -m "design-md-consolidation 3.1: remove 2 Rust code blocks from Decision 10 (Type::Fn + Suppress) — schema lives in §Data-model"
```

---

## Task 2: Invert §Data-model SoT relationship

**Files:** Modify `docs/DESIGN.md` (lines ~1722-1730 at iter start
in the original file; shifted by prior sweeps and Task 1; re-locate
at Step 1).

The §Data-model opener currently reads:

> The on-disk JSON-AST is what the toolchain hashes, typechecks, and
> lowers. Every node in this section is the schema mirror of an enum or
> struct in `crates/ailang-core/src/ast.rs`; whenever the two disagree,
> `ast.rs` is the source of truth. Every additive field is declared with
> `skip_serializing_if` so pre-existing fixtures keep bit-identical
> canonical-JSON hashes — that gating contract is what makes growing
> the schema cheap.

Two issues:
1. "schema mirror of an enum or struct in ast.rs" frames ast.rs as
   primary.
2. "whenever the two disagree, ast.rs is the source of truth" makes
   the SoT inversion explicit in the wrong direction.

The inverted opener:

- [ ] **Step 1: Pre-test — locate the disclaimer**

```bash
grep -n 'whenever the two disagree' docs/DESIGN.md
```

Expected: 1 match.

```bash
grep -n '^## Data model' docs/DESIGN.md
```

Expected: 1 match (around line 1722 in original numbering).

- [ ] **Step 2: Replace the opener**

Edit:
- old:
```
## Data model

The on-disk JSON-AST is what the toolchain hashes, typechecks, and
lowers. Every node in this section is the schema mirror of an enum or
struct in `crates/ailang-core/src/ast.rs`; whenever the two disagree,
`ast.rs` is the source of truth. Every additive field is declared with
`skip_serializing_if` so pre-existing fixtures keep bit-identical
canonical-JSON hashes — that gating contract is what makes growing
the schema cheap.
```
- new:
```
## Data model

The on-disk JSON-AST is what the toolchain hashes, typechecks, and
lowers. **This section is the canonical schema.** The Rust types in
`crates/ailang-core/src/ast.rs` are the in-memory projection of it;
when the two disagree, this section wins, and the drift test
`crates/ailang-core/tests/design_schema_drift.rs` fires. Every
additive field is declared with `skip_serializing_if` so pre-existing
fixtures keep bit-identical canonical-JSON hashes — that gating
contract is what makes growing the schema cheap.
```

The new opener:
- States DESIGN.md is canonical (the prominent sentence the spec
  asks for, in bold).
- Names ast.rs as the projection, not the source.
- Cites the drift test as the enforcement mechanism.
- Preserves the `skip_serializing_if` gating-contract sentence
  unchanged.

- [ ] **Step 3: Post-test**

```bash
grep -n 'whenever the two disagree' docs/DESIGN.md
```

Expected: empty.

```bash
grep -n 'This section is the canonical schema' docs/DESIGN.md
```

Expected: 1 match.

```bash
grep -n 'design_schema_drift.rs' docs/DESIGN.md
```

Expected: 1 match (the new prose pointer; the test file does not
yet exist in the tree but the prose anchors it for Task 4).

- [ ] **Step 4: Commit**

```bash
git add docs/DESIGN.md
git commit -m "design-md-consolidation 3.2: invert §Data-model SoT — DESIGN.md canonical, ast.rs projection, drift test enforces"
```

---

## Task 3: Update ast.rs module doc-comment

**Files:** Modify `crates/ailang-core/src/ast.rs` lines 1-17 (the
file-level `//!` doc-comment block).

The current doc-comment names DESIGN.md but does not establish the
SoT relationship.

- [ ] **Step 1: Pre-test — read the current doc-comment**

```bash
sed -n '1,17p' crates/ailang-core/src/ast.rs
```

Expected: a 17-line `//!` block opening with `//! AST nodes for the
AILang language.` and naming DESIGN.md as the documentation source.

- [ ] **Step 2: Replace the doc-comment**

Edit `crates/ailang-core/src/ast.rs`:
- old:
```rust
//! AST nodes for the AILang language.
//!
//! Every type in this module is the in-memory mirror of a node in the
//! AILang JSON schema documented in `docs/DESIGN.md`. The serde
//! attributes carry the schema: field renames (`as`, `type`, `fn`,
//! `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`), and
//! `skip_serializing_if` predicates that keep the canonical-JSON
//! representation backwards compatible across schema extensions.
//!
//! The entry type is [`Module`]. The two helpers [`def_name`] and
//! [`def_kind`] are intended for tools (`ail diff`, `ail manifest`) that
//! consume a [`Def`] without going through method calls.
//!
//! This module does **not** typecheck, evaluate, or hash anything — see
//! [`crate::canonical`] for canonical bytes, [`crate::hash`] for content
//! hashes, and the `ailang-check` crate for typechecking.
```
- new:
```rust
//! AST nodes for the AILang language.
//!
//! **The canonical schema lives in `docs/DESIGN.md` §"Data model"**;
//! this module is the Rust-side projection of it. When the two drift,
//! `crates/ailang-core/tests/design_schema_drift.rs` fires.
//!
//! The serde attributes carry the schema: field renames (`as`, `type`,
//! `fn`, `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`),
//! and `skip_serializing_if` predicates that keep the canonical-JSON
//! representation backwards compatible across schema extensions.
//!
//! The entry type is [`Module`]. The two helpers [`def_name`] and
//! [`def_kind`] are intended for tools (`ail diff`, `ail manifest`) that
//! consume a [`Def`] without going through method calls.
//!
//! This module does **not** typecheck, evaluate, or hash anything — see
//! [`crate::canonical`] for canonical bytes, [`crate::hash`] for content
//! hashes, and the `ailang-check` crate for typechecking.
```

The new doc-comment:
- Bold-emphasises that DESIGN.md §"Data model" is canonical.
- Names ast.rs as the projection, not the source.
- Cites the drift test as the enforcement mechanism.
- Preserves the rest of the doc-comment unchanged (serde-attribute
  description, helper-function pointer, separation-of-concerns
  callout).

- [ ] **Step 3: Verify rustdoc still builds**

```bash
cargo doc -p ailang-core --no-deps 2>&1 | tail -10
```

Expected: clean build, no warnings introduced by the doc-comment
edit (note: any pre-existing warnings unrelated to this edit are
acceptable; the edit must not add new ones).

- [ ] **Step 4: Commit**

```bash
git add crates/ailang-core/src/ast.rs
git commit -m "design-md-consolidation 3.3: ast.rs doc-comment names DESIGN.md §Data-model as canonical schema, drift test as enforcement"
```

---

## Task 4: Add `design_schema_drift.rs` (RED-first)

**Files:** Create `crates/ailang-core/tests/design_schema_drift.rs`.

Per the implement-skill TDD discipline, the test is written
RED-first: deliberately remove one anchor temporarily so the test
fails, verify the failure message is precise, then restore.

In practice for a guard test on already-aligned content, the test
will be GREEN immediately on first run. The "RED first" step here
is a single deliberate-corruption check: create the test, run it
(should be GREEN), then temporarily corrupt one anchor in
`docs/DESIGN.md` (e.g. change `"t": "lit"` to `"t": "litX"`), run
again (should be RED with a precise failure message), then revert
the corruption — the deliberate-corruption step is performed only
to verify the test's RED behaviour is correct, then immediately
reverted before commit.

- [ ] **Step 1: Write the test file**

Create `crates/ailang-core/tests/design_schema_drift.rs` with the
following content:

```rust
//! Drift detection between the AST in this crate and the canonical
//! schema in `docs/DESIGN.md` §"Data model".
//!
//! Each `ast.rs` enum (`Term`, `Pattern`, `Type`, `Def`, `Literal`,
//! `ParamMode`) discriminates on a `#[serde(rename = "...")]` tag.
//! These tests construct a sample of every variant, then check that
//! the corresponding JSON-schema anchor (e.g. `"t": "lit"`,
//! `"kind": "fn"`, `"p": "wild"`, `"k": "con"`) appears in DESIGN.md.
//!
//! The exhaustive `match` is the load-bearing piece: adding a new
//! variant without a DESIGN.md entry fails compilation here long
//! before the test runs. Once the variant is matched, the test
//! asserts DESIGN.md mentions it.
//!
//! Pair with `tests/spec_drift.rs` (Form-A surface) — the two cover
//! orthogonal axes: spec_drift checks the Form-A authoring surface,
//! design_schema_drift checks the JSON canonical schema.

use ailang_core::ast::{
    ClassDef, ClassMethod, ConstDef, Constraint, Ctor, Def, FnDef, InstanceDef,
    InstanceMethod, Literal, ParamMode, Pattern, Suppress, Term, Type, TypeDef,
};

const DESIGN_MD: &str = include_str!("../../../docs/DESIGN.md");

/// Every `Term` variant must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_term_variant() {
    let exemplars: Vec<(&str, Term)> = vec![
        ("\"t\": \"lit\"", Term::Lit { lit: Literal::Unit }),
        ("\"t\": \"var\"", Term::Var { name: "x".into() }),
        (
            "\"t\": \"app\"",
            Term::App {
                callee: Box::new(Term::Var { name: "f".into() }),
                args: vec![],
                tail: false,
            },
        ),
        (
            "\"t\": \"let\"",
            Term::Let {
                name: "x".into(),
                value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
                body: Box::new(Term::Var { name: "x".into() }),
            },
        ),
        (
            "\"t\": \"letrec\"",
            Term::LetRec {
                name: "f".into(),
                ty: Type::fn_implicit(vec![], Type::int(), vec![]),
                params: vec![],
                body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
                in_term: Box::new(Term::Var { name: "f".into() }),
            },
        ),
        (
            "\"t\": \"if\"",
            Term::If {
                cond: Box::new(Term::Lit { lit: Literal::Bool { value: true } }),
                then: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
                else_: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
            },
        ),
        (
            "\"t\": \"do\"",
            Term::Do {
                op: "io/print_int".into(),
                args: vec![],
                tail: false,
            },
        ),
        (
            "\"t\": \"ctor\"",
            Term::Ctor {
                type_name: "List".into(),
                ctor: "Nil".into(),
                args: vec![],
            },
        ),
        (
            "\"t\": \"match\"",
            Term::Match {
                scrutinee: Box::new(Term::Var { name: "x".into() }),
                arms: vec![],
            },
        ),
        (
            "\"t\": \"lam\"",
            Term::Lam {
                params: vec![],
                param_tys: vec![],
                ret_ty: Box::new(Type::int()),
                effects: vec![],
                body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
            },
        ),
        (
            "\"t\": \"seq\"",
            Term::Seq {
                lhs: Box::new(Term::Var { name: "a".into() }),
                rhs: Box::new(Term::Var { name: "b".into() }),
            },
        ),
        (
            "\"t\": \"clone\"",
            Term::Clone {
                value: Box::new(Term::Var { name: "x".into() }),
            },
        ),
        (
            "\"t\": \"reuse-as\"",
            Term::ReuseAs {
                source: Box::new(Term::Var { name: "x".into() }),
                body: Box::new(Term::Var { name: "y".into() }),
            },
        ),
    ];

    for (anchor, term) in exemplars {
        let _: &'static str = match term {
            Term::Lit { .. } => "lit",
            Term::Var { .. } => "var",
            Term::App { .. } => "app",
            Term::Let { .. } => "let",
            Term::LetRec { .. } => "letrec",
            Term::If { .. } => "if",
            Term::Do { .. } => "do",
            Term::Ctor { .. } => "ctor",
            Term::Match { .. } => "match",
            Term::Lam { .. } => "lam",
            Term::Seq { .. } => "seq",
            Term::Clone { .. } => "clone",
            Term::ReuseAs { .. } => "reuse-as",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a Term variant — \
             update docs/DESIGN.md §\"Data model\""
        );
    }
}

/// Every `Pattern` variant must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_pattern_variant() {
    let exemplars: Vec<(&str, Pattern)> = vec![
        ("\"p\": \"wild\"", Pattern::Wild),
        ("\"p\": \"var\"", Pattern::Var { name: "x".into() }),
        ("\"p\": \"lit\"", Pattern::Lit { lit: Literal::Int { value: 0 } }),
        (
            "\"p\": \"ctor\"",
            Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
        ),
    ];
    for (anchor, pat) in exemplars {
        let _: &'static str = match pat {
            Pattern::Wild => "wild",
            Pattern::Var { .. } => "var",
            Pattern::Lit { .. } => "lit",
            Pattern::Ctor { .. } => "ctor",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a Pattern variant"
        );
    }
}

/// Every `Type` variant must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_type_variant() {
    let exemplars: Vec<(&str, Type)> = vec![
        ("\"k\": \"con\"", Type::int()),
        ("\"k\": \"fn\"", Type::fn_implicit(vec![], Type::unit(), vec![])),
        ("\"k\": \"var\"", Type::Var { name: "a".into() }),
        (
            "\"k\": \"forall\"",
            Type::Forall {
                vars: vec!["a".into()],
                constraints: vec![],
                body: Box::new(Type::Var { name: "a".into() }),
            },
        ),
    ];
    for (anchor, ty) in exemplars {
        let _: &'static str = match ty {
            Type::Con { .. } => "con",
            Type::Fn { .. } => "fn",
            Type::Var { .. } => "var",
            Type::Forall { .. } => "forall",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a Type variant"
        );
    }
}

/// Every `Literal` variant must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_literal_variant() {
    let exemplars: Vec<(&str, Literal)> = vec![
        ("\"kind\": \"int\"", Literal::Int { value: 0 }),
        ("\"kind\": \"bool\"", Literal::Bool { value: true }),
        ("\"kind\": \"str\"", Literal::Str { value: "x".into() }),
        ("\"kind\": \"unit\"", Literal::Unit),
    ];
    for (anchor, lit) in exemplars {
        let _: &'static str = match lit {
            Literal::Int { .. } => "int",
            Literal::Bool { .. } => "bool",
            Literal::Str { .. } => "str",
            Literal::Unit => "unit",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a Literal variant"
        );
    }
}

/// Every `Def` variant (kind) must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_def_kind() {
    let fn_def = FnDef {
        name: "f".into(),
        doc: None,
        suppress: vec![],
        ty: Type::fn_implicit(vec![], Type::int(), vec![]),
        params: vec![],
        body: Term::Lit { lit: Literal::Int { value: 0 } },
    };
    let const_def = ConstDef {
        name: "k".into(),
        doc: None,
        ty: Type::int(),
        value: Term::Lit { lit: Literal::Int { value: 0 } },
    };
    let type_def = TypeDef {
        name: "T".into(),
        doc: None,
        vars: vec![],
        ctors: vec![Ctor { name: "C".into(), fields: vec![] }],
        drop_iterative: false,
    };
    let class_def = ClassDef {
        name: "C".into(),
        param: "a".into(),
        superclass: None,
        methods: vec![],
        doc: None,
    };
    let instance_def = InstanceDef {
        class: "C".into(),
        type_: Type::int(),
        methods: vec![],
        doc: None,
    };

    let exemplars: Vec<(&str, Def)> = vec![
        ("\"kind\": \"fn\"", Def::Fn(fn_def)),
        ("\"kind\": \"const\"", Def::Const(const_def)),
        ("\"kind\": \"type\"", Def::Type(type_def)),
        ("\"kind\": \"ClassDef\"", Def::Class(class_def)),
        ("\"kind\": \"InstanceDef\"", Def::Instance(instance_def)),
    ];

    for (anchor, def) in exemplars {
        let _: &'static str = match def {
            Def::Fn(_) => "fn",
            Def::Const(_) => "const",
            Def::Type(_) => "type",
            Def::Class(_) => "class",
            Def::Instance(_) => "instance",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a Def variant"
        );
    }
}

/// Every `ParamMode` variant must have its JSON-schema anchor in DESIGN.md.
#[test]
fn design_md_anchors_every_parammode_variant() {
    // ParamMode appears in DESIGN.md as bare quoted strings inside
    // the ParamMode enumeration block (`"implicit"`, `"own"`,
    // `"borrow"`).
    let exemplars: Vec<(&str, ParamMode)> = vec![
        ("\"implicit\"", ParamMode::Implicit),
        ("\"own\"", ParamMode::Own),
        ("\"borrow\"", ParamMode::Borrow),
    ];
    for (anchor, mode) in exemplars {
        let _: &'static str = match mode {
            ParamMode::Implicit => "implicit",
            ParamMode::Own => "own",
            ParamMode::Borrow => "borrow",
        };
        assert!(
            DESIGN_MD.contains(anchor),
            "DESIGN.md missing anchor `{anchor}` for a ParamMode variant"
        );
    }
}

/// Nested struct types (Suppress, ClassMethod, InstanceMethod,
/// Constraint) are reachable through their parent enum variants. This
/// test forces the constructors so adding a new field that breaks the
/// constructor signature surfaces as a compile error in this file
/// (alongside the variant-anchor tests above).
#[test]
fn design_md_anchors_nested_struct_keys() {
    // Suppress: the schema uses "code" and "because" keys.
    let _ = Suppress {
        code: "x".into(),
        because: "y".into(),
    };
    assert!(DESIGN_MD.contains("\"code\""));
    assert!(DESIGN_MD.contains("\"because\""));

    // ClassMethod / InstanceMethod / Constraint: shape-only, no
    // direct anchors here. The Decision 11 §"Form-A schema" section
    // anchors them via "methods" / "constraints" keys.
    let _ = ClassMethod {
        name: "show".into(),
        ty: Type::fn_implicit(vec![Type::Var { name: "a".into() }], Type::int(), vec![]),
        default: None,
    };
    let _ = InstanceMethod {
        name: "show".into(),
        body: Term::Lit { lit: Literal::Int { value: 0 } },
    };
    let _ = Constraint {
        class: "Show".into(),
        type_: "a".into(),
    };
    assert!(DESIGN_MD.contains("\"methods\""));
    assert!(DESIGN_MD.contains("\"constraints\""));
}
```

Notes for the implementer:
- Field names (`callee` vs `fn`, `type_name` vs `type`, `in_term`
  vs `in`, `ret_ty` vs `ret`, `param_tys` vs `paramTypes`) follow
  ast.rs's Rust naming, not the JSON tags. The serde attributes
  on ast.rs handle the JSON ↔ Rust mapping.
- Constructor signatures must match ast.rs exactly. If the
  compiler complains about a mismatched field, read ast.rs and
  align.
- The `Type::int()`, `Type::unit()`, `Type::fn_implicit(...)`
  helpers are public ast.rs constructors used by the existing
  spec_drift.rs; reuse them.
- `ClassDef.methods` and `InstanceDef.methods` use the names
  `methods` (verify in ast.rs lines 258-340 region).

- [ ] **Step 2: Run the test (expect GREEN — DESIGN.md already aligned)**

```bash
cargo test -p ailang-core --test design_schema_drift 2>&1 | tail -20
```

Expected: 7 tests passed (one per variant family + nested-struct
test).

If RED: the failure message must precisely name the missing
anchor. Read the message; fix DESIGN.md by adding the missing
JSON-schema anchor in §"Data model"; re-run; iterate until GREEN.

- [ ] **Step 3: Deliberate-RED probe (optional, may skip if Step 2 GREEN on first run)**

To verify the RED behaviour is correct (test fails clearly when an
anchor goes missing), the implementer MAY temporarily corrupt one
anchor in `docs/DESIGN.md` (e.g. change `"t": "lit"` to
`"t": "litX"`), re-run the test, observe a precise failure message
naming the missing anchor, then revert the corruption immediately.
Do not commit the corrupted state. This is purely a sanity check
on the test's RED behaviour.

- [ ] **Step 4: Run full workspace test sweep**

```bash
cargo test --workspace 2>&1 | grep -E "test result:|FAILED" | grep -v "0 failed" | head -10
```

Expected: empty (no FAILED lines).

- [ ] **Step 5: Commit**

```bash
git add crates/ailang-core/tests/design_schema_drift.rs
git commit -m "design-md-consolidation 3.4: add design_schema_drift.rs — exhaustive-match drift test for ast.rs vs DESIGN.md §Data-model"
```

---

## Task 5: Final validation + JOURNAL entry + roadmap update

**Files:** Read: `docs/DESIGN.md`,
`crates/ailang-core/src/ast.rs`,
`crates/ailang-core/tests/design_schema_drift.rs`. Append:
`docs/JOURNAL.md`. Modify: `docs/roadmap.md`.

- [ ] **Step 1: Composite acceptance grep**

```bash
grep -nE '^\s*(struct |enum |pub (struct|enum|fn))' docs/DESIGN.md
```

Expected: empty (no Rust type definitions remain).

```bash
grep -n 'whenever the two disagree\|ast.rs is the source of truth' docs/DESIGN.md
```

Expected: empty (the SoT-inverted disclaimer is gone).

```bash
grep -n 'This section is the canonical schema' docs/DESIGN.md
```

Expected: 1 match (the prominent SoT sentence).

```bash
grep -n 'design_schema_drift' docs/DESIGN.md crates/ailang-core/src/ast.rs
```

Expected: 1 match in DESIGN.md, 1 match in ast.rs (each names the
drift test as the enforcement mechanism).

- [ ] **Step 2: Drift test green**

```bash
cargo test -p ailang-core --test design_schema_drift 2>&1 | tail -5
```

Expected: `test result: ok` with 7 tests passed.

- [ ] **Step 3: Workspace tests stay green**

```bash
cargo test --workspace 2>&1 | grep -E "test result:|FAILED" | grep -v "0 failed" | head -10
```

Expected: empty.

- [ ] **Step 4: Bench gates stay green**

```bash
python3 bench/check.py 2>&1 | tail -3
python3 bench/compile_check.py 2>&1 | tail -3
```

Expected: each summary `0 regressed`.

- [ ] **Step 5: Sweep-1 + Sweep-2 invariants stay clean**

```bash
grep -nE 'Iter [0-9]+[a-z]?(\.[0-9]+)?|Family [0-9]+|^[^/]*2026-[0-9]{2}-[0-9]{2}|\*\*Status: |pre-[0-9]+[a-z]?|[0-9]+[a-z]? sketch|21.g' docs/DESIGN.md
```

Expected: empty (Sweep-1 invariant).

```bash
grep -nE 'REVERTED|preserved for the audit trail|Migration plan|[Oo]riginally framed|original rationale|empirically-grounded version|A future (iter|iteration|milestone|Prelude) may|A future iter that|a (later|future) iter(ation)? \(deferred\)|Concrete design deferred' docs/DESIGN.md
```

Expected: empty (Sweep-2 invariant).

- [ ] **Step 6: Append JOURNAL entry**

Template (adjust specifics to actual outcome):

```markdown

## 2026-05-10 — Iteration design-md-consolidation 3: schema SoT inversion + data-model hardening

Third iteration of the milestone defined in
`docs/specs/2026-05-10-design-md-consolidation.md`. Sweep 3
inverts the schema source-of-truth between `docs/DESIGN.md`
§"Data model" and `crates/ailang-core/src/ast.rs`: DESIGN.md is
canonical, `ast.rs` is the projection, and a new drift test
catches divergence.

Three substantive changes plus one test:

- **Two Rust code blocks removed from Decision 10.** The
  `Type::Fn` + `ParamMode` block (around line 1027 at iter
  start) and the `Suppress` struct block (around line 1132)
  are replaced by prose pointers to §"Data model". Decision
  10's prose argument (per-position metadata vs `Type::Borrow`
  variant) reads cleanly without the inline Rust.
- **§"Data model" SoT inversion.** The opener now reads "**This
  section is the canonical schema.**" The Rust types in `ast.rs`
  are framed as the in-memory projection, not the source. The
  drift test is named as the enforcement mechanism in the
  opener.
- **`ast.rs` module doc-comment.** The file-level `//!` block
  bold-emphasises that DESIGN.md §"Data model" is canonical;
  names the drift test as the enforcement; preserves the
  serde-attribute description and the entry-type pointer.
- **`crates/ailang-core/tests/design_schema_drift.rs`** is the
  new drift test. Pattern follows the existing `spec_drift.rs`:
  exhaustive `match` per enum (`Term`, `Pattern`, `Type`, `Def`,
  `Literal`, `ParamMode`) ensures adding a variant without a
  DESIGN.md anchor fails compilation; the test asserts each
  anchor literally appears in DESIGN.md. 7 tests total. All
  GREEN on first run (DESIGN.md already lists every variant).

Acceptance:

- `grep -nE '^\s*(struct |enum |pub (struct|enum|fn))' docs/DESIGN.md` → empty.
- `grep -n 'whenever the two disagree' docs/DESIGN.md` → empty.
- `grep -n 'This section is the canonical schema' docs/DESIGN.md` → 1 line.
- `cargo test -p ailang-core --test design_schema_drift` → 7 tests pass.
- `cargo test --workspace` → 0 FAILED.
- `bench/check.py` and `bench/compile_check.py` → 0 regressed.
- Sweep-1 + Sweep-2 invariants stay empty (no regression).

Tasks (commit subjects):

- design-md-consolidation 3.1: remove 2 Rust code blocks from Decision 10 (Type::Fn + Suppress) — schema lives in §Data-model
- design-md-consolidation 3.2: invert §Data-model SoT — DESIGN.md canonical, ast.rs projection, drift test enforces
- design-md-consolidation 3.3: ast.rs doc-comment names DESIGN.md §Data-model as canonical schema, drift test as enforcement
- design-md-consolidation 3.4: add design_schema_drift.rs — exhaustive-match drift test for ast.rs vs DESIGN.md §Data-model

Carried into sweep 4: workflow / cross-reference cleanup.
"Project ecosystem" `agents/` path correction; "Verification and
correctness" workflow detail; "What is not (yet) supported"
§"Recently lifted gates" removal; cross-reference audit.

Process note: design_schema_drift was GREEN on first run — the
schema in §"Data model" already anchored every variant. The
RED-first discipline reduced to a deliberate-corruption probe to
verify the failure message is precise; corruption was reverted
before commit. This matches the "guard test on already-aligned
content" pattern: TDD's RED step is the probe, not a real bug.
```

- [ ] **Step 7: Update roadmap.md**

Edit `docs/roadmap.md`:
- old:
```
    - [x] Sweep 1 — remove history anchors
    - [x] Sweep 2 — REVERTED + migration plans out
    - [ ] Sweep 3 — schema SoT inversion + data-model hardening
    - [ ] Sweep 4 — workflow / cross-reference cleanup
```
- new:
```
    - [x] Sweep 1 — remove history anchors
    - [x] Sweep 2 — REVERTED + migration plans out
    - [x] Sweep 3 — schema SoT inversion + data-model hardening
    - [ ] Sweep 4 — workflow / cross-reference cleanup
```

- [ ] **Step 8: Commit**

```bash
git add docs/JOURNAL.md docs/roadmap.md
git commit -m "design-md-consolidation 3: journal entry + roadmap sweep-3 closed"
```
