generated from coulomb/repo-seed
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:
398
.claude/ralph-loop.local.md
Normal file
398
.claude/ralph-loop.local.md
Normal 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 (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 `<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.
|
||||
|
||||
57
Application/Migration/1743206400-ihf-phase3-governance.sql
Normal file
57
Application/Migration/1743206400-ihf-phase3-governance.sql
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
8
SCOPE.md
8
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
167
Web/Controller/DecisionRecords.hs
Normal file
167
Web/Controller/DecisionRecords.hs
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
25
Web/Controller/Requirements.hs
Normal file
25
Web/Controller/Requirements.hs
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
31
Web/Types.hs
31
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
|
||||
|
||||
34
Web/View/DecisionRecords/Edit.hs
Normal file
34
Web/View/DecisionRecords/Edit.hs
Normal 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>
|
||||
|]
|
||||
108
Web/View/DecisionRecords/Index.hs
Normal file
108
Web/View/DecisionRecords/Index.hs
Normal 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"
|
||||
97
Web/View/DecisionRecords/New.hs
Normal file
97
Web/View/DecisionRecords/New.hs
Normal 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>
|
||||
|]
|
||||
206
Web/View/DecisionRecords/Show.hs
Normal file
206
Web/View/DecisionRecords/Show.hs
Normal 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)
|
||||
206
Web/View/Hubs/GovernanceDashboard.hs
Normal file
206
Web/View/Hubs/GovernanceDashboard.hs
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
69
Web/View/Requirements/Index.hs
Normal file
69
Web/View/Requirements/Index.hs
Normal 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"
|
||||
72
Web/View/Requirements/Show.hs
Normal file
72
Web/View/Requirements/Show.hs
Normal 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
125
docs/phase3-summary.md
Normal 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)
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user