commit ae57cd26f1b70a9c912f34430045ed00651fb67e
parent 24783f56cad281e43f28ab1f545c18938781941d
Author: sebasjm+llm <sebasjm@numis.ar>
Date: Tue, 6 Oct 2026 11:12:00 -0300
better docs
Diffstat:
2 files changed, 2613 insertions(+), 0 deletions(-)
diff --git a/core/endpoint-documentation-requirements.rst b/core/endpoint-documentation-requirements.rst
@@ -0,0 +1,1277 @@
+.. _endpoint-documentation-requirements:
+
+==============================================
+Endpoint and Method Documentation Requirements
+==============================================
+
+.. contents:: Table of Contents
+ :local:
+ :depth: 2
+
+Status: proposed convention. Applies to: every per-endpoint ``.rst`` file
+under ``taler-docs/core/`` (HTTP APIs: exchange, merchant, bank-\*,
+corebank, challenger, taldir, mailbox, donau, auditor, sync, terminal,
+...) and every per-operation ``.rst`` file under
+``taler-docs/core/wallet-core/`` (wallet-core methods).
+
+This document is the normative format specification. Design intent and
+rationale, the diagnostic of the current corpus, the migration plan, and
+worked examples live in :doc:`DD 105
+<../design-documents/105-machine-readable-endpoint-documentation>`.
+
+The format is language-agnostic. The shape layer describes wire-level
+types (JSON objects exchanged between client and server) via the
+historically named ``ts:def`` / ``ts:op`` directives; nothing in this
+specification ties the documented semantics to any implementation
+language, and generated artifacts may target any language.
+
+
+1. Purpose
+==========
+
+Each endpoint/method is documented in **exactly one** ``.rst`` file that is
+**completely machine-readable**: every semantic claim a client, a doc lint,
+or a use-case generator could need is expressed in a directive option, a
+field, or a typed block — never only in prose. Prose remains for human
+readers, but it is *rendering*, not *source of truth*.
+
+The goal is that the following can be **derived** from the doc corpus
+without reading prose:
+
+* per-entity state tables (states, transitions, timers, failure exits),
+ with every transition naming its trigger and guard,
+* per-actor observation projections (who can see which state, through
+ which endpoint field or notification channel),
+* cross-component flows (which call creates an identifier, which calls
+ carry or consume it, which watched source moves an entity),
+* sequence diagrams (happy-path messages from transition edges,
+ ``alt`` blocks from guards, ``loop`` blocks from timers).
+
+The docs are **authoritative**: implementations conform to them. To make
+that enforceable, the machine-readable shape layer (``ts:def`` types,
+request/response schemas) should be used to generate as much source code
+as possible (types, validators, stubs), with drift caught by CI in the
+implementation repositories.
+
+One design philosophy runs through everything below: **derive edges,
+declare anchors.** Anything that is an *edge* (a transition, an arming,
+an observation, a carriage of a value) is written once, on the edge
+itself — the transition, the method, the effect. Anything that acts as
+a *resolution anchor* (an entity, a timer name, an identifier, a
+notification channel) is declared once, minimally, so that the edges can
+be checked against it. If a fact can be derived from edges, it is never
+also declared; if a name must be resolvable, it is never left implicit.
+
+
+2. File organization
+====================
+
+R2.1 One file per method. No file documents two unrelated methods; no
+ method is documented in two files. Two declared exceptions exist:
+
+ * *Variant groups*: one logical method may be documented as
+ several directive instances in one file when the variants differ
+ only in a path parameter's value domain (per-record-type delete
+ endpoints, per-provider webhook entry points) or in long-poll
+ parameter dialect. The file is linted as one method with
+ variants.
+ * *Proxy stanzas*: wildcard delegation declarations
+ (``.. http:any::`` with wildcard paths, e.g. reverse-proxy
+ pass-throughs) are not methods; they carry no request/response
+ semantics and follow only the naming rules of R2.2.
+
+R2.2 Naming (required):
+
+ * HTTP endpoints: ``<method>-<path-segments>.rst``, lowercase,
+ path variables in upper case without ``$``
+ (``get-withdrawal-operation-WITHDRAWAL_ID.rst``),
+ placed in the per-API directory (``core/exchange/``,
+ ``core/merchant/``, ``core/bank-integration/``, ...).
+ * wallet-core operations: ``<op-name>.rst`` in kebab-case inside
+ the category directory (``core/wallet-core/balances/``).
+
+R2.3 Chapter files (``api-*.rst``) keep version history, shared
+ conventions, and ``.. include::`` of per-method files. They carry
+ **no** per-method semantics that the method file itself lacks.
+
+R2.4 Cross-method information lives in exactly two places outside the
+ method files: the **entity registry** (§4: entities, lifecycles,
+ timers, identifiers) and the **per-component notification pages**
+ (§5: real-time channels). Method files *reference* these; they
+ never restate them.
+
+
+3. The method file format
+=========================
+
+A compliant file contains the sections below **in this order**.
+Requirement levels: **REQ** (must be present), **COND** (present when
+applicable), **OPT**.
+
+3.1 Method header — REQ
+-----------------------
+
+The header is the method's machine-readable identity card. Every option
+exists because a specific consumer needs it: the lint (vocab checks),
+the use-case generator (persona closures, observation bindings), the
+code generator (shape extraction), or the exclusion filter (drafts).
+
+HTTP endpoints keep the existing identity directive, extended with new
+options:
+
+.. code-block:: rst
+
+ .. http:get:: /withdrawal-operation/$WITHDRAWAL_ID
+ :since: v1
+ :maturity: stable
+ :idempotency: readonly
+ :observes-state: withdrawal-operation.status
+ :visible-to: wallet-user, bank-customer (capability withdrawal-id)
+ :wait: long-poll(timeout_ms, old_state)
+
+ One-line summary usable standalone (no markup that breaks extraction).
+
+ Longer prose description...
+
+wallet-core operations keep ``ts:op``, extended the same way:
+
+.. code-block:: rst
+
+ .. ts:op:: getBalances
+ :read-only:
+ :since: v4
+ :maturity: stable
+ :idempotency: readonly
+ :visible-to: wallet-user
+ :wait: none
+
+Options:
+
+``:since:`` (REQ)
+ Protocol/API version introducing the method, exactly ``v<N>``.
+ Needed so derived artifacts can be sliced by protocol version (an old
+ wallet talking to a new exchange sees a genuinely different machine).
+
+``:deprecated:`` (COND)
+ Version of deprecation, exactly ``v<N>``; presence implies
+ ``:maturity: deprecated`` unless ``:maturity:`` says otherwise.
+
+``:maturity:`` (REQ, default ``stable``)
+ One of: ``stable``, ``experimental``, ``draft-no-consumers``,
+ ``proposed-unimplemented``, ``deprecated``.
+ Methods marked ``draft-no-consumers`` or ``proposed-unimplemented``
+ are excluded from use-case derivation (their files exist to document
+ intent, not behavior). Examples in the current corpus: wads and
+ reserve-control endpoints in the exchange API, ``/seed`` in donau,
+ the apns/account-directory/ebisync APIs.
+ (The option is named ``:maturity:`` and not ``:status:`` because the
+ HTTP domain already uses ``:status:`` as a field-name alias for
+ status codes.)
+
+``:idempotency:`` (REQ)
+ One of: ``readonly``, ``replay-safe``, ``keyed:<field>``, ``unsafe``.
+ See §3.5 and §7.1.
+
+``:observes-state: <entity>.<field>`` (COND)
+ The response field (dotted path into the response ``ts:def``) that
+ carries an entity's machine-readable state value
+ (e.g. ``withdrawal-operation.status``, ``order.order_status``,
+ ``transaction.state``). The field's type must be a string union or
+ an object with an enumerated discriminator — the lint resolves every
+ union member against the entity's lifecycle (§4). This is the
+ **observation binding**: it states that this method's response *is* a
+ view onto that entity's machine. Without it, the per-actor state
+ projections ("who can see which state, where") cannot be computed —
+ the generator would have to guess the binding from type names or
+ enum-member spelling, which collides across APIs (``pending``
+ appears in half a dozen unions).
+
+``:visible-to:`` (REQ)
+ Comma-separated actor surfaces that may legitimately call or observe
+ through this method (§7.3). This is the single authorization fact of
+ the file: the actor value implies the credential mechanism (§7.3
+ table), so no separate ``:auth:`` field exists. Where access is
+ gated by an unguessable identifier rather than credentials, the
+ capability is named in parentheses:
+ ``merchant-staff, tax-auditor (capability wtid)``.
+ Persona projections are computed as closures over this field.
+
+``:timer-ref: <entity>.<timer>`` (COND, repeatable)
+ References a timer **declared at the entity** (§4.3), with a ``:role:``
+ describing this method's relationship to it:
+
+ * ``arms`` — a successful call starts the clock, typically via a
+ request field named in ``:field:`` (``POST /private/orders`` arms
+ ``order.refund-deadline`` via ``refund_delay``).
+ * ``exposes`` — the response carries the timer's current value, in
+ the field named by ``:field:``
+ (``ValidationChallengeInfo.solve_expiration``).
+ * ``affected-by`` — the method's behavior changes once the timer
+ fires (``.../challenge/$ID/confirm`` fails with
+ ``TAN_CHALLENGE_EXPIRED``).
+
+ The timer's semantics are declared once at the entity; the firing
+ edges live in the lifecycle's transitions. Method files only declare
+ their *relationship* to the clock.
+
+``:wait:`` (REQ)
+ ``none``, or ``long-poll(<param1>, <param2>, ...)`` listing this
+ method's long-poll request parameters. This acknowledges that the
+ endpoint can rendezvous with a state change. It stays at the method
+ because the parameter dialects are per-endpoint and differ
+ (``timeout_ms`` alone, ``timeout_ms`` + ``old_state``, the merchant's
+ ``lp_status``/``lp_not_etag`` algebra). Real-time *push* channels
+ are **not** declared here — they live in the component's notification
+ page (§5).
+
+3.2 Request — REQ
+-----------------
+
+**Why this section is strict:** the request block is what the code
+generator turns into client stubs and validators, and what the lint
+cross-checks ``:wait:`` parameters against. A parameter that exists
+only in prose is invisible to both.
+
+.. code-block:: rst
+
+ **Request:**
+
+ :query timeout_ms:
+ *Optional.* Timeout in milliseconds for :ref:`long-polling
+ <long-polling>`. Since protocol **v3**.
+ :header Taler-Purse-Signature:
+ *Conditionally required.* Purse signature over the request.
+ :body:
+ The request body must be a `WithdrawRequest` object.
+
+ .. ts:def:: WithdrawRequest
+ ...
+
+Rules:
+
+R3.2.1 Every parameter appears exactly once as ``:query:``,
+ ``:header:``, ``:path:``, or inside the ``ts:def`` body type.
+ Optionality (``*Optional.*``, ``*Conditionally required.*``)
+ starts the description; version annotations use ``@since`` /
+ ``@deprecated`` in the existing style.
+
+R3.2.2 Every type referenced is defined in a ``ts:def`` block in this
+ file or in a reachable common file. No anonymous prose types.
+
+3.3 Response — REQ
+------------------
+
+**Why the discipline:** HTTP status codes in this ecosystem do not
+follow the naive "2xx = success, else failure" rule (see §3.4). The
+response section declares the *shape* of each outcome; the **Errors**
+section declares its *semantics*. Keeping the two apart is what lets
+the generator distinguish a failure edge from a state report.
+
+.. code-block:: rst
+
+ **Response:**
+
+ :http:statuscode:`200 OK`:
+ :schema: BankWithdrawalOperationStatus
+ The withdrawal operation is known to the bank...
+ :http:statuscode:`404 Not found`:
+ :error:
+ The operation was not found.
+
+Rules:
+
+R3.3.1 Every documented status code carries ``:schema: <TypeName>``
+ (a success-shaped body defined in ``ts:def``), ``:error:``
+ (standard `ErrorDetail` body), or both. "Both" happens for
+ non-2xx codes whose body reports state rather than failure —
+ those additionally get an **Errors** entry with
+ ``:temporality: progress`` (§3.4).
+
+3.4 Errors — REQ (explicitly empty if none)
+-------------------------------------------
+
+**Why this section exists:** error codes are overloaded across flows,
+and the overloads are not inferable. A 404 on an unfunded reserve
+means "wait, the wire hasn't arrived" (transient); a 404 on an expired
+order means "gone forever" (terminal). A 402 on the order status page
+is the *normal unpaid state* (progress); a 402 on purse merge is a
+fixable rejection. Same codes, opposite meanings. The two fields per
+entry — ``:temporality:`` (what kind of outcome this is) and
+``:recovery:`` (what moves the flow forward) — are what make the
+failure and timer columns of derived state tables computable instead
+of guessed.
+
+Every non-2xx outcome gets a structured entry — including codes that
+are **not failures** (progress signals such as 402-unpaid, 202-pending):
+
+.. code-block:: rst
+
+ **Errors:**
+
+ :error 404 TALER_EC_EXCHANGE_GENERIC_RESERVE_UNKNOWN:
+ :temporality: transient-state
+ :recovery: long-poll
+ The reserve is unknown; the wire transfer may not have arrived.
+ The wallet should long-poll the reserve status and repeat the
+ exact same request.
+ :error 409 TALER_EC_EXCHANGE_WITHDRAW_INSUFFICIENT_FUNDS:
+ :temporality: fixable
+ :recovery: retry-modified
+ The reserve balance is too low; retry withdrawing less.
+ :error 451 TALER_EC_EXCHANGE_KYC_REQUIRED:
+ :temporality: gate
+ :recovery: start-kyc
+ The account must pass KYC before this call can succeed.
+ :error 402 none:
+ :temporality: progress
+ :recovery: none
+ The order is unpaid; the body carries the ``taler_pay_uri`` the
+ wallet uses to proceed. This is the normal waiting state of the
+ flow, not a failure.
+
+Rules:
+
+R3.4.1 ``:temporality:`` (REQ per error) — one of ``transient-state``,
+ ``terminal``, ``fixable``, ``rate-limited``, ``gate``,
+ ``progress`` (§7.4). ``progress`` entries are how the
+ generator knows *not* to derive a failure edge: the code is the
+ state signal.
+
+R3.4.2 ``:recovery:`` (REQ per error) — the client action that can
+ move the flow forward, from §7.5. For ``progress`` entries this
+ is usually ``none`` or ``poll-later``.
+
+R3.4.3 The human-readable condition follows the fields. Multiple
+ error codes for one status get one entry each. Where the error
+ is caused by a timer firing, the condition references the
+ entity timer (``:timer-ref:`` in the header makes the method's
+ relationship explicit).
+
+3.5 Idempotency — COND (mutating methods)
+-----------------------------------------
+
+**Why this is a first-class field:** retries are the universal response
+to network failure, and a request that arrived-but-lost-its-response is
+indistinguishable from one that never arrived. Whether a verbatim
+replay is *safe* therefore decides whether the client's failure branch
+exists at all — and whether the derived sequence diagram draws a retry
+loop or an abort. This is not guessable: ``POST /withdraw`` is
+replay-safe by careful design (repeat the exact request, get the same
+response, coins are never lost to a network cut), while
+``POST /private/orders`` without an explicit ``order_id`` creates a
+duplicate. The label also flags design smells: an ``unsafe`` mutating
+method in a system built on at-least-once networking deserves review.
+
+Covered by the ``:idempotency:`` header option (§3.1, vocabulary in
+§7.1). For ``keyed:<field>`` the file additionally documents the key
+field's reuse semantics in prose (existing good examples: bank-wire
+``/transfer`` ``request_uid``, cashout ``request_uid``). The lint
+requires: ``POST``/``PATCH``/``DELETE`` without
+``:idempotency: unsafe | replay-safe | keyed:*`` is an error.
+
+3.6 Lifecycle linkage — COND
+----------------------------
+
+**Why two bindings and not one:** a method relates to entity machines
+(§4) in two fundamentally different ways, and conflating them breaks
+different consumers:
+
+* **Mutators** declare their transitions in **Effects** (§3.7). The
+ lint diffs these against the entity's lifecycle: an effect naming an
+ undeclared transition is a doc bug in one of the two places. This is
+ the two-sided check — the lifecycle says "call X drives A→B", the
+ method file says "I drive A→B on entity E", and the disagreement
+ surfaces. One-sided declarations silently rot; that is the lesson
+ of every doc-versus-code audit on this corpus.
+* **Readers** declare the observation binding via ``:observes-state:``
+ (§3.1). This is what makes the per-persona "user sees" column of a
+ state table *generated* rather than authored.
+
+R3.6.1 Every entity named in either place is registered in the entity
+ registry with: owner, transitions, terminal states, timers,
+ identifiers.
+
+R3.6.2 State values named anywhere in the file resolve to the
+ entity's lifecycle states (the set inferred from its
+ transitions).
+
+3.7 Effects — COND (mutating methods)
+-------------------------------------
+
+**Why this section exists:** request/response documentation describes
+what the *caller* gets back — it says nothing about what changed in the
+*world*. ``POST /private/orders`` returns an order id; the state that
+matters — "a wallet can now claim this order" — is a side effect
+visible through a *different* endpoint by a *different actor*. A
+mutation is not documented until you have said who else can now see
+what. The Effects block is also the generator's source for the
+*messages* of a sequence diagram: each effect edge is a signal to
+another lifeline.
+
+.. code-block:: rst
+
+ **Effects:**
+
+ :effect:
+ :entity: order
+ :transition: none -> unpaid
+ :observable-by: :http:post:`/orders/$ORDER_ID/claim`
+ Creates the order; a wallet can now claim it.
+
+Field by field:
+
+``:entity:``
+ The lifecycle entity whose machine moves (resolves to §4).
+
+``:transition:``
+ The exact edge performed, in the lifecycle's own notation. Must
+ agree with the entity's declared transitions (the two-sided check
+ of §3.6).
+
+``:observable-by:``
+ The endpoint through which another actor observes the change. This
+ is the join that lets a generator draw the *next* message in the
+ sequence diagram.
+
+R3.7.1 One ``:effect:`` entry per world-change beyond the response.
+
+R3.7.2 Real-time signals caused by the change (wallet notifications,
+ merchant webhooks) are **not** listed here; they follow from the
+ transition and are documented in the component's notification
+ page (§5).
+
+3.8 Identifiers — COND
+----------------------
+
+**Why this section exists:** methods do not exist in isolation — a
+value created by one call shows up in another component's API three
+hops later. This block declares, per *identifier*, the method's role
+in that value's life. Identifiers are the handles and join keys of the
+system (``reserve_pub``, ``wtid``, ``order-id``, claim nonces);
+``order-id`` *names* an order entity, but the identifier itself is a
+value, not a machine.
+
+From the union of all method-side tags, the generator derives the
+identifier's full life story (created-by / carried-by / read-by /
+consumed-by), draws the cross-component value-flow graph, and the lint
+gets its two-sided join checks. The registry (§4.4) holds only the
+anchor: the name, the capability property, and prose.
+
+.. code-block:: rst
+
+ **Identifiers:**
+
+ :identifier reserve_pub:
+ :role: carries
+ :registry: :ref:`identifier-reserve-pub`
+ Wallet-generated reserve key; delivered to the bank here, becomes
+ the wire subject of the funding transfer.
+
+The four roles, from the method's perspective:
+
+* ``creates`` — the value begins to exist here. ``POST
+ /private/orders`` creates ``order-id``; the wallet's withdrawal
+ preparation creates ``reserve_pub``.
+* ``carries`` — transports a value created elsewhere: in the URL, body,
+ response, or wire subject. The bank-integration selection POST
+ carries ``reserve_pub`` from wallet to bank; ``POST
+ /private/transfers`` carries ``wtid`` from the bank statement into
+ the merchant backend.
+* ``reads`` — uses the value as a lookup key. ``GET
+ /reserves/$RESERVE_PUB`` reads ``reserve_pub``; ``GET
+ /transfers/$WTID`` reads ``wtid``.
+* ``consumes`` — validity ends here (single-use values): the
+ challenger's ``/token`` consumes the authorization grant; a
+ successful ``/orders/$ID/pay`` consumes the claim nonce.
+
+One method can play several roles on several identifiers: ``POST
+/orders/$ID/pay`` *reads* ``order-id``, *consumes* the claim nonce, and
+*carries* ``h_contract_terms`` onward to the exchange.
+
+R3.8.1 ``:role:`` vocabulary is §7.8. The ``:registry:`` ref must
+ resolve to the identifier's registry entry (§4.4).
+
+3.9 Details/Notes — OPT
+-----------------------
+
+Free prose for anything genuinely not expressible above (rationale,
+privacy notes, examples). Must not introduce semantics that contradict
+or extend the structured sections; the lint greps for state names and
+error codes appearing *only* here.
+
+
+4. Entities and lifecycles
+==========================
+
+**Why a registry:** state lives *between* methods. A method file can
+honestly describe its own request and response, but the machine the
+methods collectively implement — the states, the transitions, the
+clocks — is a property of the *entity*, not of any endpoint. The
+registry (``core/entities.rst``, with per-API chapters linking in) is
+where that machine is declared once. Per the design philosophy, the
+registry declares anchors (names, owners, terminal states, timer names,
+identifier properties) and the edges (transitions) carry their own
+facts.
+
+Each entity has exactly one declaration:
+
+.. code-block:: rst
+
+ .. entity:: tan-challenge
+ :owner: corebank
+
+ A TAN challenge issued for multi-factor confirmation of a
+ corebank operation.
+
+ .. lifecycle::
+ :terminal: solved, expired, exhausted
+
+ :transition none -> unsolved:
+ :trigger: call <any MFA-gated call returning
+ 202 + ChallengeResponse>
+ The challenge is created as part of an MFA-gated operation.
+ :transition unsolved -> solved:
+ :trigger: call POST /accounts/$U/challenge/$ID/confirm
+ Correct TAN submitted.
+ :transition unsolved -> expired:
+ :trigger: timer tan-challenge.solve-expiration
+ The challenge was not solved in time.
+ :transition unsolved -> exhausted:
+ :trigger: call POST /accounts/$U/challenge/$ID/confirm
+ :if: the failed-confirmation counter reaches zero
+ Too many wrong TANs submitted.
+
+ .. timer:: solve-expiration
+ The clock by which the challenge must be solved. (Armed by
+ challenge creation — derived from the ``arms`` timer-refs of
+ the creating methods; exposed in
+ ``ValidationChallengeInfo.solve_expiration`` — derived from
+ ``exposes`` timer-refs; fires the ``unsolved -> expired``
+ transition — derived from the lifecycle.)
+
+ .. timer:: retransmission-cooldown
+ Throttles TAN retransmission; calls while it is active fail
+ with 429 (derived from the ``affected-by`` timer-ref and the
+ error entry of the (re)send endpoint).
+
+ .. identifier:: challenge-id
+ :capability: no (owner-authenticated)
+ Identifies the challenge on the challenge endpoints.
+
+4.1 Owner
+---------
+
+``:owner:`` (REQ) names the **component holding authoritative state and
+executing the transitions** — not merely where the entity is documented
+or displayed. Ownership determines which component's answer is truth;
+every other component's view is a projection that may lag (the wallet's
+balance is a projection of exchange-owned coin-spend state; the
+wallet's order status display is a projection of merchant-owned order
+state). An entity may be *exposed* through several APIs of the owner
+(``withdrawal-operation``: bank-integration, corebank, terminal) — name
+them in prose under the owner. Where state is genuinely shared across
+components (KYC requirement and legitimization state is projected into
+the exchange, the merchant, the bank, and the wallet), the owner is
+the component whose answer is authoritative and the projections are
+named as such; ownership is about who executes transitions, not about
+which trigger kind dominates — a wallet transaction entity is owned by
+wallet-core even though its transitions are driven by watches, timers,
+and local user actions rather than incoming calls (§6).
+
+4.2 Lifecycle
+-------------
+
+The lifecycle is the transition list. **States are inferred**: the
+state set is exactly the names appearing as transition sources or
+targets (plus the pseudo-state ``none`` for pre-existence, which is
+never a real state). ``:terminal:`` (REQ) is **declared**, not
+inferred — it is the absorbing set:
+
+* it anchors use-case success conditions ("done" = reaching a declared
+ terminal state);
+* it makes the absorbing property checkable: a transition *leaving* a
+ declared terminal state is a lint error;
+* it turns incompleteness into signal: a declared-non-terminal state
+ with no outgoing transitions is a lint warning (a silently truncated
+ machine becomes visible).
+
+``:final-reactivatable:`` (COND) declares states that anchor success the
+same way but from which declared reactivation edges are legal (a paid
+order later refunded; a ``done`` wallet transaction reactivated by a
+refund query). Transitions leaving a ``final-reactivatable`` state are
+permitted but must be listed explicitly. See §6 for the wallet
+transaction entities that need this.
+
+Each ``:transition: A -> B:`` has:
+
+``:trigger:`` (REQ, repeatable)
+ What causes the transition (§7.7). This is the causality layer —
+ the part of the system that no per-endpoint doc can see locally:
+
+ * ``call <method>`` — an external call to the owner's API
+ (resolvable to a method file). Example: the wallet's selection
+ POST drives ``pending -> selected``.
+ * ``timer <entity>.<timer>`` — a declared entity timer fires.
+ Example: ``solve-expiration`` drives ``unsolved -> expired``.
+ * ``watch <component>.<observable>`` — the owner watches an external
+ source (the exchange's wirewatch watching the bank's incoming
+ history; the merchant backend tracking exchange wire arrivals).
+ This is where **cross-component causality** lives: the transition
+ names the other component's observable, which is the join the old
+ prose never made.
+ * ``external <description>`` — a human or off-API action (bank-UI
+ 2FA confirmation, cash inserted at a terminal, an offline key
+ ceremony). Non-API actors enter the machine here, honestly marked
+ as outside every API.
+
+``:if:`` (COND)
+ The guard: the config/data condition under which this transition
+ exists at all, referencing observable fields
+ (``:if: /keys.kyc_enabled and the amount crosses the withdrawal
+ threshold``). Guards are how context forks (KYC on/off, thresholds,
+ feature flags) enter the machine without multiplying machines — one
+ canonical machine, guarded edges, instead of a combinatorial family
+ of near-identical diagrams.
+
+Prose (REQ)
+ One or two sentences of human explanation.
+
+4.3 Timers (slim anchors)
+-------------------------
+
+A timer is declared as a **name plus semantics in prose** — nothing
+more, because everything structural is derivable from edges:
+
+* *fires-to*: from the lifecycle transition whose ``:trigger:`` is this
+ timer;
+* *armed-by*: from the method whose ``:timer-ref:`` has role ``arms``;
+* *exposed-in*: from the method whose ``:timer-ref:`` has role
+ ``exposes`` (its ``:field:``);
+* *gates*: from the ``429``/``rate-limited`` error entry of the method
+ whose ``:timer-ref:`` has role ``affected-by``.
+
+The block survives at all for three reasons: it is the **lint anchor**
+every ``:trigger: timer`` / ``:timer-ref:`` resolves against (free
+strings across hundreds of files would drift); it is the home of
+**config-armed** timers no method arms (``:armed-by:`` is present only
+in that case — e.g. ``purse_timeout`` comes from ``GET /keys`` global
+fees, not from any call); and it is the **gap marker**: an entity whose
+timers are not yet specified records the gap here once (see the
+``withdrawal-operation`` example in DD 105), instead of silence
+scattered across N method files.
+
+4.4 Identifiers (slim anchors)
+------------------------------
+
+An identifier is declared as **name + ``:capability:`` + prose**:
+
+.. code-block:: rst
+
+ .. identifier:: reserve_pub
+ :capability: yes
+ Reserve public key; possession authorizes reserve-status reads.
+ Wallet-generated; becomes the wire subject of the funding
+ transfer.
+
+``:capability:`` (REQ: ``yes``/``no``) records whether possession
+alone authorizes (unguessable: ``wtid``, ``withdrawal-id``, purse/claim
+links) — the property that distinguishes "public by design" (``GET
+/keys``) from "public but unguessable" (``/transfers/$WTID`` is usable
+by tax auditors *because* of it). It is a property of the *value*, not
+of any method, so it has no other home; it is also what the
+``:visible-to:`` capability annotations resolve against (V3).
+
+Everything else is derived from the method files' **Identifiers:**
+blocks (§3.8): *created-by*, *carried-by*, *read-by*, *consumed-by*.
+One exception needs prose: identifiers created **internally** (``wtid``
+is minted by the exchange's aggregator process — no method tags
+``creates``) get a created-by sentence here, mirroring config-armed
+timers. The registry also serves as the canonical naming list — the
+single place where ``reserve_pub`` is one concept, so that hundreds of
+files of tags can be checked for spelling drift.
+
+
+5. Per-component notification pages
+===================================
+
+**Why these pages exist:** how an actor *learns* that a state changed
+is as much a part of the use case as the state change itself — and it
+was historically documented in prose scattered across chapters. The
+doctrine of this system is *signals are hints; reads are truth*: a
+notification tells you something changed, an authoritative read tells
+you what is. The notification page is where a component declares its
+channels, their delivery semantics, and — critically — the read that
+backs each signal.
+
+Every component that offers real-time signals documents them in a
+single notification page: ``core/wallet-core/notifications.rst``
+(exists today as a flat list of notification types; to be restructured
+into the channel form below), a merchant notification page (webhooks,
+currently prose in ``api-merchant.rst``), an exchange notification page
+(will honestly declare: none — pull + long-poll only), and so on.
+
+Each channel declaration:
+
+.. code-block:: rst
+
+ .. notification-channel:: webhook
+ :component: merchant
+ :registration: POST /private/webhooks
+ :delivery: at-least-once
+ :events: order_created, pay, refund, order_settled,
+ category_created, category_update, category_delete,
+ inventory_update, inventory_product_created, ...
+
+ Every event is a hint about an entity transition; the
+ authoritative read is named per event:
+ ``pay``/``refund``/``order_settled`` ->
+ ``GET /private/orders/$ORDER_ID``.
+
+Rules:
+
+R5.1 ``:delivery:`` is one of ``lossy-hint`` (no delivery guarantee;
+ re-read for truth — the wallet notification doctrine),
+ ``at-least-once`` (duplicates possible, recipient must
+ deduplicate), ``rendezvous`` (the channel itself blocks until the
+ state is available). If the implementation's delivery semantics
+ are not specified today, write ``unspecified`` — a visible gap,
+ not silence.
+
+R5.2 Every event names its **authoritative read** (the endpoint whose
+ response is the truth). The lint rejects a channel without a
+ read reference per event family.
+
+R5.3 Method files never declare push channels. The method whose
+ effect *causes* a signal is linked automatically: effect
+ transition (§3.7) + channel event mapping (here).
+
+R5.4 Long-poll is **not** a notification channel; it is a property of
+ the read endpoint and is declared via ``:wait:`` (§3.1). A
+ long-poll response is authoritative state delivered late, not a
+ hint.
+
+
+6. wallet-core method variant
+=============================
+
+``ts:op`` files follow the same requirements with this mapping:
+
+.. list-table::
+ :header-rows: 1
+
+ * - HTTP concept
+ - wallet-core op equivalent
+ * - ``:visible-to:``
+ - ``wallet-user`` (local message protocol, no credentials)
+ * - ``:http:statuscode:`` responses
+ - **Response** ``ts:def`` + **Expected errors**
+ * - ``:error`` entries
+ - **Expected errors** entries, same ``:temporality:`` /
+ ``:recovery:`` fields
+ * - ``:wait:``
+ - ``long-poll(...)`` for blocking ops (``waitTransactionState``,
+ ``waitExchangeReady``, ...), ``none`` otherwise; the wallet's
+ push channel is documented in ``notifications.rst`` (§5)
+ * - effects
+ - transitions on transaction-group entities
+ (``transaction-withdrawal``, ...) with states from the DD37
+ machine
+
+``TransactionState`` values used in ``:transition:`` fields must match
+``major[:minor][/working]`` notation of DD37 and resolve against
+``core/wallet-core/transactions/get-transaction-by-id.rst``.
+
+Wallet transaction entities extend the lifecycle model of §4 in four
+ways (rationale in DD 105):
+
+* **Final-reactivatable states.** DD37's ``done`` is not absorbing
+ (refund queries, ``rebind-session``, and late KYC requirements can
+ reactivate a transaction). Transaction entities declare
+ ``:final-reactivatable:`` (§4.2) for such states and list the
+ reactivation edges explicitly.
+* **Orthogonal modifiers instead of state explosion.** The DD37
+ ``working`` flag and the ``suspended`` / ``suspended-aborting`` /
+ ``suspended-finalizing`` shadow states are declared as modifiers
+ (same mechanism as ``:modifiers:`` on other entities), not as
+ literal states. A ``:standard-edges: suspend-retry`` declaration on
+ the lifecycle implies the suspend/resume/retry edge family on every
+ non-final state, so the inferred state and edge sets include them
+ without authoring each one. DD37 state *expressions*
+ (``pending:*``, ``pending:-``, ``/idle``, comma alternation) are
+ accepted wherever a state set is matched.
+* **Absence as signal.** The ``deleted`` state exists only in
+ notifications; its observation is the absence of the record (a
+ not-found error from ``getTransactionById``). ``:observes-state:``
+ bindings may name this explicitly (``transaction.state,
+ absence-signal: deleted``).
+* **Actions are not derived.** ``txActions`` are computed per state
+ *and* context; no "possible actions" column is generated from the
+ lifecycle.
+
+The registry entries for transaction entities are the canonical
+machine-readable form of the DD37 machines; DD37's per-type state
+tables are generated from the registry.
+
+
+7. Controlled vocabularies
+==========================
+
+Controlled vocabularies are where machine-readability lives or dies.
+A field whose values come from a closed list can be checked, counted,
+and closed over by tooling; a field with free text is prose wearing a
+costume. Every value below is part of the contract: **adding a value
+is a specification change** (tooling must learn its meaning), and using
+a non-listed value is a lint error (M8.3). For each value we give the
+definition, a real example from the corpus, and what the derivation
+tooling does with it.
+
+7.1 ``:idempotency:`` values
+----------------------------
+
+The question this field answers is the most frequent operational
+question a client faces: *"the network ate my request or its response —
+may I send the exact same bytes again?"* The answer determines whether
+the failure branch of a use case exists at all, and what the sequence
+diagram draws at every network boundary.
+
+* ``readonly`` — the call mutates nothing: no entity transition, no
+ world-change. (Logging and caching on the server side do not count
+ as mutation.) Examples: ``GET /keys``, ``getBalances``.
+ *Consumers:* the generator expects no **Effects** section; the lint
+ warns if a ``readonly`` method declares one (V6's inverse).
+
+* ``replay-safe`` — the server has recorded the request and its
+ response; a byte-identical repeat returns the same response with no
+ additional effect. Examples: ``POST /withdraw`` (repeating the exact
+ request yields the same blinded signatures — coins are never lost to
+ a network cut), donau batch-issue (keyed on the recorded request
+ hash). *Failure mode prevented:* value loss when the response is
+ lost after the server committed. *Consumers:* the generator draws
+ retry self-loops for free. Note that replay means *byte-identical*;
+ a modified request is a new operation, and conflicts there belong to
+ **Errors** (e.g. ``TALER_EC_EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET``).
+
+* ``keyed:<field>`` — the client supplies an idempotency key; reuse of
+ the key with the same body suppresses duplicate effects, reuse with a
+ *different* body is rejected (409). Examples: bank-wire
+ ``/transfer`` (``request_uid``), corebank cashouts (``request_uid``,
+ including the rule that a corrected quote needs a fresh nonce).
+ *Why it exists:* lets the client retry safely *and* lets the server
+ detect client bugs. §3.5 requires prose on the key's reuse
+ semantics.
+
+* ``unsafe`` — retries may duplicate effects. Example:
+ ``POST /private/orders`` without an explicit ``order_id`` creates a
+ second order. Must be justified in prose. *Consumers:* derivation
+ tooling surfaces ``unsafe`` mutating methods prominently — in a
+ system built on at-least-once networking, an unsafe mutation is a
+ design smell. *Guidance:* prefer having the client supply the
+ identifier (``order_id``), which turns ``unsafe`` into
+ ``keyed:order_id`` by construction.
+
+7.2 ``:maturity:`` values
+-------------------------
+
+Why this field exists: derivation must know which methods describe
+*behavior* and which describe *intent*. A documented-but-unimplemented
+endpoint is not a fact about the system; deriving use cases from it
+manufactures fiction.
+
+* ``stable`` — implemented and conformant; fully derivable.
+* ``experimental`` — implemented, but may change without a version
+ bump. Derivable, but derived artifacts are marked experimental.
+ Example: nearly all auditor diagnostic endpoints ("still
+ experimental" throughout ``api-auditor.rst``).
+* ``draft-no-consumers`` — a spec nothing implements or consumes.
+ Examples: the apns relay, ebisync, the bank account directory (all
+ self-declared "nothing depends on this API"); sync in practice.
+ Excluded from derivation; renders with a banner.
+* ``proposed-unimplemented`` — a documented proposal, explicitly not
+ implemented. Examples: wads, and the reserve-control endpoints
+ (``/reserves/$PUB/open|close|attest``) in the exchange API. Excluded
+ from derivation; kept for design reference.
+* ``deprecated`` — still functional, but new derived artifacts must not
+ use it; the replacement should be named in prose (and
+ ``:deprecated:`` carries the version).
+
+7.3 ``:visible-to:`` values (actor ⇒ implied credential)
+--------------------------------------------------------
+
+This field is the file's single authorization fact. The design
+decision: the *actor* is declared and the *mechanism* is implied by the
+table below, because declaring both would duplicate one fact in two
+drift-prone places. Persona projections are computed as closures over
+this field: a persona is a set of actors; its visible surface is the
+set of methods whose ``:visible-to:`` intersects it.
+
+.. list-table::
+ :header-rows: 1
+
+ * - Actor
+ - Implied mechanism
+ * - ``wallet-user``
+ - wallet-core local message protocol (no credentials; "visibility"
+ is possession of the device running the wallet)
+ * - ``merchant-staff`` or ``merchant-staff:<scope>``
+ - ``Authorization: Bearer secret-token:...`` with the named
+ permission scope (``orders-read``, ``transfers-write``, ...)
+ * - ``aml-officer`` / ``aml-officer:readonly``
+ - ``Taler-AML-Officer-Signature`` header; the ``:readonly``
+ variant maps to the officer's read-only access level (the
+ ``read_only`` flag in ``GET /aml/$OFFICER_PUB``)
+ * - ``exchange-operator``
+ - master-key signature (management operations)
+ * - ``auditor``
+ - auditor API authentication (see ``api-auditor.rst``)
+ * - ``tax-auditor``
+ - none — capability identifiers only (``wtid``); this actor has
+ *no* credentials anywhere in the system
+ * - ``bank-customer``
+ - external bank UI; on APIs, capability ``withdrawal-id``
+ * - ``bank-admin``
+ - corebank bearer token (admin scope)
+ * - ``kyc-provider``
+ - provider webhook credentials
+ * - ``any-unauthenticated``
+ - none — public by design (``GET /keys``). Do not confuse with
+ capability-gating: public-by-design means the *content* is
+ meant for everyone; capability-gated means the *identifier* is
+ the secret.
+ * - ``external``
+ - outside any API (bank statements, PoS displays, offline
+ ceremonies) — the honest escape hatch for non-API actors
+
+Capability-gated access is annotated in the field:
+``:visible-to: merchant-staff, tax-auditor (capability wtid)`` means
+the listed actors hold the unguessable identifier; the lint resolves
+the identifier against the registry (§4.4) and checks its
+``:capability: yes`` (V3).
+
+7.4 ``:temporality:`` values
+----------------------------
+
+The classification of an outcome's *kind* — the input that decides
+which column of the derived state table (happy / timer / failure) an
+edge lands in, and whether a non-2xx is a failure at all.
+
+* ``transient-state`` — the condition resolves without the client
+ changing anything; the identical request may succeed later.
+ Example: 404 ``RESERVE_UNKNOWN`` on an unfunded reserve.
+ *Derived as:* a wait/retry edge, not a failure exit.
+* ``terminal`` — will never succeed; abandon or restart the flow.
+ Examples: 410 expired purse; 410 already-solved challenge.
+ *Derived as:* a failure exit to a terminal documentation state.
+* ``fixable`` — a *modified* request can succeed. Examples: 409
+ insufficient funds (withdraw less); 400 parameter malformed.
+ *Derived as:* a failure edge with a return loop after modification.
+* ``rate-limited`` — retry only after the documented delay. Examples:
+ mailbox 429 with the machine-readable ``retry_delay`` field; TAN
+ retransmission cooldown. *Derived as:* a retry edge annotated with
+ the delay's source field.
+* ``gate`` — blocked on an action by the user or a third party, outside
+ this call. Examples: 451 KYC required; 202 MFA challenge; 402
+ payment-required at sync. *Derived as:* a gate edge into the
+ relevant sub-flow (KYC legitimization, MFA solving).
+* ``progress`` — **not an error**: the non-2xx code is the happy-path
+ state signal. Examples: 402 unpaid + ``taler_pay_uri`` on the order
+ status endpoint; 202 pending on deposit tracking; 304 unchanged
+ during long-poll. *Derived as:* **no failure edge** — the code *is*
+ the waiting state's observation.
+
+Classification guide (ask in order): Is it actually a failure? →
+``progress``. Can the caller fix the request? → ``fixable``. Does
+the condition resolve by itself? → ``transient-state``. Does it only
+need a delay? → ``rate-limited``. Does it need an outside action?
+→ ``gate``. Will it never succeed? → ``terminal``.
+
+7.5 ``:recovery:`` values
+-------------------------
+
+The client's forward action — the label on the edge that *leaves* this
+outcome. ``:temporality:`` says what kind of outcome it is;
+``:recovery:`` says what to do about it.
+
+* ``retry-same`` — safe verbatim replay (pairs with
+ ``:idempotency: replay-safe`` / ``keyed:``; pairing an unsafe method
+ with ``retry-same`` is a contradiction the lint should flag).
+* ``retry-modified`` — change the request per the error's guidance
+ (lower the amount; add the missing field; set ``max_age`` per the
+ ``maximum_allowed_age`` hint).
+* ``retry-after`` — wait the documented delay, then ``retry-same``
+ (429 + ``retry_delay``).
+* ``long-poll`` — wait on the status endpoint's rendezvous parameters
+ (reserve status long-poll).
+* ``poll-later`` — re-read after some time, without rendezvous (the
+ sync backup cadence; usual companion of ``progress``).
+* ``refresh-config`` — re-fetch ``/keys`` or ``/config`` first
+ (denomination unknown ⇒ outdated keys).
+* ``start-kyc`` — enter the legitimization sub-flow (451 responses
+ carry the requirement).
+* ``user-action`` — the human must act outside this API (confirm in the
+ bank app; insert cash).
+* ``abort`` — invoke the flow's own abort path (abort the withdrawal;
+ delete the purse).
+* ``give-up`` — terminal; no forward action exists.
+* ``none`` — informational; nothing to recover from.
+
+7.6 ``:timer-ref:`` roles
+-------------------------
+
+A method's relationship to an entity clock comes in three kinds, and
+each feeds a different derivation:
+
+* ``arms`` — a successful call starts the clock; ``:field:`` names the
+ arming request field (``POST /private/orders`` arms
+ ``order.refund-deadline`` via ``refund_delay``). *Feeds:* the
+ "armed when" column of timer tables. (Config-armed clocks are the
+ exception — declared ``:armed-by:`` at the anchor, §4.3.)
+* ``exposes`` — the response carries the clock's current value;
+ ``:field:`` names the response field
+ (``ValidationChallengeInfo.solve_expiration``). *Feeds:* the
+ observation layer — where an actor reads the time remaining.
+* ``affected-by`` — the method's behavior changes once the clock fires
+ (challenge confirm fails with ``TAN_CHALLENGE_EXPIRED``).
+ *Feeds:* the join between the timer's firing edge and this method's
+ error table.
+
+One method may reference several timers (one ``:timer-ref:`` each); one
+reference carries one role.
+
+7.7 ``:trigger:`` kinds
+-----------------------
+
+A transition without a cause is narrative; the trigger is what makes
+the machine causal — and what makes the sequence diagram drawable,
+since every trigger kind maps to a diagram element (message, timer
+edge, external note).
+
+* ``call <method>`` — an external call to the owner's API. Resolvable
+ to a method file (V13). The most common kind. Example: the wallet's
+ selection POST drives ``pending -> selected``.
+* ``timer <entity>.<timer>`` — a declared entity timer fires.
+ Resolvable to the §4.3 anchor. Example: ``solve-expiration`` drives
+ ``unsolved -> expired``.
+* ``watch <component>.<observable>`` — the owner watches an external
+ source. This is where *cross-component* causality lives: the
+ exchange's wirewatch watching ``bank.incoming-history``; the merchant
+ backend tracking the exchange's outgoing wires. Naming form is
+ ``component.observable`` so the join stays checkable.
+* ``external <description>`` — a human or off-API action: bank-UI 2FA
+ confirmation, cash inserted at a terminal, an offline key ceremony.
+ Non-API actors enter the machine here, honestly marked as outside
+ every API. Deliberately not lint-resolvable.
+
+Triggers are repeatable: one transition may have several (``selected ->
+confirmed`` via the confirm endpoint *or* the bank UI).
+
+7.8 Identifier roles
+--------------------
+
+The method↔identifier relationship declared in **Identifiers:**
+(§3.8): ``creates`` (the value begins to exist here), ``carries``
+(transports it onward in URL/body/response/wire subject), ``reads``
+(uses it as a lookup key), ``consumes`` (its validity ends here).
+Definitions and examples are in §3.8. The roles are always from the
+*method's* perspective: the same ``reserve_pub`` is created by the
+wallet, carried by the bank-integration selection, read by the exchange
+— and the union of those tags is what V21 checks joins with (exactly
+one creator per carried/read value, or an internal-creation note at the
+anchor).
+
+
+8. Machine-readability rules
+=============================
+
+These rules are the contract between authors and tooling. Each exists
+because a specific derivation or lint check depends on it; breaking one
+does not merely make the docs sloppy — it makes a specific derived
+artifact silently wrong. Each rule below states the requirement, why
+it exists, a typical violation, and what checks it.
+
+M8.1 One method per file, one identity directive per file (declared
+ variant groups excepted, §2).
+
+ *Why:* the file is the unit of indexing — filename conventions
+ (§2.2), lint scoping, and generated cross-references all assume
+ the path↔method mapping is one-to-one.
+ *Violation:* two endpoints documented in one file "because they
+ are similar" — their semantics can no longer be addressed
+ separately, and backfill tools cannot target them.
+ *Checked by:* V1 (count identity directives per file).
+
+M8.2 Every element in §3 marked REQ is present; COND is present or
+ the absence is obvious from method kind (e.g. GET ⇒ no Effects).
+
+ *Why:* the REQ fields are the minimum derivation set — a missing
+ REQ field means the generator must guess (the exact failure this
+ specification exists to eliminate). The COND absence rule exists
+ so the lint doesn't cry wolf on legitimately absent sections.
+ *Violation:* a POST without ``:idempotency:``.
+ *Checked by:* the REQ lint rules (V2, V3, V5, V11, ...).
+
+M8.3 All field values come from the §7 vocabularies — free text is a
+ lint error.
+
+ *Why:* a misspelled value (``rate_limited`` for
+ ``rate-limited``) is invisible to a human reviewer but silently
+ removes the entry from every derived table.
+ *Violation:* ``:temporality: transient`` (missing ``-state``).
+ *Checked by:* V2, V3, V5, V10, and the vocabulary checks.
+
+M8.4 Every name that must resolve, resolves: entity names → registry;
+ state values → lifecycle transitions (inferred set); ``:schema:``
+ types → ``ts:def`` blocks; ``:timer-ref:`` targets → entity timer
+ anchors; ``:identifier:`` names → identifier anchors;
+ ``:trigger: call`` refs → method files; ``:trigger: timer`` refs
+ → entity timers; notification channels → the component's
+ notification page.
+
+ *Why:* this rule is what turns the corpus from a pile of pages
+ into a graph. Every derived artifact (state tables, persona
+ projections, sequence diagrams) is a traversal of these edges; a
+ dangling reference is a broken join that no amount of prose
+ repairs.
+ *Violation:* ``:observes-state: withdrawal-operation.state``
+ (the field is ``status``) — the persona projection for the bank
+ customer silently loses the withdrawal states.
+ *Checked by:* V7, V8, V9, V10, V12, V13, V21.
+
+M8.5 State values and error codes appear in structured positions
+ first; prose mentions are decoration.
+
+ *Why:* prose is unparseable. A state or error that exists only
+ in a sentence is invisible to every consumer — the machine has a
+ state the tooling cannot see.
+ *Violation:* "returns 410 when the purse is gone" in the
+ description, with no 410 entry in **Errors**.
+ *Checked by:* V14 (grep prose for known state names and
+ ``TALER_EC_*`` codes absent from the structured fields).
+
+M8.6 Version annotations follow the single style ``@since **vN**`` /
+ ``@deprecated **vN**``.
+
+ *Why:* derived artifacts must be sliceable by protocol version —
+ an old wallet talking to a new exchange sees a genuinely
+ different machine, and only uniform field-level versioning makes
+ that computable. The style already exists as a convention; this
+ rule makes it enforceable.
+ *Violation:* "since v3" (unparseable variant), missing
+ annotations on newer fields.
+ *Checked by:* V16 (warn-only until backfilled).
+
+M8.7 Draft APIs carry ``:maturity: draft-no-consumers`` and a visible
+ banner; derivation tooling skips them.
+
+ *Why:* documented intent is not behavior. Deriving use cases
+ from an API nothing implements manufactures fiction with the
+ false authority of a generated artifact.
+ *Violation:* a use-case table that includes apns device
+ registration as if it were a live flow.
+ *Checked by:* V17.
+
+M8.8 The ``ts:def`` layer is the codegen source of truth: type
+ definitions must be complete enough to generate client/server
+ types and validators without consulting prose.
+
+ *Why:* the shape layer is what the spec-first model generates
+ code from (§1). A field that exists only in a sentence ("the
+ response also contains X when...") produces a generator-visible
+ type that disagrees with the documented behavior — the
+ documentation-drift disease, now with a compiler.
+ *Violation:* optional fields described only in running text;
+ union variants mentioned in prose but absent from the
+ ``ts:def``.
+ *Checked by:* partially by V4 (every outcome needs a schema);
+ fully only by codegen dry-runs diffed against implementations.
+
+
+9. Validation rules (lint specification)
+=========================================
+
+A doc lint (to live in ``taler-docs/_exts/``) enforces, as build
+warnings/errors:
+
+.. list-table::
+ :header-rows: 1
+
+ * - ID
+ - Rule
+ * - V1
+ - Identity directive present exactly once per documented method
+ (variant groups: one directive per variant, §2); options parse.
+ * - V2
+ - ``:since:`` / ``:deprecated:`` match ``v\d+``; ``:maturity:`` in
+ vocab.
+ * - V3
+ - ``:visible-to:`` present; actors and capability annotations in
+ vocab; capability identifiers resolve to identifier anchors.
+ * - V4
+ - Every ``:http:statuscode:`` has ``:schema:`` and/or ``:error:``;
+ non-2xx codes with ``:schema:`` have a ``progress`` or ``gate``
+ entry in **Errors**.
+ * - V5
+ - Every non-2xx outcome has an entry with ``:temporality:`` and
+ ``:recovery:`` in vocab.
+ * - V6
+ - Mutating methods have non-``readonly`` ``:idempotency:`` and at
+ least one ``:effect:`` (or explicit "no observable effect" note).
+ * - V7
+ - Entity names (in Effects, ``:observes-state:``,
+ ``:timer-ref:``) resolve to entity anchors; identifier names in
+ **Identifiers:** blocks resolve to identifier anchors.
+ * - V8
+ - State values resolve to the named entity's inferred state set
+ (including modifier-expanded sets for wallet transaction
+ entities, §6).
+ * - V9
+ - ``:observes-state:`` path exists in the response ``ts:def`` and
+ its type is an enumerated union.
+ * - V10
+ - ``:timer-ref:`` targets exist among the named entity's declared
+ timers; roles in vocab; ``:field:`` present when the role is
+ ``arms`` or ``exposes``; every declared timer has at least one
+ ``arms`` reference or an ``:armed-by:`` in its anchor.
+ * - V11
+ - ``:wait:`` present; long-poll parameters match the method's
+ documented ``:query:`` parameters.
+ * - V12
+ - Every real-time channel of a component is declared in that
+ component's notification page; no push-channel metadata appears
+ in method files.
+ * - V13
+ - Transition triggers resolve (``call`` → method file, ``timer``
+ → declared entity timer, ``watch`` → ``component.observable``
+ form); **Effects** transitions and lifecycle transitions agree
+ (two-sided check); ``:observable-by:`` refs resolve.
+ * - V14
+ - No ``TALER_EC_*`` or state value appears only in prose.
+ * - V15
+ - Filename and location follow §2.2.
+ * - V16
+ - ``@since`` present on every ``ts:def`` field of public types
+ (grandfathered: warn-only until backfilled).
+ * - V17
+ - ``draft-no-consumers`` / ``proposed-unimplemented`` methods
+ render a banner and are excluded from derived artifacts.
+ * - V18
+ - wallet-core ``:transition:`` values match DD37 notation, resolve
+ against the ``TransactionMajorState`` / ``TransactionMinorState``
+ enums documented in ``get-transaction-by-id.rst``, and edges
+ resolve against the transaction entity lifecycles in the
+ registry.
+ * - V19
+ - Lifecycle integrity: every declared terminal state is reachable
+ from ``none`` and absorbing (no outgoing transitions); every
+ ``final-reactivatable`` state's outgoing transitions are
+ explicitly declared reactivation edges; every inferred
+ non-terminal state has at least one outgoing transition; every
+ inferred state is reachable from ``none``.
+ * - V20
+ - ``:if:`` guards reference documented config/data fields
+ (best-effort resolvability: ``/keys`` fields, request fields,
+ documented config).
+ * - V21
+ - Identifier joins: every identifier with a ``carries`` /
+ ``reads`` tag has exactly one ``creates`` across the corpus or
+ an internal-creation note in its anchor; every ``creates`` is
+ carried or read somewhere.
diff --git a/design-documents/105-machine-readable-endpoint-documentation.rst b/design-documents/105-machine-readable-endpoint-documentation.rst
@@ -0,0 +1,1336 @@
+DD 105: Machine-Readable Endpoint and Method Documentation
+##########################################################
+
+:Design status: Draft
+:Implementation status: Not started
+:DD shepherd: TBD
+:Historical contributors: TBD
+:First published: 2026-10-06
+:Last substantive change: 2026-10-06
+:Normative references: :doc:`../core/endpoint-documentation-requirements`
+
+.. contents:: Table of Contents
+ :depth: 2
+
+Summary
+=======
+
+Every HTTP endpoint and every wallet-core operation in the Taler
+documentation is described in exactly one ``.rst`` file, and every semantic
+claim in that file that tooling could need — versioning, maturity,
+idempotency, actor visibility, error semantics, state observation, world
+effects, identifier roles, timers — is expressed in a directive option or
+typed block, never only in prose. A small number of registries (entities
+with lifecycles, timers, identifiers; per-component notification channels)
+hold the cross-method anchors. From this corpus, tooling derives per-entity
+state tables, per-actor visibility projections, cross-component flows, and
+sequence diagrams, and generates client types and validators, with drift
+against implementations caught in CI.
+
+The format is language-agnostic: the shape layer describes wire-level types
+(JSON messages exchanged between client and server) via the historically
+named ``ts:def`` / ``ts:op`` directives, and generated artifacts may target
+any implementation language.
+
+This document records the *intent*, the *diagnostic of the current state*,
+the *design decisions*, and the *migration plan*. The normative
+field-by-field format, controlled vocabularies, and lint rules are
+specified in :doc:`../core/endpoint-documentation-requirements`; where the
+two disagree during evolution, the requirements document wins for format
+questions and this DD wins for *why*.
+
+Motivation
+==========
+
+The documentation corpus is large (~490 per-endpoint files across exchange,
+merchant, bank-*, corebank, challenger, taldir, mailbox, donau, auditor,
+plus 152 wallet-core operations) and already uniform in *shape*: identity
+directive, request, response, prose details. But the semantics a reader
+most needs are locked in prose or absent:
+
+* **Error codes are overloaded across flows.** A 404 on an unfunded
+ reserve means "wait"; a 404 on an expired order means "gone"; a 402 on
+ the order status page is the *normal* unpaid state. Same codes, opposite
+ meanings, not inferable.
+* **Retry safety is never stated.** ``POST /withdraw`` is replay-safe by
+ careful design; ``POST /private/orders`` without an ``order_id``
+ duplicates. In a system built on at-least-once networking, this is the
+ most frequent operational question a client faces, and the corpus answers
+ it only occasionally, in prose.
+* **State lives between methods.** No per-endpoint file can honestly
+ describe the machine the endpoints collectively implement — the order
+ lifecycle, the withdrawal operation, the TAN challenge — so those
+ machines exist only implicitly, and cross-component causality (the
+ exchange's wirewatch driving a bank-owned state; the merchant tracking
+ exchange wire arrivals) exists only as narrative.
+* **Docs and code drift silently.** Doc-versus-code audits on this corpus
+ repeatedly find stale state names, missing error codes, and endpoints
+ whose real behavior has moved on. One-sided declarations rot; the fix is
+ two-sided, machine-checkable declarations.
+* **Deriving anything requires reading prose.** Use-case walkthroughs,
+ persona-specific API surfaces ("what can a tax auditor see?"), state
+ tables, and sequence diagrams are today hand-authored and immediately
+ stale.
+
+The goal is a documentation corpus that is the *authoritative source of
+truth* in the spec-first sense: implementations conform to it, code is
+generated from its shape layer, and derived artifacts are produced without
+reading prose.
+
+Current state of the corpus
+===========================
+
+This section is the diagnostic: what the corpus provides today, and which
+gaps the requirements document closes.
+
+What files contain today
+------------------------
+
+The current corpus already provides, per file:
+
+.. list-table::
+ :header-rows: 1
+
+ * - ID
+ - Element
+ - Encoding today
+ * - E1
+ - Method identity (HTTP method + path / op)
+ - ``.. http:get:: /p`` / ``.. ts:op:: op``
+ * - E2
+ - One-line + prose description
+ - directive body
+ * - E3
+ - Request parameters
+ - ``:query:``, body as ``.. ts:def::``
+ * - E4
+ - Response per status code
+ - ``:http:statuscode:`` + ``ts:def``
+ * - E5
+ - Error codes
+ - ``TALER_EC_*`` names in status prose
+ * - E6
+ - Field-level versioning
+ - ``@since **vN**`` / ``@deprecated`` comments
+ * - E7
+ - Chapter-level version history
+ - "Version History" section of ``api-*.rst``
+ * - E8
+ - Chapter-level auth model
+ - prose section of ``api-*.rst``
+ * - E9
+ - Long-poll parameters
+ - ``:query timeout_ms:`` + ``:ref:`` to convention
+ * - E10
+ - Idempotency
+ - *occasionally*, prose only (e.g. ``post-withdraw.rst``)
+ * - E11
+ - Read-only / deprecated flags (wallet ops)
+ - ``:read-only:``, ``:deprecated:`` on ``ts:op``
+ * - E12
+ - Expected errors + side effects (wallet ops)
+ - prose lists in **Details:**
+
+Verified against the corpus (2026-10): the only options the identity
+directives accept today are ``deprecated``/``noindex``/``synopsis`` (HTTP
+domain) and ``read-only``/``deprecated`` (``ts:op``). None of the new
+header options, and none of the ``entity`` / ``lifecycle`` / ``timer`` /
+``identifier`` / ``notification-channel`` directives, exist anywhere
+outside the specification documents themselves — all are new
+``_exts/`` work. ``core/entities.rst`` does not exist.
+``core/wallet-core/notifications.rst`` exists, but as a flat list of
+notification types with payload definitions — no channel, delivery, or
+event structure — and must be restructured into the channel form.
+
+Gaps to close
+-------------
+
+.. list-table::
+ :header-rows: 1
+
+ * - ID
+ - Missing element
+ - Why derivation needs it
+ * - G1
+ - Method maturity (stable/draft/...)
+ - drafts must be excludable from derivation
+ * - G2
+ - Per-method actor visibility
+ - persona closures are computed from who may call what
+ * - G3
+ - Error semantics
+ - same status/code is transient in one flow, terminal in another,
+ and sometimes not an error at all (progress); cannot be inferred
+ * - G4
+ - Idempotency label
+ - retries are safe or not — never guessable
+ * - G5
+ - Lifecycles with triggers and guards
+ - the entity machine: what causes each transition, and when that
+ path exists at all (config/data forks such as KYC thresholds)
+ * - G6
+ - Timers
+ - expiry/deadline edges of state tables
+ * - G7
+ - Observation channels
+ - how an actor *learns* a state changed
+ * - G8
+ - Identifier joins
+ - which values are created/carried/read/consumed where —
+ the cross-component join keys
+ * - G9
+ - Effects (structured)
+ - what changed in the world beyond the response
+
+Structural mismatches found by verification
+-------------------------------------------
+
+* **~20 files violate one-method-per-file as-is**, some deliberately:
+ ``auditor/delete-monitoring-records.rst`` carries 24 identity directives
+ (one per monitoring-record type); KYC webhook and AML transfer files
+ carry 2–4; long-poll variants (e.g. ``get-purses-PURSE_PUB-merge.rst``)
+ are documented as two directives in one file.
+* **~12 files are proxy stanzas**, not methods: ``.. http:any::`` wildcard
+ reverse-proxy delegation declarations (``merchant/any-star.rst``, seven
+ corebank ``any-...-star.rst`` files) with no request/response semantics.
+* **sync and terminal have no per-endpoint files at all**: 3 and 10
+ endpoints respectively live inline in ``api-sync.rst`` /
+ ``api-terminal.rst``.
+* **KYC state is cross-cutting**: 23+ KYC-related files across six
+ components; exchange, merchant, bank-wire, and wallet all hold
+ projections, so naive single-owner modeling fails.
+* **Wallet transaction machines** (DD37) need four lifecycle-model
+ extensions; see D10.
+
+Requirements
+============
+
+R1 Each endpoint/method is documented in exactly one ``.rst`` file whose
+ semantic content is completely machine-readable.
+
+R2 From the corpus, without reading prose, tooling can derive:
+ per-entity state tables (states, transitions, timers, failure exits),
+ per-actor observation projections, cross-component identifier flows,
+ and sequence diagrams (messages from transitions, ``alt`` from guards,
+ ``loop`` from timers).
+
+R3 The shape layer (``ts:def`` types, request/response schemas) is
+ complete enough to generate client/server types and validators; drift
+ is caught by CI in the implementation repositories.
+
+R4 Documented *intent* (drafts, unimplemented proposals) is visibly and
+ mechanically distinguishable from documented *behavior*, and excluded
+ from derivation.
+
+R5 The format must cover the existing corpus without exceptions that
+ swallow rules: HTTP APIs, wallet-core operations, long-poll dialects,
+ non-2xx progress signals, capability-gated access, config/data forks
+ (KYC thresholds), and wallet transaction lifecycles per DD37.
+
+R6 Every cross-reference a lint can check must be resolvable: entity
+ names, state values, timers, identifiers, trigger targets, channel
+ events. Free strings are lint errors.
+
+R7 Migration is incremental: the current corpus is mostly compliant in
+ shape, and backfill proceeds slice by slice (bank-integrated withdrawal
+ first), with the lint warn-only until a slice is done.
+
+Proposed Solution
+=================
+
+The full format is normatively specified in
+:doc:`../core/endpoint-documentation-requirements`. This section states
+the design as a sequence of decisions and their reasons.
+
+D1: Derive edges, declare anchors
+---------------------------------
+
+The organizing philosophy. Anything that is an *edge* — a transition, an
+arming, an observation, a carriage of a value — is written exactly once, on
+the edge itself (the lifecycle transition, the method header, the effect,
+the identifier tag). Anything that acts as a *resolution anchor* — an
+entity, a timer name, an identifier, a notification channel — is declared
+once, minimally, so edges can be checked against it. If a fact can be
+derived from edges it is never also declared; if a name must be resolvable
+it is never left implicit.
+
+This is what keeps the system from collapsing under its own bookkeeping:
+timers are slim anchors (name + prose) because their armed-by, exposed-in,
+fires-to, and gates facts are all derivable from timer-refs, error entries,
+and lifecycle triggers; identifiers are name + ``:capability:`` + prose
+because their created-by/carried-by/read-by/consumed-by story is the union
+of method-side tags. The alternative — restating everything at the anchor —
+guarantees drift.
+
+D2: One file per method, machine-readable header
+------------------------------------------------
+
+Each method's identity card is its existing directive (``.. http:get::`` /
+``.. ts:op::``) extended with options: version (``:since:`` /
+``:deprecated:``), maturity, idempotency, actor visibility, observation
+binding, timer relationships, and long-poll dialect. Every option exists
+because a named consumer needs it (lint, generator, exclusion filter); none
+is decorative.
+
+**Decision: the maturity option is named ``:maturity:``, not
+``:status:``.** The vendored sphinxcontrib-httpdomain in ``_exts/``
+already accepts ``:status:`` as a field-name alias for status codes
+(``names=('statuscode','status','code')``); a new header option with that
+name collides.
+
+**Decision: file-naming and one-directive-per-file rules get two explicit
+exemptions, not silent violations.**
+
+* *Variant groups*: a small number of files legitimately document several
+ directive instances of one logical method
+ (``auditor/delete-monitoring-records.rst`` with 24 record types, KYC
+ webhook provider variants, long-poll variants such as
+ ``exchange/get-purses-PURSE_PUB-merge.rst``). These are declared
+ variant groups and linted as one method with variants, rather than
+ being split into near-identical files.
+* *Proxy stanzas*: ~12 files using ``.. http:any::`` with wildcard paths
+ (``merchant/any-star.rst``, the corebank ``any-...-star.rst`` reverse-
+ proxy delegation declarations) are not methods with request/response
+ semantics. They get their own minimal stanza class and are excluded
+ from request/response/idempotency requirements.
+
+**Decision: sync and terminal are split.** Both APIs currently live inline
+in their chapter files (3 and 10 endpoints). They are split into
+per-endpoint files like every other API; chapter files keep only version
+history and shared conventions.
+
+D3: Errors carry temporality and recovery
+-----------------------------------------
+
+Every non-2xx outcome — including codes that are *not failures* (202
+pending, 304 unchanged, 402 unpaid) — gets a structured entry with
+``:temporality:`` (transient-state / terminal / fixable / rate-limited /
+gate / progress) and ``:recovery:`` (retry-same / retry-modified /
+retry-after / long-poll / poll-later / refresh-config / start-kyc /
+user-action / abort / give-up / none). This pair is what makes the failure
+and timer columns of derived state tables computable instead of guessed,
+and what tells the generator when *not* to derive a failure edge
+(``progress`` entries are state signals, not errors).
+
+D4: Idempotency is a first-class label
+--------------------------------------
+
+``readonly`` / ``replay-safe`` / ``keyed:<field>`` / ``unsafe``. Retries
+are the universal response to network failure, and a request that
+arrived-but-lost-its-response is indistinguishable from one that never
+arrived; whether a verbatim replay is safe decides whether the client's
+failure branch exists at all. The label also flags design smells: an
+``unsafe`` mutating method in this ecosystem deserves review, and the
+lint requires the field on every mutating method.
+
+D5: Authorization is one fact: the actor
+----------------------------------------
+
+``:visible-to:`` declares the actor surface (wallet-user,
+merchant-staff[:scope], aml-officer[:readonly], exchange-operator, auditor,
+tax-auditor, bank-customer, bank-admin, kyc-provider, any-unauthenticated,
+external), and the credential mechanism is *implied* by a fixed table.
+Declaring both actor and mechanism would duplicate one fact in two
+drift-prone places. Capability-gated access (unguessable identifiers such
+as ``wtid`` or ``withdrawal-id``) is annotated in parentheses and resolved
+against the identifier registry, which is where the ``:capability:``
+property of the *value* lives. Persona projections are computed as
+closures over this field.
+
+D6: Methods bind to entity machines in exactly two ways
+-------------------------------------------------------
+
+*Mutators* declare transitions in an **Effects** block (entity, exact edge,
+observation endpoint). *Readers* declare an observation binding
+(``:observes-state: <entity>.<field>``) stating that the response *is* a
+view onto that entity's machine. Both are checked two-sided against the
+entity registry: the lifecycle says "call X drives A→B", the method says
+"I drive A→B on entity E", and disagreement is a lint error. One-sided
+declarations silently rot; that is the lesson of every doc-versus-code
+audit on this corpus.
+
+D7: Identifiers are the join keys
+---------------------------------
+
+Methods tag their role in each identifier's life: ``creates`` / ``carries``
+/ ``reads`` / ``consumes``. The union over ~490 files yields the
+cross-component value-flow graph and the lint's join checks (exactly one
+creator per carried/read value, or an internal-creation note at the anchor
+for values like ``wtid`` minted by the exchange's aggregator). The
+registry holds only the anchor: name, ``:capability:``, prose.
+
+D8: Entities, lifecycles, triggers, guards
+------------------------------------------
+
+The entity registry declares, per entity: ``:owner:`` (the component
+holding *authoritative* state and executing transitions — not merely where
+the entity is displayed), a transition list, a declared terminal set, timer
+anchors, and identifier anchors. States are *inferred* from transitions;
+terminal states are *declared* so that use-case success conditions are
+anchored, the absorbing property is checkable, and truncated machines are
+visible (a non-terminal state with no outgoing edges is a lint warning).
+
+Every transition names its *trigger* — ``call <method>``, ``timer
+<entity>.<timer>``, ``watch <component>.<observable>``, or ``external
+<description>`` — and optionally a guard (``:if:``) for config/data forks
+(KYC on/off, thresholds, feature flags). Triggers are the causality layer
+no per-endpoint doc can see locally; guards keep one canonical machine with
+guarded edges instead of a combinatorial family of near-identical machines.
+
+D9: Notifications live on per-component pages
+---------------------------------------------
+
+How an actor *learns* that state changed is declared once per component:
+channel, registration, delivery semantics (``lossy-hint`` /
+``at-least-once`` / ``rendezvous`` / honestly-``unspecified``), events, and
+— critically — the *authoritative read* backing each event family, per the
+doctrine *signals are hints; reads are truth*. Method files never declare
+push channels; the link from effect to signal is derived. Long-poll is not
+a notification channel: it is a property of the read endpoint (``:wait:``),
+since a long-poll response is authoritative state delivered late, not a
+hint. Components with no push channels (the exchange) declare that
+negative fact explicitly.
+
+D10: Wallet transaction lifecycles need four model extensions
+-------------------------------------------------------------
+
+The wallet-core variant maps HTTP concepts onto ``ts:op`` files, with
+effects expressed as transitions on per-transaction-type entities
+(``transaction-withdrawal``, ``transaction-payment``, ...) whose state
+vocabulary is DD37's ``major[:minor][/working]``. Verification against
+DD37 and the wallet-core implementation surfaced four places where the
+base lifecycle model does not fit; each gets an explicit extension rather
+than a workaround:
+
+**D10.1 Final states may be reactivatable.** DD37 ``done`` is not
+absorbing: a payment is reactivated by refund queries and
+``rebind-session``; a deposit in ``finalizing:track`` can fall back to
+``pending`` when new KYC requirements appear. The lifecycle model
+therefore distinguishes ``:terminal:`` (absorbing) from
+``:final-reactivatable:`` (success anchor for use cases, but reactivation
+edges are legal and must be listed). The absorbing lint applies only to
+``:terminal:``.
+
+**D10.2 ``/working`` and the suspended shadow states are orthogonal
+modifiers, not states.** DD37's public state value is a tuple
+``(major, minor, working)``; ``working`` is a transitional presentation
+flag explicitly slated to become major states later, and nearly every
+non-terminal state has paired ``suspended`` / ``suspended-aborting`` /
+``suspended-finalizing`` shadow states plus retry self-loops that DD37
+deliberately omits from its diagrams. Encoding all of this as literal
+states and transitions would explode every table. Instead, wallet
+transaction entities declare:
+
+* ``working`` as a lifecycle-level orthogonal flag (same mechanism as the
+ order entity's ``:modifiers:``);
+* a ``:standard-edges: suspend-retry`` declaration that *implies* the
+ suspend/resume/retry edge family on every non-final state, so the
+ inferred state set and edge set include them without authoring them.
+
+State values in ``:transition:`` fields use DD37 notation, and DD37's
+state-*expression* language (``pending:*``, ``pending:-``, ``/idle``,
+comma alternation) is accepted wherever a state set is matched.
+
+**D10.3 Ownership is inside-out, and that is fine.** A wallet
+transaction entity is owned by wallet-core (it holds the authoritative
+record and executes transitions), but its triggers are almost entirely
+``watch <component>.<observable>`` (bank withdrawal operations, exchange
+reserves/KYC, merchant refunds, *another wallet* in p2p) plus local user
+actions and timers — the inverse of the tan-challenge reference example
+where the owner executes on ``call``. This is not a violation of the
+owner rule but its second mode, and the worked examples below include a
+watch-driven wallet entity alongside call-driven bank entities.
+Corollaries: one logical p2p transfer is two entities (debit and credit)
+with no shared machine ("public states describe each wallet's local
+progress", DD37), and hidden child transactions (``internal-withdrawal``)
+are separate entities, not states of the parent.
+
+**D10.4 Some truths are not derivable from the machine.** Three are
+declared as explicit non-goals of derivation for wallet transactions:
+
+* ``txActions`` are computed per state *and* context and are **not**
+ derivable from the lifecycle (DD37: frontends must use the returned
+ ``txActions``). No "possible actions" column is generated.
+* ``deleted`` is notification-only, never a stored state; its observation
+ is the *absence* of the record (``WALLET_TRANSACTION_NOT_FOUND``). The
+ observation model gains the notion "absence as signal" for exactly this
+ case (and wallet-core's ``none`` pseudo-state appearing in transition
+ notifications).
+* Internal states are intentionally collapsed on exposure (e.g.
+ redenomination phases report as ``pending:withdraw/working``). The
+ registry describes the *public* machine; the mapping from internal
+ status enums is implementation detail, checked by CI drift detection,
+ not authored in docs.
+
+D11: DD37's tables are generated, not mirrored
+----------------------------------------------
+
+The registry entries for wallet transaction entities become the canonical
+machine-readable form of DD37's machines, and DD37's per-type state tables
+are *generated from the registry* (with DD37 remaining the human-oriented
+prose and diagrams). Re-encoding DD37 by hand in the registry would create
+the very two-sided drift problem this system exists to eliminate. The
+lint's wallet rule resolves state *names* against the
+``get-transaction-by-id.rst`` enums and state *edges* against the registry.
+
+D12: Lint as build checks, warn-only during migration
+-----------------------------------------------------
+
+A doc lint in ``taler-docs/_exts/`` enforces the rules (identity,
+vocabulary, resolvability, two-sided lifecycle agreement, identifier joins,
+lifecycle integrity, filename/location) as Sphinx build warnings/errors.
+New rules land warn-only per slice and flip to error when the slice is
+backfilled.
+
+D13: KYC/AML is one exchange-owned entity; decisions are data, not states
+-------------------------------------------------------------------------
+
+KYC is the hardest entity to model because its state is genuinely
+cross-cutting: the wallet blocks transactions on it, the merchant shows a
+status page for it, the bank carries its wire transfers, the auditor
+observes its holds, and AML officers act on it. Verification against the
+exchange's actual schema (DD23) shows that the authoritative state lives
+entirely in the exchange: per wire target (``h_payto``), the exchange holds
+the access token, the currently triggered legitimization measures, the
+active decision/rule-set ledger, and the external provider processes.
+Everything else — the wallet's ``ExchangeWalletKycStatus`` and transaction
+minors, the merchant's ``MerchantAccountKycRedirect.status`` enum, the
+KYC SPA's requirement list, the auditor's AML-hold view — is a projection,
+named as such under the owner (per the owner rule's projection clause).
+
+**Decision: one entity, ``kyc-account``, owned by the exchange.** Its
+observable surface is ``GET /kyc-check/$H_NORMALIZED_PAYTO`` (200 = no
+mandatory action, 202 = redirect user to legitimization, 409 = account not
+yet bound to a public key, i.e. KYC-auth wire transfer required) plus the
+``aml_review`` flag, the monotonic ``rule_gen`` counter (the long-poll
+rendezvous), and the access token that authorizes the SPA. Its states are
+client-meaningful, not DB-meaningful: ``clean``, ``auth-required``,
+``action-required`` (measures open), ``in-process`` (external provider
+engaged), ``under-review`` (``aml_review``). There are no terminal states:
+a bank account never stops existing; dormancy and closure are modifiers.
+
+**Decision: officer decisions and rule sets are guards and modifiers, not
+states.** An AML officer decision (``POST /aml/$OFFICER_PUB/decision``)
+does four things: installs a new ``LegitimizationRuleSet`` (which *changes
+which transitions exist* — hence ``:if:`` guards on threshold rules, not
+new states), sets account properties (``is_frozen``, ``was_reported``,
+``pep``, ``high_risk`` — declared as ``:modifiers:`` on ``kyc-account``),
+triggers immediate measures (a ``call`` trigger into ``action-required``),
+and sets ``keep_investigating`` (the ``under-investigation`` modifier).
+This is exactly how the implementation works (the active row of
+``legitimization_outcomes`` is superseded, not stacked): decisions rewrite
+the machine's guard data. The concurrency guard of the API (409
+``AML_DECISION_MORE_RECENT_PRESENT``) is an ordinary ``fixable`` error with
+``retry-modified`` recovery (re-read, then re-decide). Officer
+provisioning (``POST /management/aml-officers``, master-key signed) is the
+``exchange-operator`` actor acting on the officer registry — separate
+entity, not part of ``kyc-account``.
+
+**Decision: projections may be restricted or lossy, and that is
+declared.** The KYC SPA's projection is *deliberately* restricted (the
+law may forbid disclosing true status; ``aml_review`` is only a hint), so
+``:observes-state:`` on the SPA endpoints is annotated as restricted. The
+merchant's status enum mixes entity states (``kyc-required``,
+``awaiting-aml-review``) with observation failures (``exchange-unreachable``,
+``logic-bug``); the lint resolves only the former against the lifecycle —
+projection-local failure members are declared at the observing method, not
+added to the entity's machine. The wallet deliberately conflates
+"user must act" with "staff review in progress" in ``pending:kyc``; the
+docs must not model it as user-actionable only.
+
+**Decision: Challenger is a separate entity, plugged in by trigger.** An
+address-validation process (Challenger or another provider) has its own
+machine (``setup → challenged → solved/failed/expired``, OAuth-flavored),
+owned by the provider component. It never decides KYC: its outcome enters
+``kyc-account`` via ``watch kyc-provider.webhook`` / ``call
+GET /kyc-proof/$PROVIDER`` triggers. Provider processes have their own
+expiration timers, distinct from the account's rule-set expiration.
+
+Worked examples
+===============
+
+These examples show the target format applied to real corpus content.
+They illustrate the requirements document; they are not themselves
+normative additions to it.
+
+A compliant method file
+-----------------------
+
+``core/bank-integration/get-withdrawal-operation-WITHDRAWAL_ID.rst``
+brought into compliance — added structure marked with ``※``:
+
+.. code-block:: rst
+
+ .. http:get:: /withdrawal-operation/$WITHDRAWAL_ID
+ :since: v1
+ :maturity: stable
+ :idempotency: readonly
+ :observes-state: withdrawal-operation.status ※
+ :visible-to: wallet-user, bank-customer (capability withdrawal-id)
+ :wait: long-poll(timeout_ms, old_state) ※
+
+ Query information about a withdrawal operation, identified by the
+ ``WITHDRAWAL_ID``.
+
+ **Request:**
+
+ :query timeout_ms: *Optional.*
+ Timeout in milliseconds, for :ref:`long-polling <long-polling>`,
+ to wait for operation state to be different from ``old_state``.
+ Since protocol **v3**.
+ :query old_state:
+ *Optional.* Defaults to "pending".
+ :query long_poll_ms: *Optional.*
+ Deprecated in protocol **v3**. Use *timeout_ms* instead.
+
+ **Response:**
+
+ :http:statuscode:`200 OK`:
+ :schema: BankWithdrawalOperationStatus ※
+ The withdrawal operation is known to the bank, and details
+ are given in the `BankWithdrawalOperationStatus` response body.
+
+ **Errors:** ※
+
+ :error 404 none:
+ :temporality: terminal
+ :recovery: give-up
+ The operation was not found. (A not-yet-propagated operation
+ id may also 404 briefly; clients that just received the id from
+ a ``taler://withdraw`` URI may retry briefly.)
+
+ **Identifiers:** ※
+
+ :identifier withdrawal-id:
+ :role: reads
+ :registry: :ref:`identifier-withdrawal-id`
+ The lookup key of the operation, conveyed via
+ ``taler://withdraw``.
+ :identifier reserve_pub:
+ :role: reads
+ :registry: :ref:`identifier-reserve-pub`
+ Wallet-selected reserve public key, returned here as part of
+ the operation state.
+
+ **Details:**
+
+ .. ts:def:: BankWithdrawalOperationStatus
+ ... (unchanged) ...
+
+Notes on the example:
+
+* The 404 entry shows the G3 problem and its resolution: the *same*
+ code is terminal for a stale id and transient for a not-yet-visible
+ one — the condition is documented, the default classification is
+ explicit.
+* ``:visible-to:`` carries the only authorization fact: wallet user and
+ bank customer, holding the unguessable ``withdrawal-id``. The
+ mechanism (no credentials, capability in the URL) follows from the
+ actor vocabulary and the registry entry — no separate auth field.
+* ``:wait:`` acknowledges long-polling and names its parameters (the
+ lint checks them against the ``:query:`` list). There is no push
+ channel for this entity; had there been one, it would be declared in
+ the bank's notification page, not here.
+* The withdrawal operation's missing expiry timer is recorded once, at
+ the entity (next example) — not here, and not scattered.
+
+Entity, timer, and identifier anchors
+-------------------------------------
+
+``withdrawal-operation`` (owner: bank):
+
+.. code-block:: rst
+
+ .. entity:: withdrawal-operation
+ :owner: bank (libeufin/corebank; exposed via the
+ bank-integration API and reused by the terminal API)
+
+ A bank-side operation coordinating one withdrawal: exchange and
+ reserve selection by the wallet, confirmation by the account
+ owner, wire transfer to the exchange.
+
+ .. lifecycle::
+ :terminal: aborted, confirmed
+
+ :transition none -> pending:
+ :trigger: call POST /accounts/$USERNAME/withdrawals
+ :trigger: external bank customer creates the operation in
+ the bank UI
+ The operation exists; the ``withdrawal-id`` is distributed
+ via a ``taler://withdraw`` URI.
+ :transition pending -> selected:
+ :trigger: call POST /withdrawal-operation/$WITHDRAWAL_ID
+ The wallet submits its selection (exchange, ``reserve_pub``).
+ :transition selected -> confirmed:
+ :trigger: call POST .../withdrawals/$WITHDRAWAL_ID/confirm
+ :trigger: external account owner confirms (2FA) in the
+ bank UI
+ The bank registers the transfer; the exchange observes the
+ funded reserve through its incoming wire history
+ (``reserve_pub`` as wire subject).
+ :transition pending|selected -> aborted:
+ :trigger: call POST /withdrawal-operation/$WITHDRAWAL_ID/abort
+ :trigger: external bank-side abort
+ 409 ``CONFIRM_ABORT_CONFLICT`` if already confirmed — the
+ absorbing property of ``confirmed`` in action.
+
+ .. timer:: operation-expiration
+ ⚠ not yet specified: no documented expiration exists today.
+ Recorded here as the single visible gap instead of silence
+ across all method files.
+
+ .. identifier:: withdrawal-id
+ :capability: yes (unguessable; possession authorizes)
+ Bank-generated operation id, distributed via
+ ``taler://withdraw`` URIs. Created by
+ ``POST /accounts/$U/withdrawals`` or the bank UI; carried and
+ read across bank, wallet and exchange endpoints (derived from
+ the method files' **Identifiers:** blocks).
+
+ .. identifier:: reserve_pub
+ :capability: yes
+ Wallet-generated reserve key; becomes the wire subject of the
+ funding transfer and names the reserve at the exchange.
+
+``tan-challenge`` (owner: corebank) is the reference example of a fully
+specified entity: declared terminal set, trigger-typed transitions (call,
+timer, guarded call), and two slim timer anchors whose structural facts
+(fires-to, armed-by, exposed-in, gates) are all derived from transitions,
+timer-refs and error entries — see the requirements document, §4.
+
+``order`` (owner: merchant backend) — implicit machine made explicit:
+
+.. code-block:: rst
+
+ .. entity:: order
+ :owner: merchant backend
+
+ .. lifecycle::
+ :terminal: wired
+ :modifiers: refunded (any state at or after paid)
+
+ :transition none -> unpaid:
+ :trigger: call POST /private/orders
+ :trigger: call POST /templates/$TEMPLATE_ID
+ Order created; a wallet can now claim it.
+ :transition unpaid -> claimed:
+ :trigger: call POST /orders/$ORDER_ID/claim
+ The wallet binds its nonce; contract terms signed.
+ :transition claimed -> unpaid:
+ :trigger: call POST /orders/$ORDER_ID/unclaim
+ The claim is released; another wallet may claim.
+ :transition claimed -> paid:
+ :trigger: call POST /orders/$ORDER_ID/pay
+ :if: coins cover the amount and any age/token requirements
+ Coins deposited; fulfillment unlocked.
+ :transition paid -> wired:
+ :trigger: watch exchange.outgoing-wire-arrivals
+ :trigger: call POST /private/transfers
+ The exchange aggregates and wires the funds; the merchant
+ confirms the bank credit and the backend reconciles.
+
+ .. timer:: pay-deadline
+ After the deadline the order can no longer be paid.
+ (Armed by order creation's ``pay_deadline`` and exposed in
+ order status responses — both derived from timer-refs.)
+
+ .. timer:: refund-deadline
+ Closes the refund window; wallet refund pickup returns 410
+ afterwards. (Arming, exposure and gating all derived.)
+
+ .. identifier:: order-id
+ :capability: yes
+ The public status/payment page is authorized by the
+ unguessable order id (plus claim token); management views
+ require bearer scopes instead — two different access
+ classes over the same value.
+
+Note that ``:modifiers:`` records orthogonal boolean flags
+(``refunded``, ``confirmed``) that the API exposes alongside the main
+chain — they extend the state space without entering the transition
+graph. This is the same mechanism wallet transaction entities use for
+``working`` and the suspended shadow states (D10.2).
+
+Notification pages
+------------------
+
+Wallet (``core/wallet-core/notifications.rst``, restructured from today's
+flat list into channel form):
+
+.. code-block:: rst
+
+ .. notification-channel:: wallet-message
+ :component: wallet-core
+ :registration: implicit (clients of the wallet-core message
+ protocol receive notifications)
+ :delivery: lossy-hint
+ :events: transaction-state-transition, exchange-state-transition,
+ balance-change, withdrawal-transition, ...
+
+ All notifications are hints, not authoritative state
+ (``api-wallet-core.rst``): the authoritative read is the
+ corresponding getter — e.g. ``transaction-state-transition`` ->
+ ``getTransactionById``; ``balance-change`` -> ``getBalances``.
+
+Merchant (to be extracted from ``api-merchant.rst``):
+
+.. code-block:: rst
+
+ .. notification-channel:: webhook
+ :component: merchant
+ :registration: POST /private/webhooks
+ :delivery: unspecified ⚠ retry/ordering/duplication semantics
+ are not documented today
+ :events: order_created, pay, refund, order_settled,
+ category_created, category_update, category_delete,
+ inventory_update, inventory_product_created, ...
+
+ Authoritative reads: order events ->
+ ``GET /private/orders/$ORDER_ID``; inventory events ->
+ ``GET /private/products/$PRODUCT_ID``.
+
+Exchange (honest negative declaration):
+
+.. code-block:: rst
+
+ .. notification-page:: exchange
+
+ The exchange offers **no** push notification channels. All
+ observation is by pull; several status endpoints accept long-poll
+ parameters (``:wait: long-poll(...)`` in their method files), e.g.
+ ``GET /reserves/$RESERVE_PUB``, ``GET /purses/$PURSE_PUB/merge``,
+ ``GET /kyc-info/$ACCESS_TOKEN``.
+
+KYC account (owner: exchange) — the D13 machine
+-----------------------------------------------
+
+.. code-block:: rst
+
+ .. entity:: kyc-account
+ :owner: exchange (exposed via /kyc-check, /kyc-info, /kyc-spa and
+ the /aml/$OFFICER_PUB/... officer API; projected into the merchant's
+ /private/kyc status, the wallet's ExchangeWalletKycStatus and
+ transaction minors, and the auditor's AML-hold view)
+
+ KYC/AML state of one bank account (wire target) at one exchange.
+ There are no terminal states; the account exists as long as the
+ exchange knows it. Officer-set properties (frozen, reported, pep,
+ high-risk) and investigation status are modifiers.
+
+ .. lifecycle::
+ :terminal: (none)
+ :modifiers: frozen, reported, pep, high-risk, under-investigation
+
+ :transition none -> clean:
+ :trigger: watch bank.incoming-wire-arrivals
+ :trigger: call <any exchange operation naming the account>
+ The exchange learns the account exists; default rules apply.
+ :transition clean -> auth-required:
+ :trigger: call <any guarded operation, e.g. POST /withdraw>
+ :if: /keys.kyc_enabled and no KYC-auth wire transfer has
+ bound the account to a public key
+ The operation is denied (409 AUTHORIZATION_KEY_UNKNOWN or
+ 451 with bad_kyc_auth); the client must send a KYC-auth
+ transfer whose subject carries the account public key.
+ :transition auth-required -> clean:
+ :trigger: watch bank.incoming-kycauth-wires
+ The KYC-auth transfer binds target_pub; /kyc-check becomes
+ usable for this account.
+ :transition clean -> action-required:
+ :trigger: watch bank.incoming-wire-arrivals
+ :trigger: call POST /kyc-wallet (balance threshold pre-announcement)
+ :trigger: call POST /aml/$OFFICER_PUB/decision (new_measures)
+ :if: the active rule set's threshold for the operation type
+ (WITHDRAW/DEPOSIT/MERGE/BALANCE/CLOSE/AGGREGATE/REFUND/
+ TRANSACTION) is crossed within its timeframe; "verboten"
+ measures deny instead of gating
+ The triggering operation is denied with 451
+ LegitimizationNeededResponse; the wallet enters pending:kyc.
+ :transition action-required -> in-process:
+ :trigger: call POST /kyc-start/$ID (via the KYC SPA)
+ An external provider process is created with its own
+ expiration.
+ :transition in-process -> action-required:
+ :trigger: timer kyc-account.process-expiration
+ :trigger: watch kyc-provider.webhook (user-failure /
+ provider-failure)
+ :transition in-process -> clean:
+ :trigger: watch kyc-provider.webhook (success)
+ :trigger: call POST /kyc-upload/$ID (form evidence)
+ :trigger: call GET /kyc-proof/$PROVIDER (OAuth redirect)
+ The AML program evaluates the outcome and installs a new
+ rule set; the blocked operation may now be retried.
+ :transition action-required|in-process -> under-review:
+ :trigger: watch aml-program.outcome (to_investigate)
+ :trigger: call POST /aml/$OFFICER_PUB/decision
+ (keep_investigating)
+ Staff review; clients only see aml_review=true and wait.
+ :transition under-review -> clean:
+ :trigger: call POST /aml/$OFFICER_PUB/decision
+ The officer's new rule set supersedes the active outcome.
+ :transition clean -> action-required:
+ :trigger: timer kyc-account.rule-set-expiration
+ :if: a successor measure is configured
+ Rule-set expiry re-triggers legitimization.
+
+ .. timer:: rule-set-expiration
+ LegitimizationRuleSet.expiration_time of the active outcome;
+ fires into the successor measure (or default rules).
+ .. timer:: process-expiration
+ Per external provider process; armed by /kyc-start, fires
+ in-process -> action-required.
+ .. timer:: kyc-auth-subject-expiration
+ Validity of KYC-auth wire instructions (merchant kycauth
+ response field ``expiration``).
+ .. timer:: wallet-threshold-expiration
+ WalletKycCheckResponse.expiration_time; the wallet's granted
+ balance threshold lapses and the wallet re-checks.
+
+ .. identifier:: kyc-access-token
+ :capability: yes
+ Bearer of the SPA session; issued per wire target, distributed
+ via /kyc-check responses to authenticated clients.
+ .. identifier:: account-pub
+ :capability: no (bound by KYC-auth wire transfer)
+ The public key a KYC-auth transfer binds to the account.
+
+Notes:
+
+* ``GET /kyc-check`` carries ``:observes-state: kyc-account.status``;
+ its ``min_rule`` / ``lpt`` parameters are the long-poll dialect
+ (``:wait: long-poll(timeout_ms, min_rule, lpt)``), and ``rule_gen`` is
+ the monotonic rendezvous counter.
+* The officer decision endpoint is ``:idempotency: unsafe`` in
+ appearance but guarded: 409 ``AML_DECISION_MORE_RECENT_PRESENT``
+ (``:temporality: fixable``, ``:recovery: retry-modified``) forces the
+ officer to re-read the ledger before re-deciding.
+* Challenger's address validation is a separate entity
+ (``address-validation``, owner: challenger) with states
+ ``setup / challenged / solved / failed / expired``; its OAuth code is
+ consumed by ``POST /token`` and its outcome reaches ``kyc-account``
+ only through the ``watch kyc-provider.webhook`` trigger.
+
+Flow: bank-integrated withdrawal
+--------------------------------
+
+Entities in play: ``withdrawal-operation`` (bank, shown above),
+``reserve`` (exchange), ``transaction-withdrawal`` (wallet-core). Join
+key: ``reserve_pub`` — created by the wallet (``confirmWithdrawal`` /
+``acceptManualWithdrawal``), carried to the bank by
+``POST /withdrawal-operation/$WITHDRAWAL_ID``, becomes the wire subject
+(read by the exchange's wirewatch), read by ``GET /reserves/$RESERVE_PUB``
+and ``POST /withdraw``.
+
+.. code-block:: rst
+
+ .. entity:: reserve
+ :owner: exchange
+
+ Funds pooled under a reserve public key by an incoming wire
+ transfer; drained by withdrawals.
+
+ .. lifecycle::
+ :terminal: closed
+
+ :transition none -> open:
+ :trigger: watch bank.incoming-wire-arrivals
+ The wire subject is the reserve_pub. There is no create
+ call: the wallet long-polls GET /reserves/$RESERVE_PUB
+ (404 transient-state, recovery long-poll) until the wire
+ arrives.
+ :transition open -> closed:
+ :trigger: call POST /reserves/$RESERVE_PUB/close
+ :trigger: timer reserve.expiration
+ Residual value is wired back to last_origin.
+
+ .. timer:: expiration
+ Armed by the funding wire (per exchange configuration);
+ exposed in ReserveSummary.reserve_expiration.
+
+ .. entity:: transaction-withdrawal
+ :owner: wallet-core
+
+ The wallet's withdrawal transaction group (DD37), here the
+ bank-integrated variant. Note the D10 extensions: working as a
+ modifier, implied suspend/retry edges, and watch-driven triggers.
+
+ .. lifecycle::
+ :terminal: done, failed, aborted
+ :modifiers: working
+ :standard-edges: suspend-retry
+
+ :transition none -> dialog:proposed:
+ :trigger: call prepareBankIntegratedWithdrawal
+ The taler://withdraw URI is resolved against the
+ bank-integration API.
+ :transition dialog:proposed -> pending:bank-register-reserve/working:
+ :trigger: call confirmWithdrawal
+ The wallet creates reserve_pub and registers it with the
+ bank (POST /withdrawal-operation/$WITHDRAWAL_ID).
+ :transition pending:bank-register-reserve -> pending:bank-confirm-transfer:
+ :trigger: watch bank.withdrawal-operation.status (= selected)
+ :transition pending:bank-confirm-transfer -> pending:exchange-wait-reserve:
+ :trigger: watch bank.withdrawal-operation.status (= confirmed)
+ The account owner confirmed; the bank wires the funds.
+ :transition pending:exchange-wait-reserve -> pending:withdraw/working:
+ :trigger: watch exchange.reserve (GET /reserves/$RESERVE_PUB
+ long-poll returns 200)
+ :transition pending:withdraw -> done:
+ :trigger: internal coins withdrawn (POST /withdraw replay-safe)
+ :transition pending:* -> pending:kyc:
+ :trigger: call <guarded operation>
+ :if: exchange signals legitimization (451 /
+ wallet_balance_limit_without_kyc)
+ :transition pending:kyc -> pending:*:
+ :trigger: watch exchange.kyc-account (GET /kyc-check long-poll)
+ :transition pending:bank-confirm-transfer -> aborting:bank/working:
+ :trigger: call abortTransaction
+ :transition aborting:bank -> aborted:bank:
+ :trigger: watch bank.withdrawal-operation.status (= aborted)
+ A 409 from the bank abort means it confirmed concurrently —
+ the standard-edges retry path returns to exchange-wait-reserve.
+
+Flow: payment from a template
+-----------------------------
+
+Entities in play: ``order`` (merchant, shown above — note its
+``none -> unpaid`` transition already lists ``call POST
+/templates/$TEMPLATE_ID`` as a second trigger), ``transaction-payment``
+(wallet-core). Identifiers: ``order-id`` (created by the template
+instantiation), the claim ``nonce`` (created by the wallet at claim,
+consumed at pay), ``h_contract_terms`` (derived from the claim response,
+carried into every later public call and into the exchange's deposit).
+
+The template itself is a static merchant resource, not a lifecycle
+entity: ``POST /private/templates`` and its siblings are CRUD with no
+machine, and say so (``:idempotency: keyed:template_id`` and no Effects).
+
+.. code-block:: rst
+
+ .. entity:: transaction-payment
+ :owner: wallet-core
+
+ DD37 payment transaction. Demonstrates :final-reactivatable::
+ a done payment is reactivated by explicit refund queries.
+
+ .. lifecycle::
+ :terminal: failed, aborted, expired
+ :final-reactivatable: done
+ :modifiers: working
+ :standard-edges: suspend-retry
+
+ :transition none -> dialog:proposed:
+ :trigger: call preparePayForTemplateV2
+ Instantiates the template at the merchant (public
+ POST /templates/$TEMPLATE_ID): order none -> unpaid.
+ :transition dialog:proposed -> pending:submit-payment/working:
+ :trigger: call confirmPay
+ The wallet claims the order (nonce bound) and signs.
+ :transition pending:submit-payment -> done:
+ :trigger: watch merchant.order (POST /orders/$ORDER_ID/pay 200)
+ :transition done -> pending:check-refund/working:
+ :trigger: call startRefundQuery
+ :trigger: timer order.auto-refund (auto-refund probing)
+ Reactivation edge out of a final-reactivatable state.
+ :transition pending:check-refund -> done:
+ :trigger: watch merchant.order.refunds (GET /orders/$ORDER_ID
+ long-poll await_refund_obtained)
+ :transition dialog:proposed -> expired:
+ :trigger: timer order.pay-deadline
+ :transition dialog:* -> dialog:waiting-for-other-wallet:
+ :trigger: call unclaimPayment
+ The claim nonce is released; another wallet may claim.
+
+ .. timer:: auto-refund
+ Armed from contract terms (auto_refund); exposed to the wallet
+ in contract terms, fires the refund-probing reactivation.
+
+Flow: deposit to a bank account
+--------------------------------
+
+Entities in play: ``deposit`` (exchange), ``transaction-deposit``
+(wallet-core), and ``kyc-account`` (the deposit flow is its most
+frequent client). Identifiers: ``h_wire`` (hash of the target payto),
+``h_contract_terms``, ``wtid`` (created internally by the exchange's
+aggregator, carried into the tracking response and onward to merchant
+reconciliation).
+
+.. code-block:: rst
+
+ .. entity:: deposit
+ :owner: exchange
+
+ A batch deposit of coins to one wire target.
+
+ .. lifecycle::
+ :terminal: wired, refunded
+
+ :transition none -> accepted:
+ :trigger: call POST /batch-deposit
+ Coins are deposited; the exchange wires after the refund
+ deadline and before wire_transfer_deadline.
+ :transition accepted -> held:
+ :trigger: watch aml-program.outcome
+ :if: aggregation KYC triggers (tracking returns 202 with
+ kyc_ok:false; auditor reports deferral_reason="KYC")
+ :transition held -> wired:
+ :trigger: watch exchange.kyc-account (clearance)
+ :transition accepted -> wired:
+ :trigger: timer deposit.wire-deadline (aggregation runs)
+ GET /deposits/$H_WIRE/... now returns 200 with wtid and
+ execution_time.
+ :transition accepted -> refunded:
+ :trigger: call <merchant refund before deadline>
+
+ .. timer:: wire-deadline
+ Armed by BatchDepositRequest.wire_transfer_deadline ("never"
+ is rejected); exposed in tracking responses as execution_time.
+
+ .. entity:: transaction-deposit
+ :owner: wallet-core
+
+ DD37 deposit transaction — the KYC-richest machine: pre-submission
+ KYC (kyc-auth transfer) and post-submission aggregation KYC are
+ distinct guarded regions.
+
+ .. lifecycle::
+ :terminal: failed, aborted
+ :final-reactivatable: done
+ :modifiers: working
+ :standard-edges: suspend-retry
+
+ :transition none -> pending:deposit/working:
+ :trigger: call createDepositGroup
+ :transition pending:deposit -> pending:kyc-auth:
+ :trigger: call POST /batch-deposit (denied)
+ :if: target account not yet bound (409 AUTHORIZATION_KEY_UNKNOWN)
+ kycAuthTransferInfo names the debit account that must send
+ the KYC-auth transfer.
+ :transition pending:kyc-auth -> pending:deposit:
+ :trigger: watch exchange.kyc-account (auth wire observed via
+ GET /kyc-check long-poll lpt=1)
+ :transition pending:deposit -> finalizing:track:
+ :trigger: watch exchange.deposit (POST /batch-deposit 200)
+ :transition finalizing:track -> pending:kyc:
+ :trigger: watch exchange.deposit (tracking 202, kyc_ok:false)
+ :if: aggregation KYC required
+ :transition pending:kyc -> finalizing:track:
+ :trigger: watch exchange.kyc-account (kyc_ok via lpt=2)
+ :transition finalizing:track -> done:
+ :trigger: watch exchange.deposit (tracking 200, wtid)
+ :transition pending:deposit -> aborting/working:
+ :trigger: call abortTransaction
+ :transition aborting -> aborted:deposit-abort-recovered:
+ :trigger: internal (refund + refresh completed)
+ :transition aborting -> done:deposit-abort-too-late:
+ :trigger: watch exchange.deposit (already wired)
+ Terminal done-with-minor: the money arrived despite the abort.
+
+Migration plan
+==============
+
+Compliance snapshot (verified file counts, 2026-10):
+
+.. list-table::
+ :header-rows: 1
+
+ * - Corpus
+ - Files
+ - Status vs. the requirements
+ * - wallet-core ops
+ - 152 (plus the notifications page)
+ - E1–E6, E11, E12 ✔; need: header options, structured errors,
+ ``:wait:``, effects/observation links to the DD37 machine via the
+ D10 extensions
+ * - exchange
+ - 65
+ - E1–E7 ✔, retry/idempotency prose good → convert to fields;
+ need: lifecycle registry entries (reserve, purse),
+ ``:visible-to:``, notification page (declares: no push channels)
+ * - merchant
+ - 114
+ - E1–E7 ✔; documented scopes become
+ ``:visible-to: merchant-staff:<scope>``; webhooks move to the
+ merchant notification page; need: order lifecycle, structured
+ errors
+ * - bank-side
+ - 65 (bank-integration 4, corebank 39, bank-wire 10,
+ bank-transfer 3, bank-revenue 2, bank-conversion-info 5,
+ account-directory 2)
+ - E1–E7 ✔; withdrawal/TAN states exist as unions → lifecycle
+ registry entries; need: error classification, timers,
+ cashout status gap flagged (API lacks a status field)
+ * - identity/misc
+ - 92 (challenger 7, taldir 5, mailbox 6, donau 13, ebisync 3,
+ auditor 58)
+ - mixed; challenger close to compliant; taldir/mailbox need
+ timers + error fields; drafts get ``:maturity:`` banners
+ * - sync, terminal
+ - 3 + 10 endpoints, inline today
+ - must be split into per-endpoint files first (D2)
+
+Steps, in order:
+
+1. Build the tooling in ``taler-docs/_exts/``: the new directive options,
+ the ``entity`` / ``lifecycle`` / ``timer`` / ``identifier`` /
+ ``notification-channel`` directives, and the lint (all rules warn-only
+ initially).
+2. Backfill the bank-integrated withdrawal slice end to end:
+ bank-integration, corebank withdrawal + TAN, exchange reserve +
+ withdraw, wallet withdrawal ops, registry entries for
+ ``withdrawal-operation``, ``tan-challenge``, ``reserve``,
+ ``reserve_pub`` and ``withdrawal-id``, wallet and exchange
+ notification pages. Flip the lint to error for this slice. This is
+ the acceptance gate for the format itself.
+3. Payment/refund slice: merchant order lifecycle, wallet payment and
+ refund ops, merchant notification page, ``order`` / ``order-id``
+ anchors.
+4. P2P slice: purse entities, wallet peer-push/peer-pull transaction
+ entities, deposit.
+5. The long tail: KYC/AML (with the shared-ownership treatment of D8's
+ owner rule), sync + terminal splitting, auditor, drafts and banners.
+
+Corpus corrections folded into the requirements document
+--------------------------------------------------------
+
+Verification against the corpus produced these adjustments, which the
+requirements document incorporates:
+
+* ``:maturity:`` instead of ``:status:`` (httpdomain field-name
+ collision; D2).
+* Variant-group and proxy-stanza exemptions to the one-method-per-file
+ rule (D2).
+* Sync and terminal APIs are to be split into per-endpoint files (D2).
+* The wallet notification page exists but must be *restructured* into
+ channel form; it is not already compliant (D9).
+* wallet-core op count is 152, not 153 (the 153rd file is
+ ``notifications.rst``).
+* KYC state is cross-cutting and gets the projections-named-under-owner
+ treatment rather than a naive single owner.
+* Wallet transaction entities get the four extensions of D10 and the
+ canonical-registry relationship to DD37 of D11.
+
+Test Plan
+=========
+
+* The lint (V1–V21 in the requirements document) runs in the
+ documentation CI; the bank-integrated withdrawal slice (bank-
+ integration, corebank withdrawal + TAN, exchange reserve + withdraw,
+ wallet withdrawal ops, registry entries for ``reserve_pub`` and
+ ``withdrawal-id``) must lint clean as the acceptance gate for the
+ format itself.
+* Codegen dry-run: generate client types and validators from the
+ slice's ``ts:def`` layer and diff against wallet-core's and the
+ exchange's corresponding wire types; mismatches fail the
+ implementation CI.
+* Derivation smoke tests: generate the withdrawal-operation state table,
+ the bank-customer persona projection, and a withdrawal sequence diagram
+ from the corpus; review against the hand-written flow documentation.
+* Wallet extension tests: lint the ``transaction-withdrawal`` registry
+ entry against DD37's generated tables; check that reactivation edges out
+ of ``done`` are legal under ``:final-reactivatable:`` and rejected under
+ ``:terminal:``.
+
+Definition of Done
+==================
+
+* All new directives (``entity``, ``lifecycle``, ``timer``, ``identifier``,
+ ``notification-channel``, extended ``http:*``/``ts:op`` options) are
+ implemented in ``_exts/`` and render correctly.
+* The lint enforces the full rule set as errors on the backfilled slices.
+* Every per-endpoint file (including the newly split sync and terminal
+ endpoints) is compliant or carries an explicit draft/banner maturity.
+* The entity registry covers at minimum the withdrawal, payment/refund,
+ and p2p flows end to end, including wallet transaction entities with
+ the D10 extensions.
+* DD37's state tables are generated from the registry.
+* At least one implementation repository runs codegen drift detection in
+ CI against the corpus.
+
+Alternatives
+============
+
+**OpenAPI/Swagger as the source of truth.** Covers the shape layer well
+but has no home for lifecycles, temporality semantics, actor projections,
+identifier roles, or notification doctrine — the parts this design exists
+for. The ``ts:def`` layer already plays OpenAPI's shape role inside the
+prose-capable Sphinx corpus; replacing the corpus would lose the prose
+that remains the human interface.
+
+**Generate docs from code.** Inverts the authority relationship: the
+design intent (idempotency guarantees, temporality classification,
+capability properties) is not present in code in checkable form, so
+generated docs would silently reflect implementation bugs as
+specification. The spec-first model with CI drift detection keeps the
+docs authoritative while still catching divergence.
+
+**Full anchor-side declarations** (state sets, timer facts, identifier
+life stories restated at the registry). Rejected: every derivable fact
+declared twice is a drift bug waiting to happen (D1).
+
+**Per-type substate unions instead of DD37's flat minor-state union.**
+Rejected for this design: the flat global union is the deployed,
+implemented vocabulary; partitioning it is a DD37 change, out of scope.
+
+**Modeling ``/working`` and suspended states as literal states.**
+Rejected: combinatorial table explosion for zero informational gain;
+orthogonal modifiers plus implied standard edges carry the same semantics
+(D10.2).
+
+**A separate ``:auth:`` field.** Rejected: actor implies mechanism;
+duplication invites contradiction (D5).
+
+Drawbacks
+=========
+
+* **Authoring cost.** Compliant files are substantially more work than
+ prose, and ~490 files need backfill. Mitigated by incremental migration
+ and by the lint telling authors exactly what is missing.
+* **Format rigidity.** Edge cases that do not fit (today: variant groups,
+ proxy stanzas, KYC's shared ownership) need explicit format extensions
+ rather than prose escape hatches, which slows documentation of unusual
+ endpoints. Accepted: silent exceptions are worse than visible ones.
+* **Two specification artifacts to maintain.** This DD, the requirements
+ document, and (for wallets) DD37 must evolve together. Mitigated by the
+ normative-reference hierarchy and by generating DD37's tables from the
+ registry (D11).
+* **Tooling must exist before the value does.** Until the lint and at
+ least one generator land, the new fields are overhead without payoff.
+ Mitigated by doing the withdrawal slice end-to-end first, tooling
+ included.
+* **The wallet extensions add vocabulary** (``:final-reactivatable:``,
+ ``:standard-edges:``, modifier flags) that only wallet transaction
+ entities use, slightly diluting the "one vocab fits all" property.
+
+Discussion / Q&A
+================
+
+**Why not put triggers on the method files instead of the lifecycle?**
+A method can honestly describe its own effects (and does, in **Effects**),
+but timer firings, watches of other components, and external human actions
+have no method file to live in. Causality is a property of the entity's
+machine, so triggers live on transitions; the two-sided check with method
+effects keeps both honest.
+
+**Why is long-poll not a notification channel?**
+A long-poll response is authoritative state delivered late; a notification
+is a hint that must be backed by a read. The delivery semantics, the lint
+rules, and the generated sequence-diagram edges differ. Conflating them
+would make "pull + long-poll only" components (the exchange) look like
+they offer push.
+
+**Why does the tax auditor have no credentials?**
+That is the system as designed: the auditor's access is authorized by
+possession of unguessable identifiers (``wtid``). The format makes this
+visible (``capability`` annotations resolving to ``:capability: yes``
+anchors) rather than hiding it in prose.
+
+**Are wallet transaction entities really "owned" by wallet-core if nothing
+calls wallet-core to drive them?**
+Yes: ownership is about authoritative state and transition execution, not
+about the trigger kind. Wallet-core's DB record is the truth; its
+transitions execute locally, driven by watches, timers, and user actions.
+The bank-owned withdrawal operation the wallet watches is a *different*
+entity with its own owner and machine — the two are joined by the
+``watch`` trigger, which is exactly the cross-component causality link the
+old prose never made.