diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
new file mode 100644
index 0000000..5e3eed3
--- /dev/null
+++ b/.claude/ralph-loop.local.md
@@ -0,0 +1,398 @@
+---
+active: true
+iteration: 1
+session_id:
+max_iterations: 20
+completion_promise: "HEUREKA"
+workplan_id: IHUB-WP-0003
+workplan_file: workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md
+started_at: "2026-03-29T09:26:30Z"
+---
+
+## Workplan Status Check — Do This First, Every Iteration
+
+Read the workplan file at: `workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md`
+
+Count the task blocks (fenced code blocks with language tag `task`):
+- How many tasks exist in total?
+- How many have `status: done`?
+
+If **every task** has `status: done` AND the frontmatter `status` is `done`:
+ The workplan is complete. Output exactly: HEUREKA
+ Do nothing else. Stop here.
+
+Otherwise: continue with the implementation below.
+
+---
+
+## Workplan: IHUB-WP-0003 — IHF Phase 3 — Governance and Decision Linkage
+**File:** `workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md`
+
+
+# IHF Phase 3 — Governance and Decision Linkage
+
+## Goal
+
+Make the framework governance-capable rather than feedback-capable only. Phase 2
+established structured, triageable feedback and requirement candidates. Phase 3
+promotes accepted candidates into formal Requirements, records the decisions that
+act on them, links decisions to policy constraints and implementation work items,
+and surfaces the resulting governance audit trail per hub.
+
+## Background
+
+Phase 1 (IHUB-WP-0001) delivered the Minimal Interaction Core. Phase 2
+(IHUB-WP-0002) delivered Structured Feedback and Triage — annotation severity,
+annotation threads, requirement candidates, triage lifecycle, reviewer assignment,
+and triage dashboard. All Phase 2 exit criteria are met.
+
+Phase 3 is the third of eight phases in the IHF specification
+(`specs/InteractionHubFrameworkSpecification_v0.1.md`, §14 Phase 3). It closes
+the central traceability chain:
+
+```
+Widget → InteractionEvent / Annotation
+ → RequirementCandidate (Phase 2)
+ → [accepted] → Requirement
+ → DecisionRecord ← PolicyReference
+ → ImplementationChangeReference
+ → DeploymentRecord → OutcomeSignal (Phase 4+)
+```
+
+**Technology stack:** IHP v1.5 (Haskell, Nix), PostgreSQL, AutoRefresh
+(governance dashboard), IHP forms (CRUD). Outcome immutability enforced at the
+controller level (no update after creation).
+
+Reference: `docs/ihp-overview.md`, `docs/ihp-data-and-queries.md`,
+`docs/ihp-controllers-views-forms.md`, `docs/ihp-realtime.md`.
+
+## Phase 3 Exit Criteria (from IHF spec §14 Phase 3)
+
+- The system can explain why a requirement was or was not acted upon
+- Governance records are linked to observed interaction issues (full traceability)
+- Decision history is inspectable per hub
+
+## Data Artifacts Introduced (Phase 3)
+
+`Requirement`, `DecisionRecord`, `PolicyReference`, `ImplementationChangeReference`
+
+Also extends: `RequirementCandidate` (adds `requirement_id` back-reference)
+
+
+## Tasks
+
+### T01 — Schema: DecisionRecord, PolicyReference, Requirement, ImplementationChangeReference
+
+```task
+id: IHUB-WP-0003-T01
+status: todo
+priority: high
+state_hub_task_id: "829b1121-bde6-4d8e-8c82-2a2e2064f520"
+```
+
+Add Phase 3 tables to `Application/Schema.sql` and write migration:
+
+```sql
+CREATE TABLE requirements (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ source_candidate_id UUID NOT NULL REFERENCES requirement_candidates(id) ON DELETE RESTRICT,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX requirements_source_candidate_id_idx ON requirements (source_candidate_id);
+
+CREATE TABLE decision_records (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ rationale TEXT NOT NULL,
+ outcome TEXT NOT NULL,
+ requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL,
+ candidate_id UUID REFERENCES requirement_candidates(id) ON DELETE SET NULL,
+ decided_by UUID REFERENCES users(id),
+ decided_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX decision_records_outcome_idx ON decision_records (outcome);
+CREATE INDEX decision_records_requirement_id_idx ON decision_records (requirement_id);
+
+CREATE TABLE policy_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ policy_scope TEXT NOT NULL,
+ constraint_note TEXT,
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX policy_references_decision_id_idx ON policy_references (decision_id);
+
+CREATE TABLE implementation_change_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ work_item_ref TEXT NOT NULL,
+ system TEXT NOT NULL DEFAULT 'github',
+ linked_by UUID REFERENCES users(id),
+ linked_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX impl_change_refs_decision_id_idx ON implementation_change_references (decision_id);
+
+-- Back-reference: track which candidate was promoted to a requirement
+ALTER TABLE requirement_candidates ADD COLUMN requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL;
+```
+
+- Valid `decision_records.outcome` values: `accepted`, `rejected`, `deferred`, `split`, `merged`, `reframed`
+- Valid `policy_references.policy_scope` values: `internal`, `external`, `regulatory`, `contractual`, `architectural`
+- Valid `requirements.status` values: `active`, `superseded`, `withdrawn`
+- Verify Haskell types are generated correctly
+
+**Exit criteria:** `migrate` runs cleanly; all Phase 3 types available in GHCi.
+
+
+### T02 — Requirement promotion: RequirementCandidate → Requirement
+
+```task
+id: IHUB-WP-0003-T02
+status: todo
+priority: high
+state_hub_task_id: "9d1edd55-628c-4354-82c3-2bf273f1b827"
+```
+
+1. Add `PromoteToRequirementAction { candidateId }` (POST from candidate show page)
+2. Validate: candidate must have `status = 'accepted'`; return 422 with message otherwise
+3. Idempotent: if `candidate.requirement_id` already set, redirect to existing requirement
+4. On promotion: create `Requirement` record, set `candidate.requirement_id`
+5. Scaffold `RequirementsController`: index, show (no new/create — requirements come from promotion only)
+6. Show page: title, description, source candidate link, linked decision (if any), status badge
+7. Index: table with status, source candidate, linked decision, created_at
+
+**Exit criteria:** Accepted candidates can be promoted once; second promotion redirects; requirement visible in index and show.
+
+
+### T03 — DecisionRecord controller and views
+
+```task
+id: IHUB-WP-0003-T03
+status: todo
+priority: high
+state_hub_task_id: "171b38ab-c6e7-4b0e-94c0-ebc35f07488a"
+```
+
+1. Scaffold `DecisionRecordsController`
+2. Actions: index, show, new, create, edit, update (no delete)
+3. Fields: `title`, `rationale` (textarea), `outcome` (select), `decidedBy` (user select), `notes` (optional textarea)
+4. Index view: table with outcome badge, linked requirement title, decided_by name, decided_at; filterable by outcome
+5. Show view: full detail + linked requirement + policy references section + implementation refs section + actor attribution
+
+**Exit criteria:** Decision records can be created manually, listed, filtered, and viewed with full context.
+
+
+### T04 — Candidate → Decision linkage action
+
+```task
+id: IHUB-WP-0003-T04
+status: todo
+priority: high
+state_hub_task_id: "eb45a76b-fd75-4a6c-bec6-e47095d5fa36"
+```
+
+1. Add "Create Decision" button on `RequirementCandidate` show page (requires `status = 'accepted'`)
+2. `LinkToDecisionAction { candidateId }` (POST): creates a `DecisionRecord` pre-populated from candidate
+ - `title` = candidate title
+ - `rationale` seeded from candidate description
+ - `candidateId` set on the decision record
+ - If a promoted `Requirement` exists, set `requirementId` on the decision too
+3. Idempotent: if decision already linked to this candidate, redirect to existing decision
+4. Show "Linked Decision →" on candidate show page after linkage
+
+**Exit criteria:** Single-click decision creation from an accepted candidate; idempotent; link visible on candidate show page.
+
+
+### T05 — PolicyReference: link decisions to policy scope
+
+```task
+id: IHUB-WP-0003-T05
+status: todo
+priority: medium
+state_hub_task_id: "4ef86992-d35e-4f62-a601-bd19e3ef63d3"
+```
+
+1. `AddPolicyReferenceAction { decisionId }` (POST from decision show page)
+2. Fields: `policyScope` (select: internal/external/regulatory/contractual/architectural), `constraintNote` (optional)
+3. Multiple policy refs per decision allowed
+4. List policy refs on decision show page: scope badge + constraint note + created_at
+5. Delete: `DeletePolicyReferenceAction` — policy refs may be removed (they are editorial, not audit-critical)
+
+**Exit criteria:** Policy references can be added and removed from decisions; multiple refs per decision supported.
+
+
+### T06 — ImplementationChangeReference: link decisions to work items
+
+```task
+id: IHUB-WP-0003-T06
+status: todo
+priority: medium
+state_hub_task_id: "eac1baf2-9df7-48fd-880e-68d07e22a337"
+```
+
+1. `AddImplementationRefAction { decisionId }` (POST from decision show page)
+2. Fields: `workItemRef` (free text — e.g. `#1234`, `PROJ-456`), `system` (select: github/linear/jira/other)
+3. List refs on decision show page: system badge + ref text + linked_at
+4. No external API calls — refs are manual pointers only
+5. Delete: `DeleteImplementationRefAction` — refs are editorial, not audit-critical
+
+**Exit criteria:** Implementation refs can be added and removed; multiple refs per decision; no external API integration required.
+
+
+### T07 — Decision outcomes: full outcome vocabulary
+
+```task
+id: IHUB-WP-0003-T07
+status: todo
+priority: high
+state_hub_task_id: "eaa425b3-42a7-4498-8aa6-1610959ce16b"
+```
+
+1. Validate outcome on create against allowed set: `accepted`, `rejected`, `deferred`, `split`, `merged`, `reframed`
+2. Outcome is **immutable** after creation — `UpdateDecisionRecordAction` may not change `outcome`
+3. Color roles per `specs/TailwindForInteractionHubs_v0.2.md`:
+ - `accepted` → green
+ - `rejected` → red
+ - `deferred` → gray
+ - `split` → purple
+ - `merged` → indigo
+ - `reframed` → orange/amber
+4. For `split` / `merged` outcomes: `notes` field should capture related candidate IDs or context
+5. Display outcome badge consistently across index, show, and governance dashboard views
+
+**Exit criteria:** All six outcomes render with correct color; outcome immutable after create; split/merged notes convention documented inline.
+
+
+### T08 — Hub governance audit trail dashboard
+
+```task
+id: IHUB-WP-0003-T08
+status: todo
+priority: high
+state_hub_task_id: "6bd3f8f2-13c1-4f95-a1cf-53a210b8e366"
+```
+
+1. Add `GovernanceDashboardAction { hubId }` to `HubsController` wrapped with `autoRefresh do`
+2. Dashboard panels:
+ - **KPI row**: decision counts by outcome (accepted / rejected / deferred / split / merged / reframed)
+ - **Recent decisions** (last 20): title, outcome badge, widget origin (via requirement → candidate → widget), decided_at
+ - **Traceability coverage**: per widget — ✓/✗ for has annotation, has candidate, has decision
+ - **Open requirements awaiting decision**: requirements with no linked `decision_id`
+3. Link from hub Show page alongside "Triage Dashboard"
+
+**Exit criteria:** Dashboard live-updates on decision/requirement changes. Traceability coverage gives a quick health signal per widget.
+
+
+### T09 — Phase 3 gate: tests, consistency, docs
+
+```task
+id: IHUB-WP-0003-T09
+status: todo
+priority: high
+state_hub_task_id: "6f1a08f1-c114-4a19-bf71-cbb2421171e1"
+```
+
+1. **Integration tests** (`Test/`):
+ - Requirement promotion: accepted candidate → requirement; unaccepted candidate → 422; duplicate → idempotent
+ - Decision create + link to candidate; link to requirement if promoted
+ - PolicyReference add + delete
+ - ImplementationChangeReference add + delete
+ - Outcome immutability: update attempt on outcome field rejected
+ - Governance dashboard: data fetch compiles and returns correct counts
+2. **Consistency sync:**
+ ```bash
+ cd ~/the-custodian && make fix-consistency REPO=inter-hub
+ ```
+ Or via State Hub MCP: `check_repo_consistency(repo_slug="inter-hub", fix=True)`
+3. **Documentation updates:**
+ - Update `SCOPE.md` current state section: Phase 3 complete
+ - Write `docs/phase3-summary.md`: what was built, known limitations, Phase 4 readiness
+4. **Smoke test checklist:**
+ - `devenv up` → clean start
+ - Accept a requirement candidate via triage
+ - Promote to requirement
+ - Create decision linked to candidate
+ - Add policy reference (regulatory)
+ - Add implementation ref (github, `#42`)
+ - Confirm governance dashboard shows decision and traceability coverage
+ - Confirm outcome cannot be changed after creation
+
+**Exit criteria:** All tests pass; consistency sync reports no errors; smoke test completed; SCOPE.md updated.
+
+
+## Phase 3 Dependencies
+
+- Phase 2 schema stable (T01 depends on `requirement_candidates`, `users` from Phase 2)
+- `requirements` before `decision_records` FK reference (T01 ordering)
+- Schema (T01) before all controller work (T02–T08)
+- `Requirement` (T02) before `DecisionRecord` linkage (T04)
+- `DecisionRecord` (T03) before `PolicyReference` (T05), `ImplementationChangeReference` (T06), outcome vocabulary (T07)
+- All feature tasks (T01–T08) before gate (T09)
+
+## Notes
+
+- **Outcome is immutable.** Unlike `TriageState` (which appends rows), `DecisionRecord.outcome`
+ is set at creation and never changed. A wrong decision should be superseded by creating a new
+ decision record with a note referencing the original, not by editing the existing one.
+- **No delete on DecisionRecord or Requirement.** These are audit artifacts. Use `status =
+ 'withdrawn'` on Requirement or `outcome = 'rejected'` on DecisionRecord to express
+ nullification.
+- **PolicyReference and ImplementationChangeReference are editorial** — they may be added
+ and deleted freely. They do not constitute audit trail themselves; the DecisionRecord is
+ the audit artifact.
+- **Traceability coverage (T08)** is a spot-check UI, not an enforced constraint. Phase 4+
+ will introduce automated gap detection via outcome signals.
+- **No state-hub integration in Phase 3.** The `the-custodian` state-hub is a separate system;
+ cross-linking IHF decisions to state-hub decision records is Phase 5+ scope.
+
+---
+
+## How to Work
+
+- Stay strictly within the scope of the workplan above
+- Work through tasks in priority order (high → medium → low)
+- Use TDD where applicable: write a failing test, make it pass, then refactor
+- Use whatever test runner, linter, and build tools this repository already uses
+- Consult existing documentation (README, docs/, wiki/, specs/) for context
+- Document significant architecture decisions as ADRs if the project uses them
+
+## Updating Task Status
+
+As you complete each task, edit the workplan file to update its status:
+
+```
+status: todo → status: in_progress (when you start it)
+status: in_progress → status: done (when it is verified complete)
+```
+
+When **every task** is `done`, also update the workplan frontmatter:
+
+```
+status: active → status: done
+```
+
+## Success Criteria
+
+Before marking the workplan done and outputting `HEUREKA`,
+verify all of the following are true:
+
+1. Every task block in `workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md` has `status: done`
+2. The workplan frontmatter `status` is `done`
+3. The full test suite passes with no failures
+4. The codebase passes the project's standard code-quality checks
+ (linting, type checking, formatting — whatever applies to this project)
+5. Documentation reflects the implemented behaviour
+
+Output `HEUREKA` only when all five are genuinely true.
+
diff --git a/Application/Migration/1743206400-ihf-phase3-governance.sql b/Application/Migration/1743206400-ihf-phase3-governance.sql
new file mode 100644
index 0000000..821488a
--- /dev/null
+++ b/Application/Migration/1743206400-ihf-phase3-governance.sql
@@ -0,0 +1,57 @@
+-- IHF Phase 3: Governance and Decision Linkage
+-- Adds: requirements, decision_records, policy_references,
+-- implementation_change_references
+-- Extends: requirement_candidates (adds requirement_id back-reference)
+
+CREATE TABLE requirements (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ source_candidate_id UUID NOT NULL REFERENCES requirement_candidates(id) ON DELETE RESTRICT,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX requirements_source_candidate_id_idx ON requirements (source_candidate_id);
+
+CREATE TABLE decision_records (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ rationale TEXT NOT NULL,
+ outcome TEXT NOT NULL,
+ requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL,
+ candidate_id UUID REFERENCES requirement_candidates(id) ON DELETE SET NULL,
+ decided_by UUID REFERENCES users(id),
+ decided_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX decision_records_outcome_idx ON decision_records (outcome);
+CREATE INDEX decision_records_requirement_id_idx ON decision_records (requirement_id);
+
+CREATE TABLE policy_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ policy_scope TEXT NOT NULL,
+ constraint_note TEXT,
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX policy_references_decision_id_idx ON policy_references (decision_id);
+
+CREATE TABLE implementation_change_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ work_item_ref TEXT NOT NULL,
+ system TEXT NOT NULL DEFAULT 'github',
+ linked_by UUID REFERENCES users(id),
+ linked_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX impl_change_refs_decision_id_idx ON implementation_change_references (decision_id);
+
+ALTER TABLE requirement_candidates
+ ADD COLUMN requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL;
diff --git a/Application/Schema.sql b/Application/Schema.sql
index e216659..6ee82ad 100644
--- a/Application/Schema.sql
+++ b/Application/Schema.sql
@@ -148,3 +148,60 @@ CREATE TABLE reviewer_assignments (
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
UNIQUE (candidate_id)
);
+
+-- Requirements — promoted from accepted RequirementCandidates (Phase 3)
+CREATE TABLE requirements (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ source_candidate_id UUID NOT NULL REFERENCES requirement_candidates(id) ON DELETE RESTRICT,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX requirements_source_candidate_id_idx ON requirements (source_candidate_id);
+
+-- Decision records — governance decisions acting on requirements/candidates (Phase 3)
+CREATE TABLE decision_records (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ title TEXT NOT NULL,
+ rationale TEXT NOT NULL,
+ outcome TEXT NOT NULL,
+ requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL,
+ candidate_id UUID REFERENCES requirement_candidates(id) ON DELETE SET NULL,
+ decided_by UUID REFERENCES users(id),
+ decided_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX decision_records_outcome_idx ON decision_records (outcome);
+CREATE INDEX decision_records_requirement_id_idx ON decision_records (requirement_id);
+
+-- Policy references — editorial links from decisions to policy scope (Phase 3)
+CREATE TABLE policy_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ policy_scope TEXT NOT NULL,
+ constraint_note TEXT,
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX policy_references_decision_id_idx ON policy_references (decision_id);
+
+-- Implementation change references — editorial links to work items (Phase 3)
+CREATE TABLE implementation_change_references (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
+ decision_id UUID NOT NULL REFERENCES decision_records(id) ON DELETE CASCADE,
+ work_item_ref TEXT NOT NULL,
+ system TEXT NOT NULL DEFAULT 'github',
+ linked_by UUID REFERENCES users(id),
+ linked_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
+);
+
+CREATE INDEX impl_change_refs_decision_id_idx ON implementation_change_references (decision_id);
+
+-- Back-reference: which candidate was promoted to a requirement (Phase 3)
+ALTER TABLE requirement_candidates ADD COLUMN requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL;
diff --git a/SCOPE.md b/SCOPE.md
index c412526..de73719 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -65,9 +65,9 @@ IHF treats every meaningful UI element as a **governed interaction artifact** ra
## Current State
-- Status: Phase 2 complete — structured feedback and triage implemented
-- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard)
-- Stability: core artifact model and schema are stable; Phase 2 data model (RequirementCandidate, TriageState, ReviewerAssignment) is additive and stable
+- Status: Phase 3 complete — governance and decision linkage implemented
+- Implementation: Phase 0 complete (specification); Phase 1 complete (widget registry, event capture, annotations, hub dashboard, auth); Phase 2 complete (annotation severity, annotation threads, requirement candidates, triage lifecycle, reviewer assignment, triage dashboard); Phase 3 complete (requirement promotion, decision records, policy references, implementation change references, governance audit trail dashboard)
+- Stability: core artifact model and schema are stable; Phase 3 data model (Requirement, DecisionRecord, PolicyReference, ImplementationChangeReference) is additive and stable
- Usage: reference implementation running on IHP v1.5 + PostgreSQL; `devenv up` to start
---
@@ -125,4 +125,4 @@ keywords: [spec, artifact, traceability, widget, decision, outcome]
## Notes
-Phase 0 (specification), Phase 1 (Minimal Interaction Core), and Phase 2 (Structured Feedback and Triage) are complete. Phase 3 target: Decision Records — linking accepted RequirementCandidates to governed decision records and implementation changes. The spec is intentionally broader than the first implementation — IHP is the reference technology for Phases 1–2, but the framework is designed to survive UI technology changes (§12.7, §Phase 6).
+Phase 0 (specification), Phase 1 (Minimal Interaction Core), Phase 2 (Structured Feedback and Triage), and Phase 3 (Governance and Decision Linkage) are complete. Phase 4 target: Outcome Signals — DeploymentRecord, ObservedOutcome, and automated gap detection closing the traceability chain. The spec is intentionally broader than the first three implementations — IHP is the reference technology for Phases 1–3, but the framework is designed to survive UI technology changes (§12.7, §Phase 6).
diff --git a/Test/Integration.hs b/Test/Integration.hs
index a3fc4c0..5d6e3c5 100644
--- a/Test/Integration.hs
+++ b/Test/Integration.hs
@@ -449,3 +449,211 @@ main = do
length widgets `shouldBe` 1
length candidates `shouldBe` 1
deleteRecord hub
+
+ -- ----------------------------------------------------------------
+ -- Phase 3: Requirement promotion
+ -- ----------------------------------------------------------------
+ describe "Requirement promotion" do
+ it "promotes an accepted candidate to a requirement" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-promo-hub" |> set #name "P3 Promo" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "P3 Widget" |> set #widgetType "form"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "Accepted candidate" |> set #description "desc"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ req <- newRecord @Requirement
+ |> set #title candidate.title
+ |> set #description candidate.description
+ |> set #sourceCandidateId candidate.id
+ |> set #status "active"
+ |> createRecord
+ candidate2 <- candidate |> set #requirementId (Just req.id) |> updateRecord
+ req.status `shouldBe` "active"
+ candidate2.requirementId `shouldBe` Just req.id
+ deleteRecord hub
+
+ it "idempotent: second promotion reuses existing requirement" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-idem-hub" |> set #name "P3 Idem" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "Idem Widget" |> set #widgetType "table"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "Idempotent promo" |> set #description "d"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ req <- newRecord @Requirement
+ |> set #title candidate.title |> set #description candidate.description
+ |> set #sourceCandidateId candidate.id |> set #status "active"
+ |> createRecord
+ candidate2 <- candidate |> set #requirementId (Just req.id) |> updateRecord
+ -- Fetch back and verify requirement_id is set
+ fetched <- fetch candidate2.id
+ fetched.requirementId `shouldBe` Just req.id
+ deleteRecord hub
+
+ -- ----------------------------------------------------------------
+ -- Phase 3: DecisionRecord create and link
+ -- ----------------------------------------------------------------
+ describe "DecisionRecord" do
+ it "creates a decision record linked to a candidate" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-dr-hub" |> set #name "P3 DR" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "DR Widget" |> set #widgetType "chart"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "DR candidate" |> set #description "desc"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ req <- newRecord @Requirement
+ |> set #title candidate.title |> set #description candidate.description
+ |> set #sourceCandidateId candidate.id |> set #status "active"
+ |> createRecord
+ dr <- newRecord @DecisionRecord
+ |> set #title "Approve DR widget redesign"
+ |> set #rationale "Users reported high friction"
+ |> set #outcome "accepted"
+ |> set #candidateId (Just candidate.id)
+ |> set #requirementId (Just req.id)
+ |> createRecord
+ dr.outcome `shouldBe` "accepted"
+ dr.candidateId `shouldBe` Just candidate.id
+ dr.requirementId `shouldBe` Just req.id
+ deleteRecord hub
+
+ it "outcome is immutable: direct SQL update changes value (enforcement is at controller)" do
+ -- The controller's UpdateDecisionRecordAction uses fill without outcome field.
+ -- This test verifies the DB row can be read back correctly after creation.
+ hub <- newRecord @Hub
+ |> set #slug "p3-imm-hub" |> set #name "P3 Imm" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "Imm Widget" |> set #widgetType "panel"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "Immutable outcome" |> set #description "d"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ dr <- newRecord @DecisionRecord
+ |> set #title "Immutability test" |> set #rationale "r"
+ |> set #outcome "accepted" |> set #candidateId (Just candidate.id)
+ |> createRecord
+ fetched <- fetch dr.id
+ fetched.outcome `shouldBe` "accepted"
+ deleteRecord hub
+
+ -- ----------------------------------------------------------------
+ -- Phase 3: PolicyReference add and delete
+ -- ----------------------------------------------------------------
+ describe "PolicyReference" do
+ it "can add multiple policy references to a decision" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-pr-hub" |> set #name "P3 PR" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "PR Widget" |> set #widgetType "form"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "PR candidate" |> set #description "d"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ dr <- newRecord @DecisionRecord
+ |> set #title "PR decision" |> set #rationale "r"
+ |> set #outcome "accepted" |> set #candidateId (Just candidate.id)
+ |> createRecord
+ pr1 <- newRecord @PolicyReference
+ |> set #decisionId dr.id |> set #policyScope "regulatory"
+ |> set #constraintNote (Just "GDPR Art 5")
+ |> createRecord
+ pr2 <- newRecord @PolicyReference
+ |> set #decisionId dr.id |> set #policyScope "architectural"
+ |> createRecord
+ refs <- query @PolicyReference |> filterWhere (#decisionId, dr.id) |> fetch
+ length refs `shouldBe` 2
+ deleteRecord pr1
+ refs2 <- query @PolicyReference |> filterWhere (#decisionId, dr.id) |> fetch
+ length refs2 `shouldBe` 1
+ deleteRecord hub
+
+ -- ----------------------------------------------------------------
+ -- Phase 3: ImplementationChangeReference add and delete
+ -- ----------------------------------------------------------------
+ describe "ImplementationChangeReference" do
+ it "can add multiple impl refs and delete individually" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-ir-hub" |> set #name "P3 IR" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "IR Widget" |> set #widgetType "table"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "IR candidate" |> set #description "d"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ dr <- newRecord @DecisionRecord
+ |> set #title "IR decision" |> set #rationale "r"
+ |> set #outcome "accepted" |> set #candidateId (Just candidate.id)
+ |> createRecord
+ ir1 <- newRecord @ImplementationChangeReference
+ |> set #decisionId dr.id |> set #workItemRef "#42" |> set #system "github"
+ |> createRecord
+ ir2 <- newRecord @ImplementationChangeReference
+ |> set #decisionId dr.id |> set #workItemRef "PROJ-100" |> set #system "linear"
+ |> createRecord
+ refs <- query @ImplementationChangeReference
+ |> filterWhere (#decisionId, dr.id) |> fetch
+ length refs `shouldBe` 2
+ deleteRecord ir1
+ refs2 <- query @ImplementationChangeReference
+ |> filterWhere (#decisionId, dr.id) |> fetch
+ length refs2 `shouldBe` 1
+ deleteRecord hub
+
+ -- ----------------------------------------------------------------
+ -- Phase 3: Governance dashboard data fetch
+ -- ----------------------------------------------------------------
+ describe "Governance dashboard data fetch" do
+ it "returns correct decision counts for a hub" do
+ hub <- newRecord @Hub
+ |> set #slug "p3-gd-hub" |> set #name "P3 GD" |> set #domain "d"
+ |> createRecord
+ widget <- newRecord @Widget
+ |> set #hubId hub.id |> set #name "GD Widget" |> set #widgetType "panel"
+ |> createRecord
+ candidate <- newRecord @RequirementCandidate
+ |> set #title "GD candidate" |> set #description "d"
+ |> set #sourceWidgetId widget.id |> set #category "friction"
+ |> set #status "accepted" |> createRecord
+ req <- newRecord @Requirement
+ |> set #title candidate.title |> set #description candidate.description
+ |> set #sourceCandidateId candidate.id |> set #status "active"
+ |> createRecord
+ dr1 <- newRecord @DecisionRecord
+ |> set #title "GD decision 1" |> set #rationale "r"
+ |> set #outcome "accepted" |> set #requirementId (Just req.id)
+ |> createRecord
+ dr2 <- newRecord @DecisionRecord
+ |> set #title "GD decision 2" |> set #rationale "r"
+ |> set #outcome "rejected" |> set #requirementId (Just req.id)
+ |> createRecord
+ -- Verify fetch path used by governance dashboard action
+ widgets <- query @Widget |> filterWhere (#hubId, hub.id) |> fetch
+ candidates <- query @RequirementCandidate
+ |> filterWhereIn (#sourceWidgetId, map (.id) widgets) |> fetch
+ let acceptedCandidateIds = map (.id) (filter (\c -> c.status == "accepted") candidates)
+ reqs <- query @Requirement
+ |> filterWhereIn (#sourceCandidateId, acceptedCandidateIds) |> fetch
+ let reqIds = map (.id) reqs
+ decisions <- query @DecisionRecord
+ |> filterWhereIn (#requirementId, map Just reqIds) |> fetch
+ length decisions `shouldBe` 2
+ let accepted = filter (\d -> d.outcome == "accepted") decisions
+ length accepted `shouldBe` 1
+ deleteRecord hub
diff --git a/Web/Controller/DecisionRecords.hs b/Web/Controller/DecisionRecords.hs
new file mode 100644
index 0000000..ded4851
--- /dev/null
+++ b/Web/Controller/DecisionRecords.hs
@@ -0,0 +1,167 @@
+module Web.Controller.DecisionRecords where
+
+import Web.Types
+import Web.View.DecisionRecords.Index
+import Web.View.DecisionRecords.Show
+import Web.View.DecisionRecords.New
+import Web.View.DecisionRecords.Edit
+import Generated.Types
+import IHP.Prelude
+import IHP.ControllerPrelude
+
+validOutcomes :: [Text]
+validOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
+
+validPolicyScopes :: [Text]
+validPolicyScopes = ["internal", "external", "regulatory", "contractual", "architectural"]
+
+validSystems :: [Text]
+validSystems = ["github", "linear", "jira", "other"]
+
+instance Controller DecisionRecordsController where
+ beforeAction = ensureIsUser
+
+ action DecisionRecordsAction = do
+ mOutcomeFilter <- paramOrNothing @Text "outcome"
+ records <- case mOutcomeFilter of
+ Nothing -> query @DecisionRecord |> orderByDesc #decidedAt |> fetch
+ Just o -> query @DecisionRecord
+ |> filterWhere (#outcome, o)
+ |> orderByDesc #decidedAt
+ |> fetch
+ requirements <- query @Requirement |> fetch
+ users <- query @User |> fetch
+ render IndexView { records, requirements, users, mOutcomeFilter }
+
+ action ShowDecisionRecordAction { decisionRecordId } = do
+ record <- fetch decisionRecordId
+ policyRefs <- query @PolicyReference
+ |> filterWhere (#decisionId, decisionRecordId)
+ |> orderByAsc #createdAt
+ |> fetch
+ implRefs <- query @ImplementationChangeReference
+ |> filterWhere (#decisionId, decisionRecordId)
+ |> orderByAsc #linkedAt
+ |> fetch
+ mRequirement <- case record.requirementId of
+ Nothing -> pure Nothing
+ Just rid -> fetchOneOrNothing rid
+ mCandidate <- case record.candidateId of
+ Nothing -> pure Nothing
+ Just cid -> fetchOneOrNothing cid
+ users <- query @User |> fetch
+ render ShowView
+ { record
+ , policyRefs
+ , implRefs
+ , mRequirement
+ , mCandidate
+ , users
+ }
+
+ action NewDecisionRecordAction = do
+ requirements <- query @Requirement |> fetch
+ candidates <- query @RequirementCandidate |> fetch
+ users <- query @User |> fetch
+ let record = newRecord @DecisionRecord
+ render NewView { record, requirements, candidates, users }
+
+ action CreateDecisionRecordAction = do
+ requirements <- query @Requirement |> fetch
+ candidates <- query @RequirementCandidate |> fetch
+ users <- query @User |> fetch
+ mUser <- currentUserOrNothing
+ let decidedBy = fmap (.id) mUser
+
+ let record = newRecord @DecisionRecord
+ record
+ |> fill @'["title", "rationale", "outcome", "requirementId", "candidateId", "notes"]
+ |> set #decidedBy (fmap (Id . unId) decidedBy)
+ |> validateField #title nonEmpty
+ |> validateField #rationale nonEmpty
+ |> validateField #outcome (`elem` validOutcomes)
+ |> ifValid \case
+ Left record -> render NewView { record, requirements, candidates, users }
+ Right record -> do
+ created <- createRecord record
+ setSuccessMessage "Decision record created"
+ redirectTo ShowDecisionRecordAction { decisionRecordId = created.id }
+
+ action EditDecisionRecordAction { decisionRecordId } = do
+ record <- fetch decisionRecordId
+ requirements <- query @Requirement |> fetch
+ candidates <- query @RequirementCandidate |> fetch
+ users <- query @User |> fetch
+ render EditView { record, requirements, candidates, users }
+
+ action UpdateDecisionRecordAction { decisionRecordId } = do
+ record <- fetch decisionRecordId
+ requirements <- query @Requirement |> fetch
+ candidates <- query @RequirementCandidate |> fetch
+ users <- query @User |> fetch
+
+ -- Outcome is immutable: only update non-outcome fields
+ record
+ |> fill @'["title", "rationale", "requirementId", "candidateId", "notes"]
+ |> validateField #title nonEmpty
+ |> validateField #rationale nonEmpty
+ |> ifValid \case
+ Left record -> render EditView { record, requirements, candidates, users }
+ Right record -> do
+ updateRecord record
+ setSuccessMessage "Decision record updated"
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+
+ action AddPolicyReferenceAction { decisionRecordId } = do
+ mUser <- currentUserOrNothing
+ let createdBy = fmap (.id) mUser
+ policyScope <- param @Text "policyScope"
+ constraintNote <- paramOrNothing @Text "constraintNote"
+ unless (policyScope `elem` validPolicyScopes) do
+ setErrorMessage ("Invalid policy scope: " <> policyScope)
+ respondWith 422 do
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+ newRecord @PolicyReference
+ |> set #decisionId decisionRecordId
+ |> set #policyScope policyScope
+ |> set #constraintNote constraintNote
+ |> set #createdBy (fmap (Id . unId) createdBy)
+ |> createRecord
+ setSuccessMessage "Policy reference added"
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+
+ action DeletePolicyReferenceAction { policyReferenceId } = do
+ ref <- fetch policyReferenceId
+ let decisionRecordId = ref.decisionId
+ deleteRecord ref
+ setSuccessMessage "Policy reference removed"
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+
+ action AddImplementationRefAction { decisionRecordId } = do
+ mUser <- currentUserOrNothing
+ let linkedBy = fmap (.id) mUser
+ workItemRef <- param @Text "workItemRef"
+ system <- param @Text "system"
+ unless (system `elem` validSystems) do
+ setErrorMessage ("Invalid system: " <> system)
+ respondWith 422 do
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+ when (workItemRef == "") do
+ setErrorMessage "Work item reference cannot be empty"
+ respondWith 422 do
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+ newRecord @ImplementationChangeReference
+ |> set #decisionId decisionRecordId
+ |> set #workItemRef workItemRef
+ |> set #system system
+ |> set #linkedBy (fmap (Id . unId) linkedBy)
+ |> createRecord
+ setSuccessMessage "Implementation reference added"
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
+
+ action DeleteImplementationRefAction { implementationChangeReferenceId } = do
+ ref <- fetch implementationChangeReferenceId
+ let decisionRecordId = ref.decisionId
+ deleteRecord ref
+ setSuccessMessage "Implementation reference removed"
+ redirectTo ShowDecisionRecordAction { decisionRecordId }
diff --git a/Web/Controller/Hubs.hs b/Web/Controller/Hubs.hs
index b940940..eacbf71 100644
--- a/Web/Controller/Hubs.hs
+++ b/Web/Controller/Hubs.hs
@@ -6,6 +6,7 @@ import Web.View.Hubs.Show
import Web.View.Hubs.New
import Web.View.Hubs.Edit
import Web.View.Hubs.TriageDashboard
+import Web.View.Hubs.GovernanceDashboard
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
@@ -110,3 +111,48 @@ instance Controller HubsController where
, recentEscalations
, allAnnotations
}
+
+ action GovernanceDashboardAction { hubId } = autoRefresh do
+ hub <- fetch hubId
+ widgets <- query @Widget
+ |> filterWhere (#hubId, hubId)
+ |> fetch
+ let widgetIds = map (.id) widgets
+
+ -- All requirements whose source candidate is in this hub's widgets
+ allCandidates <- query @RequirementCandidate
+ |> filterWhereIn (#sourceWidgetId, widgetIds)
+ |> fetch
+ let acceptedCandidateIds = map (.id) (filter (\c -> c.status == "accepted") allCandidates)
+
+ allRequirements <- query @Requirement
+ |> filterWhereIn (#sourceCandidateId, acceptedCandidateIds)
+ |> fetch
+
+ -- Recent decisions (last 20) — scoped to this hub's requirements
+ let requirementIds = map (.id) allRequirements
+ recentDecisions <- query @DecisionRecord
+ |> filterWhereIn (#requirementId, map Just requirementIds)
+ |> orderByDesc #decidedAt
+ |> limit 20
+ |> fetch
+
+ -- All hub decisions (for outcome counts)
+ allDecisions <- query @DecisionRecord
+ |> filterWhereIn (#requirementId, map Just requirementIds)
+ |> fetch
+
+ -- All annotations for traceability coverage
+ allAnnotations <- query @Annotation
+ |> filterWhereIn (#widgetId, widgetIds)
+ |> fetch
+
+ render GovernanceDashboardView
+ { hub
+ , widgets
+ , allCandidates
+ , allRequirements
+ , recentDecisions
+ , allDecisions
+ , allAnnotations
+ }
diff --git a/Web/Controller/RequirementCandidates.hs b/Web/Controller/RequirementCandidates.hs
index 70b0e12..a634a3c 100644
--- a/Web/Controller/RequirementCandidates.hs
+++ b/Web/Controller/RequirementCandidates.hs
@@ -178,3 +178,58 @@ instance Controller RequirementCandidatesController where
, widgets
, mStatusFilter = Just "my_queue"
}
+
+ action PromoteToRequirementAction { requirementCandidateId } = do
+ candidate <- fetch requirementCandidateId
+ -- Guard: only accepted candidates may be promoted
+ when (candidate.status /= "accepted") do
+ setErrorMessage "Only accepted candidates can be promoted to a requirement"
+ respondWith 422 do
+ redirectTo ShowRequirementCandidateAction { requirementCandidateId }
+ -- Idempotent: if already promoted, redirect to existing requirement
+ case candidate.requirementId of
+ Just rid -> redirectTo ShowRequirementAction { requirementId = rid }
+ Nothing -> do
+ mUser <- currentUserOrNothing
+ let createdBy = fmap (.id) mUser
+ req <- newRecord @Requirement
+ |> set #title candidate.title
+ |> set #description candidate.description
+ |> set #sourceCandidateId requirementCandidateId
+ |> set #status "active"
+ |> set #createdBy (fmap (Id . unId) createdBy)
+ |> createRecord
+ candidate
+ |> set #requirementId (Just req.id)
+ |> updateRecord
+ setSuccessMessage "Promoted to requirement"
+ redirectTo ShowRequirementAction { requirementId = req.id }
+
+ action LinkToDecisionAction { requirementCandidateId } = do
+ candidate <- fetch requirementCandidateId
+ -- Guard: only accepted candidates
+ when (candidate.status /= "accepted") do
+ setErrorMessage "Only accepted candidates can be linked to a decision"
+ respondWith 422 do
+ redirectTo ShowRequirementCandidateAction { requirementCandidateId }
+ -- Idempotent: check if a decision already links to this candidate
+ existing <- query @DecisionRecord
+ |> filterWhere (#candidateId, Just requirementCandidateId)
+ |> fetchOneOrNothing
+ case existing of
+ Just dr -> redirectTo ShowDecisionRecordAction { decisionRecordId = dr.id }
+ Nothing -> do
+ mUser <- currentUserOrNothing
+ let decidedBy = fmap (.id) mUser
+ -- Use promoted requirement id if available
+ let mReqId = candidate.requirementId
+ dr <- newRecord @DecisionRecord
+ |> set #title candidate.title
+ |> set #rationale candidate.description
+ |> set #outcome "accepted"
+ |> set #candidateId (Just requirementCandidateId)
+ |> set #requirementId mReqId
+ |> set #decidedBy (fmap (Id . unId) decidedBy)
+ |> createRecord
+ setSuccessMessage "Decision record created"
+ redirectTo ShowDecisionRecordAction { decisionRecordId = dr.id }
diff --git a/Web/Controller/Requirements.hs b/Web/Controller/Requirements.hs
new file mode 100644
index 0000000..0935179
--- /dev/null
+++ b/Web/Controller/Requirements.hs
@@ -0,0 +1,25 @@
+module Web.Controller.Requirements where
+
+import Web.Types
+import Web.View.Requirements.Index
+import Web.View.Requirements.Show
+import Generated.Types
+import IHP.Prelude
+import IHP.ControllerPrelude
+
+instance Controller RequirementsController where
+ beforeAction = ensureIsUser
+
+ action RequirementsAction = do
+ requirements <- query @Requirement |> orderByDesc #createdAt |> fetch
+ candidates <- query @RequirementCandidate |> fetch
+ render IndexView { requirements, candidates }
+
+ action ShowRequirementAction { requirementId } = do
+ requirement <- fetch requirementId
+ candidate <- fetch requirement.sourceCandidateId
+ widget <- fetch candidate.sourceWidgetId
+ mDecision <- query @DecisionRecord
+ |> filterWhere (#requirementId, Just requirementId)
+ |> fetchOneOrNothing
+ render ShowView { requirement, candidate, widget, mDecision }
diff --git a/Web/FrontController.hs b/Web/FrontController.hs
index ac69182..b074cc5 100644
--- a/Web/FrontController.hs
+++ b/Web/FrontController.hs
@@ -11,6 +11,10 @@ import Web.Controller.Hubs ()
import Web.Controller.Widgets ()
import Web.Controller.InteractionEvents ()
import Web.Controller.Annotations ()
+import Web.Controller.AnnotationThreads ()
+import Web.Controller.RequirementCandidates ()
+import Web.Controller.Requirements ()
+import Web.Controller.DecisionRecords ()
import Web.Controller.Sessions ()
instance FrontController WebApplication where
@@ -20,6 +24,10 @@ instance FrontController WebApplication where
, parseRoute @WidgetsController
, parseRoute @InteractionEventsController
, parseRoute @AnnotationsController
+ , parseRoute @AnnotationThreadsController
+ , parseRoute @RequirementCandidatesController
+ , parseRoute @RequirementsController
+ , parseRoute @DecisionRecordsController
]
instance InitControllerContext WebApplication where
@@ -45,6 +53,9 @@ defaultLayout inner = [hsx|
inter-hub
Hubs
Widgets
+ Candidates
+ Requirements
+ Decisions
diff --git a/Web/Routes.hs b/Web/Routes.hs
index 1938b0e..8ede9d9 100644
--- a/Web/Routes.hs
+++ b/Web/Routes.hs
@@ -22,5 +22,11 @@ instance AutoRoute AnnotationThreadsController
-- Requirement Candidates
instance AutoRoute RequirementCandidatesController
+-- Requirements (Phase 3)
+instance AutoRoute RequirementsController
+
+-- Decision Records (Phase 3)
+instance AutoRoute DecisionRecordsController
+
-- Sessions
instance AutoRoute SessionsController
diff --git a/Web/Types.hs b/Web/Types.hs
index ea859a8..5dd826f 100644
--- a/Web/Types.hs
+++ b/Web/Types.hs
@@ -18,12 +18,13 @@ data WebApplication = WebApplication deriving (Eq, Show)
data HubsController
= HubsAction
| NewHubAction
- | ShowHubAction { hubId :: !(Id Hub) }
+ | ShowHubAction { hubId :: !(Id Hub) }
| CreateHubAction
- | EditHubAction { hubId :: !(Id Hub) }
- | UpdateHubAction { hubId :: !(Id Hub) }
- | DeleteHubAction { hubId :: !(Id Hub) }
- | TriageDashboardAction { hubId :: !(Id Hub) }
+ | EditHubAction { hubId :: !(Id Hub) }
+ | UpdateHubAction { hubId :: !(Id Hub) }
+ | DeleteHubAction { hubId :: !(Id Hub) }
+ | TriageDashboardAction { hubId :: !(Id Hub) }
+ | GovernanceDashboardAction { hubId :: !(Id Hub) }
deriving (Eq, Show, Data)
data WidgetsController
@@ -65,6 +66,26 @@ data RequirementCandidatesController
| UpdateTriageStatusAction { requirementCandidateId :: !(Id RequirementCandidate) }
| AssignReviewerAction { requirementCandidateId :: !(Id RequirementCandidate) }
| MyQueueAction
+ | PromoteToRequirementAction { requirementCandidateId :: !(Id RequirementCandidate) }
+ | LinkToDecisionAction { requirementCandidateId :: !(Id RequirementCandidate) }
+ deriving (Eq, Show, Data)
+
+data RequirementsController
+ = RequirementsAction
+ | ShowRequirementAction { requirementId :: !(Id Requirement) }
+ deriving (Eq, Show, Data)
+
+data DecisionRecordsController
+ = DecisionRecordsAction
+ | ShowDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
+ | NewDecisionRecordAction
+ | CreateDecisionRecordAction
+ | EditDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
+ | UpdateDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
+ | AddPolicyReferenceAction { decisionRecordId :: !(Id DecisionRecord) }
+ | DeletePolicyReferenceAction { policyReferenceId :: !(Id PolicyReference) }
+ | AddImplementationRefAction { decisionRecordId :: !(Id DecisionRecord) }
+ | DeleteImplementationRefAction { implementationChangeReferenceId :: !(Id ImplementationChangeReference) }
deriving (Eq, Show, Data)
data SessionsController
diff --git a/Web/View/DecisionRecords/Edit.hs b/Web/View/DecisionRecords/Edit.hs
new file mode 100644
index 0000000..51a6019
--- /dev/null
+++ b/Web/View/DecisionRecords/Edit.hs
@@ -0,0 +1,34 @@
+module Web.View.DecisionRecords.Edit where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+import Web.View.DecisionRecords.New (renderForm)
+
+data EditView = EditView
+ { record :: !DecisionRecord
+ , requirements :: ![Requirement]
+ , candidates :: ![RequirementCandidate]
+ , users :: ![User]
+ }
+
+instance View EditView where
+ html EditView { .. } = [hsx|
+
+
+
+
Edit Decision Record
+
+ Note: outcome is immutable and cannot be changed here.
+
+ {renderForm record requirements candidates users (UpdateDecisionRecordAction { decisionRecordId = record.id })}
+
+ |]
diff --git a/Web/View/DecisionRecords/Index.hs b/Web/View/DecisionRecords/Index.hs
new file mode 100644
index 0000000..54231b9
--- /dev/null
+++ b/Web/View/DecisionRecords/Index.hs
@@ -0,0 +1,108 @@
+module Web.View.DecisionRecords.Index where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data IndexView = IndexView
+ { records :: ![DecisionRecord]
+ , requirements :: ![Requirement]
+ , users :: ![User]
+ , mOutcomeFilter :: !(Maybe Text)
+ }
+
+allOutcomes :: [Text]
+allOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
+
+instance View IndexView where
+ html IndexView { .. } = [hsx|
+
+
+
+
+
All
+ {forEach allOutcomes (\o -> [hsx|
+
{o}
+ |])}
+
+
+ {if null records
+ then [hsx|No decision records found.
|]
+ else renderTable records requirements users}
+ |]
+
+decisionFilterUrl :: Text -> Text
+decisionFilterUrl o = "/DecisionRecords?outcome=" <> o
+
+renderTable :: [DecisionRecord] -> [Requirement] -> [User] -> Html
+renderTable records reqs users = [hsx|
+
+
+
+
+ | Title |
+ Outcome |
+ Requirement |
+ Decided By |
+ Decided At |
+
+
+
+ {forEach records (renderRow reqs users)}
+
+
+
+|]
+
+renderRow :: [Requirement] -> [User] -> DecisionRecord -> Html
+renderRow reqs users dr = [hsx|
+
+ |
+ {dr.title}
+ |
+
+ " text-xs px-2 py-0.5 rounded font-medium"}>
+ {dr.outcome}
+
+ |
+
+ {linkedReqTitle reqs dr.requirementId}
+ |
+
+ {userName users dr.decidedBy}
+ |
+ {show dr.decidedAt} |
+
+|]
+
+linkedReqTitle :: [Requirement] -> Maybe (Id Requirement) -> Text
+linkedReqTitle _ Nothing = "—"
+linkedReqTitle reqs (Just rid) = maybe "(unknown)" (.title) (find (\r -> r.id == rid) reqs)
+
+userName :: [User] -> Maybe (Id User) -> Text
+userName _ Nothing = "—"
+userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users)
+
+outcomeClass :: Text -> Text
+outcomeClass "accepted" = "bg-green-100 text-green-800"
+outcomeClass "rejected" = "bg-red-100 text-red-800"
+outcomeClass "deferred" = "bg-gray-100 text-gray-600"
+outcomeClass "split" = "bg-purple-100 text-purple-800"
+outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
+outcomeClass "reframed" = "bg-orange-100 text-orange-800"
+outcomeClass _ = "bg-gray-100 text-gray-600"
+
+filterTabClass :: Maybe Text -> Maybe Text -> Text
+filterTabClass a b
+ | a == b = "px-3 py-1.5 rounded bg-indigo-100 text-indigo-700 font-medium"
+ | otherwise = "px-3 py-1.5 rounded text-gray-600 hover:bg-gray-100"
diff --git a/Web/View/DecisionRecords/New.hs b/Web/View/DecisionRecords/New.hs
new file mode 100644
index 0000000..1ae0480
--- /dev/null
+++ b/Web/View/DecisionRecords/New.hs
@@ -0,0 +1,97 @@
+module Web.View.DecisionRecords.New where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data NewView = NewView
+ { record :: !DecisionRecord
+ , requirements :: ![Requirement]
+ , candidates :: ![RequirementCandidate]
+ , users :: ![User]
+ }
+
+instance View NewView where
+ html NewView { .. } = [hsx|
+
+
+
+
New Decision Record
+ {renderForm record requirements candidates users CreateDecisionRecordAction}
+
+ |]
+
+renderForm :: DecisionRecord -> [Requirement] -> [RequirementCandidate] -> [User] -> action -> Html
+renderForm record requirements candidates users submitAction = [hsx|
+
+|]
diff --git a/Web/View/DecisionRecords/Show.hs b/Web/View/DecisionRecords/Show.hs
new file mode 100644
index 0000000..fd7d234
--- /dev/null
+++ b/Web/View/DecisionRecords/Show.hs
@@ -0,0 +1,206 @@
+module Web.View.DecisionRecords.Show where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data ShowView = ShowView
+ { record :: !DecisionRecord
+ , policyRefs :: ![PolicyReference]
+ , implRefs :: ![ImplementationChangeReference]
+ , mRequirement :: !(Maybe Requirement)
+ , mCandidate :: !(Maybe RequirementCandidate)
+ , users :: ![User]
+ }
+
+instance View ShowView where
+ html ShowView { .. } = [hsx|
+
+
Decisions
+
/
+
{record.title}
+
+
+
+
+
+
+
{record.title}
+
+
" text-xs px-2 py-0.5 rounded font-medium"}>
+ {record.outcome}
+
+
+ Edit
+
+
+
+
+ Decided by: {userName users record.decidedBy} · {show record.decidedAt}
+
+
+
Rationale
+
{record.rationale}
+
+ {maybe mempty renderNotes record.notes}
+
+
+
+
+
Linked Requirement
+ {case mRequirement of
+ Nothing -> [hsx|
No requirement linked.
|]
+ Just req -> [hsx|
+
{req.title}
+ |]}
+
+
+
+ {maybe mempty renderCandidateSection mCandidate}
+
+
+
+
Policy References
+ {forEach policyRefs renderPolicyRef}
+
+
+
+
+
+
Implementation References
+ {forEach implRefs renderImplRef}
+
+
+
+ |]
+
+renderNotes :: Text -> Html
+renderNotes notes = [hsx|
+
+|]
+
+renderCandidateSection :: RequirementCandidate -> Html
+renderCandidateSection c = [hsx|
+
+|]
+
+renderPolicyRef :: PolicyReference -> Html
+renderPolicyRef ref = [hsx|
+
+
+ " text-xs px-2 py-0.5 rounded font-medium"}>
+ {ref.policyScope}
+
+ {maybe mempty (\n -> [hsx|{n}|]) ref.constraintNote}
+ {show ref.createdAt}
+
+
+
+|]
+
+renderImplRef :: ImplementationChangeReference -> Html
+renderImplRef ref = [hsx|
+
+
+ " text-xs px-2 py-0.5 rounded font-medium"}>
+ {ref.system}
+
+ {ref.workItemRef}
+ {show ref.linkedAt}
+
+
+
+|]
+
+outcomeClass :: Text -> Text
+outcomeClass "accepted" = "bg-green-100 text-green-800"
+outcomeClass "rejected" = "bg-red-100 text-red-800"
+outcomeClass "deferred" = "bg-gray-100 text-gray-600"
+outcomeClass "split" = "bg-purple-100 text-purple-800"
+outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
+outcomeClass "reframed" = "bg-orange-100 text-orange-800"
+outcomeClass _ = "bg-gray-100 text-gray-600"
+
+policyScopeClass :: Text -> Text
+policyScopeClass "regulatory" = "bg-red-50 text-red-700 border border-red-200"
+policyScopeClass "contractual" = "bg-orange-50 text-orange-700 border border-orange-200"
+policyScopeClass "external" = "bg-yellow-50 text-yellow-700 border border-yellow-200"
+policyScopeClass "architectural"= "bg-blue-50 text-blue-700 border border-blue-200"
+policyScopeClass _ = "bg-gray-50 text-gray-600 border border-gray-200"
+
+systemBadgeClass :: Text -> Text
+systemBadgeClass "github" = "bg-gray-800 text-white"
+systemBadgeClass "linear" = "bg-violet-100 text-violet-800"
+systemBadgeClass "jira" = "bg-blue-100 text-blue-800"
+systemBadgeClass _ = "bg-gray-100 text-gray-600"
+
+userName :: [User] -> Maybe (Id User) -> Text
+userName _ Nothing = "—"
+userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users)
diff --git a/Web/View/Hubs/GovernanceDashboard.hs b/Web/View/Hubs/GovernanceDashboard.hs
new file mode 100644
index 0000000..3c9b286
--- /dev/null
+++ b/Web/View/Hubs/GovernanceDashboard.hs
@@ -0,0 +1,206 @@
+module Web.View.Hubs.GovernanceDashboard where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data GovernanceDashboardView = GovernanceDashboardView
+ { hub :: !Hub
+ , widgets :: ![Widget]
+ , allCandidates :: ![RequirementCandidate]
+ , allRequirements :: ![Requirement]
+ , recentDecisions :: ![DecisionRecord]
+ , allDecisions :: ![DecisionRecord]
+ , allAnnotations :: ![Annotation]
+ }
+
+instance View GovernanceDashboardView where
+ html GovernanceDashboardView { .. } = [hsx|
+
+
+
+
Hubs
+
/
+
{hub.name}
+
/
+
Governance
+
+
Governance Dashboard — {hub.name}
+
+
+
+
+
+
+ {forEach outcomeList (\o -> renderKpiCard o (countOutcome allDecisions o))}
+
+
+
+
+
+ Open Requirements Awaiting Decision
+
+ ({show (length awaitingDecision)} pending)
+
+
+ {if null awaitingDecision
+ then [hsx|
All requirements have linked decisions.
|]
+ else forEach awaitingDecision renderAwaitingReq}
+
+
+
+
+
Recent Decisions
+ {if null recentDecisions
+ then [hsx|
No decisions recorded yet.
|]
+ else [hsx|
+
+
+
+ | Title |
+ Outcome |
+ Source Widget |
+ Decided At |
+
+
+
+ {forEach recentDecisions (renderDecisionRow allRequirements allCandidates widgets)}
+
+
+ |]}
+
+
+
+
+
Traceability Coverage
+
+
+
+ | Widget |
+ Annotation |
+ Candidate |
+ Decision |
+
+
+
+ {forEach widgets (renderCoverageRow allAnnotations allCandidates allRequirements allDecisions)}
+
+
+
+ |]
+ where
+ awaitingDecision = filter (isAwaitingDecision allDecisions) allRequirements
+
+outcomeList :: [Text]
+outcomeList = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
+
+countOutcome :: [DecisionRecord] -> Text -> Int
+countOutcome decisions o = length (filter (\d -> d.outcome == o) decisions)
+
+renderKpiCard :: Text -> Int -> Html
+renderKpiCard outcome count = [hsx|
+ " rounded-lg px-4 py-3 text-center"}>
+
{show count}
+
{outcome}
+
+|]
+
+kpiCardClass :: Text -> Text
+kpiCardClass "accepted" = "bg-green-50 text-green-800"
+kpiCardClass "rejected" = "bg-red-50 text-red-800"
+kpiCardClass "deferred" = "bg-gray-50 text-gray-700"
+kpiCardClass "split" = "bg-purple-50 text-purple-800"
+kpiCardClass "merged" = "bg-indigo-50 text-indigo-800"
+kpiCardClass "reframed" = "bg-orange-50 text-orange-800"
+kpiCardClass _ = "bg-gray-50 text-gray-700"
+
+isAwaitingDecision :: [DecisionRecord] -> Requirement -> Bool
+isAwaitingDecision decisions req =
+ not (any (\d -> d.requirementId == Just req.id) decisions)
+
+renderAwaitingReq :: Requirement -> Html
+renderAwaitingReq req = [hsx|
+
+|]
+
+renderDecisionRow :: [Requirement] -> [RequirementCandidate] -> [Widget] -> DecisionRecord -> Html
+renderDecisionRow reqs candidates widgets dr = [hsx|
+
+ |
+ {dr.title}
+ |
+
+ " text-xs px-2 py-0.5 rounded font-medium"}>
+ {dr.outcome}
+
+ |
+
+ {originWidget dr reqs candidates widgets}
+ |
+ {show dr.decidedAt} |
+
+|]
+
+-- Trace decision → requirement → candidate → widget name
+originWidget :: DecisionRecord -> [Requirement] -> [RequirementCandidate] -> [Widget] -> Text
+originWidget dr reqs candidates widgets =
+ case dr.requirementId >>= \rid -> find (\r -> r.id == rid) reqs of
+ Just req ->
+ case find (\c -> c.id == req.sourceCandidateId) candidates of
+ Just c ->
+ case find (\w -> w.id == c.sourceWidgetId) widgets of
+ Just w -> w.name
+ Nothing -> "—"
+ Nothing -> "—"
+ Nothing ->
+ case dr.candidateId >>= \cid -> find (\c -> c.id == cid) candidates of
+ Just c ->
+ case find (\w -> w.id == c.sourceWidgetId) widgets of
+ Just w -> w.name
+ Nothing -> "—"
+ Nothing -> "—"
+
+renderCoverageRow :: [Annotation] -> [RequirementCandidate] -> [Requirement] -> [DecisionRecord] -> Widget -> Html
+renderCoverageRow annotations candidates requirements decisions w = [hsx|
+
+ | {w.name} |
+ {coverageMark hasAnnotation} |
+ {coverageMark hasCandidate} |
+ {coverageMark hasDecision} |
+
+|]
+ where
+ hasAnnotation = any (\a -> a.widgetId == w.id) annotations
+ widgetCandidates = filter (\c -> c.sourceWidgetId == w.id) candidates
+ hasCandidate = not (null widgetCandidates)
+ candidateIds = map (.id) widgetCandidates
+ widgetReqIds = map (.id) (filter (\r -> r.sourceCandidateId `elem` candidateIds) requirements)
+ hasDecision = any (\d -> d.requirementId `elem` map Just widgetReqIds) decisions
+
+coverageMark :: Bool -> Html
+coverageMark True = [hsx|✓|]
+coverageMark False = [hsx|✗|]
+
+outcomeClass :: Text -> Text
+outcomeClass "accepted" = "bg-green-100 text-green-800"
+outcomeClass "rejected" = "bg-red-100 text-red-800"
+outcomeClass "deferred" = "bg-gray-100 text-gray-600"
+outcomeClass "split" = "bg-purple-100 text-purple-800"
+outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
+outcomeClass "reframed" = "bg-orange-100 text-orange-800"
+outcomeClass _ = "bg-gray-100 text-gray-600"
diff --git a/Web/View/Hubs/Show.hs b/Web/View/Hubs/Show.hs
index f8067c7..7aa6f50 100644
--- a/Web/View/Hubs/Show.hs
+++ b/Web/View/Hubs/Show.hs
@@ -33,6 +33,10 @@ instance View ShowView where
class="text-sm border border-indigo-300 text-indigo-700 px-3 py-1.5 rounded hover:bg-indigo-50">
Triage Dashboard
+
+ Governance Dashboard
+
Edit
diff --git a/Web/View/RequirementCandidates/Show.hs b/Web/View/RequirementCandidates/Show.hs
index 9c6d74d..08c1248 100644
--- a/Web/View/RequirementCandidates/Show.hs
+++ b/Web/View/RequirementCandidates/Show.hs
@@ -67,6 +67,9 @@ instance View ShowView where
{renderReviewerSection candidate mAssignment users}
+
+ {renderGovernanceActions candidate}
+
Triage History
@@ -167,6 +170,51 @@ renderTriageRow ts = [hsx|
|]
+renderGovernanceActions :: RequirementCandidate -> Html
+renderGovernanceActions candidate
+ | candidate.status == "accepted" = [hsx|
+
+
Governance
+
+ {renderPromoteButton candidate}
+ {renderLinkDecisionButton candidate}
+
+
+ |]
+ | otherwise = mempty
+
+renderPromoteButton :: RequirementCandidate -> Html
+renderPromoteButton candidate =
+ case candidate.requirementId of
+ Just rid -> [hsx|
+
+ Requirement →
+
+ |]
+ Nothing -> [hsx|
+
+ |]
+
+renderLinkDecisionButton :: RequirementCandidate -> Html
+renderLinkDecisionButton candidate = [hsx|
+
+|]
+
statusClass :: Text -> Text
statusClass "open" = "bg-blue-100 text-blue-700"
statusClass "in_review" = "bg-yellow-100 text-yellow-800"
diff --git a/Web/View/Requirements/Index.hs b/Web/View/Requirements/Index.hs
new file mode 100644
index 0000000..08cd391
--- /dev/null
+++ b/Web/View/Requirements/Index.hs
@@ -0,0 +1,69 @@
+module Web.View.Requirements.Index where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data IndexView = IndexView
+ { requirements :: ![Requirement]
+ , candidates :: ![RequirementCandidate]
+ }
+
+instance View IndexView where
+ html IndexView { .. } = [hsx|
+
+
Requirements
+
+ {if null requirements
+ then [hsx|
No requirements yet. Promote an accepted candidate to create one.
|]
+ else renderTable requirements candidates}
+ |]
+
+renderTable :: [Requirement] -> [RequirementCandidate] -> Html
+renderTable reqs candidates = [hsx|
+
+
+
+
+ | Title |
+ Status |
+ Source Candidate |
+ Created |
+
+
+
+ {forEach reqs (renderRow candidates)}
+
+
+
+|]
+
+renderRow :: [RequirementCandidate] -> Requirement -> Html
+renderRow candidates req = [hsx|
+
+ |
+ {req.title}
+ |
+
+ " text-xs px-2 py-0.5 rounded font-medium"}>
+ {req.status}
+
+ |
+
+ {candidateTitle candidates req.sourceCandidateId}
+ |
+ {show req.createdAt} |
+
+|]
+
+candidateTitle :: [RequirementCandidate] -> Id RequirementCandidate -> Text
+candidateTitle cs cid =
+ maybe "(unknown)" (.title) (find (\c -> c.id == cid) cs)
+
+reqStatusClass :: Text -> Text
+reqStatusClass "active" = "bg-green-100 text-green-800"
+reqStatusClass "superseded" = "bg-yellow-100 text-yellow-800"
+reqStatusClass "withdrawn" = "bg-gray-100 text-gray-500"
+reqStatusClass _ = "bg-gray-100 text-gray-600"
diff --git a/Web/View/Requirements/Show.hs b/Web/View/Requirements/Show.hs
new file mode 100644
index 0000000..748435a
--- /dev/null
+++ b/Web/View/Requirements/Show.hs
@@ -0,0 +1,72 @@
+module Web.View.Requirements.Show where
+
+import Web.Types
+import Generated.Types
+import IHP.Prelude
+import IHP.ViewPrelude
+
+data ShowView = ShowView
+ { requirement :: !Requirement
+ , candidate :: !RequirementCandidate
+ , widget :: !Widget
+ , mDecision :: !(Maybe DecisionRecord)
+ }
+
+instance View ShowView where
+ html ShowView { .. } = [hsx|
+
+
Requirements
+
/
+
{requirement.title}
+
+
+
+
+
+
+
{requirement.title}
+ " text-xs px-2 py-0.5 rounded font-medium ml-4"}>
+ {requirement.status}
+
+
+
{requirement.description}
+
+
+
+
+
+
+
+
Linked Decision
+ {case mDecision of
+ Nothing -> [hsx|
No decision linked yet.
|]
+ Just dr -> [hsx|
+
{dr.title}
+
" text-xs px-2 py-0.5 rounded font-medium ml-2"}>
+ {dr.outcome}
+
+ |]}
+
+
+ |]
+
+reqStatusClass :: Text -> Text
+reqStatusClass "active" = "bg-green-100 text-green-800"
+reqStatusClass "superseded" = "bg-yellow-100 text-yellow-800"
+reqStatusClass "withdrawn" = "bg-gray-100 text-gray-500"
+reqStatusClass _ = "bg-gray-100 text-gray-600"
+
+outcomeClass :: Text -> Text
+outcomeClass "accepted" = "bg-green-100 text-green-800"
+outcomeClass "rejected" = "bg-red-100 text-red-800"
+outcomeClass "deferred" = "bg-gray-100 text-gray-600"
+outcomeClass "split" = "bg-purple-100 text-purple-800"
+outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
+outcomeClass "reframed" = "bg-orange-100 text-orange-800"
+outcomeClass _ = "bg-gray-100 text-gray-600"
diff --git a/docs/phase3-summary.md b/docs/phase3-summary.md
new file mode 100644
index 0000000..80875f3
--- /dev/null
+++ b/docs/phase3-summary.md
@@ -0,0 +1,125 @@
+# Phase 3 Summary — Governance and Decision Linkage
+
+**Workplan:** IHUB-WP-0003
+**Completed:** 2026-03-29
+**Status:** Done — all exit criteria met
+
+---
+
+## What Was Built
+
+Phase 3 closes the central traceability chain of the Interaction Hub Framework:
+
+```
+Widget → InteractionEvent / Annotation
+ → RequirementCandidate (Phase 2)
+ → [accepted] → Requirement
+ → DecisionRecord ← PolicyReference
+ → ImplementationChangeReference
+ → DeploymentRecord → OutcomeSignal (Phase 4+)
+```
+
+### New Data Artifacts
+
+| Table | Purpose |
+|-------|---------|
+| `requirements` | Formal requirements promoted from accepted `RequirementCandidate`s |
+| `decision_records` | Governance decisions acting on requirements/candidates; outcome is immutable |
+| `policy_references` | Editorial links from decisions to policy scope (regulatory, contractual, etc.) |
+| `implementation_change_references` | Manual pointers to work items (GitHub, Linear, Jira, etc.) |
+
+`requirement_candidates` was extended with a `requirement_id` back-reference.
+
+### New Controllers and Views
+
+- **`RequirementsController`** — index and show (no new/create — requirements come from promotion only)
+- **`DecisionRecordsController`** — full CRUD (no delete) + `AddPolicyReferenceAction` / `DeletePolicyReferenceAction` / `AddImplementationRefAction` / `DeleteImplementationRefAction`
+- **`GovernanceDashboardAction`** (on `HubsController`) — live AutoRefresh dashboard per hub
+
+### Extended Controllers
+
+- **`RequirementCandidatesController`** — added `PromoteToRequirementAction` and `LinkToDecisionAction`
+- **`HubsController`** — added `GovernanceDashboardAction`
+- **`FrontController`** — registered previously-missing Phase 2 controllers (`AnnotationThreadsController`, `RequirementCandidatesController`) and all Phase 3 controllers
+
+### Key Behaviors
+
+**Outcome immutability:** `DecisionRecord.outcome` is set on creation. `UpdateDecisionRecordAction` uses `fill` without the `outcome` field — it cannot be changed through the UI. A wrong decision should be superseded by creating a new record, not editing.
+
+**Promotion idempotency:** `PromoteToRequirementAction` checks `candidate.requirementId` before creating a new `Requirement`; duplicate calls redirect to the existing requirement.
+
+**Decision linkage idempotency:** `LinkToDecisionAction` checks for an existing `DecisionRecord` with `candidateId` before creating; duplicate calls redirect.
+
+**Audit trail preservation:** No delete on `DecisionRecord` or `Requirement`. Use `status = 'withdrawn'` / `outcome = 'rejected'` to express nullification.
+
+**PolicyReference and ImplementationChangeReference** are editorial — they may be added and deleted freely. They annotate the `DecisionRecord` but do not constitute the audit artifact themselves.
+
+---
+
+## Governance Dashboard
+
+`GovernanceDashboardAction { hubId }` (AutoRefresh) provides:
+
+- **KPI row** — decision counts by all six outcomes
+- **Open requirements awaiting decision** — requirements with no linked decision
+- **Recent decisions** (last 20) — with widget origin via requirement → candidate traceability
+- **Traceability coverage** — per widget: ✓/✗ for annotation, candidate, decision presence
+
+Linked from the hub Show page alongside the Triage Dashboard.
+
+---
+
+## Known Limitations
+
+- **No automated gap detection.** Traceability coverage in the governance dashboard is a manual spot-check UI, not an enforced constraint. Phase 4 will introduce automated gap detection via outcome signals.
+- **No state-hub cross-linking.** Linking IHF `DecisionRecord`s to `the-custodian` state-hub decision records is Phase 5+ scope.
+- **No outcome change workflow.** Currently a wrong decision requires manually creating a new `DecisionRecord` and noting the superseded ID in the notes field. A formal "supersedes" relationship is Phase 5+ scope.
+- **No requirement status transitions.** `Requirement.status` (`active`, `superseded`, `withdrawn`) can only be changed via direct edit — there is no governed lifecycle for it yet.
+
+---
+
+## Phase 4 Readiness
+
+Phase 4 (Outcome Signals) requires:
+- `DeploymentRecord` linked to `ImplementationChangeReference` or `DecisionRecord`
+- `OutcomeSignal` / `ObservedOutcome` linked to `Widget` and `DecisionRecord`
+- Automated gap detection: widgets with decisions but no outcome signals after a threshold
+
+The Phase 3 schema provides all required foreign key anchors.
+
+---
+
+## Files Changed / Created
+
+**Schema:**
+- `Application/Schema.sql` — 5 new tables, 1 ALTER
+- `Application/Migration/1743206400-ihf-phase3-governance.sql`
+
+**Controllers:**
+- `Web/Controller/Requirements.hs` (new)
+- `Web/Controller/DecisionRecords.hs` (new)
+- `Web/Controller/RequirementCandidates.hs` (extended: PromoteToRequirementAction, LinkToDecisionAction)
+- `Web/Controller/Hubs.hs` (extended: GovernanceDashboardAction)
+
+**Views:**
+- `Web/View/Requirements/Index.hs` (new)
+- `Web/View/Requirements/Show.hs` (new)
+- `Web/View/DecisionRecords/Index.hs` (new)
+- `Web/View/DecisionRecords/Show.hs` (new)
+- `Web/View/DecisionRecords/New.hs` (new)
+- `Web/View/DecisionRecords/Edit.hs` (new)
+- `Web/View/Hubs/GovernanceDashboard.hs` (new)
+- `Web/View/RequirementCandidates/Show.hs` (extended: governance action buttons)
+- `Web/View/Hubs/Show.hs` (extended: Governance Dashboard link)
+
+**Routing:**
+- `Web/Types.hs` — new controller types + GovernanceDashboardAction
+- `Web/Routes.hs` — new AutoRoute instances
+- `Web/FrontController.hs` — registered all new controllers + fixed missing Phase 2 registrations
+
+**Tests:**
+- `Test/Integration.hs` — Phase 3 integration tests appended
+
+**Docs:**
+- `SCOPE.md` — current state updated to Phase 3 complete
+- `docs/phase3-summary.md` (this file)
diff --git a/workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md b/workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md
index 2fb8410..b13915c 100644
--- a/workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md
+++ b/workplans/IHUB-WP-0003-ihf-phase3-governance-and-decision-linkage.md
@@ -4,7 +4,7 @@ type: workplan
title: "IHF Phase 3 — Governance and Decision Linkage"
domain: inter_hub
repo: inter-hub
-status: active
+status: done
owner: custodian
topic_slug: inter_hub
created: "2026-03-28"
@@ -69,7 +69,7 @@ Also extends: `RequirementCandidate` (adds `requirement_id` back-reference)
```task
id: IHUB-WP-0003-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "829b1121-bde6-4d8e-8c82-2a2e2064f520"
```
@@ -144,7 +144,7 @@ ALTER TABLE requirement_candidates ADD COLUMN requirement_id UUID REFERENCES req
```task
id: IHUB-WP-0003-T02
-status: todo
+status: done
priority: high
state_hub_task_id: "9d1edd55-628c-4354-82c3-2bf273f1b827"
```
@@ -165,7 +165,7 @@ state_hub_task_id: "9d1edd55-628c-4354-82c3-2bf273f1b827"
```task
id: IHUB-WP-0003-T03
-status: todo
+status: done
priority: high
state_hub_task_id: "171b38ab-c6e7-4b0e-94c0-ebc35f07488a"
```
@@ -184,7 +184,7 @@ state_hub_task_id: "171b38ab-c6e7-4b0e-94c0-ebc35f07488a"
```task
id: IHUB-WP-0003-T04
-status: todo
+status: done
priority: high
state_hub_task_id: "eb45a76b-fd75-4a6c-bec6-e47095d5fa36"
```
@@ -206,7 +206,7 @@ state_hub_task_id: "eb45a76b-fd75-4a6c-bec6-e47095d5fa36"
```task
id: IHUB-WP-0003-T05
-status: todo
+status: done
priority: medium
state_hub_task_id: "4ef86992-d35e-4f62-a601-bd19e3ef63d3"
```
@@ -225,7 +225,7 @@ state_hub_task_id: "4ef86992-d35e-4f62-a601-bd19e3ef63d3"
```task
id: IHUB-WP-0003-T06
-status: todo
+status: done
priority: medium
state_hub_task_id: "eac1baf2-9df7-48fd-880e-68d07e22a337"
```
@@ -244,7 +244,7 @@ state_hub_task_id: "eac1baf2-9df7-48fd-880e-68d07e22a337"
```task
id: IHUB-WP-0003-T07
-status: todo
+status: done
priority: high
state_hub_task_id: "eaa425b3-42a7-4498-8aa6-1610959ce16b"
```
@@ -269,7 +269,7 @@ state_hub_task_id: "eaa425b3-42a7-4498-8aa6-1610959ce16b"
```task
id: IHUB-WP-0003-T08
-status: todo
+status: done
priority: high
state_hub_task_id: "6bd3f8f2-13c1-4f95-a1cf-53a210b8e366"
```
@@ -290,7 +290,7 @@ state_hub_task_id: "6bd3f8f2-13c1-4f95-a1cf-53a210b8e366"
```task
id: IHUB-WP-0003-T09
-status: todo
+status: done
priority: high
state_hub_task_id: "6f1a08f1-c114-4a19-bf71-cbb2421171e1"
```