3.6 The Scenarios View

The four views so far are static portraits. The Logical view says what the system is, the Process view what it does at once, the Development view how it is packaged, the Physical view where it runs. The +1 view sets them in motion. A scenario — a use case, a journey — walks a real goal end to end, and in walking it exercises the other four and validates that they hold up. Kruchten made scenarios the +1 for exactly this reason: a view is validated by walking it, and the scenario is the walk. Because the walk re-checks the other four against a real goal, the scenario is where the models are held honest — the Alignment Thesis turned back on the views themselves.

One general type anchors the view. A user journey names the interfaces a user moves through and the actions they take to accomplish something of value. Four real models embody the view: the user-journey model (the product-goal-to-code bridge), the agent-orchestration model (the developer-journey counterpart, the scenarios view pointed at the fleet rather than the user), journey-criticality-to-test-placement, and coverage-to-model-node mapping. A fifth, journey task-closure, sits alongside them: it types what the journey's terminal assertion must mean, a concern the join's four do not touch. The chapter closes with the zoo's flagship demonstration — four models joined so a product goal reaches all the way to how the deploy rations its tests.

Two background ideas come first — how node coverage differs from line coverage, and why a protocol needs a temporal-logic model checker.

Coverage over model nodes vs line coverage

Line coverage counts lines — which source lines a test suite executed. It is easy to game and easy to misread: a high percentage can hide the one untested branch that matters. Node coverage counts meanings instead. Project the coverage onto a model's nodes — its states, its seams, its invariants, its journey endpoints — and ask which nodes a test exercised. Now an untested invariant cannot hide inside a 95% line number, because the invariant is a node, and the node is either covered or it is a visible gap. The shift is from "how much code ran" to "which meanings are checked," and only the second answers "is the thing I care about tested?"

Protocols and TLA+

When a property is not about one component but a protocol — many actors interleaving over time, each taking steps in an order you do not control — a single state assertion is not enough and even a bounded walk of one component misses the cross-actor races. You specify the protocol in a temporal-logic language (TLA+ is the best-known), stating the actors, their steps, and the safety and liveness properties the interleaving must satisfy. A model checker then explores every interleaving the actors can produce and either proves the properties or hands you a trace. This is the heaviest tool in the zoo, reserved for the invariant whose failure lives in a schedule no example test will ever pick — a distributed reservation, a lease-and-preempt loop, a two-phase hand-off between services.

The four models that feed the flagship join each set up their part of the composition, with the full construct-and-invariant treatment of each in the appendix.

3.6.1 The user-journey model

The user-journey model makes the product's journeys first-class typed entities — each an actor pursuing a goal through ordered steps, every boundary-crossing step joined to the endpoint it calls. It assesses dependency correctness: does every step a journey declares have a real call site, and is every real call declared? A call-site drift lint checks both directions. The idea that makes it the join's entry point is that the journey copies nothing — a step's endpoint references the service-flow model, its call-site anchor references the real code, and those two references are the keys the coverage and placement models reach through. Name the journey once and the rest of the zoo joins to it.

Learn more about this governance mechanism: user-journey model.

3.6.2 The agent-orchestration model

The agent-orchestration model is the developer-journey counterpart — the scenarios view pointed at the fleet rather than the user. It models the agent lifecycle (DispatchedWorkingLandedTombstoned, with Abandoned and recovery) as a typed state machine, and the orchestrator's own refill-and-bank loop as a journey. It assesses lifecycle soundness: a tombstone before a commit, or a landed agent that never tombstones, is an illegal transition a checker catches. The distinctive move is method parity — the substrate that produces the software is governed by the same tier-derivation and drift machinery pointed at the product, so "is the fleet as checkable as what it ships" is a checked property. One of its invariants is liveness (a landed agent eventually tombstones), which routes to a temporal checker per the Process view's form-match rule. Its full construct-and-invariant treatment is in the appendix.

Learn more about this governance mechanism: agent-orchestration model.

3.6.3 Journey-criticality → test-placement

This model — the Selector in the join below — derives which environment tier a journey's tests run in from the journey's criticality: a MAJOR part runs the fast local tier and the full staging matrix, a MINOR part runs staging only. It assesses placement soundness. The tier is derived, never stored: a stored tier literal is banned, so you cannot quietly push a major path off the slow local gate to speed a run up. To move it you must demote it to MINOR — a visible edit that says out loud "this path is no longer major." A coverage-floor lint then holds the promise "every major part has a fast-tier test," which makes a green local run mean something. Its full construct-and-invariant treatment is in the appendix.

Learn more about this governance mechanism: journey-criticality test-placement.

3.6.4 Coverage → model-node mapping

Coverage-to-node mapping projects the test suite's coverage onto the model's nodes — states, seams, invariants, journey endpoints — instead of its lines. It assesses node-coverage adequacy: is every meaning I care about exercised by some test? An untested invariant cannot hide inside a 95% line number here, because the invariant is a node, and a node is either covered or a visible gap. An uncovered node is a backlog item; an uncovered node in the critical subset is a build-blocking gate. In the join below it answers the plain question the criticality floor rests on — are the journey's endpoints tested at all? Its full construct-and-invariant treatment is in the appendix.

Learn more about this governance mechanism: coverage-to-node mapping.

3.6.5 Journey task-closure — asserting the goal completed

The three models above ask whether a journey's test exists, runs, and runs in which tier. None asks what the test asserts at the end. Split the pair the model turns on: a flow assertion says the journey ran — a page returned 200, a card appeared — while a task assertion says the user got the thing they came for. A test built on flow alone can green while the task is broken, the artifact never painted, the file never downloaded. The gap is one hop past the last flow assertion, and it is where a real production break hid — a navigation to the editor route succeeded while the accessibility view it should have shown failed to load. Journey task-closure closes that hop. It promotes the journey's terminal "what works means" out of a review-checklist paragraph into a typed closure post-condition: a small boolean expression over reusable observable predicates, joined by AND, OR, and NOT, "the artifact rendered AND a control is inspectable AND no error-fallback is shown." The leaves come from a shared library a journey subclasses rather than hand-rolling, and each prefers an accessibility observable — a role, an accessible name — because that is how the user perceives that the task is done.

From the expression a pure function derives a closure-strength (TASK_CLOSED, FLOW_ONLY, or DISABLED), stored nowhere by hand. Two leaves are flow-only: navigated to a URL, a route returned 2xx. An expression may reference them, but a closure built only from them derives FLOW_ONLY, and on a major journey that is a build finding — the mechanical form of "a journey test should have caught it." That asymmetry is the model's teeth. This model assesses closure meaning, the axis its three siblings leave to human review: presence and tier versus the meaning of the terminal assertion. And because the closure is typed rather than prose, the same definition resolves two ways: a browser assertion in the fast local tier, and a headless probe against the pre-promotion canary. That restores "staging catches anything local catches" for the task dimension, a containment the fast post-deploy battery had been blind to because it checks page integrity, never task closure. Its full construct-and-invariant treatment is in the appendix.

Learn more about this governance mechanism: journey task-closure.

---

3.6.6 Worked join — the journey↔coverage↔deploy-policy composition

Four models composed on a shared key, so a product goal reaches all the way to how the deploy runs its tests. A journey's criticality derives which tests run on which host tier, and that placement joins to the deploy execution policy, which rations each host by fanning tests out or serializing them.

Watch one fact — a journey is major — travel through four models with nothing said twice. The other model pages each show a single model; this one shows them joining, not repeating. The user-journey model (M13) names the journey and its endpoints. The coverage-to-node map (M5) asks whether those endpoints are tested at all. The journey-criticality-to-test-placement model (M6, the Selector) derives which host tier each test runs on from the journey's criticality. And the invariant-DAG execution policy (M7, the Scheduler) decides how each host runs the tests it was handed — fan out, or serialize. No model owns another's facts; each joins on the journey and its endpoints.

The three questions the join answers

Three questions the raw deploy scripts and test config cannot answer on their own.

The four models and their join keys

The four models compose through shared keys.

The through-line: a journey's endpoint is the coverage join key (M13→M5); its criticality is the Selector's input (M13/M6); the Selector's per-host test set is what the deploy phase runs; and the Scheduler's profile decides whether that host fans those tests out or serializes them (M6→M7).

The composite picture

The join has no single appendix diagram, so this composite is composed from the four real panels, adding only the join edges between them. The data-flow reads left to right — criticality derives placement, placement joins to coverage, and the deploy Scheduler rations the resulting test set per host, in Figure 3.6-1:

The scenarios join: journey to selector to coverage to scheduler A major journey names an endpoint. The Selector derives that endpoint's tests to the local-plus-staging tier (a minor one to staging only), and a coverage-floor lint fails if a major endpoint has no local test. The coverage model joins to the same endpoint node to check it is tested at all. The resulting test set flows to the deploy Scheduler, which reads a per-host profile: when only a cost gate applies it fans the tests out in parallel; when a cost gate and a scarce host combine it serializes them. Journey (M13) Journey: goal criticality: MAJOR calls endpoint Selector (M6) major? yes no local + staging staging only Coverage-floor lint Coverage (M5) covered? node join Scheduler (M7) plan_for(host) HostLoadProfile ceiling · budget cost gate only fan out — parallel cost gate + a scarce host serialize — fan / join
Figure 3.6-1. The Four-Model Join. A journey's criticality derives its test tier, coverage joins on the same endpoint node, and the Scheduler rations the resulting set per host — parallel when only a cost gate applies, serialized when a scarce host is in play.

The invariants the join spans

The join's invariants span all four models, collected in Table 3.6-1. All are safety properties; the liveness that concurrency hands the fleet is stated and routed in the Process view, whose form-match rule sends a liveness invariant to a temporal checker.

Table 3.6-1. Invariants of the Scenarios view — the properties the journey-to-test join spans and how each is checked.
InvariantHow it is checked
Every major journey-part has a test in the fast local tierCoverage-floor lint (M6) walks the model; a major part with no local test is a finding.
A journey's declared deps match its real call sites, both waysCall-site drift lint (M13): every declared dep has a real call site, every call site is declared.
Every declared journey endpoint is exercised by some testUndertested-journey audit (M5): join coverage to the endpoint nodes; an uncovered endpoint is a gap.
The host tier is a pure function of criticalityDerive-and-assert (M6): a stored tier literal is banned; the lint recomputes the derivation.
No deploy edge carries the LOAD intentLoad-edge lint (M7) reads the graph and the Scheduler's graph-resident intents; a LOAD edge is a finding.
Staging's test set is a superset of local's and of prod'sContainment property test plus a live-model lint (M6): recompute containment by calling the selector, not auditing a matrix.

The Selector and Scheduler in code

Two pieces of real policy code make the join concrete. The Selector derives a host's test roster from the journey model; the Scheduler decides how that host runs it:

from dataclasses import dataclass
from enum import Enum

# --- M6: the Selector — criticality derives the host tier, tier derives the test roster ---
class Criticality(Enum):
    MAJOR = "major"
    MINOR = "minor"

def tier_for(crit: Criticality) -> frozenset[str]:
    """Pure derivation: a MAJOR part runs local + staging; a MINOR part runs staging only.
       Stored nowhere by hand — a tier literal is banned, so moving a part off the local
       floor forces a visible MAJOR->MINOR demotion, not a silent tier edit."""
    if crit is Criticality.MAJOR:
        return frozenset({"local", "staging"})
    return frozenset({"staging"})

def roster_for(host: str, journey_parts: dict[str, Criticality]) -> list[str]:
    """The test set this host runs: every part whose derived tier contains this host."""
    return [name for name, crit in journey_parts.items() if host in tier_for(crit)]

# --- M7: the Scheduler — a per-host profile decides fan-out vs serialize for that roster ---
@dataclass(frozen=True)
class HostLoadProfile:
    concurrency_ceiling: int   # 1 = single scarce box; large = elastic
    budget: float              # inf = unbounded (cost gates relaxable); finite = honor $ gates

@dataclass(frozen=True)
class ConcurrencyPlan:
    permits: int               # how many roster items may run at once
    honor_cost_gate: bool      # run an expensive test only if its cheap gate passed

def plan_for(profile: HostLoadProfile) -> ConcurrencyPlan:
    """The load + cost rationing decision, per host, from the profile alone.
       Fan OUT freely when only a $ (COST_GATE) gate applies (elastic box, permits high);
       fan/join — SERIALIZE — when a $ gate AND a scarce-resource (LOAD) gate both apply
       (single box: permits collapse to 1, the serialize case a scarce box forces)."""
    permits = profile.concurrency_ceiling          # LOAD rationing: a semaphore, not an edge
    honor_cost_gate = profile.budget != float("inf")  # COST_GATE: honored unless budget unbounded
    return ConcurrencyPlan(permits=permits, honor_cost_gate=honor_cost_gate)

# Three named host profiles — moving the stress burst between hosts is a one-row edit:
PROFILES = {
    "local":   HostLoadProfile(concurrency_ceiling=1,    budget=100.0),   # scarce VM: serialize
    "staging": HostLoadProfile(concurrency_ceiling=999,  budget=float("inf")),  # elastic: fan out
    "prod":    HostLoadProfile(concurrency_ceiling=4,    budget=50.0),    # low finite smoke ceiling
}

The behavior the code makes concrete: staging (ceiling 999, unbounded budget) permits the whole ready wave and relaxes cost gates — pure fan-out, no box scarce. Local (ceiling 1, budget 100) collapses to one permit and honors cost gates — the serialize case, where a cost gate and a scarce-box load gate both apply, so the locally-placed tests run one at a time behind their cheap gates. Raising the local ceiling is a one-cell profile edit, never a graph edit — which is why LOAD stays out of the DAG.

Tracing one fact through four models

Model-from-code, joined. Every leg derives from the code: the journey's deps from its real call sites (M13), coverage from the real test run (M5), the host tier from the criticality field by a pure function (M6), the rationing plan from the per-host profile (M7). Nothing is hand-stored — a stored tier or a LOAD edge is banned outright. The join keys chain: the endpoint ties M13 to M5, the criticality ties M13 to M6, the derived tier ties M6 to M7's roster, and the host profile ties the roster to the plan. A reader round-trips from "this journey is major" to "these tests serialize on the local box" by following those four keys, and the drift lints mechanize each hop.

Also seen in: Physical (the Scheduler half). This join is why the Physical and Scenarios chapters cross-reference each other most; it is rendered in full here.

---

Five views, walked on real code, each with its invariants and the checker that holds them true. The zoo has twenty-nine named models, and the number makes Kruchten's point for him: no single view is ever the whole picture. What the safety-critical world did because lives were at stake — keep the models honest, trace every requirement to the code that realizes it — the rest of us now do too. We do it because an agent will pay the upkeep, and because there is no cheaper way to trust what a fleet ships.

© James C. Davis, 2026–present