Section A · Core entities

Schema Overview

The six core entities, the full SQL schema, and the rationale behind every load-bearing design decision.

The six core entities

EntityWhat it representsGrain
customersTenant boundary; the platform's customer (the buyer of the platform)One row per platform customer
suppliersVendors / counterparties in the customer's contractsOne row per canonical supplier (post-resolution)
documentsPhysical files: PDFs, DOCXs, scansOne row per file
agreementsLogical contracts (an MSA + amendments + SOWs = one agreement)One row per logical contract
obligationsExtracted commitments per agreementOne row per (agreement, obligation, extraction)
spendInvoice / PO actualsOne row per spend line item

Plus three supporting entities:

  • document_agreement_links: M:N table connecting documents to agreements with role (master / amendment / sow / exhibit).
  • extractions: per-document extraction records with model_version, fields, confidence, provenance.
  • supplier_name_resolutions: raw-supplier-name → canonical-supplier-id mappings with method and confidence.

Full SQL schema

The complete schema in PostgreSQL dialect. Adapt to Snowflake / BigQuery as needed; the shapes don't change.

schema.sql
-- Customers (tenant boundary)
CREATE TABLE customers (
  customer_id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Suppliers (canonical entities; post-resolution)
CREATE TABLE suppliers (
  supplier_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers,
  canonical_name TEXT NOT NULL,
  parent_supplier_id TEXT REFERENCES suppliers,
  country TEXT,
  tax_id TEXT,
  industry TEXT,
  first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (customer_id, canonical_name)
);
CREATE INDEX idx_suppliers_customer ON suppliers (customer_id);
CREATE INDEX idx_suppliers_parent ON suppliers (parent_supplier_id);

-- Documents (physical files)
CREATE TABLE documents (
  document_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers,
  source_uri TEXT NOT NULL,
  file_hash TEXT NOT NULL,
  doc_class TEXT NOT NULL,
  page_count INT NOT NULL,
  ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (customer_id, file_hash)
);
CREATE INDEX idx_documents_customer_class ON documents (customer_id, doc_class);
CREATE INDEX idx_documents_ingested ON documents (ingested_at);

-- Agreements (logical contracts)
CREATE TABLE agreements (
  agreement_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers,
  supplier_id TEXT NOT NULL REFERENCES suppliers,
  agreement_type TEXT NOT NULL,
  effective_date DATE,
  current_term_end DATE,
  parent_agreement_id TEXT REFERENCES agreements,
  status TEXT NOT NULL DEFAULT 'active',
  status_changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_agreements_supplier ON agreements (supplier_id);
CREATE INDEX idx_agreements_term_end ON agreements (current_term_end);
CREATE INDEX idx_agreements_status ON agreements (customer_id, status);

-- Document ↔ Agreement linkage (M:N)
CREATE TABLE document_agreement_links (
  document_id TEXT NOT NULL REFERENCES documents,
  agreement_id TEXT NOT NULL REFERENCES agreements,
  role TEXT NOT NULL,  -- master | amendment | sow | exhibit
  PRIMARY KEY (document_id, agreement_id)
);
CREATE INDEX idx_dal_agreement ON document_agreement_links (agreement_id);

-- Obligations (extracted commitments per agreement)
CREATE TABLE obligations (
  obligation_id TEXT PRIMARY KEY,
  agreement_id TEXT NOT NULL REFERENCES agreements,
  obligation_type TEXT NOT NULL,
  party_obligated TEXT,
  value JSONB,
  currency TEXT,
  due_at DATE,
  status TEXT NOT NULL DEFAULT 'pending',
  confidence FLOAT,
  extracted_from_document_id TEXT REFERENCES documents,
  model_version TEXT NOT NULL,
  extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_obligations_agreement ON obligations (agreement_id);
CREATE INDEX idx_obligations_due ON obligations (due_at);

-- Spend (invoice / PO actuals; sometimes linked to agreements)
CREATE TABLE spend (
  spend_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers,
  supplier_id TEXT NOT NULL REFERENCES suppliers,
  agreement_id TEXT REFERENCES agreements,
  invoice_id TEXT,
  amount NUMERIC(15, 2) NOT NULL,
  currency TEXT NOT NULL,
  accrued_at DATE NOT NULL,
  spend_category TEXT
);
CREATE INDEX idx_spend_supplier ON spend (supplier_id);
CREATE INDEX idx_spend_agreement ON spend (agreement_id);
CREATE INDEX idx_spend_accrued ON spend (accrued_at);

-- Extractions (versioned per-document outputs)
CREATE TABLE extractions (
  extraction_id TEXT PRIMARY KEY,
  document_id TEXT NOT NULL REFERENCES documents,
  customer_id TEXT NOT NULL REFERENCES customers,
  model_version TEXT NOT NULL,
  pipeline_version TEXT NOT NULL,
  fields JSONB NOT NULL,
  qa JSONB,
  extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (document_id, model_version)
);
CREATE INDEX idx_extractions_doc_version ON extractions (document_id, model_version);

-- Supplier name resolutions (raw extracted names → canonical)
CREATE TABLE supplier_name_resolutions (
  resolution_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers,
  raw_supplier_name TEXT NOT NULL,
  resolved_supplier_id TEXT NOT NULL REFERENCES suppliers,
  resolution_method TEXT NOT NULL,  -- exact_tax_id | fuzzy_name | customer_map | manual
  confidence FLOAT NOT NULL,
  resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  resolved_by TEXT NOT NULL  -- model_version or analyst_id
);
CREATE INDEX idx_snr_raw ON supplier_name_resolutions (customer_id, raw_supplier_name);

Design decisions, explained

Why separate documents and agreements

A signed MSA, two amendments, and three SOWs are four physical documents but one logical contract. Most analytical queries ("how much spend with this supplier under their current MSA") operate on agreements. Most debugging ("why is the renewal date wrong on this contract") operates on documents. Separating them lets both reads be efficient.

Why parent_supplier_id on suppliers

Acme Cloud is a subsidiary of Acme Holdings. Spend rollups want to consolidate at the parent. The recursive parent reference enables rollup queries (see chapter 07) without separate hierarchy tables.

Why file_hash uniqueness on documents

Re-ingest must be idempotent. document_id is derived from file_hash; the same file re-uploaded gets the same ID. Without this, every re-ingestion would create duplicate documents.

Why (document_id, model_version) uniqueness on extractions

Each model version produces its own extraction record for each document. Older extractions stay for audit. The composite unique constraint ensures a single extraction record per (document, model_version) — re-running the same model is idempotent.

Why obligations are per-extraction, not per-agreement

When a new model version re-extracts a document, the obligations it finds are new rows. Old obligations stay for audit. The "current" obligations for an agreement is the set extracted by the latest model version — surfaced via a mart, not by overwriting.

Why supplier_name_resolutions as a separate table

Resolution is a separable concern from extraction. If you change the resolution algorithm or fix a wrong merge, you can re-resolve without re-extracting. The audit trail of "this raw name resolved to this canonical supplier via this method at this time" lives in this table.

Why value JSONB on obligations

Obligations have heterogeneous shapes — a renewal-notice obligation has {auto_renew, term, notice_period}; a payment obligation has {amount, currency, due_date}. A JSONB column accommodates both without proliferating sparse columns. The cost: queries on specific obligation fields need JSON-path expressions. The benefit: schema flexibility.

Why fields JSONB on extractions

Similar reasoning. Each doc class extracts different fields. JSONB stores them all; marts (chapter 09) materialize per-class typed columns for query convenience.

What this schema is not

Not a CLM workflow database

If you need to model drafting, approval routing, e-signature, redlining — that's CLM territory. This schema treats the signed document as input; it doesn't track contract creation workflow.

Not a full procurement system

Spend is captured as a fact table for cross-referencing with agreements. Procurement workflow (3-way match exceptions, PO approval, receiver matching) belongs in the customer's ERP / S2P system.

Not vendor master / supplier risk in depth

The suppliers table captures enough to do rollups and resolution. Deep supplier risk modeling (financial health, sanctions screening, ESG scores) typically lives in a dedicated supplier-risk product, joined to suppliers via supplier_id.

Not legal review / clause library

Clause-by-clause comparison ("this MSA's indemnification differs from our standard") is a related but separate problem. Could be modeled on top of this schema with an additional clauses table; included in production platforms but not in this reference.

Not multi-tenant in itself

customer_id is on every row, making single-tenant-per-row possible. True multi-tenant deployment requires row-level security, separate schemas per tenant, or other isolation. This is application-layer concern.