feat(P3): IHF Phase 3 complete — Governance and Decision Linkage

Implements the full governance layer:
- Schema: requirements, decision_records, policy_references,
  implementation_change_references; requirement_candidates gets
  requirement_id back-reference
- RequirementsController (index/show; promotion-only create)
- DecisionRecordsController (CRUD + policy/impl ref management)
- GovernanceDashboardAction on HubsController (AutoRefresh)
- PromoteToRequirementAction + LinkToDecisionAction on candidates
- Outcome immutability enforced at controller level (fill excludes outcome)
- Full six-outcome vocabulary with Tailwind color roles
- Integration tests for all Phase 3 paths
- FrontController: registers Phase 2 missing controllers + all Phase 3
- SCOPE.md + docs/phase3-summary.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 10:38:50 +00:00
parent 840b0e5c7b
commit 7f9a8dd441
23 changed files with 2039 additions and 19 deletions

398
.claude/ralph-loop.local.md Normal file
View File

@@ -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: <promise>HEUREKA</promise>
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 (T02T08)
- `Requirement` (T02) before `DecisionRecord` linkage (T04)
- `DecisionRecord` (T03) before `PolicyReference` (T05), `ImplementationChangeReference` (T06), outcome vocabulary (T07)
- All feature tasks (T01T08) 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 `<promise>HEUREKA</promise>`,
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 `<promise>HEUREKA</promise>` only when all five are genuinely true.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 12, 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 13, but the framework is designed to survive UI technology changes (§12.7, §Phase 6).

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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|
<a href={HubsAction} class="font-semibold text-indigo-600">inter-hub</a>
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
<a href={RequirementCandidatesAction} class="text-sm text-gray-600 hover:text-gray-900">Candidates</a>
<a href={RequirementsAction} class="text-sm text-gray-600 hover:text-gray-900">Requirements</a>
<a href={DecisionRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Decisions</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div>

View File

@@ -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

View File

@@ -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

View File

@@ -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|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<a href={ShowDecisionRecordAction { decisionRecordId = record.id }}
class="hover:text-gray-700">{record.title}</a>
<span>/</span>
<span>Edit</span>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-2">Edit Decision Record</h1>
<p class="text-sm text-amber-600 mb-6">
Note: outcome is immutable and cannot be changed here.
</p>
{renderForm record requirements candidates users (UpdateDecisionRecordAction { decisionRecordId = record.id })}
</div>
|]

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Decision Records</h1>
<a href={NewDecisionRecordAction}
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700">
New Decision
</a>
</div>
<!-- Outcome filter tabs -->
<div class="flex gap-2 mb-5 text-sm flex-wrap">
<a href={DecisionRecordsAction}
class={filterTabClass Nothing mOutcomeFilter}>All</a>
{forEach allOutcomes (\o -> [hsx|
<a href={decisionFilterUrl o}
class={filterTabClass (Just o) mOutcomeFilter}>{o}</a>
|])}
</div>
{if null records
then [hsx|<p class="text-sm text-gray-400">No decision records found.</p>|]
else renderTable records requirements users}
|]
decisionFilterUrl :: Text -> Text
decisionFilterUrl o = "/DecisionRecords?outcome=" <> o
renderTable :: [DecisionRecord] -> [Requirement] -> [User] -> Html
renderTable records reqs users = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">Title</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Outcome</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Requirement</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Decided By</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Decided At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach records (renderRow reqs users)}
</tbody>
</table>
</div>
|]
renderRow :: [Requirement] -> [User] -> DecisionRecord -> Html
renderRow reqs users dr = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-indigo-600 hover:text-indigo-800 font-medium">{dr.title}</a>
</td>
<td class="px-4 py-3">
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{dr.outcome}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
{linkedReqTitle reqs dr.requirementId}
</td>
<td class="px-4 py-3 text-gray-600">
{userName users dr.decidedBy}
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show dr.decidedAt}</td>
</tr>
|]
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"

View File

@@ -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|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<span>New</span>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">New Decision Record</h1>
{renderForm record requirements candidates users CreateDecisionRecordAction}
</div>
|]
renderForm :: DecisionRecord -> [Requirement] -> [RequirementCandidate] -> [User] -> action -> Html
renderForm record requirements candidates users submitAction = [hsx|
<form method="POST" action={submitAction} class="bg-white rounded-lg border border-gray-200 px-6 py-5 space-y-4">
{hiddenField "authenticity_token"}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" value={record.title}
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Outcome</label>
<select name="outcome"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="accepted">accepted</option>
<option value="rejected">rejected</option>
<option value="deferred">deferred</option>
<option value="split">split</option>
<option value="merged">merged</option>
<option value="reframed">reframed</option>
</select>
<p class="text-xs text-gray-400 mt-1">Outcome cannot be changed after creation.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rationale</label>
<textarea name="rationale" rows="4"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required>{record.rationale}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Linked Requirement (optional)</label>
<select name="requirementId"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value=""> None </option>
{forEach requirements (\r -> [hsx|<option value={show r.id}>{r.title}</option>|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Linked Candidate (optional)</label>
<select name="candidateId"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value=""> None </option>
{forEach candidates (\c -> [hsx|<option value={show c.id}>{c.title}</option>|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
<textarea name="notes" rows="2"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="For split/merged: list related candidate IDs or context"
>{maybe "" id record.notes}</textarea>
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700">
Create Decision
</button>
<a href={DecisionRecordsAction}
class="text-sm text-gray-500 px-4 py-2 rounded hover:bg-gray-100">Cancel</a>
</div>
</form>
|]

View File

@@ -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|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<span>{record.title}</span>
</div>
<div class="max-w-3xl space-y-6">
<!-- Header card -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<div class="flex items-start justify-between mb-3">
<h1 class="text-2xl font-semibold">{record.title}</h1>
<div class="flex gap-2 ml-4">
<span class={outcomeClass record.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{record.outcome}
</span>
<a href={EditDecisionRecordAction { decisionRecordId = record.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit
</a>
</div>
</div>
<div class="text-xs text-gray-400 mb-3">
Decided by: {userName users record.decidedBy} · {show record.decidedAt}
</div>
<div class="mb-3">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Rationale</p>
<p class="text-sm text-gray-700 leading-relaxed">{record.rationale}</p>
</div>
{maybe mempty renderNotes record.notes}
</div>
<!-- Linked requirement -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Linked Requirement</h2>
{case mRequirement of
Nothing -> [hsx|<p class="text-sm text-gray-400">No requirement linked.</p>|]
Just req -> [hsx|
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{req.title}</a>
|]}
</div>
<!-- Source candidate -->
{maybe mempty renderCandidateSection mCandidate}
<!-- Policy references -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Policy References</h2>
{forEach policyRefs renderPolicyRef}
<form method="POST" action={AddPolicyReferenceAction { decisionRecordId = record.id }}
class="mt-3 flex items-end gap-2">
{hiddenField "authenticity_token"}
<div>
<label class="text-xs text-gray-500 block mb-1">Scope</label>
<select name="policyScope"
class="text-sm border border-gray-300 rounded px-2 py-1.5">
<option value="internal">internal</option>
<option value="external">external</option>
<option value="regulatory">regulatory</option>
<option value="contractual">contractual</option>
<option value="architectural">architectural</option>
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500 block mb-1">Constraint note (optional)</label>
<input type="text" name="constraintNote"
class="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
placeholder="e.g. GDPR Art. 17 right-to-erasure" />
</div>
<button type="submit"
class="text-sm bg-gray-100 border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-200">
Add
</button>
</form>
</div>
<!-- Implementation references -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Implementation References</h2>
{forEach implRefs renderImplRef}
<form method="POST" action={AddImplementationRefAction { decisionRecordId = record.id }}
class="mt-3 flex items-end gap-2">
{hiddenField "authenticity_token"}
<div>
<label class="text-xs text-gray-500 block mb-1">System</label>
<select name="system"
class="text-sm border border-gray-300 rounded px-2 py-1.5">
<option value="github">github</option>
<option value="linear">linear</option>
<option value="jira">jira</option>
<option value="other">other</option>
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500 block mb-1">Work item ref</label>
<input type="text" name="workItemRef"
class="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
placeholder="e.g. #1234, PROJ-456" />
</div>
<button type="submit"
class="text-sm bg-gray-100 border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-200">
Add
</button>
</form>
</div>
</div>
|]
renderNotes :: Text -> Html
renderNotes notes = [hsx|
<div class="mt-2">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Notes</p>
<p class="text-sm text-gray-600 italic">{notes}</p>
</div>
|]
renderCandidateSection :: RequirementCandidate -> Html
renderCandidateSection c = [hsx|
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Source Candidate</h2>
<a href={ShowRequirementCandidateAction { requirementCandidateId = c.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{c.title}</a>
</div>
|]
renderPolicyRef :: PolicyReference -> Html
renderPolicyRef ref = [hsx|
<div class="flex items-start justify-between py-2 border-b border-gray-100 last:border-0">
<div class="flex items-center gap-2 text-sm">
<span class={policyScopeClass ref.policyScope <> " text-xs px-2 py-0.5 rounded font-medium"}>
{ref.policyScope}
</span>
{maybe mempty (\n -> [hsx|<span class="text-gray-600">{n}</span>|]) ref.constraintNote}
<span class="text-xs text-gray-400">{show ref.createdAt}</span>
</div>
<form method="POST"
action={DeletePolicyReferenceAction { policyReferenceId = ref.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-xs text-red-500 hover:text-red-700 ml-2">Remove</button>
</form>
</div>
|]
renderImplRef :: ImplementationChangeReference -> Html
renderImplRef ref = [hsx|
<div class="flex items-start justify-between py-2 border-b border-gray-100 last:border-0">
<div class="flex items-center gap-2 text-sm">
<span class={systemBadgeClass ref.system <> " text-xs px-2 py-0.5 rounded font-medium"}>
{ref.system}
</span>
<span class="font-mono text-gray-700">{ref.workItemRef}</span>
<span class="text-xs text-gray-400">{show ref.linkedAt}</span>
</div>
<form method="POST"
action={DeleteImplementationRefAction { implementationChangeReferenceId = ref.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-xs text-red-500 hover:text-red-700 ml-2">Remove</button>
</form>
</div>
|]
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)

View File

@@ -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|
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-2 text-sm text-gray-500 mb-1">
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
<span>/</span>
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
<span>/</span>
<span>Governance</span>
</div>
<h1 class="text-2xl font-semibold">Governance Dashboard {hub.name}</h1>
</div>
<div class="flex gap-2">
<a href={TriageDashboardAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Triage Dashboard
</a>
<a href={ShowHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Hub Overview
</a>
</div>
</div>
<!-- KPI row: decision outcomes -->
<div class="grid grid-cols-3 gap-4 mb-6 sm:grid-cols-6">
{forEach outcomeList (\o -> renderKpiCard o (countOutcome allDecisions o))}
</div>
<!-- Open requirements awaiting decision -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Open Requirements Awaiting Decision
<span class="ml-2 text-xs font-normal text-gray-400">
({show (length awaitingDecision)} pending)
</span>
</h2>
{if null awaitingDecision
then [hsx|<p class="text-sm text-gray-400">All requirements have linked decisions.</p>|]
else forEach awaitingDecision renderAwaitingReq}
</div>
<!-- Recent decisions -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Recent Decisions</h2>
{if null recentDecisions
then [hsx|<p class="text-sm text-gray-400">No decisions recorded yet.</p>|]
else [hsx|
<table class="w-full text-sm">
<thead class="border-b border-gray-100">
<tr>
<th class="text-left py-2 text-xs font-medium text-gray-500">Title</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Outcome</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Source Widget</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Decided At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{forEach recentDecisions (renderDecisionRow allRequirements allCandidates widgets)}
</tbody>
</table>
|]}
</div>
<!-- Traceability coverage per widget -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Traceability Coverage</h2>
<table class="w-full text-sm">
<thead class="border-b border-gray-100">
<tr>
<th class="text-left py-2 text-xs font-medium text-gray-500">Widget</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Annotation</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Candidate</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Decision</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{forEach widgets (renderCoverageRow allAnnotations allCandidates allRequirements allDecisions)}
</tbody>
</table>
</div>
|]
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|
<div class={kpiCardClass outcome <> " rounded-lg px-4 py-3 text-center"}>
<div class="text-2xl font-bold">{show count}</div>
<div class="text-xs mt-0.5 opacity-75">{outcome}</div>
</div>
|]
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|
<div class="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{req.title}</a>
<span class="text-xs text-gray-400">{show req.createdAt}</span>
</div>
|]
renderDecisionRow :: [Requirement] -> [RequirementCandidate] -> [Widget] -> DecisionRecord -> Html
renderDecisionRow reqs candidates widgets dr = [hsx|
<tr>
<td class="py-2 pr-4">
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-indigo-600 hover:text-indigo-800">{dr.title}</a>
</td>
<td class="py-2 pr-4">
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{dr.outcome}
</span>
</td>
<td class="py-2 pr-4 text-gray-600 text-xs">
{originWidget dr reqs candidates widgets}
</td>
<td class="py-2 text-gray-400 text-xs">{show dr.decidedAt}</td>
</tr>
|]
-- 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|
<tr>
<td class="py-2 pr-4 text-sm text-gray-700">{w.name}</td>
<td class="py-2 text-center">{coverageMark hasAnnotation}</td>
<td class="py-2 text-center">{coverageMark hasCandidate}</td>
<td class="py-2 text-center">{coverageMark hasDecision}</td>
</tr>
|]
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|<span class="text-green-600 font-bold"></span>|]
coverageMark False = [hsx|<span class="text-gray-300"></span>|]
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"

View File

@@ -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
</a>
<a href={GovernanceDashboardAction { hubId = hub.id }}
class="text-sm border border-purple-300 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-50">
Governance Dashboard
</a>
<a href={EditHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit

View File

@@ -67,6 +67,9 @@ instance View ShowView where
{renderReviewerSection candidate mAssignment users}
</div>
<!-- Phase 3: Promote to Requirement / Link to Decision -->
{renderGovernanceActions candidate}
<!-- Triage history -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Triage History</h2>
@@ -167,6 +170,51 @@ renderTriageRow ts = [hsx|
</li>
|]
renderGovernanceActions :: RequirementCandidate -> Html
renderGovernanceActions candidate
| candidate.status == "accepted" = [hsx|
<div class="bg-white rounded-lg border border-indigo-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Governance</h2>
<div class="flex flex-wrap gap-3">
{renderPromoteButton candidate}
{renderLinkDecisionButton candidate}
</div>
</div>
|]
| otherwise = mempty
renderPromoteButton :: RequirementCandidate -> Html
renderPromoteButton candidate =
case candidate.requirementId of
Just rid -> [hsx|
<a href={ShowRequirementAction { requirementId = rid }}
class="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-1.5 rounded hover:bg-green-100">
Requirement
</a>
|]
Nothing -> [hsx|
<form method="POST"
action={PromoteToRequirementAction { requirementCandidateId = candidate.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Promote to Requirement
</button>
</form>
|]
renderLinkDecisionButton :: RequirementCandidate -> Html
renderLinkDecisionButton candidate = [hsx|
<form method="POST"
action={LinkToDecisionAction { requirementCandidateId = candidate.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-sm bg-gray-700 text-white px-3 py-1.5 rounded hover:bg-gray-800">
Create Decision Record
</button>
</form>
|]
statusClass :: Text -> Text
statusClass "open" = "bg-blue-100 text-blue-700"
statusClass "in_review" = "bg-yellow-100 text-yellow-800"

View File

@@ -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|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Requirements</h1>
</div>
{if null requirements
then [hsx|<p class="text-sm text-gray-400">No requirements yet. Promote an accepted candidate to create one.</p>|]
else renderTable requirements candidates}
|]
renderTable :: [Requirement] -> [RequirementCandidate] -> Html
renderTable reqs candidates = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">Title</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Source Candidate</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach reqs (renderRow candidates)}
</tbody>
</table>
</div>
|]
renderRow :: [RequirementCandidate] -> Requirement -> Html
renderRow candidates req = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-indigo-600 hover:text-indigo-800 font-medium">{req.title}</a>
</td>
<td class="px-4 py-3">
<span class={reqStatusClass req.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{req.status}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
{candidateTitle candidates req.sourceCandidateId}
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show req.createdAt}</td>
</tr>
|]
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"

View File

@@ -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|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={RequirementsAction} class="hover:text-gray-700">Requirements</a>
<span>/</span>
<span>{requirement.title}</span>
</div>
<div class="max-w-3xl space-y-6">
<!-- Header card -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<div class="flex items-start justify-between mb-3">
<h1 class="text-2xl font-semibold">{requirement.title}</h1>
<span class={reqStatusClass requirement.status <> " text-xs px-2 py-0.5 rounded font-medium ml-4"}>
{requirement.status}
</span>
</div>
<p class="text-sm text-gray-700 leading-relaxed">{requirement.description}</p>
</div>
<!-- Source candidate -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Source Candidate</h2>
<a href={ShowRequirementCandidateAction { requirementCandidateId = candidate.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{candidate.title}</a>
<p class="text-xs text-gray-400 mt-1">Widget: {widget.name}</p>
</div>
<!-- Linked decision -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Linked Decision</h2>
{case mDecision of
Nothing -> [hsx|<p class="text-sm text-gray-400">No decision linked yet.</p>|]
Just dr -> [hsx|
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{dr.title}</a>
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium ml-2"}>
{dr.outcome}
</span>
|]}
</div>
</div>
|]
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"

125
docs/phase3-summary.md Normal file
View File

@@ -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)

View File

@@ -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"
```