Section 02
Domain Model
The entities, their relationships, and the single most consequential change from the old system: moving off MongoDB.
Entity relationships
erDiagram
ORGANISATION ||--o{ MEMBERSHIP : has
ORGANISATION ||--|| SUBSCRIPTION : "is billed by"
ORGANISATION ||--o{ MATTER : owns
USER ||--o{ MEMBERSHIP : "belongs via"
USER ||--o{ DEVICE : "registers for push"
MATTER }o--o| USER : "assigned to"
COURT ||--o{ MATTER : "is heard in"
COURT ||--o{ CASE_TYPE : defines
COURT ||--o{ CAUSELIST : publishes
MATTER ||--o{ HEARING : "has history of"
MATTER ||--o{ ORDER_DOC : accumulates
MATTER ||--o{ CHANGE_EVENT : emits
CAUSELIST ||--o{ CAUSELIST_ENTRY : contains
CAUSELIST_ENTRY }o--o| MATTER : matches
CHANGE_EVENT ||--o{ NOTIFICATION : triggers
COURT ||--o{ CONNECTOR_RUN : "polled by"
MATTER ||--o{ CONNECTOR_RUN : "refreshed by"
MATTER ||--o{ MATTER_NOTE : accumulates
ORGANISATION ||--o{ ORG_COURT : "practises in"
ORG_COURT }o--|| COURT : references
Fig 2.1 — Core domain entities
Matters are owned by an organisation and optionally assigned to a user. An individual advocate is an organisation with one member — see Tenancy, Roles & Plans for why that is one model rather than two, and for the roles, plan limits, and isolation rules built on it.
The central entity
MATTER is the heart of the system — it was referenced 1,501 times in the old codebase, more than three times any other collection. A matter is one court case that one user has asked Clerkify to watch.
Identity
The old system composes a natural key and it is a good one:
unique_id = "{court.unique_id}-{case_type}/{case_number}/{case_year}"
# e.g. "AllahabadHC-WRITA/12345/2024"
Keep this rule. The change is that it becomes a database-enforced unique constraint on (user_id, court_id, case_type, case_number, case_year) rather than a string assembled in application code.
Why Postgres, not MongoDB
The old system ships a maintenance command called remove-duplicate-matters. That command is the entire argument. Duplicate matters are not a data-entry problem to be swept up periodically — they are the predictable result of a natural key that no database enforces. A unique index makes the bug structurally impossible, and deletes the command.
Beyond that: cause-list matching is a join, subscription billing wants real transactions, and the reporting surface is relational. The one thing Mongo was genuinely good for here — storing whatever shape a court site returns — is covered by a JSONB column holding the raw parse output alongside the normalised fields.
| Concern | MongoDB today | Postgres proposed |
|---|---|---|
| Duplicate matters | Periodic cleanup command | Unique index — impossible |
| Cause-list matching | App-side loops over collections | Indexed join |
| Raw court payload | Native documents | JSONB column |
| Subscription billing | No transactions | ACID |
| Orphaned records | No referential integrity | Foreign keys |
| Ops burden | Self-hosted, single node | Managed, replicated |
Cost of being wrong: low. If a genuinely document-shaped workload appears later, JSONB absorbs it. The reverse migration — bolting integrity onto Mongo after the fact — is what the old system tried and failed to do.
Matter lifecycle
stateDiagram-v2
[*] --> Pending: user adds case
Pending --> Active: first poll succeeds
Pending --> Invalid: not found at court
Active --> Active: poll, no change
Active --> Updated: poll detects change
Updated --> Active: notification sent
Active --> Disposed: court marks disposed
Active --> Stale: N consecutive poll failures
Stale --> Active: poll recovers
Stale --> Investigate: connector flagged broken
Invalid --> Pending: user corrects details
Disposed --> [*]
Fig 2.2 — Matter states
Stale is the state the old system lacks, and its absence is the core reliability gap. Today a matter that stops updating because a court changed its markup is indistinguishable from a matter where nothing is happening. Making staleness explicit is what converts a silent failure into an alert.
Supporting entities
| Entity | Purpose | Old equivalent |
|---|---|---|
COURT | Registry: name, jurisdiction, connector id, base URL, config | high_courts |
CASE_TYPE | Per-court case-type codes and labels | embedded in high_courts |
HEARING | Append-only history of hearing dates and stages | overwritten fields on matter |
ORDER_DOC | Orders and judgments, with object-store pointer | matters_judgement |
CAUSELIST | Daily published list per court, tagged tentative or final | causelist_history |
CHANGE_EVENT | What changed, when — the notification trigger | implicit, none |
NOTIFICATION | Delivery record per channel, with status | notification_logs |
CONNECTOR_RUN | Every poll: duration, outcome, cost attribution | none |
MATTER_NOTE | Private, per-user note against a matter | none |
ORG_COURT | Courts/tribunals an organisation practises in — independent of which matters exist yet | implicit "main court" on user profile |
HEARING, CHANGE_EVENT, and CONNECTOR_RUN are new. The first two make history and notification auditable — "why did I get this alert?" is currently unanswerable. The third is what makes cost per poll measurable, which the economic constraint makes non-optional.
Why ORG_COURT exists separately from MATTER
A matter's court_id tells you where one case is heard. It does not tell you which courts and tribunals an organisation practises in before they have added a case there — and onboarding, the display board, and multi-court cause-list reports all need that answer. An advocate who covers Patna High Court, the Patna DRT, and the district eCourts complex is one organisation with a set of practice courts, not three accounts.
CREATE TABLE org_court (
org_id BIGINT NOT NULL REFERENCES organisation(id) ON DELETE CASCADE,
court_id TEXT NOT NULL REFERENCES court(id),
is_primary BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (org_id, court_id)
);
Adding a matter at a court not yet in org_court adds it automatically — the table is a convenience index over practice scope, never a gate on which cases can be tracked. Courts are never plan-limited, and this table must not become a backdoor way to limit them.
Hearing date precedence
Three possible sources for one date. They are never merged into one column.
A matter's next-hearing date can come from three places: the court's own structured field, an AI extraction from an order (Flow 5), or the advocate typing in a date they know is correct — because they were told in the corridor, or the portal has not caught up yet. Each is stored in its own column, and the user override always wins when present.
| Priority | Column | Set by | Can it be shown as fact? |
|---|---|---|---|
| 1 — highest | next_hearing_override | Advocate, manually | Yes — it is their own case |
| 2 | next_hearing | Court connector, parsed | Yes — the authoritative record |
| 3 — lowest | next_hearing_suggested | AI extraction from an order | Never — always a suggestion pending confirmation |
Confirming an AI suggestion writes it into next_hearing_override, the same column a manual entry uses — from the system's point of view a confirmed AI date and a typed date are indistinguishable, because both are the advocate's own assertion. Only an unconfirmed suggestion is second-class.
The override is never silently cleared. If the court's own field later disagrees with a standing override, the matter surfaces a reconciliation prompt rather than picking a side — see Core Flows for the precedence flow and the conflict case.
Learning-loop entities
The captcha and document parsing pipelines share one pattern — a cheap deterministic path, captured failures, AI-assisted research, and a versioned artifact promoted back into the fast path. They therefore share entity shapes.
| Entity | Purpose |
|---|---|
CAPTCHA_ATTEMPT | Image, prediction, confidence, court acceptance. A successful attempt is a free verified label. |
CAPTCHA_MODEL | Versioned per-court model: artifact URI, charset, test accuracy, live accuracy, rollback target. |
CAUSELIST_TEMPLATE | Versioned per-court layout: column bands, row rules, validation patterns. |
PARSE_ATTEMPT | Document, template version, entries produced, validation outcome. |
RESEARCH_ITEM | Deduplicated novel failure awaiting AI or human labelling. Shared queue for both pipelines. |
Both artifact types — models and templates — follow identical lifecycle rules: version, shadow-evaluate against the incumbent, promote, retain the predecessor for instant rollback. Building that machinery once and using it twice is the reason these live in one section of the model rather than two.
Tenancy entities
ORGANISATION, MEMBERSHIP, ROLE, TEAM, PLAN, and AUDIT_EVENT are specified in Tenancy, Roles & Plans. The constraint that matters here: every tenant-scoped table carries org_id and is protected by row-level security, so a missing scope check in application code yields an empty result rather than another tenant's cases.
Practice tools entities v2.0
Client management, invoicing, and the personal document vault are specified here so the v1 schema does not have to be reshaped to fit them later — see the out-of-scope note and Roadmap for why they ship after v1 rather than in it.
erDiagram
ORGANISATION ||--o{ CLIENT : has
CLIENT ||--o{ MATTER : "matters belong to"
CLIENT ||--o{ INVOICE : "billed by"
INVOICE ||--o{ INVOICE_LINE_ITEM : contains
INVOICE_LINE_ITEM }o--o| MATTER : "may reference"
MATTER ||--o{ CASE_DOCUMENT : accumulates
Fig 2.3 — Client, billing, and document entities
| Entity | Purpose |
|---|---|
CLIENT | The advocate's or firm's own client — name, contact, GSTIN. Distinct from MEMBERSHIP: a client has no login by default and never counts against a seat. |
MATTER.client_id | Assignment of a case to a client, separate from MATTER.assigned_to (which staff member works it). A matter can have a client and no assignee, an assignee and no client, or both. |
INVOICE | Billed to one client, optionally itemised against specific matters. Status: draft, sent, paid, overdue. |
INVOICE_LINE_ITEM | Description, quantity or hours, rate, amount. Referencing a matter is optional — retainer lines don't. |
CASE_DOCUMENT | Personal documents against a matter — briefs, evidence, correspondence — kept distinct from ORDER_DOC, which is the court's own record and must never be edited or deleted by a user. |
Keep CASE_DOCUMENT and ORDER_DOC structurally separate
An order downloaded from the court is evidence of what the court did; it must be immutable and independently reproducible. A personal document is the user's own file. Collapsing them into one table with a "source" flag is the kind of shortcut that eventually lets a user's edit or deletion touch the court record. Two tables, two permission models, no shared mutation path.
Display board entities v2.0
Specified alongside the Live Display Board module. A board is a read-only, unauthenticated-by-design surface — see that document for why pairing rather than login is the right access model for a screen in a waiting area.
erDiagram
ORGANISATION ||--o{ DISPLAY_BOARD : configures
DISPLAY_BOARD ||--o{ BOARD_DEVICE : "paired to"
DISPLAY_BOARD }o--o{ ORG_COURT : "scoped to"
Fig 2.4 — Display board entities
| Entity | Purpose |
|---|---|
DISPLAY_BOARD | A named board config: which courts/tribunals, which assignees or teams, refresh interval, layout. |
BOARD_DEVICE | A paired screen — pairing code, last check-in, org scope. No user session; revoking the device is the only "logout". |
Indicative schema
Illustrative rather than final — the constraints are the point, not the column list.
CREATE TABLE matter (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
court_id TEXT NOT NULL REFERENCES court(id),
case_type TEXT NOT NULL,
case_number TEXT NOT NULL,
case_year SMALLINT NOT NULL,
status matter_status NOT NULL DEFAULT 'pending',
petitioner TEXT,
respondent TEXT,
stage TEXT,
cnr TEXT,
-- three columns, never merged — see "Hearing date precedence" above
next_hearing DATE, -- from the court connector, authoritative
next_hearing_override DATE, -- advocate-entered or AI-confirmed; wins when set
next_hearing_suggested DATE, -- unconfirmed AI extraction; display-only, never authoritative
assigned_to BIGINT REFERENCES app_user(id),
client_id BIGINT REFERENCES client(id), -- v2.0
raw JSONB, -- whatever the court returned
last_polled_at TIMESTAMPTZ,
last_changed_at TIMESTAMPTZ,
consecutive_failures SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- the constraint that deletes remove-duplicate-matters
CONSTRAINT matter_identity UNIQUE
(user_id, court_id, case_type, case_number, case_year)
);
CREATE INDEX matter_due_poll ON matter (court_id, last_polled_at)
WHERE status IN ('active', 'stale');
CREATE INDEX matter_next_hearing ON matter (user_id, COALESCE(next_hearing_override, next_hearing))
WHERE COALESCE(next_hearing_override, next_hearing) IS NOT NULL;
The partial index on matter_due_poll is what lets the scheduler select the day's work cheaply — see Core Flows, Fig 6.2. The index on matter_next_hearing is built on COALESCE deliberately: every read path that answers "when is this next heard" — Today, the board, the cause-list report — must apply the same precedence order, not re-derive it per screen.