A single call, a structured answer, and the questions the UI actually wants to ask stop being answered by inference across five tables.
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.
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.
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.
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.
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.
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.
An unmet block-level requirement. Direct answer to "why can't
this proceed?" — not something the frontend derives by composing artifact and
module states.
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.
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.
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.
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.
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.
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"
}
]
}
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'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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.