Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture — the four layers and the lint pipeline

Every static diagram on this page is hand-authored: it exists to explain the format and the linter’s pass structure — it is not a projection of a live corpus. Where a diagram and the format disagree, the format wins: spk explain format, spk explain references, and spk explain lint-rules are the machine-generated truth these diagrams compress. The linter emits self-describing findings and the rule catalog is generated from the same table the binary uses, so a stale catalog here will out-argue this page.

For diagrams derived from a live corpus at build time — wiring, per-file state machines, traceability, the revision-labeled typing table — see Graph views. Never restate those by hand: the corpus changes, and a hand-drawn view of it becomes an unconsulted claim.

Four-layer relationship — one file, one spec

A spec file is one markdown document carrying four layers. The Intent (YAML frontmatter) is the machine-linted top claim; Constraints are machine-checkable claims that trace to it; the Model reads a lifecycle whose guards cite those claims; Properties derive from Constraints and give them their verifying behavior. The wiki-links in the table cells are typed foreign keys — the rows link across layers with these references:

flowchart TB
    FM["<b>Intent</b><br>frontmatter: id · kind · statement<br><i>EARS-patterned SHALL claim</i>"]
    subgraph CONS ["<b>Constraints</b> — machine-checkable claims"]
        CI["invariant<br><i>lifecycle claim</i>"]
        CE["effect<br><i>file-owned observable</i>"]
        CX["extension_point<br><i>published contract</i>"]
    end
    subgraph MODEL ["<b>Model</b> — States + Transitions table"]
        ST["<b>State</b> s"]
        TI["<b>Transition</b> t1<br><i>success path</i>"]
        TF["<b>Transition</b> t2<br><i>failure path</i>"]
    end
    subgraph PROPS ["<b>Properties</b> — verifying behavior"]
        PU["<b>unit property</b>"]
        PL["<b>law property</b><br><i>enumerated cases</i>"]
    end

    CI -- "traces_to" --> FM
    CE -- "traces_to" --> FM
    CX -- "traces_to<br><i>(satisfies is an outbound leaf, not an edge)</i>" --> FM
    TI -- "guard cites invariant" --> CI
    TI -- "from/to" --> ST
    TF -- "guard cites the siblings' union<br><i>(guard_negation_total)</i>" --> CI
    ST -- "emits effect" --> CE
    CE -- "observes<br><i>(consuming row at the declared observable)</i>" --> CX
    PU -- "derives_from" --> CI
    PL -- "derives_from" --> CI

    style FM fill:#e8eaf6,stroke:#3f51b5
    style MODEL fill:#e8f5e9,stroke:#2e7d32
    style CONS fill:#fff8e1,stroke:#f9a825
    style PROPS fill:#fce4ec,stroke:#c2185b

Reading the diagram: traces_to anchors every Constraint in the intent (coverage-style guarantees run the other way through derives_from); a guard cites an invariant Constraint or a State (the “has reached state X” pattern); a state’s emits cites only effect Constraints; observes points from a consuming row at an effect Constraint — a declared observable. The full typing table (which field may appear on which row kind, and what it must resolve to) is served by spk explain references, and the resolution rules for file-qualified [[<file-id>.<row-id>]] links (file-id-namespaced) live there too. The coverage guarantee is mandatory: every Constraint needs a deriving Property (linter.coverage), and every Property must derive from at least one Constraint (linter.no_orphan_property) — that bidirectional floor is what keeps the four layers one spec, not four documents.

For the same four layers rendered from a live corpus, see the state-machine view in Graph views — never restate it here.

Lint checker pipeline — families and the advisory/gating split

The linter’s pass structure is a join point, not a dependency DAG: each checker family reads the structured layers (frontmatter, table rows, the model, the corpus graph) independently, and findings accumulate until lint emits. There is no machine-readable family-dependency order served by spk explain lint-rules, so the diagram below is deliberately a layered pipeline, not an invented dependency DAG — checker families are grouped by what they read, and the advisory/gating distinction is marked on the rules that carry it. The rule catalog is generated from the same table the binary uses; the 34 rule ids served by spk explain lint-rules are the authoritative enumeration. One stated dependency exists: the failure-shape checker rides the orchestrator’s lint stage “dependent on linter.model_shape” — its walk reads the Model’s states, transitions and emits edges, so it runs only once the model is proven well-formed (dependency_respecting_skip, openspec/specs/linter-failure_shape/spec.md).

Three rules are advisory: linter.observability and linter.skew_advisory warn on the warnings channel with exit 0, and linter.single_root_reachable is tiered — its cross-file-only half warns (rows with no path to ANY intent still hard-fail). Every other rule in the catalog gates: exit 1, and the lint pass fails.

flowchart TB
    TARGET["<b>Corpus</b><br>one spec file is one spec — structured fields only"]

    subgraph FMF ["<b>frontmatter family</b> — reads the intent layer"]
        R1["linter.frontmatter_valid<br>linter.id_matches_file<br>linter.unique_id<br>linter.no_conjoined_id<br>linter.no_universal_in_id<br>linter.ears_syntax"]
    end
    subgraph SHAPE ["<b>shape families</b> — read the rows"]
        R2["<b>schema_shape</b><br>linter.model_present<br>linter.constraint_kind_closed<br>linter.property_kind_closed<br>linter.schema_matches_typing_table"]
        R3["<b>graph_shape</b> — reads the model's edges<br>linter.guard_required<br>linter.no_self_ref<br>linter.acyclic"]
    end
    subgraph MODEL ["<b>model family</b> — reads the Model section"]
        R4["linter.every_state_used<br>linter.every_transition_valid"]
    end
    subgraph REACH ["<b>reachability family</b> — reads the linkage graph"]
        R5["linter.single_root_reachable<br><i>tiered: cross-file-only rows</i><br><i>warn (exit 0); no path to ANY intent</i><br><i>hard-fails</i>"]
    end
    subgraph INT ["<b>integrity family</b> — reads the corpus graph"]
        R6["linter.total_refs<br>linter.coverage<br>linter.no_orphan_property<br>linter.law_cases"]
    end
    subgraph DUAL ["<b>dual-format family</b> — reads the openspec mirror"]
        R7["linter.requirement_drift<br>linter.dual_format_valid"]
    end
    subgraph FAIL ["<b>failure-shape family</b>"]
        R8["linter.terminal_states_emit<br>linter.error_labels_unique<br>linter.guard_negation_total"]
    end
    subgraph CHECK ["<b>external-completeness family</b> — reads the checklist"]
        R9["linter.checklist_well_formed<br>linter.every_item_accounted<br>linter.covered_maps_resolve<br>linter.waiver_has_rationale<br>linter.no_duplicate_claim"]
    end
    subgraph PACK ["<b>pack family</b> — reads kind: profile files"]
        R10["linter.pack_shape<br>linter.orphan_vocabulary"]
    end

    ADV1["<b>linter.observability</b><br><i>advisory — warnings channel, exit 0</i>"]
    ADV2["<b>linter.skew_advisory</b><br><i>advisory — warnings channel, exit 0</i>"]

    TARGET --> FMF
    TARGET --> SHAPE
    TARGET --> MODEL
    TARGET --> REACH
    TARGET --> INT
    TARGET --> DUAL
    TARGET --> FAIL
    TARGET --> CHECK
    TARGET --> PACK

    MODEL -. "stated dependency:<br>failure_shape runs only once<br>the model is well-formed" .-> FAIL

    R1 & R2 & R3 & R4 & REACH & R6 & R7 & R8 & R9 & R10 --> JOIN{{"<b>lint</b> — the join point<br><i>all gating rules pass, or exit 1</i>"}}
    R5 -- "warn-half<br><i>(exit 0)</i>" --> JOIN
    ADV1 --> JOIN
    ADV2 --> JOIN

    style JOIN fill:#e3f2fd,stroke:#1565c0
    style R5 fill:#fff3e0,stroke:#ef6c00
    style ADV1 fill:#fff3e0,stroke:#ef6c00
    style ADV2 fill:#fff3e0,stroke:#ef6c00

Reading the diagram: the families are join-point checkers, not a dependency DAG — the linter’s actual pass structure accumulates findings per family and emits at the join point, so no invented edges between families — the one dashed edge below is the dependency the failure-shape spec itself declares. Only the orange boxes are advisory; every other rule in the catalog gates (exit 1). The per-rule semantics (one line each, generated from the same table the binary uses) are served by spk explain lint-rules, and the per-family spec files live under the Lint Rules section. Dogfood: just lint-specs runs this linter over the corpus itself.

Verify workflow — the canonical lifecycle

The artifact lifecycle is the pipeline’s own Model, declared and machine-linted like any other spec’s. Stage gates are enforced by the guards the lifecycle spec itself declares:

flowchart LR
    DR["draft"] --> PA["parsed"]
    PA -- "frontmatter_valid ·<br>id_matches_file" --> LI["linted"]
    LI -- "every owned checker<br>passes (the join point)" --> CO["compiled"]
    CO -- "coverage ·<br>law_cases" --> MC["model_checked"]
    MC -- "model_present" --> VE["verified"]
    VE -- "no_counterexample ·<br>properties_pass ·<br>opted-in invariants<br>verified" --> DONE(["complete"])
    style DONE fill:#e8f5e9,stroke:#2e7d32

Reading the diagram: verify is bounded model checking of the claims the spec opts into — prose-only invariants stay explicitly unchecked and are never counted as verified. Verification is not a substitute for the application’s own test suite. The lifecycle’s guards carry rule ids served by spk explain lifecycle; the stage gates are enforced by the linter’s guard rules, and the guard_required rule makes every transition’s guard non-null. The same lifecycle is what the linter’s own Model section declares — the pipeline self-hosts. Full recipes for running the pipeline by hand are in Command Reference.