Status: Accepted · Date: 2026-09 · Deciders: Aarokya Engineering ·
Affects:
clinic Postgres schema, benefit_entities, PolicyStatus,
verification-operator authContext
Narayana runs a clinic queue. That queue was an insurance projection:GET /insurance_policies/verification_queue listed every policy in Pending or
Verification, and an operator toggled between those two states. Narayana now
also offers a health checkup as a benefit in its own right, whose instance is
a benefit_entities row with no owning table — so it had no queue at all.
The queue has to serve both, and every future benefit type, without the clinic’s
day-to-day operations reaching into the tables that decide whether a member is
covered.
Options Considered
Widen BenefitEntityStatus with an in-progress variant
Widen BenefitEntityStatus with an in-progress variant
Pros: one status column for everything; the existing status endpoint and
its Superposition transition table are reused verbatim; no new table.Cons: three, and the first is fatal.Cardinality is not 1:1. One entitlement can produce several visits — a
member who no-shows and returns next week. As a status, the first visit is
overwritten and “no-showed, then returned” is unrepresentable.The distinction the queue exists to make does not survive.
PolicyStatus::Pending and ::Verification both coarsen to
BenefitEntityStatus::Created, so the ledger cannot tell “waiting” from “at
the clinic”.It puts a front-desk click on a column that moves money. A benefit instance
reaching ACTIVE is one of two preconditions for autopay, read by
core::mandate::update_mandate and the Auto Mandate Starter consumer. It
would also mean re-authoring the benefit.status_transition.* rows for every
benefit type in every environment, with a live consumer parking events in the
DLQ on any edge missed.Verdict: rejected.A separate deployable service with its own database
A separate deployable service with its own database
Pros: hard isolation; clinic staff never touch the admin origin; failures
cannot reach the benefit pipeline.Cons: admission has to validate the benefit instance it queues, which
becomes a cross-service call that can succeed while the local insert fails.
Per-operator authorization has to be re-implemented in the new service, or it
calls the API with a single service identity and becomes a confused deputy —
losing the per-tenant guarantee
require_verification_operator_strict
provides today.Verdict: rejected for now. The isolation is real but the price is a
distributed-consistency problem and a second security boundary, for a
workflow that reads and writes four tables in one database.A new table in the public schema
A new table in the public schema
Pros: joins to
users, mrns and benefits work normally; no tooling
changes.Cons: nothing separates the clinic’s operational data from the benefit
pipeline’s beyond naming. The requirement was explicitly not to “mess the main
tables”; a table sitting beside them satisfies the letter and not the intent.Verdict: rejected.A separate table in a separate clinic schema (chosen)
A separate table in a separate clinic schema (chosen)
Pros: the boundary is enforceable by a database role rather than
convention — the service can be granted
ALL on clinic.* and SELECT on
only the public tables it reads. The queue’s own status enum cannot collide
with or constrain the benefit statuses. Nothing in public references it, so
it can be moved to its own database later by relocating the schema.Cons: it is the first non-public schema in the repo, and Diesel’s
tooling is per-schema in two ways that are easy to miss.Verdict: accepted.Decision
clinic.clinic_queue, in a new clinic schema, keyed by benefit_entity_id —
the benefit instance’s own id is the row’s primary key.
The queue tracks the visit; benefit_entities.status tracks the
entitlement. Neither writes the other.
Three consequences follow from the schema boundary, all of them load-bearing:
-
diesel print-schemais per-schema. A bare run emitspubliconly, so a non-publictable is silently absent fromschema.rs— no error, just a missing table.just db-schema-updatecarries a second-s clinicline writingclinic_schema.rs. -
There are no cross-schema joins, and there cannot be.
allow_tables_to_appear_in_same_query!is emitted per schema, so pairingclinic.clinic_queuewith anypublictable needs cross-pairTableNotEqualimpls neither generated file will ever emit — it does not compile, and patching a@generatedfile is clobbered on the next regen. Sobenefit_provider_idis denormalised onto the row for tenant scoping, and member details are resolved by separate queries. - The primary key is the idempotency mechanism. Admission posts to a caller-known key, so a redelivered event from an at-least-once consumer trips the PK instead of queueing a member twice — no conflict handling, no idempotency key, and an already-queued row is returned untouched rather than reset.
Consequences
Gained
- A benefit-agnostic queue: insurance and health checkup queue identically, and future types come for free
PolicyStatus,BenefitEntityStatusand the autopay preconditions are untouched- Adding a workflow step costs one enum label, not a config migration across environments
- The clinic’s data can be split to its own database later without a rewrite
- Idempotent admission with no bespoke machinery
- The schema owns its audit trail:
clinic.event_logmirrorspublic.event_logandclinic.event_logger()writes to it, so the history travels with the schema when it moves, and a clinic row is never confused with a futurepublic.clinic_queuerow — the table it landed in is the schema
Trade-offs
- One queue entry per benefit instance ever — re-admitting after
COMPLETEDneeds a delete or a third status - The list is N+1 on the member lookup, bounded by the page limit
- A Superposition outage collapses the transition table to empty, which refuses every status write
- Reading the audit trail takes an explicit
?schema=onGET /admin/event_logs: there is no single view over both tables, because a cross-schema union would undo the relocatability the separate table buys
Related
- Clinic Queue module
- Benefit Entities module — the entitlement ledger this deliberately does not extend
- ADR-007: Config-Driven Action Engine — the Superposition traps that shaped the transition table’s defaults