medOS ultra

Backend Contracts

Extensible shared shapes every module must produce: EntityContract, status enum, event payload, audit log, policy-gate row, queue placement.

12 min read diagramsUpdated 2026-05-02docs/module-loop/BACKEND-CONTRACTS.md

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 (createdBy vs created_by vs userRef vs requester)
  • 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 have user_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:

  1. Values are lower_snake_case. Never UPPER_CASE, camelCase, or human strings ("Pending Review").
  2. Two universal sentinel values: cancelled and rejected. If your entity supports either, use these strings — never voided, aborted, denied, discarded, etc.
  3. Every status enum has a paired state machine declared in stage 03 §2 — never a status enum without a state machine.
  4. 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:

  1. Event name format: <domain>.<entity>.<past-tense-verb>pathology.specimen.accessioned, NOT pathology.specimen.accession or accessionPathologySpecimen.
  2. Past tense — events describe what happened, not what should happen.
  3. Always include tenant_id + event_id for orchestrator dedupe.
  4. data is module-specific; document its shape in stage 03’s data-model doc.
  5. 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:

  1. Every clinical entity (orders, requests, encounters, results, signatures, dispensations, reactions) gets an audit table.
  2. Trigger fires before the change so we capture before and after atomically.
  3. Audit log is APPEND-ONLY. No update, no delete (except retention sweeps, which are themselves audited).
  4. Stage 04’s done predicate verifies presence of _audit_log for 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:

  1. Trigger names match event names (so pathology.specimen.cancel gates the cancel action; pathology.specimen.cancelled is the event after the gate passes).
  2. Condition is a JSON-serialisable predicate evaluated by usePolicyGate (frontend) and the backend guard service (defense in depth).
  3. Reason is bilingual — UI shows reason[locale].
  4. 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:

  1. Every fan-out target from stage 03 §7 maps to a dept_type. Use existing dept_type values where possible — don’t fork the namespace.
  2. Queue insertions happen in the orchestrator handler, not directly from the frontend (frontend writes to entity, entity emits event, orchestrator projects to queue).
  3. QueueManagementFloater (per docs/architecture/queue-management-floater.md) auto-renders any dept_type. New dept_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:

  1. Every write goes through pickPayload. No raw upsert(row).
  2. Every write logs payload + status on failure (console.error('[<module>] <op> failed', {...})).
  3. Every read prefers view; every write goes to base table.
  4. 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:

  1. Node IDs match the status enum values from §2 (lower_snake_case).
  2. Every edge declares its trigger label bilingually.
  3. Every node declares its policy_trigger if a policy gate gates the next transition.
  4. 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:

  1. Every key has a value in EVERY supported locale; missing values block stage 05 done predicate.
  2. Keys are namespaced <module>.<feature>.<element>.
  3. Pluralization uses i18next-standard _one, _other suffixes.

12. How to extend the contracts

Adding a new field to a contract is fine. Two rules:

  1. Append, never rename. Add field_v2, deprecate field_v1 with a @deprecated comment, eventually delete after every consumer migrates.
  2. Optional first. New fields are ?: (TypeScript) and NULL-able (SQL) until every consumer is updated.

Adding a new contract entirely (e.g. Contract):

  1. Add the interface to packages/platform-api-schema/src/_contracts/
  2. Document it in this file (new section)
  3. Add adoption rules to whichever stage prompt is most relevant
  4. 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.

Ask Anything