Retainer intake walkthrough

The retainer-intake flow is a pair of durable state machines per Notation, declared in the frontmatter of notation_templates/onboarding/retainer.md and walked by the web::retainer_walk module:

  1. Questionnaire walker — one question per request, one Answer per advance, one Notation Event per transition. Walks the state chain BEGINclient_nameclient_emailproject_nameproduct_descriptionEND.
  2. Post-intake workflow — fires once the questionnaire reaches END. Walks intake_persisted__clientstaff_reviewdocument_open__retainer_pdfsent_for_signature__pendingEND, driving render, PDF persistence, and "sent for signature".

Both timelines share the same runtime surface (workflows::StateMachineRuntime), keyed by (MachineKind, notation_id), and run as a single Restate virtual object per Notation. The worker that hosts the object lives in workflows-service/.

Questionnaire state machine

stateDiagram-v2
    [*] --> client_name : _
    client_name --> client_email : _
    client_email --> project_name : _
    project_name --> product_description : _
    product_description --> [*] : _

The bare _ condition is the only signal that advances a questionnaire (the canonical "respondent answered"). State names are bare question codes — no __discriminator suffix — because a questionnaire only ever asks one respondent.

Post-intake workflow

stateDiagram-v2
    [*] --> intake_persisted__client : intake_submitted
    intake_persisted__client --> staff_review : retainer_rendered
    staff_review --> document_open__retainer_pdf : approved
    staff_review --> [*] : rejected
    document_open__retainer_pdf --> sent_for_signature__pending : pdf_persisted
    sent_for_signature__pending --> [*] : signature_received

State names use the <prefix>__<discriminator> form so workflows::step_kind_for can pick the right actor class (system / staff / respondent) per state.

HTTP surface

Four routes, all under web::retainer_walk:

Every state-changing request carries a CSRF token; auth is enforced by the require_auth layer on the admin router.

One POST through the stack

What a single POST /portal/admin/notations/:id/step looks like when RESTATE_BROKER_URL is set (the in-cluster restate Service in KIND, or the GKE-managed broker in production):

sequenceDiagram
    participant Chrome
    participant web as web (host)
    participant ingress as Restate ingress (:8080)
    participant worker as workflows-service
    participant pg as Postgres

    Chrome->>web: POST /portal/admin/notations/:id/step
    web->>pg: INSERT answers (question_id, person_id, value)
    web->>ingress: POST /notation/:id/questionnaire_signal {condition:"_"}
    ingress->>worker: dispatch handler
    worker->>worker: ctx.get(spec_yaml, state)
    worker->>worker: next_state(...)
    worker->>worker: ctx.set(state, next)
    worker->>pg: ctx.run("append-event", append_event(...))
    worker-->>ingress: {next_state:"client_name"}
    ingress-->>web: 200 OK
    web-->>Chrome: 303 → /portal/admin/notations/:id/step

The two pg arrows have two different writers: the walker writes Answers directly; the worker is the sole writer of Notation Events, inside ctx.run so a crash + replay reuses the cached row id instead of double-inserting.

Persistence

Restate is the source of truth for state; the notation_events table is the durable projection of that state. A signal lands in Restate's keyed state first; the Postgres row is the worker's ctx.run side effect, journaled so a replay never double-writes.

Each transition is recorded as one row in notation_events (store::entity::notation_event), the append-only journal that mirrors workflows::WorkflowEvent. The "current state" of a (notation_id, machine_kind) machine is the to_state of the latest row — see latest_for_kind. For a questionnaire signal, the payload column carries {"answer_value": "…"}; for a workflow signal it is None.

Answers themselves are stored in the answers table, keyed by (question_id, person_id). The walker pre-fills the prior answer when the user navigates back so re-display is read-only.

Durable execution

Restate is the production target. The workflows-service crate registers a Notation virtual object with the broker; each questionnaire_signal and workflow_signal handler reads the spec yaml + current state from Restate's keyed state, computes the next state, persists it back, and appends one row to notation_events inside ctx.run("append-event", …) so a replay reuses the cached row id instead of double-writing.

The application-side adapter (workflows::runtime_restate::RestateRuntime) posts to the broker's ingress port. When RESTATE_BROKER_URL is unset, web falls back to the in-process InMemoryRuntime used in tests and local dev.

The signature seam

web::signature::SignatureProvider is a one-method async trait:

#[async_trait]
pub trait SignatureProvider: Send + Sync {
    async fn send_for_signature(
        &self,
        notation_id: i32,
        pdf: &[u8],
    ) -> Result<SignatureRequestId, SignatureError>;
}

Google Workspace eSignature has no public API today (it is a UI-only feature inside Docs / Drive — see Google's own docs), so the shipped implementation is StubSignatureProvider, which records every call to an internal Mutex<Vec<…>>. Tests assert on it; dev runs against it. A real DocuSign or Dropbox Sign adapter implements the same trait and is plugged in by swapping the Arc<dyn SignatureProvider> in web::AppState.

Test coverage