Backend Contracts
Extensible shared shapes every module must produce: EntityContract, status enum, event payload, audit log, policy-gate row, queue placement.
What this is. Every module loop produces backend artefacts (NestJS modules, Mongoose schemas, Supabase tables, edge functions, Moleculer events). Without standardized contracts, every module re-invents shapes, the loop’s stages 03/04/06 reinvent helpers, and the orchestrator/policy-gate/queue/ack systems get bespoke wiring per module. These contracts are the single source of truth for the shapes a module loop must produce.
Property: every contract is an extension point, not a fixed schema. New modules add an entry; they do not edit the contract itself. New optional fields are appended; never renamed in place.
Read order: stage 03 (data-model) and stage 04 (backend) reference this file. Stage 06 (wiring) references it for fan-out + policy-gate shapes.
0. Why contracts (the failure mode they prevent)
Without contracts, every module produces:
- A Mongo schema with subtly different field names (
createdByvscreated_byvsuserRefvsrequester) - A Moleculer event with non-standard payload (some include
encounter_id, some don’t) - An audit-log table with different columns (some have
actor, some haveuser_id) - A policy-gate trigger string that doesn’t follow
<domain>.<entity>.<action>convention
This costs ~30% of the fix-loop time on ID/payload/wiring mismatches (Blood-Bank evidence). Contracts eliminate that class.
1. Entity Contract — the shape every persisted record obeys
Every entity stored anywhere (Mongo or Supabase) implements EntityContract:
// In packages/platform-api-schema/src/_contracts/entity.contract.ts (create if missing)
export interface EntityContract {
// Identity
id: string; // primary key. Mongo: `_id` aliased to `id`. Supabase: native `id`.
mongo_ref?: string; // 24-hex ObjectId, set when entity originates in Mongo. Cross-store reconciliation.
// Audit (every entity, no exceptions)
created_at: string; // ISO 8601, UTC
created_by: string; // user.id of creator
updated_at?: string; // ISO 8601, UTC; populated on every update
updated_by?: string; // user.id of last updater
// Soft-delete (never hard delete clinical entities)
deleted_at?: string | null;
deleted_by?: string | null;
deleted_reason?: string | null; // bilingual reason; required when deleted_at is set
// Cancellation discipline (per docs/delivery-room-system-checklist.md row 0.10/0.11)
cancelled_at?: string | null;
cancelled_by?: string | null;
cancelled_reason?: string | null; // bilingual
// Multi-tenant (every entity must declare tenant ownership)
tenant_id?: string; // facility / hospital / clinic group id
}
Every Mongo schema extends this:
// Example: services/<svc>/src/api/<svc>/modules/<entity>/<entity>.schema.ts
import { EntityContract } from '@platform-api-schema/_contracts/entity.contract';
export interface PathologySpecimen extends EntityContract {
// Module-specific fields
order_id: string;
specimen_type: PathologySpecimenType;
organ?: string;
status: PathologySpecimenStatus;
received_at?: string;
// ... etc
}
Every Supabase table includes the contract columns (define a SQL macro to copy in migrations):
-- Place at top of every <entity> migration
-- Reuse via: CREATE TABLE pathology_specimens ( ... <columns> ..., LIKE _contract_audit_columns INCLUDING ALL );
-- Or, simpler: paste these columns into every CREATE TABLE.
CREATE TABLE _contract_audit_columns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ,
updated_by UUID,
deleted_at TIMESTAMPTZ,
deleted_by UUID,
deleted_reason JSONB, -- { th: '...', en: '...' }
cancelled_at TIMESTAMPTZ,
cancelled_by UUID,
cancelled_reason JSONB, -- { th: '...', en: '...' }
tenant_id UUID NOT NULL
);
Stage 04 done predicate adds: every new table includes the contract columns; if a column is omitted, justify why in the migration’s COMMENT ON TABLE.
2. Status Enum Contract — every status enum follows one shape
Every entity with a status field uses lower-snake-case strings:
// In packages/platform-api-schema/src/<svc>/<entity>/status.ts
export enum PathologySpecimenStatus {
RECEIVED = 'received',
ACCESSIONED = 'accessioned',
GROSSED = 'grossed',
ON_SLIDE = 'on_slide',
SCANNED = 'scanned',
SIGNED_OUT = 'signed_out',
RELEASED = 'released',
AMENDED = 'amended',
REJECTED = 'rejected',
CANCELLED = 'cancelled',
}
Hard rules:
- Values are
lower_snake_case. NeverUPPER_CASE,camelCase, or human strings ("Pending Review"). - Two universal sentinel values:
cancelledandrejected. If your entity supports either, use these strings — nevervoided,aborted,denied,discarded, etc. - Every status enum has a paired state machine declared in stage 03 §2 — never a status enum without a state machine.
- Adding a new status = adding a new enum value AND a new transition in the state machine AND a new column in the workflow JSON.
3. Moleculer Event Contract — every cross-service event shape
Every event the backend emits follows MoleculerEventContract:
export interface MoleculerEventPayload<TData = unknown> {
// Event identity
event: string; // dot-notation: <domain>.<entity>.<action>
// examples: 'pathology.specimen.accessioned',
// 'blood.request.cancelled',
// 'medication.order.dispensed'
// Entity reference (always include both forms)
entity_id: string; // Mongo `_id` or Supabase `id`
entity_type: string; // e.g. 'pathology_specimen'
mongo_ref?: string; // if entity originates in Mongo
// Encounter/patient context (when applicable; almost always)
encounter_id?: string;
patient_id?: string;
tenant_id: string;
// Actor (who triggered)
user_id: string;
user_role?: string;
// Timestamp
emitted_at: string; // ISO 8601, UTC
// Idempotency (orchestrator handlers MUST be idempotent)
event_id: string; // UUID; orchestrator dedupes on this
// Module-specific data
data: TData;
}
Hard rules:
- Event name format:
<domain>.<entity>.<past-tense-verb>—pathology.specimen.accessioned, NOTpathology.specimen.accessionoraccessionPathologySpecimen. - Past tense — events describe what happened, not what should happen.
- Always include
tenant_id+event_idfor orchestrator dedupe. datais module-specific; document its shape in stage 03’s data-model doc.- Every event registered in stage 04 §1.6 in BOTH the emitting service’s
events:block AND the orchestrator’s subscription list.
Orchestrator handler signature (Deno edge function pattern):
export async function handleSpecimenAccessioned(
payload: MoleculerEventPayload<{ specimen_id: string; accepted_by: string }>,
ctx: OrchestratorCtx,
): Promise<void> {
// 1. Idempotency check
if (await ctx.alreadyHandled(payload.event_id)) return;
// 2. Project into read-model
await ctx.supabase.from('pathology_specimens').upsert({...});
await ctx.supabase.from('department_queues').insert({...});
// 3. Update encounter cache
await ctx.upsertEncounterCache(payload.encounter_id, {pathology_summary: {...}});
// 4. Mark handled
await ctx.markHandled(payload.event_id);
}
4. Audit Log Contract — every clinical entity has a sibling audit table
Every clinical entity has a <entity>_audit_log table with this shape:
CREATE TABLE <entity>_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_id UUID NOT NULL, -- FK to the audited entity
action TEXT NOT NULL CHECK (action IN ('insert','update','delete','cancel','reject','amend','sign_out')),
actor_id UUID NOT NULL, -- user.id of actor
actor_role TEXT,
before JSONB, -- previous state (null on insert)
after JSONB, -- new state (null on delete)
diff JSONB, -- computed diff for UI display
reason JSONB, -- bilingual reason (when action requires one)
emitted_event_id UUID, -- link to MoleculerEvent if a sibling event was emitted
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE TRIGGER trg_<entity>_audit
BEFORE INSERT OR UPDATE OR DELETE ON <entity>
FOR EACH ROW EXECUTE FUNCTION audit_log_writer('<entity>');
Hard rules:
- Every clinical entity (orders, requests, encounters, results, signatures, dispensations, reactions) gets an audit table.
- Trigger fires before the change so we capture
beforeandafteratomically. - Audit log is APPEND-ONLY. No update, no delete (except retention sweeps, which are themselves audited).
- Stage 04’s done predicate verifies presence of
_audit_logfor clinical entities.
5. Policy-Gate Contract — every block/warn rule
Every runtime rule (block this if X, warn on Y) lives in policy_gates per docs/architecture/policy-gates.md:
export interface PolicyGate {
trigger: string; // <domain>.<entity>.<action>; matches MoleculerEvent name space
condition: Record<string, unknown>; // JSON predicate (Mongo-query-like)
action: 'allow' | 'block' | 'warn';
reason: { th: string; en: string; [locale: string]: string }; // bilingual
active: boolean;
scope?: { tenant_id?: string; role?: string[] };
priority?: number; // higher wins on conflict
}
Hard rules:
- Trigger names match event names (so
pathology.specimen.cancelgates the cancel action;pathology.specimen.cancelledis the event after the gate passes). - Condition is a JSON-serialisable predicate evaluated by
usePolicyGate(frontend) and the backend guard service (defense in depth). - Reason is bilingual — UI shows
reason[locale]. - Stage 03 §6 enumerates every needed gate; stage 07 seeds them; stage 06 wires them with
usePolicyGate.
6. Queue Contract — every queue placement
Every workflow that creates work for a downstream role uses department_queues per docs/architecture/encounter-orchestrator-triggers.md:
export interface DepartmentQueueRow extends EntityContract {
dept_type: string; // canonical dept_type (`pathology_grossing`, `medical_coder_pending`, ...)
source_entity_id: string; // the source row that spawned the queue entry
source_entity_type: string; // 'pathology_specimen', 'blood_request', etc.
patient_id?: string;
encounter_id?: string;
priority: 'routine' | 'urgent' | 'stat';
state: 'queued' | 'in_progress' | 'on_hold' | 'completed' | 'cancelled';
assigned_to?: string; // user.id when picked up
picked_at?: string;
completed_at?: string;
data?: Record<string, unknown>; // queue-specific payload (e.g. specimen type, blood group)
}
Hard rules:
- Every fan-out target from stage 03 §7 maps to a
dept_type. Use existingdept_typevalues where possible — don’t fork the namespace. - Queue insertions happen in the orchestrator handler, not directly from the frontend (frontend writes to entity, entity emits event, orchestrator projects to queue).
QueueManagementFloater(perdocs/architecture/queue-management-floater.md) auto-renders anydept_type. Newdept_types require zero frontend work to surface.
7. Acknowledgement Contract — every “needs explicit OK” workflow
Per docs/architecture/acknowledgement-system.md:
export interface AcknowledgementRequest extends EntityContract {
source_event: string; // <domain>.<entity>.<action>
source_entity_id: string;
source_entity_type: string;
ask_user_id?: string; // specific user
ask_role?: string; // OR a role
ask_department?: string; // OR a department
ask_group_id?: string; // OR a group
channels: ('app' | 'web' | 'email' | 'sms' | 'push')[];
due_at?: string;
reminder_rrule?: string; // RRULE for repeating reminders
escalation_chain?: string[]; // user_ids to escalate to if unanswered
state: 'pending' | 'acknowledged' | 'declined' | 'expired';
acknowledged_at?: string;
acknowledged_by?: string;
decline_reason?: { th: string; en: string };
}
Hard rule: if a spec row says “user X must confirm Y before Z”, use AcknowledgementRequest. Don’t roll a custom dialog — `` is mounted globally in App.tsx and surfaces every pending ack.
8. Cron Job Contract — every scheduled task
Per docs/architecture/cron-jobs-registry.md, every pg_cron schedule is registered in cron_jobs:
export interface CronJob {
name: string; // unique, kebab-case
schedule: string; // cron expression
function_name: string; // pg function or edge function name
enabled: boolean;
description: { th: string; en: string };
last_run_at?: string;
last_run_status?: 'success' | 'error';
last_run_message?: string;
}
Hard rule: never cron.schedule(...) directly in a migration without also inserting into cron_jobs. Drift detection at /super-admin/cron-jobs will flag invisible schedules.
9. Service-Layer Contract — every web/src/services/medbase/<module>.medbase.ts
Every module’s service-layer file follows this shape (per stage 06):
// 1. Column whitelists (one per write target)
export const <ENTITY>_COLUMNS = [...] as const;
export type <Entity>Column = typeof <ENTITY>_COLUMNS[number];
// 2. Whitelist-applying picker
export function pick<Entity>Payload(row: Partial<<Entity>Row>): Partial<<Entity>Row> { ... }
// 3. Row type (matches the view shape — wider than the table)
export interface <Entity>Row extends EntityContract { /* view + table columns */ }
// 4. Read functions (Supabase first, REST fallback)
export async function list<Entity>(filters?: <Entity>Filters): Promise<<Entity>Row[]> { ... }
export async function get<Entity>ById(id: string): Promise<<Entity>Row | null> { ... }
// 5. Write functions (whitelist applied, errors surfaced loudly)
export async function upsert<Entity>(row: Partial<<Entity>Row>): Promise<<Entity>Row> { ... }
export async function cancel<Entity>(id: string, reason: BilingualReason): Promise<<Entity>Row> { ... }
Hard rules:
- Every write goes through
pickPayload. No rawupsert(row). - Every write logs payload + status on failure (
console.error('[<module>] <op> failed', {...})). - Every read prefers view; every write goes to base table.
BilingualReason = { th: string; en: string }.
10. Workflow JSON Contract — every state machine declared
Per docs/pathology/README.md reuse pattern, every workflow JSON at web/packages/medical-kit/src/medical-worklist/defaults/<module>-<flow>-workflow.json:
{
"name": "Pathology Laboratory v1",
"dept_type": "pathology_laboratory",
"version": 1,
"nodes": [
{
"id": "received",
"type": "queue_node",
"label": { "th": "รับสิ่งส่งตรวจ", "en": "Received" },
"policy_trigger": "pathology.specimen.accept"
},
...
],
"edges": [
{ "from": "received", "to": "accessioned", "trigger_label": { "th": "รับเข้า", "en": "Accept" } },
...
]
}
Hard rules:
- Node IDs match the status enum values from §2 (lower_snake_case).
- Every edge declares its trigger label bilingually.
- Every node declares its
policy_triggerif a policy gate gates the next transition. - The JSON IS the seed for
workflow_templates— no separate definition.
11. Locale Contract — bilingual labels everywhere
Every UI string registered in 4 locale files (per stage 05 §1.6):
web/src/locales/{en,th}/<module>.json
web/public/locales/{en,th}/<module>.json
If shipping for a non-Thai market, also:
web/src/locales/{ja,fil}/<module>.json
web/public/locales/{ja,fil}/<module>.json
Hard rules:
- Every key has a value in EVERY supported locale; missing values block stage 05 done predicate.
- Keys are namespaced
<module>.<feature>.<element>. - Pluralization uses i18next-standard
_one,_othersuffixes.
12. How to extend the contracts
Adding a new field to a contract is fine. Two rules:
- Append, never rename. Add
field_v2, deprecatefield_v1with a@deprecatedcomment, eventually delete after every consumer migrates. - Optional first. New fields are
?:(TypeScript) andNULL-able (SQL) until every consumer is updated.
Adding a new contract entirely (e.g. Contract):
- Add the interface to
packages/platform-api-schema/src/_contracts/ - Document it in this file (new section)
- Add adoption rules to whichever stage prompt is most relevant
- Update
00-MASTER.md§1 if the contract should be a hard rule
13. Contract compliance check (the loop’s lint)
A small script the loop can run to verify a module obeys the contracts:
# In docs/module-loop/scripts/contract-check.sh (sketch)
MODULE=$1
echo "Checking module $MODULE for contract compliance..."
# §1: every entity in platform-api-schema extends EntityContract
grep -L "extends EntityContract" packages/platform-api-schema/src/*/$MODULE/*.ts && echo "FAIL: missing extends EntityContract"
# §3: every event name follows <domain>.<entity>.<verb> dot-notation past tense
grep -E "ctx\.broker\.emit\(" services -r | grep -vE "'[a-z_]+\.[a-z_]+\.[a-z_]+ed'" && echo "FAIL: non-conforming event name"
# §4: every clinical table has a sibling audit-log table
# (compare CREATE TABLE list against _audit_log list)
# §9: every write goes through pickPayload helper
grep -rEn "supabase\.from\([^)]+\)\.upsert\(" web/src/services/medbase/$MODULE.medbase.ts | grep -v pickPayload && echo "FAIL: raw upsert"
Stage 06’s done predicate runs this. Failures = stage 06 not done.
14. Reference: existing contracts already in the repo
These are sources of truth — if you change a contract, update them too:
| Contract | Source file |
|---|---|
| EntityContract | (proposed) packages/platform-api-schema/src/_contracts/entity.contract.ts — does not exist yet; first module loop run that needs it creates it |
| Status enum convention | mirrored across packages/platform-api-schema/src/*/status.ts files; see bloodRequestStatus.ts, bloodTestStatus.ts, appointmentStatus.ts |
| Moleculer event contract | services/*/src/api/*/services/<svc>.service.ts — the events: blocks are the de-facto schema; codify here |
| Audit log pattern | <entity>AuditLog modules across services (e.g. bloodDonorAuditLog, bloodRequestAuditLog, imagingRequestAuditLog) |
| Policy gates | docs/architecture/policy-gates.md + policy_gates Supabase table |
| Queue contract | docs/architecture/encounter-orchestrator-triggers.md + department_queues table |
| Acknowledgement contract | docs/architecture/acknowledgement-system.md + AcknowledgementRequest entity |
| Cron job contract | docs/architecture/cron-jobs-registry.md + cron_jobs table |
| Service-layer pattern | web/src/services/medbase/bloodBank.medbase.ts (107 functions, the canonical example) |
| Workflow JSON | web/packages/medical-kit/src/medical-worklist/defaults/*-workflow.json |
If a contract drifts (different modules use different shapes), it’s a stage-03 design failure — surface and fix; don’t accumulate divergence.