LegalPresence — System Architecture v1.9

Revised 2026-07-09 · Host: lp1 (65.109.87.173) · Single-tenant working baseline

LegalPresence is a litigation-support platform: a public site, an authenticated CRM + AI legal workspace, an encrypted secrets vault, and an OTOC-gated reasoning engine over a case system of record. This document describes the revised three-tier architecture and the routing that implements it.

Three tiers

Public tier public

legalpresence.com · www.legalpresence.com — the marketing / product site (static) and the /api/signup capture endpoint. Currently gated behind HTTP Basic auth (realm "LegalPresence — Restricted", via Cloudflare) as a pre-launch private site; open it publicly at launch. CDN-friendly; WAF/rate-limit at the edge.

Application tier authenticated

crm.legalpresence.com — the Twenty CRM (system of record) and the Legal Studio (AI evidence workspace, RAG, document viewer with ontology concept markup). Auth-gated. This is where the legal work happens.

Infrastructure tier internal only

SecureVault (credentials), PostgreSQL ×3, Redis, NATS messaging, and the reasoning engine. Not publicly routed — reachable only from the application tier. Reasoning outputs land in a staging schema and promote to the CRM only on counsel approval.

Routing map

HostnamePathBackendRole
legalpresence.com/static /var/www/htmlMarketing site
legalpresence.com/api/signup:8462Signup capture
crm.legalpresence.com/graphql /metadata /rest /auth /filesTwenty CRM :3360System of record
crm.legalpresence.com/studio /ai /queryLegal Studio :3361AI evidence workspace
(internal)vault API / adminSecureVault :8480 / :3380Secrets — internal only

Component diagram

flowchart TB
  V(["Visitors"]):::actor --> PUB["PUBLIC TIER — legalpresence.com
static site · /api/signup · pre-launch gated"]:::pub C(["Counsel"]):::actor --> CRM subgraph APPT["APPLICATION TIER · crm.legalpresence.com"] direction LR CRM["Twenty CRM :3360
system of record"]:::app STU["Legal Studio :3361
RAG · concept markup"]:::app end subgraph INF["INFRASTRUCTURE TIER · internal only"] direction LR VLT["SecureVault"]:::infra DBS[("Postgres ×3 · Redis")]:::infra NTS["NATS"]:::infra RSN["Reasoning engine"]:::infra STG["lp_reasoning_staging
PENDING"]:::infra end STU --> CRM CRM --> DBS STU --> VLT RSN --> STG STG -->|promote on counsel approval| CRM APPT --> INF classDef actor fill:#eef1f6,stroke:#5c6577,color:#141b2e; classDef pub fill:#e7f2ec,stroke:#2e7d5b,color:#141b2e; classDef app fill:#e8eefa,stroke:#1f3a5f,color:#141b2e; classDef infra fill:#fbecef,stroke:#b23a3a,color:#141b2e;

The reasoning stack — from document markup to reasoning object

The tiers above show where things run. This shows how one fact moves through the stack — from a marked-up sentence in an evidence document to a stability-gated reasoning object the rules engine can score. Every layer is live on lp1; the snippets below are real (trimmed) — the same worked example end to end.

flowchart LR
  DOC["① Document markup
Legal Studio concept tags"]:::app CRM2["② CRM record
Twenty — system of record"]:::app SKOS["③ SKOS vocabulary
controlled terms + crosswalk"]:::infra RSN2["⑤ Reasoning object
OTOC-scored RDF assertion"]:::infra SHACL["④ SHACL shapes
pySHACL admissibility gate"]:::infra OBS["⑥ Obsidian vault
graph view for humans"]:::pub DOC --> CRM2 CRM2 -->|project_rdf · project_assertions| RSN2 SKOS -.governs vocabulary.-> CRM2 SHACL -.validates on ingest.-> RSN2 RSN2 -->|export_obsidian_full.py| OBS classDef app fill:#e8eefa,stroke:#1f3a5f,color:#141b2e; classDef infra fill:#fbecef,stroke:#b23a3a,color:#141b2e; classDef pub fill:#e7f2ec,stroke:#2e7d5b,color:#141b2e;

1Document markup Legal Studio · application tier

A sentence in an evidence document is tagged with the ontology concepts it touches, and linked to the evidence artifact and the legal element it bears on. 1,250 of 1,483 documents are concept-tagged.

<span class="lp-concept"
      data-evidence="id/evidence/22b1e037…"
      data-element="id/element/2bab1625…">
  Counsel for the Plaintiffs stated their engineer and prior counsel
  inspected the unit and found it compliant with the Certificate of Occupancy.
</span>

2CRM record — the system of record Twenty · application tier

The tag becomes a factAssertion row in Twenty — the single source of truth. Nothing downstream is hand-maintained; it all projects from here. Born UNREVIEWED (the counsel gate) and unscored until the OTOC gate runs.

factAssertion {
  factText:               "…inspected the unit and found it compliant with the C of O.",
  supportedByEvidenceRef: "id/evidence/22b1e037…",
  supportsElementRef:     "id/element/2bab1625…",
  assertionStrength:      "INDIRECT",
  assertionReviewStatus:  "UNREVIEWED",   // counsel gate — never auto-approved
  stabilityStatus:        "STABLE",       // written by the OTOC gate (step 5)
  otocScore:              0.0,
  llmGenerated:           "YES"
}

3SKOS vocabulary — controlled terms + crosswalk vocabularies.ttl · infrastructure

Every enum is a skos:Concept in a ConceptScheme; drifted CRM values map to the canonical concept with skos:closeMatchnon-destructive (records keep their current value; the graph gains interoperable terminology). Roles are matter-scoped: the same person is PLAINTIFF in one index and DEFENDANT in another.

lpc:PartyRoleScheme a skos:ConceptScheme .
lpc:PLAINTIFF a skos:Concept ; skos:inScheme lpc:PartyRoleScheme ;
    skos:prefLabel "Plaintiff" ;
    skos:scopeNote "Trial-level action; party bringing suit." .
# a drifted CRM enum value maps to the canonical concept — record untouched
crm:"Claimant"  skos:closeMatch  lpc:PLAINTIFF .
# the same party = PLAINTIFF in one index, DEFENDANT in a related action

4SHACL shapes — the admissibility gate shapes.ttl · pySHACL · infrastructure

Hard integrity shapes run by rules.py layer 1. A node that violates them cannot promote from graph:ingest to graph:reviewed. Email evidence must carry a real DKIM verdict — not left blank, not NOT_APPLICABLE.

lp:EvidenceArtifactShape a sh:NodeShape ;
  sh:targetClass lp:EvidenceArtifact ;
  sh:property [ sh:path lp:contentHash ; sh:minCount 1 ;
      sh:message "Content hash required for integrity + admissibility." ] ;
  sh:property [ sh:path lp:counselReviewStatus ; sh:minCount 1 ;
      sh:in ( "UNREVIEWED" "IN_REVIEW" "APPROVED" "REJECTED" "NEEDS_SUPPLEMENT" ) ] ;
  sh:property [ sh:path lp:dkimStatus ;
      sh:in ( "PASS" "FAIL" "NONE" "NOT_APPLICABLE" ) ] .

5Reasoning object — OTOC-scored RDF assertion assertions-graph.ttl · infrastructure

The projected RDF the rules/inference engines reason over. The OTOC gate re-extracts under K perturbations: otocScore = (K − reproduced)/K. Only STABLE assertions count toward element readiness — and only after counsel approval.

<id/assertion/00232d7c…> a lp:Assertion ;
    rdfs:label "…inspected the unit and found it compliant with the C of O." ;
    lp:supportedByEvidence  <id/evidence/22b1e037…> ;
    lp:supportsLegalElement <id/element/2bab1625…> ;
    lp:hasStrength "INDIRECT" ;
    lp:assertionReviewStatus "UNREVIEWED" ;
    lp:otocScore 0.0 ;
    lp:stabilityStatus "STABLE" .   # survived K perturbations → counts toward readiness

The gate in action: the opposing prior-sworn statement "all mechanical equipment … installed at the penthouse roof" scored otocScore 1.0QUARANTINED (perturbation-fragile) and is excluded from readiness. Same pipeline, opposite verdict — the hallucination filter doing its job, auditable rather than silent.

What ties the layers together: the CRM is the source; project_rdf.py / project_assertions.py emit the RDF; shapes.ttl (pySHACL) is the hard admissibility gate; vocabularies.ttl (SKOS) is the shared, matter-scoped vocabulary the CRM enums crosswalk to; otoc_score.py stamps stability; rules.py scores element readiness counting only STABLE + counsel-approved assertions. Reasoning stays a one-way derived layer — it never writes back to the record except through the counsel-gated staging promotion.

Obsidian as the graph view — and where it stops

export_obsidian_full.py regenerates an Obsidian vault from the live RDF graph — one markdown note per Matter / EvidenceArtifact / Assertion / LegalElement / StrategyCandidate, with every relation rendered as an Obsidian [[wikilink]]. Obsidian's built-in graph view then becomes the mapper — no bespoke graph-rendering UI to build or maintain. It is a pure regeneration from the graph (never hand-edited; overwritten on every run), so it can't drift from the system of record.

LayerDoes Obsidian add value?
Metadata / instancesYES Browse parties, evidence and assertions as linked notes; the graph view surfaces clusters and orphans a table can't. Effectively free — the exporter already exists.
SKOS vocabularyYES skos:broader/narrower/related become graph edges, so a concept scheme turns into a navigable map — and it's a genuinely good surface for authoring the vocabulary as linked notes before it's promoted to vocabularies.ttl.
SHACL validationNO (as a gate) Obsidian does not enforce shapes — pySHACL does. It can display a violation if we write it into the note, but it is a viewer, never the validator.
System of record / runtimeNO It is a regenerated, read-mostly human surface. The CRM stays the source of truth; the RDF + SHACL + OTOC pipeline stays the runtime.

Verdict: keep Obsidian as a generated read / explore / author layer over the same RDF — high value for humans navigating the metadata and the SKOS concept schemes, near-zero cost since it's a pure export. It does not replace SHACL (validation) or the CRM (storage); treat any hand-edit to the vault as disposable, because the next regeneration overwrites it.

What the vault looks like

Three real views, generated from the current graph (1,646 notes: 886 assertions · 725 evidence · 25 elements · 5 matters · 4 strategies). The graph below is the Factor-2 (Irreparable Harm) cluster; the note is the actual element note; both are pure regenerations from the RDF.

◐ Graph view — Factor-2 cluster (Obsidian dark theme)
Engineer (P.E.) Architect (AIA) NON_FUNCTIONAL NON_COMPLIANT · R-410A BOILER_PRIMARY_HEAT boiler = primary heat temp roof membrane expired "sole source" (quarantined) Factor 2 Irreparable Harm Vacate the PI element strategy evidence assertion · STABLE assertion · QUARANTINED
type: LegalElement · weight: 2.0
W.T. Grant Factor 2 — Irreparable Harm (Evaporated)
Element of [[Vacate the Preliminary Injunction (CPLR §5015(a)(3) + W.T. Grant factors negated)]].
Support: 0 approved / 99 candidate assertions.
## Supporting assertions
[[[NON_FUNCTIONAL] The bulkhead Daikin VRF unit is completely non-functional, leaking refrig…]]
[[[BOILER_PRIMARY_HEAT] Unit 1 has a gas boiler in the cellar boiler room that provides…]]
[[[NON_COMPLIANT] The rooftop unit utilizes an outdated R-410A refrigeration system that is…]]
[[[STATED_UNDER_OATH] Witness: the rooftop unit is an AC unit, not a heating unit…]]
[[The temporary roof membrane installed December 2023 with a 12-month service life is…]]
… + 94 more (each links back to its source evidence note)
LegalPresence-Obsidian-Vault/
├── Litigation Map.md          # index — every matter, element, strategy
├── Matters/                   (5)
├── Legal Elements/            (25)
├── Evidence/                  (725)
├── Assertions/                (886)
└── Strategies/                (4)

Regenerate with export_obsidian_full.py after any batch; open the folder as a vault and Obsidian's own graph view renders the above live — pan from an element to its evidence to its assertions in one canvas. Same QUARANTINED nodes the rules engine excludes are visible (greyed) rather than hidden.

Rules engine — the deterministic spine (gate · derive · score · motion viability)

The rules engine (rules.py + inference_engine.py + shapes.ttl) is the non-probabilistic core. The LLM handles detection, extraction and natural-language framing; the rules engine alone does anything that carries legal weight. The contract is "LLM proposes, rules dispose" — nothing touching admissibility, readiness, motion viability, or a court filing is decided by model output. OTOC sits between them as the stability filter.

RoleWhat it doesWhere
GateSHACL admissibility shapes — content-hash, date-provenance, DKIM verdict, counsel-review status must be present, or the node can't promote ingest → reviewed.shapes.ttl · pySHACL · layer 1
DeriveDeterministic entailments — authentication (DKIM/court-record), party attribution + provenance tier, authored-by edges. Never asked of the model.inference_engine.py
ScoreElement readiness — only STABLE + counsel-approved assertions add weight; gated-out items surfaced, never silently dropped.rules.py · layer 3
ConcludePer-motion viability as an elements-test (below), with contradiction discount + defensive exposure.motion_viability.py

Motion viability — the pre-hearing strategy layer

Each motion is encoded as its legal test, not a soft weight sum: OR motions succeed on any one sufficient ground (PI-vacatur — a negated W.T. Grant factor or CPLR 5015(a)(3) fraud); AND motions need every element (contempt — order & knowledge & mandate & violation & prejudice & proof). Two new pieces feed it: project_contradictions.py projects the contradiction records into RDF (lp:contradictsAssertion / lp:impeachedBy), and rules.py discounts any approved support impeached by a STABLE+approved contradiction — so an argument can't rest on a fact the other side can destroy. The engine reports, per motion: bringable-now / potential / gaps / defensive exposure / next best move.

◐ motion_viability.py — sample readout (role labels only)
MOTION VIABILITY — deterministic elements-test
nothing counsel-approved yet → "bringable NOW" is False everywhere by design

### PI_VACATUR   [OR-test · CPLR 6314 / 5015(a)(3)]   🟡 POTENTIAL
    Any ONE sufficient ground (a negated W.T. Grant factor OR 5015(a)(3) fraud) supports modify/vacate.
    elements: 0 approved · 4 candidate · 4 gap  (of 8)
    GAPS (obtain STABLE evidence):
        ✗ W.T. Grant Factor 4 — Public Interest
        ✗ CPLR 6312(b) — Undertaking / bond defect
        ✗ CPLR 6314 — Lift-condition
    DEFENSIVE EXPOSURE (STABLE contradictions hitting our own support):
        ⚠  1× DIRECT           · risk=UNCLEAN_HANDS
        ⚠  1× IMPEACHMENT      · risk=ESTOPPEL
        ⚠  9× DIRECT           · risk=CREDIBILITY
    NEXT: APPROVE the strongest ground « CPLR 5015(a)(3) — Fraud » (30 candidates, w3)

### CONTEMPT   [AND-test · Judiciary Law §753]   🟡 POTENTIAL
    Conjunctive — ALL six elements required. 6 candidate · 0 gap → fully satisfiable on approval.
    ⚠ 2 supporting assertion(s) impeached by a STABLE contradiction (candidate exposure)
    NEXT: APPROVE 23 candidate(s) for « Violation of the Mandate »

The boundary: the engine gives the evidence-readiness substrate — which motion is bringable, on what, with what gaps and what exposure — and flags when a plan rests on impeached or inadmissible support. It does not make the strategic calls (which vehicle to lead with, fight vs. settle, tone, sequencing to the judge). Those stay with counsel; capability is not authority.

Theory layer — contradictions made operational

One level above motion viability sits the theory layer (theory_engine.py): affirmative legal theories — credibility impeachment, changed-position estoppel, unclean hands as sword, bad-faith injunction posture, undertaking defect, changed-circumstances vacatur — each scored on a transparent composite of grounding & stability (share of OTOC-STABLE support), element coverage (how much of the legal test has evidence), declared leverage (strategic payoff), and inverted risk (structural penalties like reargument exposure, plus impeached-support fraction). A stable contradiction is no longer a flag: it is routed by declared selectors into the theories it arms, deduplicated on the assertion pair so a twice-detected inconsistency counts once.

Nothing is hardcoded. The theory registry, contradiction selectors, leverage multipliers, risk penalties, risk narratives and the composite weights themselves are all declarative data in theories.ttl — the engine is a generic evaluator. A new theory, a recalibrated weight, or an entirely new case’s theory set is an ontology edit, never a code change. This is also the learning hook: closed cases with known outcomes can promote de-identified theory-outcome observations through the cross-case privilege firewall, and the calibrated priors return as counsel-reviewed edits to the same data file.

Design principles

Hardening roadmap

PriorityItem
DONEVault is IP-allowlisted (deny all except localhost + admin IPs) — no public access. Add remaining admin IPs (co-counsel / client / Berlin) when known.
BY DESIGNSite is intentionally restricted (HTTP Basic auth, realm "LegalPresence — Restricted") — a private pre-launch gate. Remove the gate when launching publicly.
SOONResolve multi-tenancy: commit to flat <case>.legalpresence.com (with the login-config fix) or remove the dormant wildcard and run single-workspace.
SOONSplit databases + vault to a second host — lp1 is currently a single point of failure for a live matter.
DONEReasoning-engine staging boundary (lp_reasoning_staging + review/promote) live.

Audit-readiness gap register

Honest self-assessment against a third-party audit (technical · evidentiary · security · governance). The design is sound as an investigative / work-product engine; the gaps below are what a litigation-grade reasoning engine additionally requires before any court output depends on it. Severity: P0 audit-blocking · P1 must-fix · P2 hardening.

IDSevFindingRemediationOwnerStatus
A-01P0 No measured error rate (Daubert/Frye). No eval harness — validate.py checks SHACL structure only; extraction, OTOC and the contradiction detector have no precision/recall or false-positive rate, and the contradiction detector is known to emit false positives. Build a gold-standard labelled sample; measure precision/recall/FP per component; document the method. Keep AI strictly investigative work-product, never the evidence itself. Eng + CounselPARTIAL
A-02P0 Audit log not tamper-evident. decisions.ttl is a flat append with no hash-chain, signing, or external anchor. Per-file SHA-256 seals cover evidence content, not the decision trail — alterations to the reasoning record are undetectable. Hash-chain DecisionRecords (prevHash), sign, RFC-3161 timestamp, anchor a daily Merkle root. EngPARTIAL
A-03P0 Single point of failure + unverified backup of the system of record. CRM DB, vault DB, rules DB, vault API, engine and site all on one host; only ad-hoc manual DB dumps exist and the DR job pushes source, not Postgres data. Untested backup = no backup. Scheduled pg_dump of the CRM + vault DBs offsite; a tested restore drill; a second host / HA. Security/OpsPARTIAL
B-01P1 OTOC measures reproducibility, not truth. Token-overlap stability (no embeddings); grounding is a shallow quote-in-OCR match. A STABLE assertion can still misread its source. Add a citation-faithfulness / entailment verifier that the cited passage actually supports the assertion. EngPARTIAL
B-02P1 Authentication tiered only for email. Non-email evidence (chat exports, PDFs, scans) has no authentication-basis or custodian-affidavit linkage; the court-ready rule recognises only DKIM / court-record. Add a general authenticationBasis field + custodian-affidavit evidence type; extend the court-ready gate. Eng + CounselPARTIAL
B-03P1 No privilege / work-product segregation or litigation hold. The graph holds work product and potential admissions against interest (the engine labels own-side statements QUARANTINED / flags unclean-hands) — discoverable and damaging if not privileged. Document a privilege + discoverability strategy; mark reasoning artifacts work-product; access-control; counsel sign-off; litigation hold. CounselOPEN
B-04P1 No per-record model / prompt pinning. Extraction model is an env var; the exact model version + prompt hash aren't stamped per assertion, so an extraction can't be deterministically replayed or explained at trial. Stamp model-id + prompt-hash + params + timestamp on every assertion and decision record. EngPARTIAL · BUILT fwd
B-05P1 Prompt-injection exposure. Opposing-party-produced documents are fed straight into extraction prompts; a crafted document could steer output. No documented input isolation. Treat document text as untrusted (delimit/sandbox); add injection regression tests. EngOPEN
B-06P1 Hand-assigned element weights. Readiness % and motion viability depend on subjective weights with no rationale or sensitivity analysis. Document weight rationale; run sensitivity analysis; ideally derive from legal-authority mapping. Eng + CounselOPEN
B-07P1 PII / cross-border data protection. Party PII, medical mentions and settlement communications sit in the graph and the exported vault; an EU party implicates GDPR. No retention/deletion/DPA posture; at-rest DB encryption + key rotation undocumented. Data-protection policy (retention, deletion, GDPR basis); encrypt DBs at rest; document KMS + rotation; minimise PII in the Obsidian export. Security/Ops + CounselOPEN
C-01P2 Corpus completeness not quantified. Reasons over a partial, mostly-unreviewed corpus with no blind-spot metric. Coverage metric + explicit accounting of the un-reasoned set. EngOPEN
C-02P2 Approval not role-restricted / non-repudiable. APPROVED is an unrestricted status; not proven to be gated to a licensed-attorney account or immutably attributed (who/when). RBAC restricting approval to attorney accounts; immutable approval log. Eng + CounselOPEN
C-03P2 No court-presentable provenance report. DecisionRecords are machine JSON; no plain-English "why is this counted / where did it come from" output for a judge or opposing counsel. Human-readable provenance report generator per assertion / bundle. EngOPEN
C-04P2 OTOC thresholds unvalidated. TAU_LOW/TAU_HIGH/K chosen, not validated for sensitivity. Sensitivity analysis over thresholds against the gold set (depends on A-01). EngPLANNED
C-05P2 Extractors are partisan by construction. Prompts mine one side's facts (legitimate advocacy) — must not be represented to an auditor as neutral fact-finding. Label extraction outputs as advocacy work-product in the schema and any audit representation. Eng + CounselOPEN
C-06P2 No separation of duties. Builder and validator are the same; ADRs exist but no independent review / SDLC gate. Independent validation + code review before reliance; document the SDLC. Eng + CounselOPEN

Mitigating controls already in place (credit at audit): counsel gate + court-ready rule, OTOC hallucination filter with visible quarantine, single-source projection + CI drift-check, SHACL admissibility gate, DKIM provenance tiering, per-file SHA-256 content seal, append-only decision records, no-mail-without-approval, and party-name anonymization on all public documentation. The register is scored against those — the P0s are the audit-blocking remainder.

P0 + B-04 remediation — built & evidenced (2026-07-09)

Standards this register maps to

StandardRelevance
FRE 702 (reliable methods; proponent shows admissibility "more likely than not")A-01 measured error rate; C-04 threshold validation
FRE 901 (authentication — item is what it's claimed to be)B-02 authentication basis; admissibility tiering
RFC 3161 (trusted timestamp protocol)A-02 external anchoring of the decision ledger
W3C PROV-O (entity/activity/agent provenance)B-04 per-record reproducibility manifest
OWASP LLM01 (prompt injection)B-05 hostile-document input isolation
NIST SP 800-92 / 800-53 (log management; audit & accountability controls)C-02 immutable approval/access logs

Target-state definitions (to remove ambiguity for auditors)

Assertion lifecycle states (strict): CANDIDATEREVIEWEDCOUNSEL_APPROVEDADMISSIBLE, plus terminal IMPEACHED / EXCLUDED. Only COUNSEL_APPROVED+ADMISSIBLE, non-IMPEACHED support may feed a motion score.

Admissibility tierMeaning
A1Authenticated original / certified record / admissible business record
A2Strong source, authentication pending
B1Email/message with metadata; DKIM/header/custodian proof pending
B2Screenshot / copied text, weak authentication
CInvestigative lead only — not filing-ready
XPrivileged, excluded, impeached or unreliable
Motion labelStrict meaning
NOT_READYOne or more mandatory elements has no approved support
POTENTIALElements have candidate support, but not enough approved / authenticated / admissible
BRINGABLE_NOWEvery required element has attorney-approved, authenticated, admissible, non-impeached support
HIGH_RISK_BRINGABLELegally supportable but exposes major counterattack (unclean hands / estoppel / waiver / credibility / sanctions)

How to describe the system to an auditor

"An attorney-supervised litigation-intelligence platform. It does not autonomously determine legal truth or make filing decisions. It extracts candidate assertions, maps them to legal elements, detects possible contradictions, identifies evidentiary gaps, and generates motion-readiness diagnostics. Court-facing reliance requires human counsel approval, authenticated source evidence, admissibility review, and immutable provenance." — safer than "AI legal reasoning engine."

Architecture decisions

ADR-001 · CRM platform — Twenty vs. Supabase Accepted · 2026-07-09

Decision: stay on Twenty for the proof-of-concept. Supabase is the leading candidate for the eventual product backend; re-evaluate at the PoC→product transition, and never migrate the system of record while a motion is in flight.

Context. LegalPresence runs on Twenty (open-source CRM) as the system of record — 46 objects, ~1,700 fields, a GraphQL API, with the Legal Studio and the reasoning engine layered on top. A live matter (the vacatur) is in flight. Twenty has produced friction this cycle: a multi-workspace login break, workspace-auth failures, a metadata/enum desync, a 60-row pagination cap, and GraphQL/metadata-API overhead. Much of the stack (ontology mapping, reasoning staging) is already raw Postgres.

Options considered.

OptionGainsCosts
A · Twenty (status quo)Full CRM UI out of the box; dynamic object/field definition — the data model evolves with no-code and no SQL migrations (well-suited to a still-forming schema); team knows it; the live matter stays put.Generic sales-CRM UX shoehorned into litigation; metadata layer + its quirks; GraphQL friction.
B · SupabaseUnified Postgres (ontology/reasoning stop being side-schemas); standard auth + RLS (per-matter isolation done right); PostgREST/GraphQL auto-API; already self-hosted elsewhere; removes every Twenty-specific trap.No application UI — must build the front end; a data-access refactor (GraphQL→SQL); migration effort.

The UX case (the decisive dimension). Twenty's native UX is deals / pipelines / kanban — the wrong vocabulary for counsel, who work in evidence, exhibits, elements, admissions, and filings. A purpose-built litigation UX (matter dashboard, evidence workspace, review queues, motion workspace, testimony digests) is materially better for the actual users — and is the product LegalPresence would need regardless. That UX cannot be Twenty's; it argues for a custom front end on a clean backend (Supabase) if and when LegalPresence becomes a product.

Rationale for staying (PoC). (1) Never re-platform the system of record while a motion is in flight — case-data risk outweighs any cleanliness gain. (2) For a proof-of-concept, Twenty's out-of-box UI is good enough to validate the evidence/reasoning workflow. (3) The migration is mechanical (data is already Postgres) and the refactor simplifies code — so deferring costs little and preserves optionality. (4) The move only pays off once the product question is answered — a post-PoC decision. (5) Twenty's metadata-driven object/field definition is complementary to a PoC whose schema is still evolving — you add and reshape objects and fields with no-code and no migrations, where Supabase would require a SQL migration per schema change.

Consequences. Continue hardening Twenty (fix the metadata desync, keep single-workspace, staging gate live). The ontology + reasoning side-schemas stay Postgres-native — deliberately portable, so a future move is low-friction. No new Supabase dependency now.

Revisit trigger + spike (deferred). When LegalPresence moves from PoC to product (multi-matter, external users, a real counsel UX), run a one-day spike: stand up the core objects (matter, evidenceItem, assertion, contradiction) as clean Supabase tables, copy a data sample, and repoint one Studio screen (the Review Queue) at Supabase — testing "does the code simplify" and "does a purpose-built screen feel right," zero risk to the case. Do the full cutover only after the current matter's filings are complete.

Revision history

v1.9 — 2026-07-12 — Added the theory layer: theory_engine.py scores affirmative legal theories (contradiction-derived swords and element-anchored grounds) on a four-dimension composite — grounding/stability, element coverage, declared leverage, inverted risk. Every theory, selector, weight and risk penalty is DATA in theories.ttl (no rules hardcoded in the engine); duplicate contradiction pairs are collapsed before counting. Feeds the command-center theory table and the ranked counsel worklist.
v1.8 — 2026-07-09 — Built B-04 reproducibility manifest: model/prompt-hash/extractor-version/source-hash/gen-params on every assertion (PROV-O-style), wired into all extractors (forward-path proven), 913/913 backfilled, projected into RDF.
v1.7 — 2026-07-09 — Added the Audit-readiness gap register (P0/P1/P2, severity·finding·remediation·owner·status), standards mapping (FRE 702/901, RFC 3161, PROV-O, OWASP LLM01, NIST 800-92/53), target-state definitions (assertion states, admissibility tiers, strict motion labels), and the auditor-positioning statement. P0 remediations (ledger hash-chain, tested DB backup, eval-metric baseline) built in core — see status column.
v1.6 — 2026-07-09 — Added "Rules engine — the deterministic spine": the gate/derive/score/conclude roles and the new motion-viability layer (per-motion elements-test AND/OR, contradiction projection + readiness discount, defensive-exposure readout). Built in core: project_contradictions.py, motion_viability.py, rules.py v2026-07-09.4.
v1.5 — 2026-07-09 — Added "What the vault looks like" — three Obsidian sample views (graph-view SVG of the Irreparable-Harm cluster, the rendered element note, the vault tree). Anonymized all worked examples to role labels (Plaintiff / Defendant / Engineer (P.E.) / Architect (AIA) / Witness) — no party names on the page.
v1.4 — 2026-07-09 — Added "The reasoning stack" worked example (document markup → CRM record → SKOS vocabulary → SHACL shape → OTOC-scored reasoning object, with real trimmed snippets) and an Obsidian-value section (graph view over the RDF via export_obsidian_full.py; adds value for metadata/SKOS, not a substitute for SHACL validation or the CRM).
v1.3 — 2026-07-09 — Added ADR-001 (CRM platform — Twenty vs. Supabase): decision to stay on Twenty for the PoC; Supabase deferred to the product transition; noted Twenty's dynamic object/field definition as a complementary strength.
v1.2 — 2026-07-09 — Correction: vault is already IP-allowlisted (deny-all except admin IPs), not publicly routed — hardening item marked done.
v1.1 — 2026-07-09 — Component diagram rendered in Mermaid; public-tier gate documented as by-design (pre-launch Basic auth).
v1.0 — 2026-07-09 — Initial revised three-tier architecture (public / app / infra); staging & promotion boundary for the reasoning engine documented; hardening roadmap (vault-unpublish first).

Enlarged screenshot