Milestone 3 — Human Validation Workflow
Status: Complete
Completed: 2026-06-07
Goal
Ensure that no candidate answer reaches the knowledge graph without a human validation decision from an authorised reviewer.
Stakeholder story: "Every answer that enters the knowledge graph has been explicitly approved by someone with the authority to approve answers of that type. Conflicts between candidates are flagged automatically."
What Was Built
State Machine
ValidationStateMachine enforces a nine-state transition table derived directly from ValidationState.permitted_transitions(). The machine is a pure domain service — it operates only on values and returns new immutable ValidationAssignment instances.
States:
| State | Description |
|---|---|
pending |
Queued, awaiting a validator |
in_review |
Claimed by a validator |
approved |
Approved by an authorised reviewer |
revised |
Revised answer text submitted; triggers re-classification |
rejected |
Definitively rejected (terminal) |
escalated |
Routed to a higher authority |
superseded |
Replaced by a newer decision (terminal) |
disputed |
Flagged for adjudication post-approval |
retired |
Removed from active use (terminal) |
Transitions:
pending → in_review(claim)in_review → approved | revised | rejected | escalatedrevised → approved | in_reviewescalated → in_reviewapproved → superseded | disputed | retireddisputed → in_reviewrejected | superseded | retired→ none (terminal)
Role-Based Authority Table
AuthorityChecker enforces the KGS authority table mapping Horn types to approved reviewer roles. Authority is checked at the moment of an APPROVED decision — not at assignment time.
| Horn Type | Required Roles |
|---|---|
fact |
subject_matter_expert, principal |
concept |
content_strategist, principal |
procedure |
technical_writer, principal |
process |
engineer, principal |
principle |
principal only |
reference |
content_strategist, principal |
troubleshooting |
support_engineer, principal |
recommendation |
consultant, principal |
open_issue |
(empty — never approvable) |
Grammar-Level Conflict Detection
ConflictDetector compares new APPROVED assignments against all active assignments. A conflict exists when two candidates assert different object_value values for the same subject + predicate + scope triple. Conflicts are recorded as ConflictFlag objects and attached to the assignment. When block_on_conflict=True (env: BLOCK_VALIDATION_ON_CONFLICT), APPROVED decisions with conflicts raise ConflictDetectedError before persisting.
Validation Queue (Redis + In-Memory)
RedisValidationQueue routes assignments to per-Horn-type Redis lists (raw_to_knowledge:val_queue:{horn_type}) using RPUSH/LPOP (FIFO). InMemoryValidationQueue is used for tests and development. The queue implementation is swapped via dependency injection without changing the application layer.
Immutable Audit Trail
ValidationRecord is a frozen Pydantic v2 model. Once created it is never modified — each decision produces a new record with prior_record_id linking to the previous one. The repository offers only save_record, never update_record.
Registry Write Guard
ValidationService.require_approved_record(candidate_id) raises UnvalidatedRegistryWriteError when called for a candidate with no APPROVED validation record. M4 (Knowledge Graph Write) calls this as its first action.
API
All endpoints are under /v1/validation/:
| Method | Path | Description |
|---|---|---|
POST |
/assignments |
Assign a candidate for validation |
GET |
/assignments |
List assignments (filterable by state, horn_type, actor) |
GET |
/assignments/{id} |
Get assignment by ID |
POST |
/assignments/{id}/claim |
Claim (PENDING → IN_REVIEW) |
POST |
/assignments/{id}/decide |
Submit a decision |
GET |
/candidates/{id}/history |
Full audit trail for a candidate |
GET |
/candidates/{id}/state |
Current state for a candidate |
GET |
/queue/depth |
Pending counts per horn type |
HTTP status codes:
201— Created (assignment or decision)403— Role not authorised for approval404— Assignment not found409— Invalid state transition or conflict blocked422— Invalid enum value or missing required field
Database Schema (Migration 003)
Three new tables:
validation_assignments— mutable work items (state, actor, record/flag ID lists)validation_records— immutable decision audit trailconflict_flags— grammar conflicts detected between assignments
Configuration
| Env var | Default | Description |
|---|---|---|
REDIS_URL |
redis://localhost:6379/0 |
Redis connection URL |
BLOCK_VALIDATION_ON_CONFLICT |
false |
When true, conflicts block APPROVED decisions |
Tests
Unit tests (no DB required):
- tests/unit/test_state_machine.py — exhaustive valid and invalid transition matrix, claim, flag_conflict, terminal/approved properties
- tests/unit/test_authority_checker.py — all Horn types, authorised/unauthorised roles, OPEN_ISSUE enforcement, custom table override
- tests/unit/test_conflict_detector.py — GrammarSnapshot normalisation, same_key, conflicts_with, multi-conflict detection, self-comparison skip
Integration tests (requires DATABASE_URL):
- tests/integration/test_validation_roundtrip.py — full assign→claim→decide pipeline, authority enforcement, REVISED triggers reclassification log, conflict detection, immutability guard, registry write guard
Design Decisions
- Frozen models for state:
ValidationAssignmentuses Pydantic frozen +model_copy(update=...)to produce new instances on each transition. This keeps the domain free of mutability side-effects. - Redis FIFO per Horn type: Each Horn type gets its own list key so validators can subscribe only to types they're authorised to review — prevents routing PRINCIPLE assignments to technical writers.
- Conflict detection on APPROVED only: Running detection at REVISED or REJECTED would produce noise. Running at APPROVED means only answers that would enter the knowledge graph are checked.
- Log event for re-classification: In M3, REVISED decisions emit a structured log event. M6 will hook a Celery task to this log line; no plumbing change required.