Stance
Model as durable contracts, not final storage
This page translates the product schema into Postgres-shaped structures so the team can review identity, lifecycle, causality, and explainability. It intentionally avoids storage-engine commitments like partitioning strategy, index tuning, and service boundaries.
Schema namespaces
core
Cases, relationships, decision packets, governed actions, projections.
gov
Governance candidates, review, activation, policy evaluations.
runtime
Runtime artifacts, artifact versions, artifact reviews.
knowledge
The epistemic spine: signals, sources, claims, justifications, validation, authority, conflicts, corrections, and world-model truth.
ledger
Event and projection envelopes.
identity
People, users, teams, roles. Sketched only where needed.
Reading the field tables
In table view every field carries a purpose line and an origin marker — who or what sets its value. Fields I'd question for MVP also get a simplification flag.
Field origin
MVP simplification flag
Research
Prior art & design constraints
We pressure-tested this model against published research and production systems before building deeper. The shape is validated — the closest production analog (Zep / Graphiti) is nearly 1:1, and 2026 agent-architecture papers independently converged on the same spine-plus-projections design. But several failure modes are well documented; these are the constraints we retain to design against them.
abstain / not_enough_info verdict (from FEVER fact-checking).
Constraints we design against
Evidence must be causal, verified at freeze
Up to 57% of LLM citations are "post-rationalized" — the claim is written first, then a source found to fit. A citation can be correct yet not actually support the claim, and freezing makes that error permanent. Provenance proves where, not whether the source was true.
Our rule: generation is constrained by evidence; run an entailment/faithfulness check before a bundle is frozen; record a trust signal per source, not just a pointer. arXiv 2412.18004
Bitemporal — invalidate, don't delete
Flagged independently by three of four research threads. The "evidence frozen / truth live" split is the database world's transaction-time vs valid-time distinction — a solved, performant pattern. Contradicted facts are invalidated, not deleted.
Our rule: every assertion carries valid_from/valid_to (world time) separate from observed_at/superseded_at (system time); current truth re-projects from the latest non-invalidated facts. Zep / Graphiti, XTDB
Authority is a prior, not hard precedence
Rule-based "system X always wins" is the MDM golden-record trap — it doesn't scale and is context-blind (who owns the VPN differs for billing vs ops vs security). Knowledge Vault learned per-source reliability and fused it with a plausibility prior instead.
Our rule: authority_rules are a declared prior; evidence + freshness can override; human corrections feed back as training labels (the review loop is free supervised data). Knowledge Vault, KDD 2014; MDM survivorship
Identity merges must be reversible
The CDP industry's most expensive failure: once profiles merge, they can't be unmerged (Segment). Transitive-closure clustering over-merges — one bad alias link collapses unrelated people into one identity.
Our rule: merged_into_person_id is a retractable, evidence-backed bitemporal edge, not a destructive fold; cluster don't transitively close; require deterministic identifiers (or human confirmation) before a stitch can authorize an action. Segment / CDP identity graphs
No live justification network (the TMS grave)
Truth Maintenance Systems died at scale: keeping a global dependency graph consistent on every update is combinatorially expensive. Our snapshot design already dodges this — keep it that way.
Our rule: bundles are foundationalist — a bounded, acyclic, de-duped set of cited sources. Re-validating a retracted source re-derives; it never naively restores old conclusions (AGM "Recovery" is contested). Doyle / de Kleer TMS; AGM belief revision
Corroboration ≠ independence
"Many sources agree" inflates confidence when sources copy each other (a CMDB auto-filling a wiki). On real data, sophisticated trust-weighting did worse than plain voting when a copying cluster propagated a false value.
Our rule: track source independence; discount correlated sources; calibrate confidence rather than multiplying it. Dong, Berti-Équille, Srivastava, VLDB 2009
Staleness is the #1 failure; ingest is an attack surface
"Entity decay" — a once-true value served confidently after it changed — is the dominant accuracy failure. Slack/email ingest is a documented memory-poisoning surface; one bad reflection scales across a long-lived agent.
Our rule: confidence decays with freshness; contradiction-check new claims at write time; bound how far one observation propagates before review. Mem0; memory-poisoning research
Replay-safe side effects
If events trigger outbound calls, projection rebuild/replay re-fires them — re-sending Slack messages, re-executing governed actions. This is the classic agent-product footgun.
Our rule: every outbound effect (Slack post, ticket write, governed action) sits behind a port that is inert during replay. Fowler, Event Sourcing
Erasure vs the immutable PII log
The spine holds people's identities and message content; an append-only log structurally conflicts with right-to-erasure.
Our rule: decide crypto-shredding vs PII-by-reference before finalizing event payloads; rebuild logic must tolerate "forgotten" events. GDPR + event-sourcing practice
Don't event-source everything
A single universal log is the riskiest framing, and "property-sourcing" (events that mirror columns) carries no value.
Our rule: spine for business-meaningful facts (observations, claims, corrections, actions, approvals); CRUD for config/reference; model business operations, not field writes; treat full projection-rebuild time as the scaling canary. Young; Dudycz
Key sources
- Zep / Graphiti — temporal KG for agent memory (closest production analog) · arxiv.org/abs/2501.13956
- Knowledge Vault — web-scale probabilistic knowledge fusion · Dong et al., KDD 2014
- Truth discovery survey — Li et al., SIGKDD Explorations 2015 · kdd.org
- Citation faithfulness — 57% post-rationalized citations · arXiv 2412.18004
- FEVER — claim + evidence → verdict pipeline · Thorne et al., NAACL 2018 · arXiv 1803.05355
- TMS / ATMS — Doyle 1979, de Kleer 1986 · AGM belief revision — Gärdenfors
- Bitemporality — XTDB · W3C PROV — w3.org/TR/prov-dm
- Identity resolution / no-unmerge — Segment / Twilio
- Event sourcing — Fowler · Greg Young (versioning) · Dudycz
Research
Signal selection & the knowledge layer
Everything is a signal — external (Slack / email / tickets) and internal (governed actions, case-state changes, decisions). The hard question is which signals become durable knowledge and which we drop — the field's currently-recognized bottleneck (the "write step"). We researched how mature systems condense high-volume streams without keeping everything; the proven recipe is strikingly consistent across agent memory, stream processing, observability sampling, and salience research.
Constraints we design against
Separate retention, fidelity, and attention
Sampling conflates three different decisions. SIEM risk-based alerting separates them: whether to keep a signal at all (retention), at what resolution (fidelity), and whether it surfaces to a person (attention).
Our rule: decide each independently, by signal class — not one blanket sample rate. Splunk risk-based alerting
Don't sample by default — agents have no intuition for missing data
For agent products, naive sampling and rollups are actively harmful: "missing data = broken reasoning," and rollups that drop entity dimensions (case-id, user) leave the agent at a dead end.
Our rule: internal signals (actions, case changes, decisions) kept 100% structured, never sampled; external signals kept as reference + digest + redacted summary long-term, raw body only 7–30d for replay then hashed. Expiry driven by privacy/compliance, not cost. ClickHouse "three villains"; OpenTelemetry; GDPR minimization
Two-gate selection + an offline reflection pass
The proven stream-to-record pattern: a worth gate, then a redundancy gate, with periodic reflection. Score worth by belief-change (Bayesian surprise — "how much does this move our posterior"), not raw novelty and not a single LLM importance score (LLMs are systematically overconfident).
Our rule: worth (composite, calibrated) → redundancy (ADD / UPDATE / NOOP / SUPERSEDE) → offline reflection that synthesizes low-salience signals. Reflection is the only defense against dropping the slow-burn that mattered. Generative Agents; Mem0; Itti & Baldi (Bayesian surprise)
No single global similarity threshold — merge on a band
A single cosine cutoff for "is this the same thing" is the most-cited fragility in incremental knowledge-base construction.
Our rule: use a band — high-confidence auto-merge, low-confidence auto-add, a middle "adjudicate" zone routed to review; key merges on event identity (timestamp, case-id, actor), not phrasing, so distinct-but-similar events never collapse. iText2KG; DIAL-KG
Supersede, never hard-delete
Given LLM miscalibration, deletion is the irreversible mistake — the dropped signal that later mattered can't be recovered.
Our rule: every drop/merge is a reversible supersede with bitemporal validity, not a destructive delete (reinforces the bitemporal constraint above). Zep / Graphiti; Mem0
"Closed" is not immutable
Late or out-of-order signals reopen a "closed" Source; over/under-segmentation is the central quality failure of every grouping system.
Our rule: make merge / split / retract first-class operations; give each Source a grace period and a bounded max-age (unbounded accumulation state is the #1 stream-processing failure). Kafka / Flink session windows; CEP
Protect rare-but-critical and logical anomalies
Rarity should raise worth; frequency-based dedup punishes exactly the rare signal. And "interesting" must include a bad decision that returned success, not just error-labeled events.
Our rule: never reduce errors, governed-action denials, or anomalies; novelty raises a signal's worth rather than lowering it. tail-based sampling; log-reduction research
Boundaries are the highest-risk items
Prediction error peaks at boundaries — the handoff message ("by the way, different issue…") is the one most likely to be mis-assigned to the wrong Source.
Our rule: treat the first and last signals of a Source as ambiguous / bridging; allow them to belong to two Sources. Event Segmentation Theory (Zacks)
Ingested content is an attack surface; the write path is silent
External Slack/email content can poison memory (the payoff is time-decoupled — it waits), and memory failures throw no exceptions — bad selection propagates as silent reasoning drift.
Our rule: never let ingested content mint durable governed-truth unvalidated; support quarantine/rollback by source cohort; don't iteratively re-summarize (drift erodes incident detail — keep evidence, point to it); instrument write-accept-rate, contradiction-at-write, and post-drop "regret." AgentPoison; agent-memory surveys
Key sources
- Generative Agents — importance scoring + reflection · Park et al., UIST 2023
- Mem0 — the write step (ADD/UPDATE/DELETE/NOOP) · arXiv 2504.19413 · Memory-R1 (learned ops > heuristics) · arXiv 2508.19828
- Bayesian Surprise — belief-change beats raw novelty · Itti & Baldi, NIPS 2006
- Event Segmentation Theory — boundaries at prediction-error spikes · Zacks; comp. model Reynolds/Zacks/Braver 2007
- Session windows / Dataflow — Akidau et al. · Bayesian Online Changepoint Detection · Adams & MacKay 2007
- Tail-based sampling — OpenTelemetry · Refinery dynamic sampling — Honeycomb
- "Three villains of agentic observability" — ClickHouse · Risk-based alerting — Splunk ES
- Incremental KG commit — iText2KG · data-protection-by-design — EDPB Guidelines 4/2019
Types
State vocabulary, embedded where used
The first simplification is to stop treating every lifecycle enum as a Case status. case_status and resolution_state are the durable lifecycle axes on Case. A single derived ownership court (agent vs waiting) replaces the old automation_state and human_needed_state; the precise actor and all deadlines live on the decision packet and in core.case_timers. stale_state describes projection freshness. The remaining statuses belong to Decision Packet, Governed Action, Governance Candidate, and Runtime Artifact.
In table view, enum definitions appear as expandable chips beside the fields that use them. SQL view keeps the raw create type statements.
create schema if not exists core;
create schema if not exists gov;
create schema if not exists runtime;
create schema if not exists knowledge;
create schema if not exists ledger;
create schema if not exists identity;
create type core.case_status as enum (
'open',
'resolved',
'closed',
'archived'
);
create type core.resolution_state as enum (
'unresolved',
'resolved',
'duplicate',
'withdrawn',
'merged'
);
create type core.ownership_state as enum (
'agent',
'waiting'
);
create type core.escalation_action as enum (
'extend',
'nudge',
'advance',
'reassign',
'broaden',
'fail'
);
create type core.stale_state as enum (
'fresh',
'possibly_stale',
'stale',
'regenerating',
'superseded'
);
create type core.decision_packet_status as enum (
'active',
'answered',
'expired',
'stale',
'superseded',
'cancelled'
);
create type core.governed_action_status as enum (
'draft',
'pending_policy',
'needs_approval',
'needs_expert_review',
'blocked',
'approved',
'executing',
'succeeded',
'failed',
'partially_failed',
'cancelled',
'superseded'
);
create type gov.candidate_review_status as enum (
'draft',
'submitted',
'under_review',
'needs_changes',
'approved',
'denied',
'stale',
'superseded',
'archived'
);
create type gov.candidate_activation_status as enum (
'not_applicable',
'not_activated',
'scheduled',
'activated',
'rolled_back',
'superseded'
);
create type runtime.artifact_status as enum (
'draft',
'under_review',
'approved',
'active',
'paused',
'rolled_back',
'superseded',
'archived'
);
create type knowledge.signal_origin as enum (
'external',
'internal'
);
create type knowledge.signal_disposition as enum (
'dropped',
'buffered',
'kept'
);
create type knowledge.source_status as enum (
'open',
'closed',
'merged',
'split',
'superseded'
);
create type knowledge.claim_status as enum (
'candidate',
'active',
'superseded',
'refuted'
);
create type knowledge.validation_verdict as enum (
'supported',
'refuted',
'conflict',
'not_enough_info',
'needs_review'
);
Core
Case structures
core.cases is a projection, not a mutable row — a materialized read model built from the case event stream. Only the seed is fixed (case_id, tenant_id, case_number, created_at — set at CaseCreated, the FK anchor for everything else); every operational field (title, status, ownership, assignment, domain, entities) is projected from events and rebuildable. The write path appends business events — CaseAssigned, OwnershipChanged, StatusChanged, EntitiesExtracted — and this row is reprojected from them.
UPDATEd, and rebuildable from the stream. No version (optimistic concurrency doesn't apply) and no staleness metadata — the row always reflects the latest events. Freshness / stale_state lives on the async generated projections (summaries, world-model facts), which can lag — not here.
create table core.cases (
case_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_number text not null,
title text, -- LLM-generated label, human-overridable
owning_team_id uuid not null,
department_id uuid,
assigned_user_id uuid,
agent_active boolean not null default false,
requester_ref jsonb, -- raw source actor: { source_system, external_id, display_name, email }
requester_id uuid references identity.people(person_id), -- resolved canonical person, nullable
watcher_ids uuid[] not null default '{}',
case_status core.case_status not null default 'open',
resolution_state core.resolution_state not null default 'unresolved',
ownership core.ownership_state generated always as (
case when agent_active then 'agent'::core.ownership_state
else 'waiting'::core.ownership_state end
) stored,
domain text,
subdomain text,
primary_entity_ids text[] not null default '{}',
affected_user_ids uuid[] not null default '{}',
affected_system_ids text[] not null default '{}',
customer_refs jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(), -- seed: set at CaseCreated, immutable
updated_at timestamptz not null default now(), -- time of the latest event applied; always current
unique (tenant_id, case_number)
);
create table core.case_external_refs (
case_external_ref_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
ref_kind text not null, -- channel, thread, file, source_object, external_ticket
source_system text not null,
external_id text not null,
external_url text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (tenant_id, source_system, external_id)
);
create table core.case_relationships (
relationship_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
from_case_id uuid not null references core.cases(case_id),
to_case_id uuid not null references core.cases(case_id),
relationship_type text not null, -- related, duplicate_candidate, parent, child, merged_into
status text not null default 'active',
reason text,
created_by uuid,
created_at timestamptz not null default now(),
superseded_by uuid,
check (from_case_id <> to_case_id)
);
create table core.case_timers (
timer_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
subject_ref jsonb, -- what it watches: the case, a decision packet, a governed action
kind text not null, -- processing_watchdog, sla, ask_expiry, follow_up
fire_at timestamptz not null, -- indexed; the scheduler scans this
escalate_to jsonb, -- where the ball goes when it fires: court / actor / team
on_fire core.escalation_action not null default 'nudge', -- default rung; policy may override
policy_ref uuid, -- escalation-ladder policy, re-evaluated on fire
status text not null default 'pending', -- pending, fired, cancelled, superseded
created_at timestamptz not null default now(),
fired_at timestamptz
);
Lifecycle
Case lifecycle diagram
case_status is just lifecycle — active → finished. Everything operational (processing, waiting, blocked) is the court (ownership), not a status. The governing invariant: a case is never static — it is either the agent's (processing), or it has an open ask waiting on a human, or it is finished. "Blocked" is never bare; it always means an open ask aimed at someone, and a deadline that will move it if no one answers.
Case status — lifecycle only
stateDiagram-v2
[*] --> Open: case created
Open --> Resolved: outcome reached
Resolved --> Closed: retention / audit checkpoint
Closed --> Archived: no longer active work
Resolved --> Open: reopen on material change
Closed --> Open: reopen by authorized user
note right of Open
Open = active. Processing vs waiting is the
court (ownership), not a status. Blocked is
never bare: it is always an open ask + a timer.
end note
Court and escalation ladder
flowchart LR Created([case created]) --> Agent Agent[Agent processing
ownership = agent] -->|resolved| Finished([resolved / closed]) Agent -->|cannot proceed: raise ask| Waiting[Waiting on a human
ownership = waiting] Waiting -->|answer received| Agent Waiting -->|human resolves| Finished Agent -. arms .-> Timer{{case_timers
watchdog / sla / ask_expiry}} Waiting -. arms .-> Timer Timer -. fires, runs policy .-> Ladder[Escalation ladder
nudge → advance → reassign → broaden → fail] Ladder -. moves the court .-> Waiting
Projections
Generated case projections
Generated objects need source events, generator version, stale state, and evidence refs. The frontend should query these instead of reconstructing case meaning from raw events.
create table core.case_summaries (
case_summary_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
summary_version integer not null,
brief text not null,
current_status_text text,
attention_reason text, -- why the case needs attention right now (SLA approaching, new reply, blocker cleared)
next_action jsonb, -- suggested next action(s); an object, may carry several ranked suggestions + governed-action refs
source_event_ids uuid[] not null default '{}',
generator_version text not null,
evidence_refs jsonb not null default '[]'::jsonb,
hidden_evidence_state jsonb not null default '{}'::jsonb,
confidence numeric(5,4),
stale_state core.stale_state not null default 'fresh',
stale_reason text,
superseded_by uuid,
generated_at timestamptz not null default now(),
unique (case_id, summary_version)
);
create table core.case_chapters (
chapter_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
chapter_type text not null,
title text not null,
summary text not null,
expanded_summary text,
chapter_status text not null default 'open',
started_at timestamptz not null,
closed_at timestamptz,
source_event_range tstzrange,
event_refs uuid[] not null default '{}',
evidence_refs jsonb not null default '[]'::jsonb,
hidden_evidence_state jsonb not null default '{}'::jsonb,
state_changes jsonb not null default '[]'::jsonb,
revision_reason text,
confidence numeric(5,4),
superseded_by uuid,
generated_at timestamptz not null default now()
);
create table core.case_operational_projections (
projection_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
effective_priority jsonb,
sla_state jsonb,
risk_state jsonb,
placement_reason jsonb,
required_actor jsonb,
ownership core.ownership_state not null,
escalation_state jsonb,
policy_evaluation_refs uuid[] not null default '{}',
stale_state core.stale_state not null default 'fresh',
generated_at timestamptz not null default now(),
source_event_ids uuid[] not null default '{}',
generator_version text not null
);
Decisions
Decision packets and governed actions
Decision Packet is the portable human ask. Governed Action is the world-changing operation behind an action button, approval, tool execution, correction application, message send, case merge, or artifact activation.
create table core.decision_packets (
decision_packet_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid not null references core.cases(case_id),
question text not null,
decision_type text not null,
attention_reason text, -- why this decision is being asked now
impact jsonb not null default '{}'::jsonb,
suggested_actions jsonb not null default '[]'::jsonb, -- ranked recommended responses; each may map to / create a governed action
required_actor jsonb,
eligible_actors jsonb not null default '[]'::jsonb,
actor_reason text,
delivery_routes jsonb not null default '[]'::jsonb,
evidence_refs jsonb not null default '[]'::jsonb,
evidence_summary text,
hidden_evidence_state jsonb not null default '{}'::jsonb,
source_authority_refs uuid[] not null default '{}',
conflicts jsonb not null default '[]'::jsonb,
linked_governed_action_ids uuid[] not null default '{}',
status core.decision_packet_status not null default 'active',
-- expiry deadline lives in core.case_timers (kind = ask_expiry), not here
stale_reason text,
superseded_by uuid,
answered_by uuid,
answered_at timestamptz,
created_at timestamptz not null default now()
);
create table core.decision_packet_deliveries (
delivery_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
decision_packet_id uuid not null references core.decision_packets(decision_packet_id),
channel text not null,
external_message_ref text,
delivered_to jsonb not null default '[]'::jsonb,
delivery_status text not null default 'pending',
delivered_at timestamptz,
last_synced_at timestamptz,
metadata jsonb not null default '{}'::jsonb
);
create table core.governed_actions (
action_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid references core.cases(case_id),
action_kind text not null,
tool_id text,
source_system text,
target_refs jsonb not null default '[]'::jsonb,
requested_by uuid,
requested_at timestamptz not null default now(),
proposed_result jsonb,
status core.governed_action_status not null default 'draft',
policy_evaluation_ref uuid,
policy_refs uuid[] not null default '{}',
risk_score numeric(6,3),
permission_state jsonb not null default '{}'::jsonb,
automation_scope text,
approval_requirement jsonb,
expert_review_requirement jsonb,
dry_run_result jsonb,
block_reason text,
approval_state jsonb not null default '{}'::jsonb,
required_approvers jsonb not null default '[]'::jsonb,
eligible_approvers jsonb not null default '[]'::jsonb,
self_approval_block boolean not null default false,
delegation_state jsonb not null default '{}'::jsonb,
approval_decision jsonb,
execution_state text,
idempotency_key text not null,
external_operation_refs jsonb not null default '[]'::jsonb,
started_at timestamptz,
completed_at timestamptz,
result jsonb,
failure_reason text,
partial_failure jsonb,
compensating_action_ref uuid,
event_refs uuid[] not null default '{}',
evidence_refs jsonb not null default '[]'::jsonb,
decision_packet_id uuid references core.decision_packets(decision_packet_id),
actor_trace jsonb not null default '[]'::jsonb,
replay_ref uuid,
created_at timestamptz not null default now(),
unique (tenant_id, idempotency_key)
);
Lifecycles
Decision and action lifecycle diagrams
Decision Packet
stateDiagram-v2
[*] --> Active: created
Active --> Answered: actor responds
Active --> Expired: expires_at reached
Active --> Stale: source/case changed
Active --> Superseded: replaced by newer ask
Stale --> Superseded: regenerated
Expired --> Superseded: regenerated
Active --> Cancelled: no longer needed
Answered --> [*]
Cancelled --> [*]
Superseded --> [*]
Governed Action
stateDiagram-v2
[*] --> Draft
Draft --> PendingPolicy: requested
PendingPolicy --> Blocked: policy denies
PendingPolicy --> NeedsApproval: policy requires approval
PendingPolicy --> NeedsExpertReview: expert required
PendingPolicy --> Approved: policy permits
NeedsApproval --> Approved: approval granted
NeedsApproval --> Blocked: approval denied
NeedsExpertReview --> Approved: expert clears
NeedsExpertReview --> Blocked: expert blocks
Approved --> Executing
Executing --> Succeeded
Executing --> Failed
Executing --> PartiallyFailed
Failed --> Executing: retry
PartiallyFailed --> Executing: recover
Failed --> Cancelled
Blocked --> Superseded: newer action replaces
Draft --> Cancelled
Succeeded --> [*]
Cancelled --> [*]
Superseded --> [*]
Identity
Identity and channel aliasing
One canonical person carries a central UUID; each platform identity (Slack, email, ticketing) is an alias that points at it. Inbound requesters arrive as a source-shaped requester_ref and are resolved to a person through the alias table — lazily creating a provisional contact when the channel identity is new.
(source_system, external_id) maps to exactly one person, idempotently. Deciding that two people across channels are the same human is a separate merged_into_person_id step — not an intake decision.
create table identity.people (
person_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
kind text not null default 'external_contact', -- internal_user, external_contact
status text not null default 'provisional', -- provisional, active, merged
display_name text,
primary_email text,
confidence numeric(5,4),
merged_into_person_id uuid references identity.people(person_id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table identity.person_aliases (
alias_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
person_id uuid not null references identity.people(person_id),
source_system text not null, -- slack, gmail, zendesk, teams
external_id text not null,
handle text,
display_name text,
email text,
verified boolean not null default false,
confidence numeric(5,4),
resolution_method text, -- exact_email, slack_profile, manual
evidence_refs jsonb not null default '[]'::jsonb,
first_seen_at timestamptz not null default now(),
last_seen_at timestamptz not null default now(),
unique (tenant_id, source_system, external_id)
);
create table identity.users (
user_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
person_id uuid not null references identity.people(person_id),
email text not null,
display_name text,
status text not null default 'active', -- active, invited, suspended
roles text[] not null default '{}',
created_at timestamptz not null default now(),
unique (tenant_id, email),
unique (tenant_id, person_id)
);
Governance
Governance candidate structures
Governance Candidate is the review wrapper. Its payload may be a policy rule, authority rule, notification rule, runtime artifact, permission rule, or approval rule.
create table gov.governance_candidates (
candidate_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
candidate_type text not null,
review_status gov.candidate_review_status not null default 'draft',
activation_status gov.candidate_activation_status not null default 'not_activated',
version integer not null default 1,
created_by uuid,
created_at timestamptz not null default now(),
origin_channel text,
origin_case_id uuid references core.cases(case_id),
origin_object_ref jsonb,
origin_context_label text,
source_run_id uuid,
source_thread_id text,
paired_one_off_ref uuid,
condition jsonb not null default '{}'::jsonb,
outcome jsonb not null default '{}'::jsonb,
scope jsonb not null default '{}'::jsonb,
rationale text,
payload_ref jsonb not null default '{}'::jsonb,
affected_capabilities text[] not null default '{}',
evidence_refs jsonb not null default '[]'::jsonb,
example_case_ids uuid[] not null default '{}',
conflict_refs uuid[] not null default '{}',
risk_summary jsonb not null default '{}'::jsonb,
blast_radius jsonb not null default '{}'::jsonb,
reversibility jsonb not null default '{}'::jsonb,
review_requirement jsonb not null default '{}'::jsonb,
reviewers jsonb not null default '[]'::jsonb,
review_comments jsonb not null default '[]'::jsonb,
decision text,
decision_reason text,
decided_by uuid,
decided_at timestamptz,
simulation_refs uuid[] not null default '{}',
activation_scope jsonb,
effective_at timestamptz,
activated_ref jsonb,
monitoring_plan jsonb,
rollback_ref jsonb,
superseded_by uuid
);
create table gov.candidate_status_events (
candidate_status_event_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
candidate_id uuid not null references gov.governance_candidates(candidate_id),
previous_review_status gov.candidate_review_status,
next_review_status gov.candidate_review_status,
previous_activation_status gov.candidate_activation_status,
next_activation_status gov.candidate_activation_status,
reason text,
actor_ref jsonb,
occurred_at timestamptz not null default now(),
event_ref uuid
);
Lifecycle
Governance Candidate lifecycle
Review and activation are separate axes. A reviewer can approve a candidate without activating it immediately.
Review lifecycle
stateDiagram-v2
[*] --> Draft
Draft --> Submitted
Submitted --> UnderReview
UnderReview --> NeedsChanges
NeedsChanges --> Submitted
UnderReview --> Approved
UnderReview --> Denied
Submitted --> Stale: source context changed
Approved --> Superseded: newer candidate replaces
Denied --> Archived
Stale --> Archived
Superseded --> Archived
Approved --> Archived: no activation needed
Activation lifecycle
stateDiagram-v2
[*] --> NotActivated
NotActivated --> Scheduled: approved and scheduled
Scheduled --> Activated: effective_at reached
NotActivated --> Activated: immediate activation
Activated --> RolledBack: rollback action
Activated --> Superseded: newer version activated
Scheduled --> NotActivated: cancelled before effect
RolledBack --> [*]
Superseded --> [*]
Runtime
Runtime artifact structures
Policy should be represented as artifact_kind = 'policy', with PolicyRule as the structured payload of an artifact version.
create table runtime.runtime_artifacts (
artifact_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
artifact_kind text not null,
name text not null,
owner jsonb not null default '{}'::jsonb,
scope jsonb not null default '{}'::jsonb,
status runtime.artifact_status not null default 'draft',
current_version_id uuid,
activation_scope jsonb,
created_from_candidate_id uuid references gov.governance_candidates(candidate_id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table runtime.artifact_versions (
artifact_version_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
artifact_id uuid not null references runtime.runtime_artifacts(artifact_id),
semver text not null,
payload_schema text not null,
payload jsonb not null,
change_summary text,
source_evidence_refs jsonb not null default '[]'::jsonb,
eval_summary jsonb not null default '{}'::jsonb,
safety_status text,
rollback_target_version_id uuid,
created_by uuid,
created_at timestamptz not null default now(),
unique (artifact_id, semver)
);
create table runtime.artifact_reviews (
artifact_review_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
artifact_version_id uuid not null references runtime.artifact_versions(artifact_version_id),
reviewer uuid,
decision text not null,
decision_reason text,
conditions jsonb not null default '[]'::jsonb,
activation_scope jsonb,
decided_at timestamptz not null default now()
);
alter table runtime.runtime_artifacts
add constraint runtime_artifacts_current_version_fk
foreign key (current_version_id)
references runtime.artifact_versions(artifact_version_id);
Policy
Policy rule and evaluation structures
PolicyEvaluation must answer "why this?" by preserving matched rules, skipped rules, overrides, conflicts, and the winning effective value.
create table gov.policy_rules (
rule_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
rule_type text not null,
owner jsonb not null default '{}'::jsonb,
scope jsonb not null default '{}'::jsonb,
conditions jsonb not null default '{}'::jsonb,
outcomes jsonb not null default '{}'::jsonb,
exceptions jsonb not null default '[]'::jsonb,
priority integer not null default 100,
effective_at timestamptz,
expires_at timestamptz,
version integer not null default 1,
runtime_artifact_version_ref uuid references runtime.artifact_versions(artifact_version_id),
rollback_ref jsonb,
created_at timestamptz not null default now()
);
create table gov.policy_evaluations (
evaluation_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid references core.cases(case_id),
input_projection_ref uuid,
policy_version_refs uuid[] not null default '{}',
rule_matches jsonb not null default '[]'::jsonb,
skipped_rules jsonb not null default '[]'::jsonb,
winning_rule uuid,
overrides jsonb not null default '[]'::jsonb,
conflicts jsonb not null default '[]'::jsonb,
effective_value jsonb not null default '{}'::jsonb,
reason text,
confidence numeric(5,4),
evaluated_context_hash text,
evaluated_at timestamptz not null default now()
);
create table gov.policy_simulations (
simulation_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
candidate_id uuid references gov.governance_candidates(candidate_id),
input_rule jsonb not null,
sample_case_ids uuid[] not null default '{}',
before_after jsonb not null default '[]'::jsonb,
conflicts jsonb not null default '[]'::jsonb,
warnings jsonb not null default '[]'::jsonb,
uncertainty jsonb not null default '{}'::jsonb,
generated_at timestamptz not null default now()
);
Knowledge
The knowledge layer — how the app learns truth
This is the epistemic spine: a signal (external observation or internal system fact) is conceptualized into a source, which yields claims (hypotheses), backed by frozen justifications (evidence), tested by validation (authority + conflict + review), producing world-model facts — the validated, bitemporal truth. Internal signals are authoritative facts recorded directly; external signals are observations that must earn their way.
create table knowledge.signals (
signal_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
origin knowledge.signal_origin not null, -- external = observation, internal = system fact
signal_type text not null, -- message, email, ticket_update | action_executed, case_changed, decision_made
source_system text not null,
source_ref jsonb not null, -- reference + digest (external) or ledger event_id (internal); never the raw body
subject_refs jsonb not null default '[]'::jsonb, -- entities/cases it touches; empty = touches nothing
case_id uuid references core.cases(case_id),
observed_at timestamptz not null,
recorded_at timestamptz not null default now(),
worth jsonb, -- calibrated composite: belief_change, decision_relevance, source_authority, rarity, extraction_confidence
disposition knowledge.signal_disposition not null default 'buffered', -- dropped, buffered, kept
source_id uuid references knowledge.sources(source_id), -- set once grouped into a source
fidelity text not null default 'reference_digest', -- structured, reference_digest, raw_short
retention_class text not null default 'standard'
);
create table knowledge.sources (
source_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
source_system text not null,
kind text not null, -- conversation, email_thread, ticket, document
title text, -- generated label
summary text, -- redacted digest, not raw content
signal_ids uuid[] not null default '{}', -- the signals it grouped (provenance)
observed_window tstzrange,
status knowledge.source_status not null default 'open', -- accumulate then close; merge/split/retract are first-class
supersedes_source_id uuid references knowledge.sources(source_id),
valid_from timestamptz, -- world time
valid_to timestamptz,
observed_at timestamptz not null default now(), -- system time
superseded_at timestamptz,
closed_at timestamptz
);
create table knowledge.claims (
claim_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
source_id uuid references knowledge.sources(source_id), -- which source it came from
claim_type text not null,
subject_ref jsonb not null, -- the entity
predicate text not null,
object jsonb not null, -- value or ref
confidence numeric(5,4), -- extraction confidence; LLMs are overconfident, so calibrate
status knowledge.claim_status not null default 'candidate', -- candidate, active, superseded, refuted
justification_id uuid references knowledge.justifications(justification_id),
valid_from timestamptz, -- world time
valid_to timestamptz,
observed_at timestamptz not null default now(), -- system time
superseded_at timestamptz,
extractor_ref jsonb
);
create table knowledge.justifications (
justification_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
asserts_ref jsonb not null, -- what it justifies: a claim, decision, artifact, or fact
supporting_signal_ids uuid[] not null default '{}', -- bounded and acyclic (foundationalist)
supporting_claim_ids uuid[] not null default '{}',
supporting_fact_ids uuid[] not null default '{}',
reasoning text,
source_trust jsonb not null default '{}'::jsonb, -- per-source trust signal; provenance is not trust
verified boolean not null default false, -- verified at freeze (entailment check)
verification jsonb,
captured_at timestamptz not null default now() -- frozen snapshot; immutable
);
create table knowledge.validations (
validation_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
claim_id uuid not null references knowledge.claims(claim_id),
subject_ref jsonb not null, -- entity/field
verdict knowledge.validation_verdict not null, -- supported, refuted, conflict, not_enough_info, needs_review
authority_refs uuid[] not null default '{}', -- authority rules applied as prior
conflict_id uuid references knowledge.conflicts(conflict_id),
effective_fact_id uuid references knowledge.world_model_facts(fact_id),
reviewer uuid, -- set when a human adjudicated the band
review_reason text,
decided_at timestamptz not null default now()
);
create table knowledge.world_model_facts (
fact_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
entity_ref jsonb not null, -- the entity, e.g. { kind: system, id: vpn }
field text not null, -- owner, status, ...
value jsonb not null, -- the effective value, e.g. Clara
confidence numeric(5,4), -- calibrated
freshness jsonb not null default '{}'::jsonb, -- last_verified, decay
authority_source jsonb, -- which source/claim won (prior + evidence)
justification_id uuid references knowledge.justifications(justification_id), -- why we believe it
valid_from timestamptz not null, -- when true in the world
valid_to timestamptz, -- null = still believed true
observed_at timestamptz not null default now(), -- when the system learned it
superseded_at timestamptz, -- invalidate, never delete
superseded_by uuid references knowledge.world_model_facts(fact_id)
);
create table knowledge.authority_rules (
rule_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
field_or_entity_scope jsonb not null,
source_precedence jsonb not null default '[]'::jsonb, -- declared PRIOR, not hard precedence
role_precedence jsonb not null default '[]'::jsonb,
learned_reliability jsonb not null default '{}'::jsonb, -- empirical per-source reliability adjusts the prior
conflict_behavior text not null,
exceptions jsonb not null default '[]'::jsonb,
owner jsonb,
runtime_artifact_version_ref uuid references runtime.artifact_versions(artifact_version_id),
created_at timestamptz not null default now(),
superseded_by uuid
);
create table knowledge.conflicts (
conflict_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
target_ref jsonb not null, -- entity/field in dispute
conflicting_claim_ids uuid[] not null,
independence jsonb not null default '{}'::jsonb, -- copying/dependence check; corroboration is not independence
current_effective_fact_id uuid references knowledge.world_model_facts(fact_id),
resolution_state text not null default 'unresolved', -- unresolved, resolved, independent
reviewer uuid,
resolution_reason text,
created_at timestamptz not null default now(),
resolved_at timestamptz
);
create table knowledge.corrections (
correction_id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
case_id uuid references core.cases(case_id),
target_ref jsonb not null,
field text not null,
proposed_value jsonb not null,
current_value jsonb,
correction_scope text not null, -- case_only, durable_truth, authority_issue, governance_candidate_trigger
reason text,
submitted_by uuid, -- human corrections are high-trust and train authority
outcome jsonb,
candidate_ref uuid references gov.governance_candidates(candidate_id),
created_at timestamptz not null default now(),
decided_at timestamptz
);
Lifecycle
Validation and truth flow
A signal is validated by what it does to current knowledge. Internal signals are facts; external observations must pass a worth gate, then a truth gate.
Signal validation funnel
flowchart TD
Signal([Signal]) --> Origin{origin?}
Origin -->|internal · system fact| Fact[recorded as fact → truth]
Origin -->|external · observation| Touch{touches a tracked
entity or case?}
Touch -->|no| Drop[reference + digest only
not knowledge]
Touch -->|yes| Source[group into Source] --> Claim[extract claim · hypothesis]
Claim --> Worth{vs current world model}
Worth -->|confirms| Reinforce[reinforce
raise confidence / freshness]
Worth -->|adds new| Validate[validate → world-model fact]
Worth -->|contradicts| Conflict[conflict → authority + review]
Worth -->|redundant| Noop[NOOP]
Epistemic spine — source to truth
flowchart LR Signal[Signal
external obs · internal fact] --> Source[Source
conceptualized unit] Source --> Claim[Claim
hypothesis + confidence] Claim --> Validation[Validation
authority + conflict + review] Validation --> Truth[World-model fact
bitemporal truth] Claim -. justified by .-> Justification[Justification
frozen evidence bundle] Truth -. justified by .-> Justification Correction[Human correction] -. high-trust .-> Validation
Ledger
Event and projection envelopes
case_id is optional on events because source, governance, artifact, and identity events can happen before or outside a case.
create table ledger.event_envelopes (
event_id uuid primary key default gen_random_uuid(),
event_type text not null,
tenant_id uuid not null,
subject_ref jsonb not null,
case_id uuid references core.cases(case_id),
occurred_at timestamptz not null,
recorded_at timestamptz not null default now(),
actor_ref jsonb,
source_ref jsonb,
correlation_id text,
causation_id uuid,
sequence bigint,
idempotency_key text,
entity_refs jsonb not null default '[]'::jsonb,
artifact_refs uuid[] not null default '{}',
payload_schema_version text not null,
payload_ref text,
payload jsonb,
access_class text not null default 'standard',
retention_class text not null default 'standard',
unique (tenant_id, idempotency_key)
);
create table ledger.projection_envelopes (
projection_id uuid primary key default gen_random_uuid(),
projection_type text not null,
tenant_id uuid not null,
subject_ref jsonb not null,
source_event_ids uuid[] not null default '{}',
source_watermark jsonb,
generated_at timestamptz not null default now(),
generator_version text not null,
payload_schema_version text not null,
policy_version_refs uuid[] not null default '{}',
artifact_version_refs uuid[] not null default '{}',
stale_state core.stale_state not null default 'fresh',
stale_reason text,
superseded_by uuid,
confidence numeric(5,4),
payload jsonb not null
);
Review
MVP review questions
- Can
case_statusbe limited to six values: open, awaiting_human, blocked, resolved, closed, archived? - Should
in_progressbe removed from Case status and represented only through automation/projection state? - Should artifact activation always create a
core.governed_actionsrow? - Should
display_policybe a candidate type, or only an artifact kind? - Should
PolicyEvaluationbe per field or bundled per case evaluation run? - Are Governance Candidate payloads better stored as typed refs, JSON payloads, or both?
- Which generated projections must exist for MVP: summary, operational projection, current ask, evidence summary, or all four?