Files
AILang/docs/specs/0046-design-ledger-formal-links.md
T
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

24 KiB
Raw Blame History

Formal Cross-Links in the design/ Ledger — Design Spec

Date: 2026-05-19 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Make the design/ ledger a browsable wiki: replace informal prose cross-references between contracts/models ((see "Str ABI" below), (see design/contracts/0011-str-abi.md …), see §"Data model") with formal, machine-checkable, file-level Markdown links, and add an in-tree hard gate that turns a broken cross-link into a same-commit test failure instead of silent rot.

This is the positive completion of the DESIGN.md → design/ split. That milestone established the negative half — history prose must not live in a contract (contracts_carry_no_decision_record_prose, clause-3). This milestone establishes the positive half — what cross-reference prose remains must resolve to a durable file. Together they form a complete invariant: every cross-reference in a contract is either a resolving file-relative link into design/-or-source, or decision-record prose that clause-3 already forbids.

The milestone has no authoring surface: it changes no .ail program, no language construct, no compiler/checker/codegen path. It is a documentation-form + Rust-test-infrastructure milestone, the same category as the immediately-preceding DESIGN.md → design/ split (docs/specs/0045-design-md-rolesplit.md), whose concrete code was likewise the structural Rust pin plus the ledger shape, audited clean. The no-.ail disposition is therefore precedent-governed and reasoned, not an omission (see Concrete code shapes §"No authoring surface").

Architecture

Four locked design commitments (converged interactively this session; the INDEX sub-choice was resolved by user MC during brainstorm Step 2). They are the spec's input contract, not open forks:

  1. File-level granularity, no in-file anchors. A link addresses a whole file. Finer reference granularity is achieved by splitting a file, never by a #fragment. A link whose target is a pure #anchor, or carries a #fragment suffix, is a contract violation the hard gate rejects.

  2. Durable-tier targets only. A formal link may target a file under design/ (contracts + models) or a source file (crates/**, runtime/**). It may never target docs/ (specs, plans, journals, or standing docs like docs/PROSE_ROUNDTRIP.md) — that is the potentially ephemeral tracking tier; linking the durable ledger into it re-couples exactly what the split decoupled.

  3. Standard relative Markdown link syntax [label](path). Not Obsidian [[wikilinks]]: wikilink name-fuzzy resolution is the prose-reference problem in another costume (it contradicts content-addressing), is not native on Gitea (the platform we actually use, self-hosted) nor in GitHub repo view, and does not uniformly address source files.

  4. File-relative path form for body prose; the INDEX spine stays repo-root-relative. Body cross-links are [label](./sibling.md) / [label](../models/x.md) / [label](../../crates/ailang-core/src/desugar.rs), resolved relative to the containing file. This is the universal standard (resolves in Gitea blob, GitHub, VS Code preview, Obsidian, static generators), serving the named primary goal (browsability) on every platform; its move-fragility is real but mechanically bounded by the resolve-check. The earlier "root-relative = the content-addressing thesis" argument was retracted as principle-overreach (the thesis kills name-fuzzy wikilinks; between two deterministic path forms it is an engineering trade). The INDEX registry table (design/INDEX.md) is a deliberately-distinct structured tier: it is machine-parsed as a table, and it is the one design/ file whose path is structurally invariant (it is THE spine — it does not move). The move-fragility rationale that drove body prose to file-relative is absent for the spine, so the INDEX keeps its repo-root-relative cells and design_index_pin.rs clause-1 is unchanged. Forcing the body rule onto the spine would itself be the principle-overreach pattern this session already retracted once.

Derived scope boundaries (forced by the commitments + Step-1 recon, not forks):

  • Intra-file directional prose is out of scope. "the constraints below", "Direction 1 above", "see §X below" within the same document is not a cross-file reference; it has no file-link form (same file) and anchors are forbidden by commitment 1. These stay as prose. The remedy for "this file is too big for 'above' to be clear" is commitment 1's file-split — a separate future concern, explicitly NOT this milestone.
  • Homeless references — three dispositions (clause-6), in priority order. A cross-reference whose referent has no durable home is: (a) retargeted to the durable design/-or-source file that actually carries that content today, if one exists; else (b) the parenthetical pointer is removed while the surrounding present-tense behavioural prose is preserved verbatim — the ledger describes the behaviour, it does not point into the non-durable tier; else (c) deleted outright only if the reference itself is relitigation residue (clause-3-class). Recon-confirmed canonical instance: design/models/0003-pipeline.md:61 and design/models/0001-authoring-surface.md:180, both (see docs/PROSE_ROUNDTRIP.md), cite the prose-mediator cycle / ail merge-prose — content design/contracts/0009-roundtrip-invariant.md does not carry (that contract is the orthogonal hashing / parse-determinism invariant). (a) is therefore unavailable and the content is real current behaviour, not relitigation, so the disposition is (b): drop the (see docs/PROSE_ROUNDTRIP.md) pointer, keep the surrounding ail merge-prose prose intact. Enumerating every instance is the recon's job; the policy is the spec's.
  • A cross-file §"Section" reference becomes a file-link. (see §"Data model" …) pointing at content in another file becomes [Data model](data-model.md) — the section title may survive only as the human link label, never as a #fragment (commitment 1). The reader lands on the file and finds the section by reading; that is file-level granularity working as designed, not a loss. (A §"Section" that names a section of the same file is intra-file directional prose — the bullet above — and is untouched.)

Scope — what counts as a cross-reference

The milestone formalises navigational cross-references, not every nominal mention of a file or symbol. The discriminator:

  • In scope (a cross-reference): a navigational pointer — the reader is being told to go look elsewhere. Forms: (see "Title" …), (see §"Section" …), (see <path>), … as in <X> …, a bare in-prose path inside a (see …), or a directional … below/… above whose named target is a different file. These convert to file-relative [label](path) Markdown links.
  • Out of scope (not a cross-reference): a nominal mention of a source symbol or path in explanatory prose — "the desugarer ailang-core::desugar lowers loop-recur", "the box layout in crates/ail-embed/src/lib.rs". Naming the implementation is not pointing the reader at a document to read. Linkifying these would make the milestone "hyperlink the whole ledger" rather than "formalise the cross-references" — a different, unrequested change. They stay as prose. Commitment 2 permits a crates/** link target only for the case where a genuine navigational (see crates/… ) pointer exists — e.g. scope-boundaries.md:88 see \ailang-core::desugar`converts todesugar` — never for a nominal mention.
  • Out of scope (schema-example documentation): a see §"…" annotation inside a fenced code block — recon+corpus-confirmed: the //-comments in data-model.md's fenced jsonc schema examples (lines 38/66/79 inside the 3087 fence; 206/226 inside the 203228 fence) — documents the schema example, not the browsable ledger. It is the inline-annotation analog of the nominal-mention carve-out: a // comment in a code example is code documentation, not a "go browse there" pointer. It stays as the informal see §"…" annotation, untouched. clause-5 correspondingly does not scan fenced regions (a ]( inside a fence is literal text, not a navigable link — a gate-correctness requirement independent of this carve-out).

Two byte-policies the conversion applies (Boss-adjudicated from recon Open Questions; the planner does not re-open them):

  • Iter-provenance suffixes are stripped on conversion. A reference like see §"Class names" / mq.1 carries a clause-3-class iter token. Converting yields [Class names](typeclasses.md) — the / mq.1 provenance is removed (it is exactly what clause-3 forbids; the clause-3 ∘ clause-5 composition requires its removal, not its survival riding on a now-formal link).
  • A directional phrase with no quoted title and no (see …) form stays prose. embedding-abi.md:51 "the frozen value layout below specifies" has no title to become a link label and is not a (see …) pointer — descriptive directional prose, left untouched. (The genuine cross-file pointer for that referent is the separate embedding-abi.md:45 (see "Frozen value layout" below), which does convert.)

Concrete code shapes

No authoring surface — the delivered concrete code

There is no .ail because the milestone touches nothing in the language: no construct, no type rule, no codegen, no fixture. Per the brainstorm infra clause, the rationalisation to refuse is "no surface so no code to show"; the honest response is to show the concrete code the milestone does deliver. For a docs-form + test-infra milestone that is (a) the new Rust hard-gate clause, (b) the real prose-reference before→after, (c) the explicit no-change statement for clause-1. All three are shown below. The governing precedent is the immediately-preceding same-category milestone (DESIGN.md → design/ split), whose spec led its concrete code with the Rust pin and the ledger shape and was audited clean — this milestone follows that precedent exactly.

(a) The new hard gate — design_index_pin.rs clause-5

A new test in the existing crates/ailang-core/tests/design_index_pin.rs (sibling of clauses 14; reuses the file's root() helper and the clause-3 fs::read_dir walk idiom verbatim). It is RED-first via an embedded synthetic-vector self-check before it asserts the live tree (a tree-scan test cannot be RED-first by tree state alone — the tree is GREEN-vacuous until the first ref is converted — so the RED proof is an in-test malformed-link vector, the honest mechanism for "scan-the-tree" gates).

#[test]
fn design_body_links_are_durable_and_resolve() {
    // clause 5 — every inline Markdown link in design/ body prose
    // (contracts/ + models/, NOT INDEX.md — the spine is the
    // structured registry tier resolved by clause-1, commitment 4)
    // resolves, relative to its CONTAINING file, to an existing
    // file under design/ or source (crates/** | runtime/**); never
    // under docs/; never an in-file #anchor. Fenced code blocks are
    // not scanned (a `](` inside ``` is literal text, not a link).
    // Composes with clause-3: a surviving cross-reference is a
    // resolving durable file-link or it is clause-3-forbidden
    // decision-record prose.

    // Fenced code (``` … ``` / ~~~ … ~~~) is literal text, not
    // Markdown — a `](` inside a fence is NOT a navigable link on
    // any renderer. Strip fenced regions before scanning (gate
    // correctness: prevents false extraction of code-example byte
    // sequences and of non-rendering in-fence links).
    fn strip_fences(md: &str) -> String {
        let mut out = String::new();
        let mut in_fence = false;
        for line in md.lines() {
            let t = line.trim_start();
            if t.starts_with("```") || t.starts_with("~~~") {
                in_fence = !in_fence;
                continue; // drop the fence marker line itself
            }
            if !in_fence {
                out.push_str(line);
                out.push('\n');
            }
        }
        out
    }

    // link target := first capture of  \]\(([^)]+)\)
    // (matches [x](y) and the ](y) of image ![x](y) alike — every
    //  parenthesised target must resolve; conservative on purpose)
    fn targets(md: &str) -> Vec<String> {
        let mut out = Vec::new();
        let b = md.as_bytes();
        let mut i = 0;
        while i + 1 < b.len() {
            if b[i] == b']' && b[i + 1] == b'(' {
                if let Some(end) = md[i + 2..].find(')') {
                    out.push(md[i + 2..i + 2 + end].trim().to_string());
                }
            }
            i += 1;
        }
        out
    }

    // classify a repo-relative resolved path
    fn is_durable(repo_rel: &str) -> bool {
        repo_rel.starts_with("design/")
            || repo_rel.starts_with("crates/")
            || repo_rel.starts_with("runtime/")
    }

    // RED-first synthetic vectors (proves the gate bites before it
    // is pointed at the live tree).
    {
        let t = targets("see [x](../docs/specs/foo.md) and [y](#sec) and [z](./gone.md)");
        assert_eq!(t, vec!["../docs/specs/foo.md", "#sec", "./gone.md"]);
        assert!(!is_durable("docs/specs/foo.md"));   // durable-tier reject
        assert!("#sec".starts_with('#'));            // in-file anchor reject
        // fenced code is not a link surface (gate correctness)
        assert!(targets(&strip_fences("```\nsee [E](e.md)\n```\n")).is_empty());
        assert_eq!(targets(&strip_fences("[k](k.md)\n```\n[n](n.md)\n```")), vec!["k.md"]);
    }

    let bases = ["design/contracts", "design/models"];
    for base in bases {
        let dir = root().join(base);
        for entry in fs::read_dir(&dir).expect("design/ subdir exists") {
            let p = entry.unwrap().path();
            if p.extension().and_then(|e| e.to_str()) != Some("md") {
                continue;
            }
            let raw = fs::read_to_string(&p).unwrap();
            let fname = format!("{base}/{}", p.file_name().unwrap().to_string_lossy());
            for tgt in targets(&strip_fences(&raw)) {
                if tgt.starts_with("http://")
                    || tgt.starts_with("https://")
                    || tgt.starts_with("mailto:")
                {
                    continue; // external — out of the ledger's scope
                }
                let file_part = tgt.split('#').next().unwrap_or(&tgt);
                assert!(
                    !file_part.is_empty(),
                    "clause-5: in-file #anchor link {tgt:?} in {fname:?} \
                     — commitment 1 forbids fragments; split the file"
                );
                // resolve RELATIVE TO THE CONTAINING FILE
                let resolved = p.parent().unwrap().join(file_part);
                let canon = resolved
                    .canonicalize()
                    .unwrap_or_else(|e| panic!(
                        "clause-5: link {tgt:?} in {fname:?} does not resolve: {e}"
                    ));
                let repo_rel = canon
                    .strip_prefix(root().canonicalize().unwrap())
                    .unwrap_or(&canon)
                    .to_string_lossy()
                    .replace('\\', "/");
                assert!(
                    is_durable(&repo_rel),
                    "clause-5: link {tgt:?} in {fname:?} targets the \
                     non-durable tier ({repo_rel:?}); commitment 2 \
                     permits design/ + crates/ + runtime/ only"
                );
            }
        }
    }
}

#[test] count in the file goes 4 → 5; clauses 14 are byte- unchanged. The exact byte form of the extractor/regex is the planner's to finalise against this shape; the contract is the four predicates (resolves-from-containing-file; durable-tier; no-fragment; fenced-code-not-scanned) and the RED-first synthetic vector.

(b) Real prose-reference before → after

Three real, in-tree references (recon-confirmed line numbers; exact bytes are the planner's):

design/contracts/0005-float-semantics.md:100  (the tidy precedent, still
  half-formal: bare root path in prose, not a Markdown link)
- before:  … a fresh heap-Str slab at the call site (see
           design/contracts/0011-str-abi.md for the dual realisation).
- after:   … a fresh heap-Str slab at the call site (see
           [Str ABI](str-abi.md) for the dual realisation).
           # float-semantics.md and str-abi.md are siblings in
           # design/contracts/ ⇒ file-relative target is `str-abi.md`

design/contracts/0003-embedding-abi.md:45  (informal AND stale-direction
  — the exact wart class motivating the milestone: "below" but the
  target is a different file)
- before:  … layout is **frozen as of M3** (see "Frozen value
           layout" below); …
- after:   … layout is **frozen as of M3** (see
           [Frozen value layout](frozen-value-layout.md)); …

design/models/0003-pipeline.md:61 + design/models/0001-authoring-surface.md:180
  (the canonical HOMELESS references — `(see docs/PROSE_ROUNDTRIP.md)`
  into the non-durable tier; recon proved roundtrip-invariant.md
  does NOT carry the merge-prose-cycle content ⇒ clause-6
  disposition (b), not a retarget)
- before (shape):  … the prompt template `ail merge-prose`
                   composes (see docs/PROSE_ROUNDTRIP.md).
- after  (shape):  … the prompt template `ail merge-prose`
                   composes.
           # disposition (b): the (see docs/…) pointer is removed;
           # the surrounding present-tense behavioural prose about
           # `ail merge-prose` is preserved verbatim. Exact bytes
           # (recon-confirmed pipeline.md:60-61,
           # authoring-surface.md:180-181) are the planner's per
           # the planner Iron Law.

(c) clause-1 is unchanged (commitment 4, INDEX = spine)

crates/ailang-core/tests/design_index_pin.rs
  fn link_target_exists(cell) { … root().join(path).exists() }   // UNCHANGED
  fn every_index_link_resolves() { … }                            // UNCHANGED

The INDEX table cells stay repo-root-relative (design/contracts/0011-str-abi.md); clause-1 keeps resolving them via root().join. No INDEX content edit, no clause-1 resolver edit. One resolution rule per tier (registry-spine: root-relative; movable body prose: file-relative), each locally justified — not one rule everywhere, by deliberate design.

Components

Component Disposition
crates/ailang-core/tests/design_index_pin.rs Modify — add clause-5 (design_body_links_are_durable_and_resolve); clauses 14 byte-unchanged; module //! extended one sentence to name clause-5.
design/contracts/*.md, design/models/*.md Modify — every genuine cross-file prose reference rewritten to a file-relative Markdown link; homeless refs retargeted-or-deleted; intra-file "above/below" untouched (out of scope).
design/INDEX.md Untouched — spine tier, commitment 4.
docs/journals/2026-05-19-design-decision-records.md Untouched — monolithic append-only relitigation archive; never a link target (commitment 2/7).
design/contracts/0007-honesty-rule.md Modify (prose only) — one sentence stating cross-references are formal file-relative links into the durable tier (the positive-half companion to the clause-3 sentence it already carries). Pin-safe: the two docs_honesty_pin.rs phrases stay verbatim+contiguous.
bench/architect_sweeps.sh Untouched — clause-5 ("does every link resolve into the durable tier?") and Sweep-1 ("does any line carry a history anchor: Iter <n>, a date, **Status:?") are orthogonal predicates over the same files. A Markdown link [label](file.md) contains no Sweep-1 anchor token, so converting prose to links cannot trip Sweep-1; clause-5 shares no regex or scope with it. No sweep change.

Data flow

cargo test → clause-5 reads each design/contracts/*.md and design/models/*.md → extracts every ](…) target → for each: external? skip. #-fragment or empty file-part? fail (commitment 1). Else resolve against the containing file's directory, canonicalize, strip the repo root → repo-relative path → assert under design/|crates/|runtime/ (commitment 2). The synthetic vector runs first and unconditionally (RED-first proof). clause-1 independently resolves INDEX cells root-relative (unchanged).

Error handling

Every failure is a panic!/assert! naming the file, the offending target, and which commitment it violates ("non-durable tier", "in-file #anchor — split the file", "does not resolve"). No silent skips except the three external schemes. A homeless reference that recon cannot map to a durable home is not worked around in the test — it is deleted from the prose as clause-3-class content (the test then passes because the reference is gone, which is the correct outcome, not a dodge).

Testing strategy

  • RED-first: clause-5's embedded synthetic-vector block asserts the extractor + durable classifier reject a docs/specs/… target and recognise a #anchor before the tree walk. Planner scripts the first task as: add clause-5 (RED on the synthetic vector if the classifier is stubbed wrong) → make the vector GREEN → then convert prose refs until the tree walk is GREEN.
  • Composition with clause-3: a per-milestone assertion (journal, not code) that on the post-milestone tree, for every design/contracts/*.md, clause-3 GREEN ∧ clause-5 GREEN — i.e. every cross-reference is a resolving durable link or it is gone.
  • Whole-suite gate: cargo test --workspace GREEN, count unchanged except +1 test. design_index_pin 5/5.
  • No bench surface: zero codegen/runtime change ⇒ the regression trio is expected pristine; any firing is the tracked-P2 noise, causally exonerable as in the M2/M3/M5 and the just-closed split.

Acceptance criteria

  1. crates/ailang-core/tests/design_index_pin.rs has a 5th test design_body_links_are_durable_and_resolve; clauses 14 are byte-identical to HEAD 2ee4087.
  2. clause-5 is demonstrably RED-able (the synthetic vector fails if the durable classifier, the anchor check, or the fence-skip is wrong) and GREEN on the final tree.
  3. Every navigational cross-file reference (per §"Scope — what counts as a cross-reference") in design/contracts/ and design/models/ is a file-relative [label](path) Markdown link resolving (from its containing file) to an existing design/-or-source file — OR, for a homeless reference, is disposed per clause-6 (a)/(b)/(c). Nominal source-symbol mentions and in-fence schema annotations are NOT linkified (out of scope). Iter-provenance suffixes on converted refs are stripped. The recon-and-corpus-verified touch-set is closed and exhaustive:
    • convert (8 prose refs): float-semantics.md:69 (§"Prelude (built-in) classes" → typeclasses.md), float-semantics.md:100 (bare path → str-abi.md), embedding-abi.md:45 ("Frozen value layout" → frozen-value-layout.md), memory-model.md:44 (§"Data model" → data-model.md), memory-model.md:105 (§"Method dispatch" → typeclasses.md), scope-boundaries.md:48 ("Str ABI" → str-abi.md), scope-boundaries.md:88 (splits: ailang-core::desugar../../crates/ailang-core/src/desugar.rs AND "Pipeline" → ../models/0003-pipeline.md).
    • disposition (b): pipeline.md:61, authoring-surface.md:180 — the (see docs/PROSE_ROUNDTRIP.md) pointer removed, surrounding ail merge-prose prose kept.
    • stay prose (out of scope): data-model.md 38/66/79/206/226 (in-fence jsonc schema annotations), embedding-abi.md:51 (no quoted title / no (see) form), every intra-file "above/below".
  4. No reference in design/ targets docs/ (any subtree) or carries a #fragment.
  5. design/INDEX.md and clause-1 are byte-unchanged; the decision-records journal is byte-unchanged and unlinked.
  6. Intra-file "above/below" directional prose is untouched (recon-enumerated as out of scope, not missed).
  7. cargo test --workspace GREEN; bench trio exit 0 or causally-exonerated per the standing P2 disposition.
  8. honesty-rule.md carries the positive-half sentence; its two docs_honesty_pin.rs pinned phrases remain verbatim+contiguous.