feat(P2+P3): IHF Phase 2 complete; register Phase 3 workplan
Phase 2 — Structured Feedback and Triage (IHUB-WP-0002):
- Schema: annotation_threads, requirement_candidates, triage_states,
reviewer_assignments; annotations extended with severity + thread_id
- AnnotationThreadsController: create threads, assign annotations
- RequirementCandidatesController: CRUD, escalation, triage lifecycle,
reviewer assignment, my-queue
- Annotation severity (low/medium/high/critical) with Tailwind color cues
- TriageDashboardAction on HubsController with autoRefresh
- Integration tests (T01–T09), SCOPE.md updated, docs/phase2-summary.md
Phase 3 — Governance and Decision Linkage (IHUB-WP-0003):
- Workplan registered: 9 tasks, State Hub workstream 5f201ee3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:34 +00:00
module Web.Controller.RequirementCandidates where
import Web.Types
import Web.View.RequirementCandidates.Index
import Web.View.RequirementCandidates.Show
import Web.View.RequirementCandidates.New
import Web.View.RequirementCandidates.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
feat(P5): IHF Phase 5 complete — agent-assisted distillation
Adds bounded AI support to the IHF governance loop. All AI outputs are
attributed (model_ref), reviewable (AgentReviewRecord), and reversible.
No autonomous decisions; no silent requirement promotion.
- T01: Schema — agent_proposals, agent_review_records,
confidence_annotations (migration 1743379200)
- T02: AgentProposalsController (index/show/accept/reject, idempotent
review guard), global nav "Agent" link
- T03: SummarizeClusterAction — Claude API cluster summary on widget show
- T04: DraftRequirementAction — AI requirement draft; acceptance creates
RequirementCandidate (human-gated)
- T05: DetectDuplicatesAction — duplicate_flag proposal on candidate show
- T06: DetectPolicySensitivityAction — policy_flag with
ConfidenceAnnotations per concern scope
- T07: ProposeImplementationAction — impl_proposal from decision show
- T08: AgentAuditDashboardAction — autoRefresh; KPI row, unreviewed queue,
recent proposals, attribution log matrix
- T09: integration tests, SCOPE.md updated, phase5-summary.md, flake.nix
adds http-conduit/aeson/string-conversions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:54:33 +00:00
import Application.Helper.Controller ( callClaudeApi )
import Data.List ( intercalate )
import Data.Aeson ( decode , Value ( .. ) , Array )
import Data.Aeson.Lens ( key , _String )
import Control.Lens ( ( ^? ) )
import Data.ByteString.Lazy ( fromStrict )
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Delivers the full Phase 9 external API layer:
- Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for
widget_type, event_type, annotation category drawn live from registry tables
- OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes
gated on active HubCapabilityManifest FK
- API key management: SHA256-hashed tokens, key_prefix for display,
one-time reveal on creation, revocation support
- TypeScript and Python consumer SDKs generated from registry tables
(/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py)
- Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries,
fire-and-forget dispatch via forkIO, 3-retry logic
- Admin API dashboard with 24h stats (request count, error rate, last seen)
- Rate limiting (per-minute) and daily quota enforcement via api_request_log
- Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK
constraint on 6 framework lifecycle topics), webhook_deliveries
(append-only trigger), api_request_log
- ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong)
- contracts/functional/interaction-reporting-v1.md extended with Phase 9
endpoint catalogue and 422 validation error format
GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint
over 6 allowed framework lifecycle topic strings (not widget event types).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:52:20 +00:00
import Web.Job.WebhookDeliveryJob ( dispatchWebhooks )
import Control.Concurrent ( forkIO )
import Data.Aeson ( ( .= ) , object )
feat(P5): IHF Phase 5 complete — agent-assisted distillation
Adds bounded AI support to the IHF governance loop. All AI outputs are
attributed (model_ref), reviewable (AgentReviewRecord), and reversible.
No autonomous decisions; no silent requirement promotion.
- T01: Schema — agent_proposals, agent_review_records,
confidence_annotations (migration 1743379200)
- T02: AgentProposalsController (index/show/accept/reject, idempotent
review guard), global nav "Agent" link
- T03: SummarizeClusterAction — Claude API cluster summary on widget show
- T04: DraftRequirementAction — AI requirement draft; acceptance creates
RequirementCandidate (human-gated)
- T05: DetectDuplicatesAction — duplicate_flag proposal on candidate show
- T06: DetectPolicySensitivityAction — policy_flag with
ConfidenceAnnotations per concern scope
- T07: ProposeImplementationAction — impl_proposal from decision show
- T08: AgentAuditDashboardAction — autoRefresh; KPI row, unreviewed queue,
recent proposals, attribution log matrix
- T09: integration tests, SCOPE.md updated, phase5-summary.md, flake.nix
adds http-conduit/aeson/string-conversions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:54:33 +00:00
import Data.Text.Encoding ( encodeUtf8 )
import Data.HashMap.Strict ( HashMap )
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Vector as Vector
import Control.Monad ( forM_ )
feat(P2+P3): IHF Phase 2 complete; register Phase 3 workplan
Phase 2 — Structured Feedback and Triage (IHUB-WP-0002):
- Schema: annotation_threads, requirement_candidates, triage_states,
reviewer_assignments; annotations extended with severity + thread_id
- AnnotationThreadsController: create threads, assign annotations
- RequirementCandidatesController: CRUD, escalation, triage lifecycle,
reviewer assignment, my-queue
- Annotation severity (low/medium/high/critical) with Tailwind color cues
- TriageDashboardAction on HubsController with autoRefresh
- Integration tests (T01–T09), SCOPE.md updated, docs/phase2-summary.md
Phase 3 — Governance and Decision Linkage (IHUB-WP-0003):
- Workplan registered: 9 tasks, State Hub workstream 5f201ee3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:34 +00:00
validStatuses :: [ Text ]
validStatuses = [ " open " , " in_review " , " accepted " , " rejected " , " deferred " ]
validCategories :: [ Text ]
validCategories = [ " friction " , " defect " , " wish " , " policy_concern " , " doc_gap " , " trust " , " other " ]
-- Allowed triage transitions
allowedTransition :: Text -> Text -> Bool
allowedTransition " open " " in_review " = True
allowedTransition " in_review " " accepted " = True
allowedTransition " in_review " " rejected " = True
allowedTransition " in_review " " deferred " = True
allowedTransition " deferred " " in_review " = True
allowedTransition _ _ = False
instance Controller RequirementCandidatesController where
beforeAction = ensureIsUser
action RequirementCandidatesAction = do
mStatusFilter <- paramOrNothing @ Text " status "
candidates <- case mStatusFilter of
Nothing -> query @ RequirementCandidate |> orderByDesc # createdAt |> fetch
Just s -> query @ RequirementCandidate
|> filterWhere ( # status , s )
|> orderByDesc # createdAt
|> fetch
-- Fetch reviewer assignments for display
assignments <- query @ ReviewerAssignment |> fetch
users <- query @ User |> fetch
widgets <- query @ Widget |> fetch
render IndexView { candidates , assignments , users , widgets , mStatusFilter }
action ShowRequirementCandidateAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
widget <- fetch candidate . sourceWidgetId
triageStates <- query @ TriageState
|> filterWhere ( # candidateId , requirementCandidateId )
|> orderByAsc # changedAt
|> fetch
mAssignment <- query @ ReviewerAssignment
|> filterWhere ( # candidateId , requirementCandidateId )
|> fetchOneOrNothing
users <- query @ User |> fetch
mSourceAnnotation <- case candidate . sourceAnnotationId of
Nothing -> pure Nothing
Just aid -> fetchOneOrNothing aid
mSourceThread <- case candidate . sourceThreadId of
Nothing -> pure Nothing
Just tid -> fetchOneOrNothing tid
render ShowView { candidate , widget , triageStates , mAssignment , users , mSourceAnnotation , mSourceThread }
action NewRequirementCandidateAction = do
widgets <- query @ Widget |> fetch
threads <- query @ AnnotationThread |> fetch
let candidate = newRecord @ RequirementCandidate
render NewView { candidate , widgets , threads }
action CreateRequirementCandidateAction = do
widgets <- query @ Widget |> fetch
threads <- query @ AnnotationThread |> fetch
mUser <- currentUserOrNothing
let createdBy = fmap ( . id ) mUser
let candidate = newRecord @ RequirementCandidate
candidate
|> fill @ '["title", "description", "sourceWidgetId", "sourceThreadId", "category"]
|> set # status " open "
|> set # createdBy ( fmap ( Id . unId ) createdBy )
|> validateField # title nonEmpty
|> validateField # description nonEmpty
|> validateField # category ( ` elem ` validCategories )
|> ifValid \ case
Left candidate -> render NewView { candidate , widgets , threads }
Right candidate -> do
created <- createRecord candidate
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Delivers the full Phase 9 external API layer:
- Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for
widget_type, event_type, annotation category drawn live from registry tables
- OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes
gated on active HubCapabilityManifest FK
- API key management: SHA256-hashed tokens, key_prefix for display,
one-time reveal on creation, revocation support
- TypeScript and Python consumer SDKs generated from registry tables
(/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py)
- Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries,
fire-and-forget dispatch via forkIO, 3-retry logic
- Admin API dashboard with 24h stats (request count, error rate, last seen)
- Rate limiting (per-minute) and daily quota enforcement via api_request_log
- Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK
constraint on 6 framework lifecycle topics), webhook_deliveries
(append-only trigger), api_request_log
- ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong)
- contracts/functional/interaction-reporting-v1.md extended with Phase 9
endpoint catalogue and 422 validation error format
GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint
over 6 allowed framework lifecycle topic strings (not widget event types).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:52:20 +00:00
-- Dispatch webhooks fire-and-forget
let webhookPayload = object
[ " event " .= ( " requirement_candidate.created " :: Text )
, " resourceId " .= created . id
, " title " .= created . title
, " category " .= created . category
]
liftIO $ void $ forkIO $
dispatchWebhooks " requirement_candidate.created " webhookPayload
feat(P2+P3): IHF Phase 2 complete; register Phase 3 workplan
Phase 2 — Structured Feedback and Triage (IHUB-WP-0002):
- Schema: annotation_threads, requirement_candidates, triage_states,
reviewer_assignments; annotations extended with severity + thread_id
- AnnotationThreadsController: create threads, assign annotations
- RequirementCandidatesController: CRUD, escalation, triage lifecycle,
reviewer assignment, my-queue
- Annotation severity (low/medium/high/critical) with Tailwind color cues
- TriageDashboardAction on HubsController with autoRefresh
- Integration tests (T01–T09), SCOPE.md updated, docs/phase2-summary.md
Phase 3 — Governance and Decision Linkage (IHUB-WP-0003):
- Workplan registered: 9 tasks, State Hub workstream 5f201ee3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:34 +00:00
setSuccessMessage " Requirement candidate created "
redirectTo ShowRequirementCandidateAction { requirementCandidateId = created . id }
action EditRequirementCandidateAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
widgets <- query @ Widget |> fetch
threads <- query @ AnnotationThread |> fetch
render EditView { candidate , widgets , threads }
action UpdateRequirementCandidateAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
widgets <- query @ Widget |> fetch
threads <- query @ AnnotationThread |> fetch
candidate
|> fill @ '["title", "description", "sourceWidgetId", "sourceThreadId", "category"]
|> validateField # title nonEmpty
|> validateField # description nonEmpty
|> validateField # category ( ` elem ` validCategories )
|> ifValid \ case
Left candidate -> render EditView { candidate , widgets , threads }
Right candidate -> do
updateRecord candidate
setSuccessMessage " Candidate updated "
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
action UpdateTriageStatusAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
newStatus <- param @ Text " status "
notes <- paramOrNothing @ Text " notes "
mUser <- currentUserOrNothing
let changedBy = fmap ( . id ) mUser
if allowedTransition candidate . status newStatus
then do
-- Insert triage state row (append-only audit trail)
newRecord @ TriageState
|> set # candidateId requirementCandidateId
|> set # status newStatus
|> set # notes notes
|> set # changedBy ( fmap ( Id . unId ) changedBy )
|> createRecord
-- Update current status on candidate
candidate
|> set # status newStatus
|> updateRecord
setSuccessMessage ( " Status updated to " <> newStatus )
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
else do
setErrorMessage ( " Invalid transition: " <> candidate . status <> " → " <> newStatus )
respondWith 422 do
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
action AssignReviewerAction { requirementCandidateId } = do
userId <- param @ ( Id User ) " userId "
mUser <- currentUserOrNothing
let assignedBy = fmap ( . id ) mUser
-- Upsert: delete existing assignment then insert
existing <- query @ ReviewerAssignment
|> filterWhere ( # candidateId , requirementCandidateId )
|> fetchOneOrNothing
case existing of
Just ra -> deleteRecord ra
Nothing -> pure ()
newRecord @ ReviewerAssignment
|> set # candidateId requirementCandidateId
|> set # userId userId
|> set # assignedBy ( fmap ( Id . unId ) assignedBy )
|> createRecord
setSuccessMessage " Reviewer assigned "
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
action MyQueueAction = do
mUser <- currentUserOrNothing
case mUser of
Nothing -> redirectTo RequirementCandidatesAction
Just user -> do
assignments <- query @ ReviewerAssignment
|> filterWhere ( # userId , user . id )
|> fetch
let candidateIds = map ( . candidateId ) assignments
candidates <- mapM fetch candidateIds
let active = filter ( \ c -> c . status ` elem ` [ " open " , " in_review " ] ) candidates
widgets <- query @ Widget |> fetch
render IndexView
{ candidates = active
, assignments
, users = [ user ]
, widgets
, mStatusFilter = Just " my_queue "
}
2026-03-29 10:38:50 +00:00
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 }
feat(P5): IHF Phase 5 complete — agent-assisted distillation
Adds bounded AI support to the IHF governance loop. All AI outputs are
attributed (model_ref), reviewable (AgentReviewRecord), and reversible.
No autonomous decisions; no silent requirement promotion.
- T01: Schema — agent_proposals, agent_review_records,
confidence_annotations (migration 1743379200)
- T02: AgentProposalsController (index/show/accept/reject, idempotent
review guard), global nav "Agent" link
- T03: SummarizeClusterAction — Claude API cluster summary on widget show
- T04: DraftRequirementAction — AI requirement draft; acceptance creates
RequirementCandidate (human-gated)
- T05: DetectDuplicatesAction — duplicate_flag proposal on candidate show
- T06: DetectPolicySensitivityAction — policy_flag with
ConfidenceAnnotations per concern scope
- T07: ProposeImplementationAction — impl_proposal from decision show
- T08: AgentAuditDashboardAction — autoRefresh; KPI row, unreviewed queue,
recent proposals, attribution log matrix
- T09: integration tests, SCOPE.md updated, phase5-summary.md, flake.nix
adds http-conduit/aeson/string-conversions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:54:33 +00:00
-- T05: Detect duplicate candidates via Claude API
action DetectDuplicatesAction { requirementCandidateId } = do
target <- fetch requirementCandidateId
others <- query @ RequirementCandidate
|> fetch
let otherLines = map ( \ c -> show c . id <> " | " <> c . title <> " : " <> c . description )
( filter ( \ c -> c . id /= requirementCandidateId ) others )
targetLine = " TARGET: " <> target . title <> " : " <> target . description
userMsg = targetLine <> " \ n \ n EXISTING: \ n " <> intercalate " \ n " otherLines
result <- liftIO $ callClaudeApi
" You are a deduplication assistant. Given a target candidate and a list of existing candidates, identify likely duplicates. Respond with JSON: { \ " duplicates \ " : [{ \ " id \ " : \ " uuid \ " , \ " reason \ " : \ " ... \ " }]}. "
userMsg
500
case result of
Left err -> do
setErrorMessage ( " Duplicate detection failed: " <> err )
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
Right content -> do
newRecord @ AgentProposal
|> set # proposalType " duplicate_flag "
|> set # sourceCandidateId ( Just requirementCandidateId )
|> set # content content
|> set # modelRef " claude-sonnet-4-6 "
|> set # status " pending "
|> createRecord
setSuccessMessage " Duplicate detection proposal created "
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- T06: Detect policy sensitivity via Claude API
action DetectPolicySensitivityAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
mWidget <- case candidate . sourceWidgetId of
Nothing -> pure Nothing
Just wid -> fetchOneOrNothing wid
let policyCtx = maybe " unknown " ( . policyScope ) mWidget
userMsg = " Title: " <> candidate . title
<> " \ n Description: " <> candidate . description
<> " \ n Policy scope context: " <> policyCtx
result <- liftIO $ callClaudeApi
" You are a policy compliance assistant. Analyse this requirement candidate for potential policy concerns. Valid scopes: internal, external, regulatory, contractual, architectural. Respond with JSON: { \ " concerns \ " : [{ \ " scope \ " : \ " ... \ " , \ " note \ " : \ " ... \ " }], \ " severity \ " : \ " low|medium|high \ " }. "
userMsg
500
case result of
Left err -> do
setErrorMessage ( " Policy check failed: " <> err )
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
Right content -> do
let confidenceScore = extractSeverityScore content
proposal <- newRecord @ AgentProposal
|> set # proposalType " policy_flag "
|> set # sourceCandidateId ( Just requirementCandidateId )
|> set # content content
|> set # modelRef " claude-sonnet-4-6 "
|> set # confidence ( Just confidenceScore )
|> set # status " pending "
|> createRecord
-- Create one ConfidenceAnnotation per concern scope
let mParsed = decode ( fromStrict ( encodeUtf8 content ) )
:: Maybe ( HashMap Text Value )
case mParsed >>= HashMap . lookup " concerns " of
Just ( Array concerns ) ->
forM_ ( Vector . toList concerns ) \ concern ->
case ( concern ^? key " scope " . _String
, concern ^? key " note " . _String ) of
( Just scope , noteM ) ->
newRecord @ ConfidenceAnnotation
|> set # proposalId proposal . id
|> set # dimension scope
|> set # score confidenceScore
|> set # explanation noteM
|> createRecord
_ -> pure ()
_ -> pure ()
setSuccessMessage " Policy check proposal created "
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- Map severity string to numeric confidence
extractSeverityScore :: Text -> Double
extractSeverityScore content
| " \ " high \ " " ` isInfixOf ` content = 0.9
| " \ " medium \ " " ` isInfixOf ` content = 0.6
| otherwise = 0.3