This paper works the schedules and articles. The manifesto is the governing text. Read the manifesto.

Companion paper · Code as Contract, principle 9

Schedules: how the internals are drafted.

A real program is not only business rules. Something must be cached, something must run later on a queue, something must be a form on a screen. Code as Contract handles all of these with one move, borrowed from legal drafting: the schedule — an annex of declared terms that the clauses cite. The schedule is frozen data; the machinery that executes it is a generic performer with no rules of its own; and every judgment the schedule requires is a pure function with case law. Three worked examples follow.

§1

The pattern, stated once

Every internal concern — caching, queuing, retries, rendering, validation — decomposes into the same three parts:

  1. Terms — a frozen dataclass (or as const object) declaring the obligation: what is owed, for how long, under what conditions. It lives next to the Definitions and is cited by name.
  2. Rulings — every judgment the terms require (is this still fresh? should this retry? is this field valid?) is a pure function of the terms and the facts. This is where the case law attaches.
  3. A generic performer — one piece of machinery per concern (a cache wrapper, a queue dispatcher, a form renderer) that reads the terms and consults the rulings. It is written once, contains zero business decisions, and never needs case law of its own — only execution-layer tests.

The test for whether you have done it right: could you change the obligation by editing only the schedule? Shorten a cache from 15 minutes to 5, add a retry, make a form field optional — each must be a one-line amendment to declared data, visible in the diff as exactly that.

§2

Schedule C — caching evidence freshness

Caching is not an optimization trick; contractually, it is a rule about how old evidence may be and still be admissible. Say the refund policy converts currency using an exchange rate: the schedule declares how long a fetched rate stands.

The schedule is data. Nothing here executes; it only declares. Note the TTL is a term with a name, not a magic number inside a decorator call.

# schedules/caching.py — Schedule C. Declared terms, no machinery.
@dataclass(frozen=True)
class CacheTerms:
    """How old a gathered fact may be and still be admissible."""
    key: str
    ttl: timedelta
    grace: timedelta = timedelta(0)   # stale-while-revalidate allowance


# Schedule C-1: exchange rates stand for 15 minutes, plus a
# 2-minute grace during which a stale rate may be served while
# a fresh one is fetched.
EXCHANGE_RATE = CacheTerms(
    key="fx:{base}:{quote}",
    ttl=timedelta(minutes=15),
    grace=timedelta(minutes=2),
)

This is the part Gherkin-era codebases never test: the freshness judgment itself. Because it is a pure function of timestamps and terms, it gets ordinary case law — no clock mocking, no cache server in the test.

# schedules/caching.py — the ruling. Pure: facts in, admissibility out.
class Admissibility(Enum):
    FRESH = "fresh"          # serve it
    STALE_IN_GRACE = "grace" # serve it, refetch in background
    EXPIRED = "expired"      # must refetch before serving


def admissibility(fetched_at: datetime, now: datetime,
                  terms: CacheTerms) -> Admissibility:
    age = now - fetched_at
    if age <= terms.ttl:
        return Admissibility.FRESH
    if age <= terms.ttl + terms.grace:
        return Admissibility.STALE_IN_GRACE
    return Admissibility.EXPIRED


# case_law/test_schedule_c_caching.py
def test_a_rate_older_than_its_ttl_but_in_grace_is_served_while_refetching():
    fetched = datetime(2026, 8, 6, 12, 0)
    now = datetime(2026, 8, 6, 12, 16)   # 16 min: past TTL, inside grace
    assert admissibility(fetched, now, EXCHANGE_RATE) is Admissibility.STALE_IN_GRACE

Written once for the whole program. It reads the terms and consults the ruling — it holds no TTLs, no keys, no policy. Swapping Redis for memcache touches this file and no schedule.

# machinery/cache.py — generic performer. One per program, rule-free.
def cached(terms: CacheTerms):
    """Wrap a fact-gatherer so Schedule C governs its evidence."""
    def wrap(gather):
        def gather_with_terms(**kw):
            key = terms.key.format(**kw)
            hit = store.read(key)                       # impure read
            match hit and admissibility(hit.fetched_at, clock.now(), terms):
                case Admissibility.FRESH:
                    return hit.value
                case Admissibility.STALE_IN_GRACE:
                    tasks.enqueue(refetch, gather, key, kw)  # Schedule D!
                    return hit.value
                case _:
                    return store.fill(key, gather(**kw), clock.now())
        return gather_with_terms
    return wrap


# usage, at the fact-gathering layer — the clause never knows:
@cached(EXCHANGE_RATE)
def fetch_exchange_rate(base: str, quote: str) -> Rate: ...
§3

Schedule D — deferred performance async / queues

A queued task is, contractually, performance that need not be immediate but must be certain. The schedule declares where it runs, how many attempts it is owed, how attempts are spaced, and when non-performance escalates into a breach someone must hear about.

Every deferred obligation in the program is one of these declarations. Grep DeliveryTerms and you have the complete inventory of what the system owes later — something no Celery decorator scatter can give you.

# schedules/delivery.py — Schedule D. What is owed later, and how surely.
@dataclass(frozen=True)
class DeliveryTerms:
    """Performance need not be immediate, but must be certain."""
    queue: str
    attempts_owed: int
    backoff_base: timedelta
    escalate_after: timedelta       # breach: page a human


# Schedule D-1: refund outcomes reach the customer within the hour
# or someone gets paged. Five attempts, exponential spacing.
REFUND_OUTCOME_NOTICE = DeliveryTerms(
    queue="notifications",
    attempts_owed=5,
    backoff_base=timedelta(seconds=30),
    escalate_after=timedelta(hours=1),
)

The retry decision is where async bugs live, and it is almost never tested — because it is usually buried in framework config. Here it is a pure ruling with case law. The names of the outcomes are Definitions, so the performer cannot improvise.

# schedules/delivery.py — the ruling. Pure: history in, next step out.
@dataclass(frozen=True)
class Retry:      at: datetime
@dataclass(frozen=True)
class Escalate:   reason: str
@dataclass(frozen=True)
class GiveUp:     pass


def after_failed_attempt(attempt_no: int, first_tried: datetime,
                         now: datetime, terms: DeliveryTerms
                         ) -> Retry | Escalate | GiveUp:
    if now - first_tried >= terms.escalate_after:
        return Escalate(reason="delivery deadline breached")
    if attempt_no >= terms.attempts_owed:
        return Escalate(reason="attempts exhausted")
    return Retry(at=now + terms.backoff_base * 2 ** (attempt_no - 1))


# case_law/test_schedule_d_delivery.py
def test_a_breached_deadline_escalates_even_with_attempts_remaining():
    first = datetime(2026, 8, 6, 12, 0)
    now = datetime(2026, 8, 6, 13, 1)     # 61 min, attempt 2 of 5
    ruling = after_failed_attempt(2, first, now, REFUND_OUTCOME_NOTICE)
    assert isinstance(ruling, Escalate)

The dispatcher is the program's only contact with the broker. It consults the ruling after every failure; it decides nothing itself. The performer from Exhibit B defers with one call, citing the schedule by name.

# machinery/tasks.py — generic performer over any broker.
def defer(fn, args: tuple, terms: DeliveryTerms) -> None:
    broker.enqueue(terms.queue, job_of(fn, args, terms))

def on_job_failure(job) -> None:
    match after_failed_attempt(job.attempt_no, job.first_tried,
                               clock.now(), job.terms):
        case Retry(at=at):        broker.reschedule(job, at)
        case Escalate(reason=r):  pager.page(job, r); broker.park(job)
        case GiveUp():            broker.park(job)


# the refund performer (Exhibit B, step 3) now defers the notice:
def perform_refund_decision(order_id, decision) -> Receipt:
    ...
    defer(notifications.send_refund_outcome,
          (order_id, decision), terms=REFUND_OUTCOME_NOTICE)
    ...
§4

Schedule F — the frontend forms / React

A form is an offer: it states what facts a party must tender, in what shape, and what happens on tender. So the form is a schedule too — fields, validation, buttons, endpoint, payload — declared as one typed object. The React component that renders it is a generic performer: it can draw any form and knows no particular one.

Everything a reviewer would ask about this form — which fields, required or not, validated how, submitting where, sending what — is answered in one declaration. The payload mapping is explicit, so the endpoint's contract is cited, not assumed.

// schedules/refundRequestForm.ts — Schedule F-1. The offer, declared.
export const refundRequestForm = {
  clause: "4",                              // the clause this form tenders facts to
  fields: [
    { name: "requestedOn", label: "Date of request",
      kind: "date", required: true,
      rules: [requestedOnMayNotBeInTheFuture] },
    { name: "reason", label: "Why are you returning this?",
      kind: "select", required: true,
      options: ["damaged_on_arrival", "changed_mind"],
      rules: [] },
  ],
  actions: [
    { label: "Request refund", kind: "submit",
      call: { method: "POST",
              endpoint: "/orders/{orderId}/refund-requests",
              payload: (v: Values) => ({
                requested_on: v.requestedOn,
                damaged_on_arrival: v.reason === "damaged_on_arrival",
              }) } },
    { label: "Cancel", kind: "dismiss" },
  ],
} satisfies FormTerms<Values>;

Frontend validation rules are named, pure, and separately testable — exactly like backend clauses. A rule returns a grievance or nothing. Case law runs in vitest with no DOM, no render, no user-event simulation.

// schedules/rules.ts — named rulings. Pure: value in, grievance out.
export function requestedOnMayNotBeInTheFuture(
  value: string, today: string
): Grievance | null {
  return value > today
    ? { field: "requestedOn", says: "The request date cannot be in the future." }
    : null;
}


// case_law/refundRequestForm.test.ts
test("a customer may not date a refund request in the future", () => {
  const grievance = requestedOnMayNotBeInTheFuture("2026-08-07", "2026-08-06");
  expect(grievance?.says).toMatch(/cannot be in the future/);
});

test("the form tenders exactly what Clause 4's endpoint is owed", () => {
  const body = refundRequestForm.actions[0].call.payload({
    requestedOn: "2026-08-05", reason: "damaged_on_arrival" });
  expect(body).toEqual({ requested_on: "2026-08-05",
                         damaged_on_arrival: true });
});

One renderer for every form in the product. It walks the terms, runs the rules, disables submit while grievances stand, and performs the declared call. Adding a field to the refund form is a diff in Schedule F-1 only — no component edits, no new tests for the renderer.

// machinery/ContractForm.tsx — generic performer. Knows no form.
export function ContractForm<V>({ terms }: { terms: FormTerms<V> }) {
  const [values, setValues] = useState<V>(initialValues(terms));
  const grievances = terms.fields.flatMap(f =>
    f.rules.map(rule => rule(values[f.name], today())).filter(Boolean));

  return (
    <form onSubmit={e => { e.preventDefault(); tender(terms, values); }}>
      {terms.fields.map(f => <Field key={f.name} field={f}
                                    value={values[f.name]}
                                    onChange={v => setValues(s => ({ ...s, [f.name]: v }))}
                                    grievances={grievances} />)}
      {terms.actions.map(a => <Action key={a.label} action={a}
                                      disabled={a.kind === "submit" && grievances.length > 0} />)}
    </form>
  );
}

// tender() performs the declared call — gather, consult, perform,
// browser edition. It never builds a payload by hand.
async function tender<V>(terms: FormTerms<V>, values: V) {
  const submit = terms.actions.find(a => a.kind === "submit")!;
  await api.call(submit.call.method, submit.call.endpoint,
                 submit.call.payload(values));
}
§5

The full docket of schedules

Caching, delivery and forms were three worked examples — the pattern covers every operational concern a program has. The test for membership: can the obligation be stated as frozen data, and its judgment as a pure function? If yes, it is a schedule. The recurring catalog:

ScheduleThe terms declareThe pure ruling
C · Cachingkey, TTL, graceadmissibility(fetched_at, now, terms)
D · Deferred performancequeue, attempts owed, backoff, deadlineafter_failed_attempt(...) → Retry | Escalate
F · Forms & screensfields, rules, actions, payload mappingfield rules → Grievance | null
P · Permissionswhich party may invoke which clausemay_invoke(actor, clause, terms) → Allowed | Denied(reason)
R · Rate limitswindow, quota, scopeover_quota(history, now, terms)
G · Feature gatesaudience, conditions, sunset dategate_is_open(actor, now, terms)
I · Idempotencykey, replay window, on-replay behavioron_replay(prior_receipt, terms) → ServePrior | Reject
T · Retention & privacywhat data, kept how long, then whatdisposition(record_age, terms) → Keep | Anonymize | Purge
S · Recurring performancecadence, grace, missed-run policyis_due(last_run, now, terms), on_missed(...)
O · Observabilitywhich events must be recorded; alert thresholdsbreach(measurements, terms) → Alert | None
L · Copy & localeevery user-facing sentence, keyed and per-locale(pure lookup — untranslated key fails the build)
M · Recordshow each Definition persists: table, columns, indexesundeclared_drift(live_schema, terms) → []

Each of the remaining schedules, worked to the same three parts — terms, ruling, case law. Open any of them:

P · Permissions — who is party to the clause

Authorization is a statement about standing: which roles are party to an interaction at all. One declaration per clause; the ruling always answers with a reason.

# schedules/permissions.py — Schedule P.
@dataclass(frozen=True)
class InvocationTerms:
    clause: str
    parties: frozenset[Role]

REQUEST_REFUND = InvocationTerms(clause="4",
                                 parties=frozenset({Role.CUSTOMER, Role.SUPPORT}))

def may_invoke(actor: Actor, terms: InvocationTerms) -> Allowed | Denied:
    if actor.role in terms.parties:
        return Allowed()
    return Denied(reason=f"{actor.role.value} is not party to clause {terms.clause}")

# case_law/test_schedule_p_permissions.py
def test_a_courier_has_no_standing_to_request_a_refund():
    ruling = may_invoke(Actor(role=Role.COURIER), REQUEST_REFUND)
    assert isinstance(ruling, Denied)
R · Rate limits — the quota is a term, not an nginx line

The quota lives with the clause it protects, not in infrastructure config where no stakeholder will ever find it.

# schedules/quotas.py — Schedule R.
@dataclass(frozen=True)
class QuotaTerms:
    scope: str
    window: timedelta
    invocations_owed: int

REFUND_REQUESTS = QuotaTerms(scope="per_customer",
                             window=timedelta(days=1), invocations_owed=3)

def over_quota(history: list[datetime], now: datetime, terms: QuotaTerms) -> bool:
    return sum(1 for t in history if now - t < terms.window) >= terms.invocations_owed

# case_law/test_schedule_r_quotas.py
def test_the_fourth_refund_request_in_a_day_is_refused():
    noon = datetime(2026, 8, 6, 12)
    history = [noon - timedelta(hours=h) for h in (1, 2, 3)]
    assert over_quota(history, noon, REFUND_REQUESTS)
G · Feature gates — every flag carries its own funeral

The sunset date is a required term. A lint rule walks the schedule and fails the build on any gate past its date — flags cannot rot.

# schedules/gates.py — Schedule G.
@dataclass(frozen=True)
class GateTerms:
    name: str
    audience: str          # cohort that may pass while the gate stands
    sunset: date           # after this, the gate is closed — and lint flags it

INSTANT_REFUNDS = GateTerms("instant_refunds",
                            audience="beta_customers", sunset=date(2026, 10, 1))

def gate_is_open(actor: Actor, today: date, terms: GateTerms) -> bool:
    return today <= terms.sunset and terms.audience in actor.cohorts

# case_law/test_schedule_g_gates.py
def test_a_gate_past_its_sunset_is_closed_even_for_its_audience():
    beta = Actor(role=Role.CUSTOMER, cohorts=frozenset({"beta_customers"}))
    assert not gate_is_open(beta, date(2026, 10, 2), INSTANT_REFUNDS)
I · Idempotency — a replay is served its prior receipt

Double-submission is a contractual question: within the window, the same tender is the same tender, and it is owed the original receipt — not a second performance.

# schedules/replays.py — Schedule I.
@dataclass(frozen=True)
class ReplayTerms:
    key: str
    window: timedelta

REFUND_SUBMISSION = ReplayTerms(key="refund:{order_id}",
                                window=timedelta(hours=24))

def on_replay(prior: Receipt | None, age: timedelta,
              terms: ReplayTerms) -> ServePrior | Proceed:
    if prior is not None and age <= terms.window:
        return ServePrior(prior)
    return Proceed()

# case_law/test_schedule_i_replays.py
def test_resubmitting_within_the_window_returns_the_original_receipt():
    prior = Receipt(id="r-1")
    ruling = on_replay(prior, timedelta(minutes=5), REFUND_SUBMISSION)
    assert ruling == ServePrior(prior)
T · Retention & privacy — data has a disposition, declared up front

What is kept, for how long, and what happens then — declared when the data is first collected, not negotiated during the audit. The sweeper job (Schedule S) consults this ruling; it decides nothing itself.

# schedules/retention.py — Schedule T.
class Disposition(Enum):
    KEEP = "keep"; ANONYMIZE = "anonymize"; PURGE = "purge"

@dataclass(frozen=True)
class RetentionTerms:
    record: str
    keep_for: timedelta
    then: Disposition

REFUND_DECISIONS = RetentionTerms("refund_decisions",
                                  keep_for=timedelta(days=7 * 365),
                                  then=Disposition.ANONYMIZE)

def disposition(age: timedelta, terms: RetentionTerms) -> Disposition:
    return terms.then if age > terms.keep_for else Disposition.KEEP

# case_law/test_schedule_t_retention.py
def test_a_decision_older_than_seven_years_is_anonymized_not_purged():
    assert disposition(timedelta(days=7 * 365 + 1),
                       REFUND_DECISIONS) is Disposition.ANONYMIZE
S · Recurring performance — what a missed run owes

Cron expressions say when; they never say what lateness means. The schedule declares the grace and the obligation a miss creates — so "the job silently didn't run for a week" becomes a breach with a named consequence.

# schedules/cadence.py — Schedule S.
@dataclass(frozen=True)
class CadenceTerms:
    task: str
    every: timedelta
    grace: timedelta

RECONCILE_LEDGER = CadenceTerms("reconcile_ledger",
                                every=timedelta(days=1), grace=timedelta(hours=2))

def standing(last_run: datetime, now: datetime,
             terms: CadenceTerms) -> Current | Due | Missed:
    since = now - last_run
    if since < terms.every:                return Current()
    if since < terms.every + terms.grace:  return Due()
    return Missed(overdue_by=since - terms.every)

# case_law/test_schedule_s_cadence.py
def test_a_run_later_than_cadence_plus_grace_is_a_breach():
    last = datetime(2026, 8, 5, 3, 0)
    now = datetime(2026, 8, 6, 5, 1)     # 26h01m: past the 2h grace
    assert isinstance(standing(last, now, RECONCILE_LEDGER), Missed)
O · Observability — the duty to notice

Which measurements the program is obliged to keep, and the thresholds whose breach must wake someone. The alerting stack becomes machinery; the threshold stops living in a Grafana panel nobody reviews.

# schedules/vigilance.py — Schedule O.
@dataclass(frozen=True)
class VigilanceTerms:
    measure: str
    threshold: float
    over: timedelta

REFUND_ESCALATIONS = VigilanceTerms(measure="refund_escalations",
                                    threshold=5, over=timedelta(hours=1))

def breach(count_in_window: int, terms: VigilanceTerms) -> Alert | None:
    if count_in_window > terms.threshold:
        return Alert(says=f"{terms.measure}: {count_in_window} in {terms.over}, "
                          f"threshold {terms.threshold}")
    return None

# case_law/test_schedule_o_vigilance.py
def test_six_escalations_in_an_hour_raises_an_alert():
    assert breach(6, REFUND_ESCALATIONS) is not None
L · Copy & locale — every sentence is a term

User-facing prose is part of the contract — it is the part the customer actually reads. Keyed terms per locale; the case law is exhaustive over the Definitions, so a new denial reason without copy fails the build in every language at once.

# schedules/copy.py — Schedule L.
COPY: dict[str, dict[str, str]] = {
    "refund.denied.window_closed": {
        "en": "The return window for this order has closed.",
        "es": "El plazo de devolución de este pedido ha finalizado.",
    },
    "refund.denied.order_not_settled": {
        "en": "This order has not settled yet — refunds open once it does.",
        "es": "Este pedido aún no se ha liquidado; podrás pedir el reembolso después.",
    },
}

def say(key: str, locale: str) -> str:
    return COPY[key][locale]          # a missing key raises — by design

# case_law/test_schedule_l_copy.py — exhaustive over the Definitions.
def test_every_denial_reason_has_copy_in_every_locale():
    for reason in DenialReason:
        for locale in ("en", "es"):
            assert say(f"refund.denied.{reason.name.lower()}", locale)
§6

Schedule M — records data models

Data models need a careful distinction, because they live on both sides of the line. The in-memory shapes — Order, RefundDecision — are not a schedule: they are the Definitions, the contract itself, and they already have a home in principle 1. What is a schedule is how a Definition persists: which table, which columns, which indexes — and how that stored form changes over time. Confuse the two and you get the classic disease: the ORM class becomes the contract, and business terms end up coupled to storage choices.

One declaration binds a Definition to its stored form. The Definition stays pure and ORM-free; the record terms are the only place storage vocabulary may appear. The fact-gathering layer (Exhibit B) reads through these terms; the clauses never see them.

# schedules/records.py — Schedule M. How the Definitions persist.
@dataclass(frozen=True)
class RecordTerms:
    """Binds one Definition to one stored form. Storage words live here only."""
    definition: type
    table: str
    columns: dict[str, str]        # field → column
    indexes: tuple[str, ...]
    owner: str                     # must agree with Article III (state ownership)


ORDER_RECORD = RecordTerms(
    definition=Order,
    table="orders",
    columns={"total_cents": "total_cents",
             "settled": "is_settled",
             "delivered_on": "delivered_on",
             "damaged_on_arrival": "damaged_on_arrival"},
    indexes=("delivered_on",),
    owner="storefront.db",
)

Drift is the dispute. Three pure rulings keep the parties honest: the record must cover its Definition, the live schema must match the record, and every record must respect Article III. Schema-scanning is impure fact-gathering in conftest; the rulings stay pure.

# schedules/records.py — the rulings.
def uncovered_fields(terms: RecordTerms) -> list[str]:
    """Every field of the Definition must map to a column."""
    return [f.name for f in fields(terms.definition)
            if f.name not in terms.columns]

def undeclared_drift(live: TableSchema, terms: RecordTerms) -> list[str]:
    """The database may hold nothing the schedule does not declare."""
    declared = set(terms.columns.values())
    return [col for col in live.columns if col not in declared]

# case_law/test_schedule_m_records.py
def test_every_field_of_order_has_a_declared_column():
    assert uncovered_fields(ORDER_RECORD) == []

def test_the_live_orders_table_holds_nothing_undeclared():
    live = scan_schema("orders")          # gathered fact (impure, conftest)
    assert undeclared_drift(live, ORDER_RECORD) == []

A migration is never a standalone SQL file: it is the amendment that reconciles the stored form with an amended Definition, and the diff must show all three together — Definition, record terms, migration. CI rejects any one without the other two.

# One amendment, three parts, one commit:

# 1. The Definition amended (the contract changes first)
@dataclass(frozen=True)
class Order:
    ...
    damaged_on_arrival: bool
    gift_wrapped: bool = False          # ← new term

# 2. Schedule M amended (the stored form follows)
ORDER_RECORD = RecordTerms(..., columns={...,
    "gift_wrapped": "gift_wrapped"})

# 3. The migration performs the amendment — it decides nothing
# migrations/0012_orders_gift_wrapped.sql
ALTER TABLE orders ADD COLUMN gift_wrapped boolean
    NOT NULL DEFAULT false;

# and the case law that motivated it, in the same commit:
def test_gift_wrapped_orders_refund_without_the_restocking_fee(): ...

The counter-test again, for models: if a field exists in the record terms but no clause ever cites its Definition field, that is data collected without a purpose — a term nobody agreed to. Schedule T (retention) will ask awkward questions about it; better to strike it.

§7

Articles — architecture as governing law

Schedules govern one concern each. Architecture is different in kind: it is the governing-law section — the terms that bind every clause at once. Which layers exist and who may import whom; what is synchronous and what is deferred; where state may live; what is one deployable. In most codebases these choices live in nobody's head and everybody's habits. Under CaC they are Articles: declared, enforced, and amended only with a recorded rationale.

The same move as a schedule — frozen data — but the subject matter is the program's own shape. Each Article states one structural obligation every clause inherits.

# articles/governing_law.py — binds every clause in the program.
@dataclass(frozen=True)
class LayeringTerms:
    """Article I — who may cite whom. Lower layers never import upward."""
    order: tuple[str, ...]

ARTICLE_I = LayeringTerms(order=(
    "machinery",     # generic performers: cache, queue, renderer
    "schedules",     # declared operational terms
    "clauses",       # pure business terms + Definitions
    "facts",         # evidence gathering (impure reads)
    "performers",    # endpoints, jobs, handlers (impure writes)
))


@dataclass(frozen=True)
class BoundaryTerms:
    """Article II — what is one deployable, and what crosses between them."""
    deployable: str
    may_call: tuple[str, ...]        # other deployables, by contract only
    crossing: str                    # "http" | "queue" — never a shared DB

ARTICLE_II = (
    BoundaryTerms("storefront", may_call=("payments",), crossing="http"),
    BoundaryTerms("payments",   may_call=(),            crossing="queue"),
)


@dataclass(frozen=True)
class StateTerms:
    """Article III — where truth lives. One owner per fact; all else is cache."""
    fact: str
    owner: str

ARTICLE_III = (StateTerms("orders", owner="storefront.db"),
               StateTerms("ledger", owner="payments.db"))

Articles are enforced, not aspirational. The ruling is pure — an import graph is just data — so the architecture check is ordinary case law run in CI. Tools like import-linter already speak this shape; here the terms stay in your Articles and the tool is mere machinery.

# articles/rulings.py — pure: an import graph in, violations out.
def layering_violations(imports: ImportGraph,
                        terms: LayeringTerms) -> list[Violation]:
    rank = {layer: i for i, layer in enumerate(terms.order)}
    return [Violation(edge) for edge in imports
            if rank[edge.importer_layer] < rank[edge.imported_layer]]


# case_law/test_article_i_layering.py — architecture case law, in CI.
def test_no_clause_may_cite_a_performer():
    graph = scan_imports("src/")          # gathered fact (impure, in conftest)
    assert layering_violations(graph, ARTICLE_I) == []


def test_deployables_share_no_database():
    bindings = scan_db_bindings("src/")
    assert ownership_violations(bindings, ARTICLE_III) == []

An ADR under CaC is not a wiki page — it is the amendment record of an Article, in the repo, adjacent to the terms it amends. The diff that changes the Article must carry its recital; CI blocks an Article edit without one.

# articles/amendments/0007_payments_becomes_async.md
Amendment 7 — to Article II (boundaries)

Recital (why): synchronous capture blocked checkout for the
p99 of processor latency; the parties accept eventual receipt
in exchange for a bounded checkout time.

Operative change: storefront → payments crossing amended
"http" → "queue", under Schedule D terms CAPTURE_PAYMENT
(attempts_owed=5, escalate_after=15min).

Case law affected:
  - test_checkout_completes_without_awaiting_capture  (new)
  - test_capture_breach_pages_the_merchant            (new)
  - test_checkout_returns_the_captured_receipt        (overruled)

The hierarchy, top to bottom: Articles bind everything; clauses bind one interaction; schedules bind one operational concern; machinery binds nothing — it obeys. Anything structural you cannot write as an Article with a pure violation check is a choice you have not actually made yet.

§8

Closing — why this completes the seed

With principles 8–10 added to the manifesto, every kind of code in a program now has a place:

  1. Business rules → named clauses, pure terms, case law. (The manifesto's original ground.)
  2. I/O and side effects → performers following gather → consult → perform, with the evidence file as the only translation point. (Principle 8.)
  3. Operational behavior — the full catalog in §5: caching, queues, forms, permissions, rate limits, gates, idempotency, retention, cron, observability, copy — → schedules + pure rulings + one generic performer per concern. (Principle 9.)
  4. Data models → the in-memory shapes are Definitions (the contract itself); their persistence is Schedule M, and every migration is a three-part amendment. (§6.)
  5. Architecture → Articles: governing law declared as data, violations found by pure rulings over the import graph, amended only through recorded ADR amendments. (§7.)
  6. Documentation → the docket, generated from case law, never drifting. (Principle 10.)
HOLDING — an internal concern is drafted correctly when the obligation is a schedule you could amend in one line, the judgment is a pure ruling with case law, and the machinery is a generic performer that would work unchanged for any other schedule of its kind. If changing a TTL, a retry count, or a form field touches machinery, the schedule has leaked; if the machinery makes a decision the ruling should have made, a term is hiding. Same dispute test, new territory.

Companion to Code as Contract vs. Gherkin and CODE_AS_A_CONTRACT.md, principles 8–10.