ADR-061 · Proposed · 2026-09-02

The Case Readiness Substrate

A single call, a structured answer, and the questions the UI actually wants to ask stop being answered by inference across five tables.

For Zareef and Matt. This is what the substrate means when you sit down to wire it into a component. Not "here is a substrate model" — but "here is what the code and the screen get to look like."

The service that is still being designed is this one

Two cards in app-core/client/src/components/cases/intake/ are deliberately blank today. Their docstrings say why, in the maintainer's own words. Read them, then look at what the substrate returns.

NextStepsCard.tsx · docstring

Still mostly blank on purpose. The design's rows pair an outstanding requirement with the action that satisfies it ("Complete Transfer into Care → Edit Transfer"), which means something has to decide which requirements are outstanding for a given case and workflow, and what resolves each one. That service is still being designed.

The obvious shortcut — listing the workflow engine's incomplete module instances — is what the previous rail panel did, and it is not the same list: a module state says where the engine got to, not what the director should do next, and the two diverge exactly where a director would rely on this card. A plausible-looking list of next steps that is subtly wrong is worse than an empty one, because it will be trusted.

TransferCard.tsx · docstring

Deliberately inert in both variants. There is no transfer model: a transfer is a scheduled_events row of type transfer, which carries a time but none of the pickup/destination detail the design asks for, and the verbal-authorization and location-type rules the intake variant shows have no columns at all. Drawing the card disabled puts the gap where the work will go, without inventing a record to fill it.

Both cards are asking the same question: given this case right now, what is true, what is missing, and what is the next thing anyone should do? Both answer with "we do not have the service that tells us." The substrate is that service.

What the substrate gives back

Every question a case screen wants to answer maps to one of five reads. The substrate's real primitives have longer names in the ADR; these are the versions that make sense while you're building a component.

Readiness Projection

What's true

The case's current facts, projected from the underlying storage the substrate reads across — artifacts, records, custody events, checklists. Not stored separately; computed on read.

Requirement · type + reason

What's needed

Every requirement carries a type (data, document, attestation, scheduled_event, compliance, prerequisite, reconciliation, artifact_print) and a human-readable reason. Reading the reason IS reading the requirement.

Blocker

What's blocking

An unmet block-level requirement. Direct answer to "why can't this proceed?" — not something the frontend derives by composing artifact and module states.

SuggestedTask · actor role

What's next

A concrete next action, produced from unresolved requirements or expected transitions. Carries a recommended actor role — Sarah, Megan, FD, Care Centre — so each persona gets their own filtered queue from one endpoint.

Milestone · Location Projection · Status Summary

Where we are

The current milestone the case has reached (Permit Obtained, Ready for Cremation Dispatch), the deceased's current location (projected from custody scans), and a short narrative sentence for the header — LLM- or template-generated server-side.

NextStepsCard, before and after

Here is what NextStepsCard renders today, and what one call to the readiness projection lets it render tomorrow. The current card is one static link to the Select Packages screen — everything else is the empty-state the docstring describes.

Today

NextStepsCard, as shipped

Next Steps
Select the package for this arrangement Edit Package
(no derived rows — the service that would populate them is still being designed)

One static row, deliberately unconditional. The docstring explains why the rest are absent: listing incomplete module instances would produce a plausible-looking list that is subtly wrong.

With substrate

NextStepsCard, populated

Next Steps
Sign Cremation & Disposition Authorization (2 signers required) Review Signing Bundle
Resolve pacemaker status Blocking Edit Prep Sheet
Book crematorium (permit received; awaiting request) Route to Crematorium
Select the package for this arrangement Edit Package

Rows are dynamic, tied to real unresolved requirements. Each carries its own action link. The blocker chip surfaces the one row that is actually stopping progression. Static Package row still shown when applicable; the substrate also produces derived rows to sit alongside it.

The response that populates it

One call — GET /api/v1/cases/:case_number/readiness — returns a structured answer the card can render row-by-row without composing across surfaces:

{
  "case_number": "KFS-2026-04812",
  "current_milestone": "Arrangement Anchored",
  "location": "Kearney BTC",
  "status_summary": "Contract signed; awaiting
    disposition permit and pacemaker status resolution.",
  "blockers": [
    {
      "key": "pacemaker_status_resolved",
      "type": "compliance",
      "reason": "Pacemaker status is a four-state enum
        required before crematorium request.",
      "resolves_at": "/cases/KFS-2026-04812/prep-sheet"
    }
  ],
  "suggested_tasks": [
    {
      "label": "Sign Cremation & Disposition Authorization",
      "detail": "2 signers required",
      "actor_role": "funeral_director",
      "resolves_at": "/cases/KFS-2026-04812/signing"
    },
    {
      "label": "Resolve pacemaker status",
      "actor_role": "funeral_director",
      "blocking": true,
      "resolves_at": "/cases/KFS-2026-04812/prep-sheet"
    },
    {
      "label": "Book crematorium",
      "detail": "permit received; awaiting request",
      "actor_role": "dispatch",
      "resolves_at": "/cases/KFS-2026-04812/crematorium"
    }
  ]
}

The component change is small

A React sketch — the shape, not the final code:

export function NextStepsCard({ caseNumber }: NextStepsCardProps) {
  const { data: readiness } = useCaseReadiness(caseNumber)

  // One static entry — surfaced because the Packages screen has no other route in.
  const staticRows = [
    { label: "Select the package for this arrangement",
      resolvesAt: `/cases/${caseNumber}/packages`,
      actionLabel: "Edit Package" }
  ]

  const derivedRows = readiness?.suggested_tasks.map((task) => ({
    label: task.detail ? `${task.label} (${task.detail})` : task.label,
    resolvesAt: task.resolves_at,
    actionLabel: actionLabelFor(task.resolves_at),
    blocking: task.blocking
  })) ?? []

  return (
    <Card>
      <Stack spacing={2}>
        <Typography variant="subtitle1">Next Steps</Typography>
        {[...derivedRows, ...staticRows].map((row) => (
          <NextStepRow key={row.resolvesAt} {...row} />
        ))}
      </Stack>
    </Card>
  )
}

The card stops being "mostly blank on purpose" and becomes a live surface, driven entirely by a projection read. No workflow-engine-state interpretation in the frontend. No misleading rows — a task appears only when its underlying requirement is genuinely unmet, and disappears the moment it's resolved.

TransferCard, without inventing a record

TransferCard's docstring names two problems: a transfer is a scheduled_events row that carries none of the pickup / destination detail, and the verbal-auth and location-type rules have no columns. The substrate does not solve either by adding a transfer model — it composes the view from surfaces that already exist.

Today

TransferCard, as shipped (record variant)

Transfer Edit Transfer
Schedulednot built yet
Pickup Fromnot built yet
Addressnot built yet
Destinationnot built yet
With substrate

TransferCard, composed from existing surfaces

Transfer Edit Transfer
ScheduledTue Sep 2, 3:15 PM
Pickup FromVancouver General Hospital
Address899 W 12th Ave, Vancouver
DestinationKearney BTC — Kingsway
Release AuthoritySigned Control of Disposition missing

No new tables, no transfers model. The scheduled time comes from scheduled_events; pickup / address / destination come from case.location_of_death plus the target facility on the event; the Release Authority row is a substrate requirement that gates on either case.first_call.verbal_transfer_authorization (no-morgue) or a signed Control of Disposition artifact (hospital / coroner). If it's missing, the substrate surfaces it as a blocker the same way NextStepsCard would.

The card's docstring can shorten to one line — Transfer view composed from the readiness projection; no dedicated transfer model.

UI wins, tied to real things

One API contract, not five

The frontend queries one readiness endpoint per case and receives structured answers. No envelope-key interpretation, no module-instance state composition, no reasoning across custody events + artifact lifecycle + checklist rows on the client.

Blockers are direct answers

Why can't this proceed? is a query response — a named requirement with a human-readable reason and a link to where to fix it. Not a computation the UI derives by joining artifact states with requirement rules.

Persona queues from one filter

Sarah's admin queue, Megan's dispatch queue, the FD's follow-up queue, Care Centre's intake queue — same SuggestedTask endpoint, one actor_role attribute. No per-persona backend surfaces to build and maintain.

No per-tenant UI branching

Different tenants have different substrate state → the same components render different things. The frontend doesn't know or care about tenant differences. New capability profiles ship without frontend changes.

Cases update themselves

When a document is signed, a permit lands, or a scan happens, the readiness state changes. Components subscribed to the readiness projection re-render with the new answer — no "manually refresh the case" affordances to maintain.

The Case Status Summary is server-side

The one-sentence narrative header ("Ms. Smith. Transferred to Kearney BTC yesterday. Arrangement meeting tomorrow. Awaiting disposition permit.") is generated on the backend from readiness + location + recent activity. The UI displays a string.

New workflow families don't touch the frontend

Aquamation, coroner-hold, repatriation, green burial arrive as new capability profiles. Their artifacts, requirements, and milestones flow through the same projection → the same components render them without change. The frontend is workflow-family-agnostic.

Audit trail comes with the discipline

Every artifact transition, milestone completion, and operator override writes to Layer 3 (case activity events). The narrative log the UI shows is the audit trail — one source, not two. Override with a recorded reason instead of inventing workarounds.

What's still open

ADR-061 is Proposed, not Accepted. Two of the six starter workflow families have been walked against it (cremation and burial). The implementation plan is next after ADR acceptance, and the substrate isn't built yet.

Before wiring against this

The primitives are stable across the two families walked so far, with one additive refinement (Assets from ADR-022 added to the storage-surfaces list during the burial walk). The remaining four families — aquamation, coroner-hold, repatriation, green burial — could still surface a gap that would require iteration on the substrate. Don't wire production surfaces against the substrate until at least a third family has walked cleanly.

Small prototypes and design sketches (like the NextStepsCard renderings above) are fine now — they clarify what the substrate needs to answer. The line to hold is don't ship changes to app-core against the substrate until the implementation plan lands and the schema is real.