Milestone 4 — Governed Answer Registry
Status: Complete
Completed: 2026-06-07
Goal
Approved answers persist with provenance. The organization can now say: "We know what we believe, who approved it, and where it came from."
Stakeholder story: "We now know what we believe, who approved it, and where it came from."
What Was Built
ApprovedAnswer Model
ApprovedAnswer is the primary unit of value in the Raw to Knowledge system. It is a frozen Pydantic v2 model — immutable at creation time. Status transitions (SUPERSEDED, DISPUTED, RETIRED) produce new instances via with_status().
Required provenance fields at creation:
| Field | Description |
|---|---|
validation_record_id |
Link to the ValidationRecord that approved this answer |
owner_role |
Role responsible for this answer's content |
evidence_links |
Non-empty list of source references |
review_due |
Date when this answer should be re-reviewed |
horn_type |
Robert Horn information type (from classification) |
version |
Monotonically increasing version counter |
AnswerStatus Enum
AnswerStatus tracks the registry lifecycle, distinct from the validation ValidationState:
| Status | Description |
|---|---|
approved |
Active, visible in standard queries |
superseded |
Replaced by a newer version; retained for history |
disputed |
Flagged for adjudication post-approval |
retired |
Permanently removed from active queries |
CandidateToAnswerTransform
Pure domain service that transforms a ClassificationRecord + ValidationRecord pair into an ApprovedAnswer. Raises ProvenanceError listing all missing fields (not just the first) so callers get a complete picture of what needs to be provided.
Registry Conflict Detection
At publish time, the new answer's grammar triple (subject + predicate + scope) is compared against all active APPROVED answers. Grammar conflicts produce RegistryConflictRecord objects attached to the answer. Unlike M3's validation-time ConflictFlag, registry conflicts do not block the write — they are recorded for audit and surfaced in readouts.
Version Chain
Supersession creates a new ApprovedAnswer (version incremented), marks the old one SUPERSEDED, and records the relationship in version_chains. The get_version_chain API follows supersedes_id back to the root, returning all versions oldest-first. Text updates are rejected by AnswerTextUpdateError — new text requires the supersede path.
Evidence Links
Evidence links are stored in a separate evidence_links table (one row per source reference per answer). Each link is a source reference string (artifact ID, URL, document reference). Retrieved as a list in the ApprovedAnswer.evidence_links tuple.
API
All endpoints under /v1/registry/:
| Method | Path | Description |
|---|---|---|
POST |
/answers |
Publish approved candidate to registry |
GET |
/answers |
Query with filters |
GET |
/answers/{id} |
Get single answer |
POST |
/answers/{id}/supersede |
Publish new version |
POST |
/answers/{id}/retire |
Mark RETIRED |
POST |
/answers/{id}/dispute |
Mark DISPUTED |
GET |
/answers/{id}/version-chain |
Version history (oldest first) |
GET |
/answers/{id}/evidence |
Evidence links |
Query filters (GET /v1/registry/answers):
| Parameter | Type | Description |
|---|---|---|
horn_type |
string | Filter by Horn type |
assertion_subtype |
string | Filter by assertion subtype |
customer_scope |
string | Scope isolation per customer |
status |
string | Default: approved only |
review_due_before |
YYYY-MM-DD | Return answers due before this date |
search_text |
string | Full-text search on answer_text and grammar subject |
limit / offset |
int | Pagination |
HTTP status codes:
201— Created (publish or supersede)404— Answer not found / no approved validation record409— Answer not in APPROVED status (supersede path only)422— Provenance incomplete or invalid input
Database Schema (Migration 004)
Four new tables:
| Table | Description |
|---|---|
approved_answers |
Primary knowledge objects with full provenance |
evidence_links |
Source references per answer |
version_chains |
Supersession relationships |
registry_conflict_records |
Grammar conflicts detected at publish time |
Full-text search index:
CREATE INDEX ix_approved_answers_fts_answer_text
ON approved_answers
USING GIN (to_tsvector('english', answer_text))
Quality Gates
| Gate | Implementation |
|---|---|
| Provenance completeness | ProvenanceError lists all missing fields at publish time |
| Version chain integrity | supersedes_id links; version_chains table records each edge |
| No silent overwrite | AnswerTextUpdateError on direct text update attempt; only supersede path allowed |
| Conflict detection on write | RegistryConflictRecord created; write not blocked |
| Zero unvalidated writes | UnvalidatedRegistryWriteError if no approved ValidationRecord |
Tests
Unit tests (no DB required):
- tests/unit/test_registry_transform.py — build, provenance checks (all-fields-listed), conflict detection, status transitions, immutability
Integration tests (requires DATABASE_URL):
- tests/integration/test_registry_roundtrip.py — publish, query filters, version chain, status transitions, immutability, evidence links, error paths
Design Decisions
- Conflict detection does not block: At registry write time, conflicts are audited but not rejected. This allows the system to capture contradictory claims from different customer contexts while preserving the full record. M7 (Graph) will surface contradictions explicitly.
- Evidence links as separate table: One row per source reference allows future enrichment (source type, fetched-at timestamp, verification status) without changing
ApprovedAnswer. - Frozen model +
with_status(): Follows the M3 pattern.ApprovedAnsweris immutable at creation; only status transitions are permitted. This keeps the domain layer free of mutability side-effects and makes the audit trail self-evident. - Full-text search via PostgreSQL GIN: Avoids the complexity of a separate search index (Elasticsearch, Typesense) at M4 scale. The GIN index on
to_tsvector('english', answer_text)satisfies p95 ≤ 2s at 10,000 records without additional infrastructure.