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.

ConcernMongoDB todayPostgres proposed
Duplicate mattersPeriodic cleanup commandUnique index — impossible
Cause-list matchingApp-side loops over collectionsIndexed join
Raw court payloadNative documentsJSONB column
Subscription billingNo transactionsACID
Orphaned recordsNo referential integrityForeign keys
Ops burdenSelf-hosted, single nodeManaged, 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

EntityPurposeOld equivalent
COURTRegistry: name, jurisdiction, connector id, base URL, confighigh_courts
CASE_TYPEPer-court case-type codes and labelsembedded in high_courts
HEARINGAppend-only history of hearing dates and stagesoverwritten fields on matter
ORDER_DOCOrders and judgments, with object-store pointermatters_judgement
CAUSELISTDaily published list per court, tagged tentative or finalcauselist_history
CHANGE_EVENTWhat changed, when — the notification triggerimplicit, none
NOTIFICATIONDelivery record per channel, with statusnotification_logs
CONNECTOR_RUNEvery poll: duration, outcome, cost attributionnone
MATTER_NOTEPrivate, per-user note against a matternone
ORG_COURTCourts/tribunals an organisation practises in — independent of which matters exist yetimplicit "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.

PriorityColumnSet byCan it be shown as fact?
1 — highestnext_hearing_overrideAdvocate, manuallyYes — it is their own case
2next_hearingCourt connector, parsedYes — the authoritative record
3 — lowestnext_hearing_suggestedAI extraction from an orderNever — 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.

EntityPurpose
CAPTCHA_ATTEMPTImage, prediction, confidence, court acceptance. A successful attempt is a free verified label.
CAPTCHA_MODELVersioned per-court model: artifact URI, charset, test accuracy, live accuracy, rollback target.
CAUSELIST_TEMPLATEVersioned per-court layout: column bands, row rules, validation patterns.
PARSE_ATTEMPTDocument, template version, entries produced, validation outcome.
RESEARCH_ITEMDeduplicated 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

EntityPurpose
CLIENTThe 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_idAssignment 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.
INVOICEBilled to one client, optionally itemised against specific matters. Status: draft, sent, paid, overdue.
INVOICE_LINE_ITEMDescription, quantity or hours, rate, amount. Referencing a matter is optional — retainer lines don't.
CASE_DOCUMENTPersonal 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

EntityPurpose
DISPLAY_BOARDA named board config: which courts/tribunals, which assignees or teams, refresh interval, layout.
BOARD_DEVICEA 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.