Behaviour — the flows that can go wrong

The four or five flows that decide whether the design holds — chosen because they can go wrong, not because they matter most on a slide. The happy path is three lines; everything after it is the artefact.

Spine concern · arc42 §6

In one paragraph

Behaviour is the small set of runtime flows where the quality goals are won or lost. Write the happy path in three lines, then the part nobody diagrams: what happens when a step half-succeeds, which steps are safe to retry and what makes them idempotent, what the user sees while the flow is degraded, and the consistency the flow actually guarantees — named as a model, not as a database.


Lens 01

The Basics

Durable. What this concern is responsible for, regardless of decade or stack.

A runtime view is the one concern where the interesting content is entirely absent from the diagram people draw. Sequence diagrams show the order of calls, which tracing now shows better and cannot get wrong. What no tool produces is the answer to what this flow does when step three of five succeeds and step four does not — and that answer is the design.

Choose flows by where the design can break

Not the most important flows and certainly not all of them. Pick the ones that cross a boundary, write in two places, touch money or personal data, or hold a user waiting on something you do not control. Four or five. A runtime view with fourteen flows is a re-description of the application and will be abandoned within two quarters.

The selection test is a single question: if this flow silently half-completes, who finds out, and how? If the answer is a customer, next month, by email, that flow belongs on the page regardless of how simple it looks.

The flows that fail this test are worth skipping deliberately. A read that hits one store and returns is not architecture, no matter how central it is to the product — it is code, and the code is clearer than any diagram of it.

Three lines of happy path, then the real content

The happy path exists to orient a reader, not to inform them. Three numbered lines: what is received, what is written, what is emitted. Anything longer duplicates the implementation, ages with every refactor, and pushes the useful part below where people stop reading.

Then the four bullets that justify the page: partial failure, retry and idempotency, what the user sees while degraded, and the consistency actually guaranteed. Every one of them is a decision someone will otherwise make silently, at 2am, under pressure, in one call site.

Written this way a flow entry fits on half a page. That is the length at which it gets updated when the flow changes, which is the only property of documentation that ultimately matters.

Partial failure is the document

Step three wrote to the database, step four called the provider and timed out. Where does the system now sit, is that state detectable, who or what resolves it, and how long is it allowed to sit there? Four questions, and a design that cannot answer them has not been finished — it has been started and deployed.

Name the mechanism rather than the intention. Compensating action, outbox with reconciliation, a scheduled sweep of records older than an hour, or an accepted inconsistency with a stated window. "Accepted, reconciled nightly, worst case 24 hours stale" is a legitimate answer and a good one; "we handle errors" is not an answer at all.

The failure path also needs an owner and a signal. An inconsistency that nothing alerts on is an inconsistency you will hear about from a customer, and by then the record is weeks old and the reconciliation is manual.

Name the consistency model, not the database

"We use a relational database, so we are consistent" stops being true the moment a cache, a read replica, a queue, a search index or a second service enters the flow — which is to say, immediately. Transactions guarantee something about one store; the flow crosses several.

So state it per flow and in the vocabulary that has meaning: read-your-writes for the user who just submitted, monotonic reads inside a session, eventual with a bounded lag for everyone else. Then say what the bound is, because an eventual guarantee with no stated lag is not a guarantee, it is a hope with a technical name.

This is the bullet where designs are most often quietly wrong, and the error is nearly always the same shape: a write goes to the primary, the next read goes to a replica, and the user sees their own change missing. It is obvious once written down and invisible in a sequence diagram.

A flow entry is complete when

The happy path is three lines
Received, written, emitted. Orientation only — the code is the authority on the detail and always will be.
Every step is marked retryable or not
Retryable means idempotent. If a step is not idempotent, the entry says what makes retrying it safe or forbids the retry.
The idempotency key is named
Where it comes from, how long it is remembered, and what happens if the same key arrives with a different payload.
The partial-failure resolution is a mechanism
Compensate, outbox, sweep, or accept with a stated window. Plus what alerts when it happens.
The degraded experience is stated
What the user is shown and told while the flow is impaired. Absent this, the fallback is a spinner or a lie.
The consistency model is named per flow
Read-your-writes, monotonic, eventual with a bound. A property of the flow, never inherited from the store.

In the spec

What the concern looks like once it is written down. No machine layer here — this one is derived from the decisions and quality goals it cites, so the artefact is the page a person reads.

arch/08-behaviour.md — one flow, four bullets
# 8. Behaviour

Five flows. The traces are the picture (Grafana → checkout exemplar);
this page is what the traces cannot tell you.

## Flow: Checkout

1. POST /orders with an idempotency key from the client.
2. Write order (pending) → charge via EXT-1 → write payment.
3. Emit `order.placed` through the outbox.

| Step | Retryable | Idempotency key | Deadline |
|---|---|---|---|
| Write order | yes | client key, kept 24h | 200ms |
| Charge EXT-1 | yes, provider dedups | client key forwarded | 2s total |
| Write payment | yes | provider txn id | 200ms |
| Emit event | yes, consumers dedup | order id + version | async |

- **On partial failure:** charge succeeded, payment write failed →
  the order stays `pending_payment`. A sweep reconciles against the
  provider every 5 min; anything older than 30 min pages the
  on-call. Never re-charge without the provider's txn id.
- **Retry and idempotency:** same key + same payload returns the
  first result. Same key + *different* payload is a 409 — we do not
  guess which order the customer meant.
- **While degraded (EXT-1 down):** checkout is disabled with a dated
  message. We do not queue payments we cannot confirm (ADR-0011).
- **Consistency actually guaranteed:** read-your-writes for the
  buyer via primary reads on the order page for 60s after placing.
  Everyone else, including reporting, is eventual — snapshot lag up
  to 24h (ADR-0022).
- **Ordering:** `order.placed` is partitioned by order id. Per-order
  ordering only; no cross-order guarantee, and consumers must
  tolerate redelivery.
arch/spine.yaml — the failure path as a check
# Behaviour has no spine.yaml key: a flow is prose plus a table,
# and the enforceable part is a test. These guardrails are what
# keep the four bullets above from being fiction.

guardrails:
  - id: G-11
    checks: [QG-1]
    kind: test
    rule: >
      Checkout survives a kill between the charge and the payment
      write: the sweep reconciles and no order is double-charged.
    threshold: "fault-injection suite green; 0 duplicate charges"
    enforced_by: tests/fault/checkout_partial.spec.ts in CI
    on_breach: fail

  - id: G-12
    checks: [QG-1]
    kind: test
    rule: >
      Replaying any consumed order.placed event twice produces no
      second side effect. Redelivery is assumed, not exceptional.
    threshold: "0 duplicate side effects"
    enforced_by: tests/contract/redelivery.spec.ts

  - id: G-13
    checks: [QG-4]
    kind: runtime
    rule: >
      No order sits in pending_payment beyond 30 minutes.
      The reconciliation sweep is the mechanism; this is the alarm.
    threshold: "0 orders > 30m; page on-call"
    enforced_by: prometheus/alerts/checkout.yaml
    on_breach: fail

Lens 02

The Current

Reviewed 2026-09-09

How it actually plays out in production now. Dated, because this is the part that decays.

Two things changed the practice here. Tracing made the happy path free to observe, which removed the last reason to hand-draw one — and asynchronous defaults made partial failure the normal case rather than the exception. Which means the authored half of a runtime view is now entirely about what the traces cannot tell you.

Traces replaced the sequence diagram, for the happy path only

A trace is a sequence diagram that cannot be out of date, and any competent observability stack produces one per request. Stop drawing the picture; link to an exemplar trace and spend the effort on semantics.

What a trace cannot express is intent: which of those calls was allowed to fail, which retry was safe, what the system should have done when the fourth span timed out. A trace of a broken flow tells you what happened, not whether it was correct, and those are different questions with different owners.

Retries are how a slow dependency becomes an outage

Three layers each retrying three times is twenty-seven requests to a service that is already struggling, arriving in a synchronised wave the moment it starts recovering. The retry is the amplifier, and every incident review that mentions a thundering herd is describing a policy nobody wrote down.

The current default worth stating per flow: exponential backoff with jitter, a budget for the whole request rather than a count per hop, and a circuit breaker on anything on a user-facing path. The budget is the part that gets skipped and the part that matters — retries that outlive the caller's deadline are pure load with no possible benefit to anyone.

And retry only what is idempotent. A retried non-idempotent write is a duplicate charge, a duplicate email, a double shipment. The rule is not that retries are dangerous; it is that a retry policy without an idempotency story is a duplicate-generation policy.

The outbox and the idempotency key are default furniture now

Exactly-once delivery does not exist across a network. Effectively-once does, and it is built the same way everywhere: at-least-once delivery plus a deduplication key with a retention window, or a transactional outbox so the write and the intent to publish commit together.

Three fields decide whether it works, so write them down per flow. Where the key comes from — client-generated, the order id, a natural key — how long the dedup record is kept, and what the system does when the same key arrives with a different payload. That third case is where the subtle bug lives: silently returning the first result is right for a retried payment and wrong for an edited one, and only the flow's author knows which.

Async makes ordering and pending states explicit design work

A queue gives you ordering per partition, not globally, and redelivery is a feature rather than an anomaly. Both facts belong in the flow entry, because a consumer written on the assumption of global order works fine in test and reorders in production under load.

The dead-letter queue needs an owner in the same way a block needs one. A DLQ nobody reads is a data-loss mechanism with a reassuring name — the messages are safe, nothing is fixed, and the discovery is usually a customer report.

Asynchrony also makes intermediate states user-visible, which turns them into design decisions. "Pending" is not an implementation detail: someone has to decide what it looks like, how long it is acceptable, whether the user can act during it, and what happens if it never resolves. Deciding that in the flow document costs a paragraph; deciding it in the UI costs a support queue.


Lens 03

Future-ready

What changes when agents write and operate the code. Opinionated on purpose.

This is the concern where generated code is most confidently wrong. An agent writes an excellent happy path — it has seen a million of them — and then invents the failure semantics, because the repository does not contain them. The result compiles, reads well, and passes tests written against the same invention.

The plausible failure path

The recurring shapes are predictable enough to enumerate: a retry loop wrapped around a non-idempotent write, an exception swallowed so the flow continues with an empty result, an unbounded backoff on a request a person is waiting for, a timeout treated as a failure when the write actually succeeded.

Each is a reasonable guess in the absence of a stated policy, and each has a postmortem attached to it in someone's incident tracker. The problem is not that the model is careless; it is that the information required to be careful — is this write idempotent, what is the deadline, who reconciles — exists nowhere in the code it was given.

Review does not catch these reliably either, because the failure branch is the part of a diff that reads as boilerplate. It is caught by a stated policy and a test that injects the failure, or it is caught in production.

Idempotency, deadlines and ordering are not in the repository

These are facts about the business and the infrastructure, not properties recoverable from source. Whether a duplicate charge is catastrophic or merely embarrassing, how long a user will wait, whether events for one customer must be processed in order — no amount of reading the code answers any of them.

Which is why the flow document is the highest-value page in the spine for generated integration code, despite having no machine layer of its own. Four bullets per flow, in the repository, is the difference between an agent implementing your failure policy and inventing one.

The practical form is a short table per flow: step, retryable, idempotency key, deadline. It is compact enough to sit in a session prompt, specific enough to remove the guesses, and it is the page an agent should be required to cite when it writes a client for a flow that appears on it.

A failure path that is not tested does not exist

Bind each failure bullet to something that runs: a fault-injection test that kills the process between two writes, a contract test that returns a 500 and a timeout, a consumer test that redelivers a message it has already handled. This is the guardrails concern applied to behaviour, and it is the only mechanism that keeps the paragraph honest.

The economics moved in your favour here. Writing failure tests was always the most tedious work in the flow and it is now the cheap part, which removes the last defensible reason for not having them. A flow whose failure semantics are documented and unexercised is now a choice, and it should be recorded as a risk rather than left as an assumption.

Flows with a model in them break the template

A step that calls a model is non-deterministic, costs money per attempt, may take seconds, and has no natural idempotency — the same prompt twice is not the same answer twice, so a retry is a new result rather than a repeat of the old one. Every bullet on the page needs a different answer for that step.

Two additions are worth making explicit. A tool call with a side effect is a write, and it needs the same idempotency key and retry policy as any other write — an agent looping on a failed tool call is a duplicate-generation policy with better manners. And an autonomous loop needs a stated budget: maximum steps, maximum spend, and the checkpoint at which a person is required. Those are runtime behaviour constraints, they belong beside the flow, and the guardrail that enforces them belongs in the enforcement layer.


How this concern fails

Named, because a failure mode you can name is one you can spot in your own repository before it costs you a quarter.

The happy-path sequence diagram

Twelve arrows, all of them succeeding. It duplicates what a trace shows better and answers none of the four questions that decide whether the flow is correct.

Retry without idempotency

A retry wrapped around a write that is not safe to repeat. It works in every test and produces duplicate charges, duplicate emails or duplicate shipments the first time the network is slow.

The documented, untested failure path

The paragraph says the sweep reconciles. Nothing ever kills the process between the two writes, so the first execution of that code is in production, during an incident.

Consistency inherited from the database

The flow claims strong consistency because one store is transactional, then reads from a replica or a cache. The user's own change appears to be missing and nobody can reproduce it.

The unowned dead-letter queue

Messages are safe, nothing is fixed, no alarm fires. It is a data-loss mechanism with a reassuring name, and the discovery arrives as a customer report weeks later.

The undesigned pending state

Asynchrony made an intermediate state visible and nobody decided what it means. The user gets a spinner, an optimistic lie, or a screen that never resolves.


Go deeper


Frequently asked

What is a runtime view in architecture documentation?

A description of how the building blocks interact at runtime for a small number of important flows. In practice the useful version is four or five flows, each with a three-line happy path and then the semantics no diagram carries: partial-failure resolution, which steps are retryable and under what idempotency key, the degraded experience, and the consistency the flow actually guarantees. The picture itself is better generated from traces.

How many runtime flows should you document?

Four or five, chosen because the quality goals are won or lost in them — flows that cross a boundary, write in two places, touch money or personal data, or make a user wait on something you do not control. The selection test is whether a silent half-completion would be noticed: if the answer is a customer, next month, the flow belongs on the page. Fourteen flows is a re-description of the application and gets abandoned.

What should you write down about a failure path?

Four things: the state the system is left in when a step half-succeeds, whether that state is detectable, the mechanism that resolves it — compensation, outbox, a scheduled sweep, or an accepted inconsistency with a stated window — and the signal that fires when it happens. "We handle errors" is not an answer; "accepted, reconciled every five minutes, pages after thirty" is, and it can be tested.

How does this map to arc42 section 6?

It is arc42 section 6 with the emphasis inverted. arc42 asks for runtime scenarios showing how blocks interact, notation-agnostic, and leaves the depth to you. The Spine caps the count at four or five, tells you to generate the sequence from traces rather than draw it, and requires four specific bullets per flow — partial failure, retry and idempotency, degraded experience, consistency guaranteed — plus a guardrail id for the test that exercises the failure.

Why does behaviour have no spine.yaml key?

Because a flow is prose plus a small table, and the part a machine acts on is already elsewhere: the enforceable half is a test or an alarm, which lives in guardrails with an id, and the external systems it touches have ids in the context list. Encoding the narrative as YAML would produce a format nobody reads and a second copy of the same facts.

How do you make a retry safe?

Give the operation an idempotency key, decide where the key comes from, and record how long it is remembered. Then answer the case that is usually missed: what happens when the same key arrives with a different payload. Returning the first result is correct for a retried payment and wrong for an edited one, so the flow's author has to choose — and a 409 is often the honest answer, because guessing which request the customer meant is worse than refusing.

What do AI coding agents get wrong about runtime behaviour?

The happy path is usually excellent; the failure semantics are invented, because they are not recoverable from source. The common results are a retry around a non-idempotent write, a swallowed exception that continues with an empty result, an unbounded backoff on a user-facing request, and a timeout treated as a failure when the write succeeded. A per-flow table of step, retryable, idempotency key and deadline removes the guesses, and a fault-injection test keeps them removed.


The other eleven concerns

Pages are being written one at a time. The ones without a page yet are still in the template, with prompts instead of prose.

Behaviour

Take the template, not the idea

The zip, the single-file variant, the schema and the agent bundle. Nothing behind an email address.

Free to fork, modify and use commercially. No attribution required.