AW-03 — My Decisions · Implementation plan
Translates
spec.mdinto architecture, schema, endpoints and phasing. The plan owns implementation detail; the spec owns behaviour.
Feature ID: aw-03-decision-queue
Spec: ./spec.md · Tasks: ./tasks.md
Status: Draft
Last updated: 2026-09-06
1. Current state in the codebase
Every path below was opened before being cited.
1.1 The two backing records
| Concern | File | What is there today |
|---|---|---|
| Escalation entity | packages/agent/src/entities/agent-escalation.entity.ts | @Entity('agent_escalations'). userId, reasonCode varchar(32), status varchar(16) default 'open', runId, taskId, workId, agentId, summary varchar(500), decisionNeeded text, attempted simple-json, confidence float nullable, confidenceSource, resolvedByUserId, resolutionNote, resolvedAt, unique dedupKey varchar(200), Tier-C tenantId/organizationId, createdAt. Indexes idx_agent_escalation_task_status, idx_agent_escalation_work_status, idx_agent_escalation_user_status. No missionId, no archive marker, no first-viewed timestamp, no child questions. |
| Escalation service | packages/agent/src/agents/agent-escalation.service.ts | record() (idempotent on dedupKey, scores confidence, mirrors to the Inbox), listForTask, listOpenForUser, listForUser, getForUser, countOpenForWork, resolve, resolveForTask. INBOX_PRODUCER and EscalationConfidenceService are both @Optional() and appended last — the positional-arity rule this package follows. |
| Escalation repository | packages/agent/src/database/repositories/agent-escalation.repository.ts | Owner-scoped reads, CAS resolve. Scope coverage is guarded by agent-escalation.repository.scope.spec.ts. |
| Escalation contracts | packages/contracts/src/agents/escalation.types.ts | AgentEscalationReasonCode (10 members), AgentEscalationStatus = 'open' | 'resolved', AGENT_ESCALATION_STATUSES, caps (MAX_SUMMARY_CHARS = 500, MAX_DECISION_CHARS = 1000, MAX_ATTEMPT_ENTRIES = 20), clampEscalationConfidence, AgentEscalationAttempt, AgentEscalationDto. |
| Escalation HTTP | apps/api/src/escalations/escalations.controller.ts + dto/escalations.dto.ts + escalations.module.ts | GET /api/escalations, GET /api/escalations/:id, POST /api/escalations/:id/resolve. Owner-scoped, 404-never-403. No colocated controller spec exists. |
| Escalation chat tools | packages/agent/src/agents/agent-escalation-tools.ts | list_escalations / resolve_escalation descriptors, reached through agent-domain-tool-sources.ts. |
| Approval entity | packages/agent/src/entities/agent-action-proposal.entity.ts | @Entity('agent_action_proposals'). userId, agentId, runId, actionType (5 members), title varchar(200), payload simple-json, riskFlags simple-json, status varchar(16) default 'pending', decidedById, decidedAt, decidedVia, Tier-A scope, createdAt/updatedAt. Indexes on (organizationId,status), (agentId), (userId,status). Its own docstring: "Actually executing / resuming the approved action is a follow-up increment — this entity is the durable queue + decision record only." |
| Approval service | packages/agent/src/agent-approvals/agent-approvals.service.ts | createProposal, listPending, list, getOne, decide (flips status/decidedById/decidedAt/decidedVia and nothing else), approveAll, requireOwned. |
| Approval DTO | packages/agent/src/agent-approvals/types.ts | AgentActionProposalDto + toAgentActionProposalDto — the single place a proposal field reaches the wire. |
| Approval risk scorer | packages/agent/src/agent-approvals/risk-scorer.ts | Pure RISK_SCORER. |
| Approval HTTP | apps/api/src/agent-approvals/agent-approvals.controller.ts + dto/agent-approval.dto.ts | list / get / approve / reject / approve-all. No colocated controller spec exists. |
1.2 The typed-question shapes that already ship
packages/contracts/src/hitl/hitl-question.types.ts
is a zero-dependency value-type module exporting a discriminated union of five
question kinds (confirm, choice, multi_choice, text, approval) with
matching answer shapes, plus parseHitlQuestion, serializeHitlQuestion,
parseHitlAnswer, serializeHitlAnswer, validateHitlAnswer,
describeHitlQuestion and the caps HITL_MAX_PROMPT_CHARS = 1000,
HITL_MAX_CONTEXT_CHARS = 4000, HITL_MAX_OPTIONS = 25,
HITL_MAX_OPTION_LABEL_CHARS = 200, HITL_MAX_TEXT_ANSWER_CHARS = 4000,
HITL_MAX_NOTE_CHARS = 1000. Its own header says G3 shipped the escalation record
and that "a free-text question cannot be rendered as a control, cannot be
validated, and cannot be answered machine-readably. This file adds the typed
half."
It is exported from packages/contracts/src/index.ts
and today has exactly one consumer: the AI chat canvas
(apps/web/src/components/ai/canvas/types.ts,
components.tsx,
apps/web/src/lib/ai/tools/canvas.tools.ts).
Nothing persists a typed question anywhere. This epic is what gives that union
storage, a queue and an answer path.
1.3 The park / restart seam that already exists
| Concern | File | What is there |
|---|---|---|
| Run entity | packages/agent/src/entities/agent-run.entity.ts | awaitingInput (agent-raised park flag, exempt from sweeper reaping), pendingInput: string[] | null (FIFO injection queue), cliSessionId (the pipeline plugin's own conversation id, survives park/restart), attentionReason, queuedReason, interruptRequested, terminalEndedReason. |
| Steering service | packages/agent/src/agents/run-steering.service.ts | steer() — injects into a live run (queued/running) and clears awaitingInput; interrupt(); resume(runId, userId, message?, ownershipScope?) — creates a new run carrying cliSessionId, seeds pendingInput, goes through RunDispatchGateService, dispatches via AGENT_TASK_EXECUTE_DISPATCHER, replays durable reviewer rejections, and returns { dispatched: 'new-run', runId, resumedFromRunId, carriedCliSession, queued, rejectionsReplayed }. isLive() and isResumable() are static predicates. Throws ConflictException when the run is not resumable or has no taskId. |
| Steering port | packages/agent/src/tasks-domain/run-steering-port.ts | RUN_STEERING_PORT DI token — the leaf interface other modules depend on. |
| Dispatch tokens | packages/agent/src/tasks-domain/task-dispatcher.ts | AGENT_TASK_EXECUTE_DISPATCHER, AGENT_CHAT_REPLY_DISPATCHER — the only sanctioned way to start background work from the agent package (Constitution IV). |
| Admission gate | packages/agent/src/agents/run-dispatch-gate.service.ts | Per-Work / per-org concurrency valve; the row that consumes the slot is created inside the critical section. |
| Task unblock | packages/agent/src/tasks-domain/task-transition.service.ts | transition() stashes previousStatus on any → blocked and clears it on blocked → *. listOpenBlockerIds(), recheckUnblockFor(), tryUnblockSingleTask() (restores previousStatus, defaulting to todo), autoUnblockResolvedTasks(). |
| Task entity | packages/agent/src/entities/task.entity.ts | status incl. blocked, previousStatus, missionId (nullable, no @ManyToOne by design), latestRunId/latestRunStatus. |
1.4 The one place resolution → restart is already implemented
packages/agent/src/inbox/inbox.service.ts
is the operator message center. Its reply() claims the row with a CAS
(markAnswered), routes by kind, and releases the claim if routing throws:
question→routeQuestionReply(steer a live run, resume a parked one)approval→routeApprovalReply→AgentApprovalsService.decideescalation→routeEscalationReply→AgentEscalationService.resolve, thentryResumeLinkedRun→RunSteeringService.resumenotice→ nothing
tryResumeLinkedRun is best-effort by contract: "the escalation IS resolved; a
resume hiccup must not undo that answer."
This is the behaviour the queue needs, at the wrong granularity — it fires on a single message reply, has no concept of "every required question answered", and is locked inside the Inbox module. §2.2 extracts it.
Supporting files: inbox-producer.port.ts
(INBOX_PRODUCER, escalationRaised, proposalPending, notice,
questionRaised), inbox.types.ts,
packages/agent/src/entities/inbox-item.entity.ts
(kind: question | approval | escalation | notice, escalationId, agentRunId),
packages/agent/src/database/repositories/inbox-item.repository.ts,
apps/api/src/inbox/inbox.controller.ts.
1.5 Web — what exists and what does not
| File | Today |
|---|---|
apps/web/src/components/approvals/ApprovalsQueue.tsx | The only decision-shaped UI in the product. Client component, per-row submitting state, risk-flag badges, approve / reject / approve-all. Rendered by (dashboard)/(home)/dashboard-client.tsx line 164, fed by (home)/page.tsx line 123. |
apps/web/src/lib/api/agent-approvals.ts | server-only typed client over /agent-approvals, using serverFetch/serverMutation from server-api.ts. |
apps/web/src/app/actions/dashboard/agent-approvals.ts | Server actions with a requireApprovalAuth() defence-in-depth guard and revalidatePath('/[locale]/(dashboard)/(home)', 'page'). |
apps/web/src/lib/api/escalations.ts | Does not exist. No web file reads escalations at all. |
apps/web/src/components/inbox/InboxClient.tsx | The Inbox surface, with an escalation item kind and a reply composer. |
apps/web/src/components/dashboard/AttentionSection.tsx | The Home "Needs attention" card list (dashboard.attention namespace, includes a taskBlocked kind). |
apps/web/src/lib/constants.ts | ROUTES at line 107; DASHBOARD_INBOX = '/inbox' at line 115, DASHBOARD_MISSIONS at 125. |
apps/web/src/components/dashboard/DashboardSidebar.tsx | Hard-coded nav array; keys resolve from dashboard.sidebar.navigation.*. |
apps/web/messages/en.json | dashboard.approvals (header / actions / actionType / riskFlags / toast), dashboard.inbox, dashboard.attention. 21 locale files live in apps/web/messages/ and must stay structurally identical. |
1.6 Cross-cutting infrastructure
| Concern | File | Note |
|---|---|---|
| Migrations | apps/api/src/migrations/ (175 files) | Not packages/agent/src/migrations/ — that directory does not exist. apps/api/typeorm.config.ts globs src/migrations/**; the API self-applies on boot. The Constitution §V text says apps/api/src/database/migrations/; the code says apps/api/src/migrations/. Follow the code; the doc drift is logged in §13. |
| Entity registration | packages/agent/src/entities/index.ts, packages/agent/src/database/_entities-inventory.ts, packages/agent/src/database/_entity-names.ts | A new entity must be added to all three, and the drift is asserted by database.module.spec.ts and database.config.spec.ts. |
| Sub-module export | packages/agent/package.json | 54 exports entries; a new ./decisions entry is required for apps/api to import it. |
| Ownership | packages/agent/src/database/ownership-scope.ts | OwnershipScope, ownershipWhere<T>() — the canonical user + Organization filter. |
| Activity log | packages/agent/src/activity-log/activity-log.service.ts, packages/agent/src/entities/activity-log.types.ts | actionType is varchar(50) on activity-log.entity.ts line 43, so appending enum members needs no migration. |
| Notifications | packages/agent/src/notifications/notification.service.ts, packages/agent/src/entities/notification.types.ts | NotificationCategory has AGENT and TASK; this epic adds no category and no default. |
| Job runtime | packages/tasks/src/tasks/trigger/ (44 tasks) + index.ts | Shape to copy: agent-run-sweeper.task.ts and digest-dispatcher.task.ts — schedules.task({ id, cron, run }) spinning a NestApplicationContext(TriggerInternalModule). |
| Preferences | packages/agent/src/entities/work-agent-preference.entity.ts | Already holds missionDefaultOutstandingCap — the natural home for the health-band overrides. |
| Tool grants | apps/api/src/tool-grants/tool-grants.controller.ts | GET /api/tool-grants/check?toolName=&workId=&agentId= returns a single decision — the verification an access ask uses. |
| API module graph | apps/api/src/api.module.ts | AgentApprovalsModule imported at line 53 / listed at 195; the new DecisionsModule slots in beside it. |
| Web BFF scope | apps/web/src/lib/api/bff-scope.ts | Selector forwarding for scoped reads. Any new web client must forward scope the same way the existing ones do, or scoped reads 400. |
2. Architecture and the seam
2.1 One read model over two tables, one new child table
┌──────────────────────────────────────────┐
│ GET /api/decisions │
└──────────────────┬───────────────────────┘
│
DecisionQueueService.list(userId, filter, scope)
│
┌─────────────────────────────────┼─────────────────────────────────┐
▼ ▼ ▼
agent_escalations agent_action_proposals decision_asks
status='open' status='pending' GROUP BY
ownershipWhere ownershipWhere (decisionType,
archivedAt IS NULL archivedAt IS NULL decisionId)
│ │ │
└───────────────┬─────────────────┘ │
▼ │
normalise to DecisionDto ◄───────────────────────────────────┘
id = `escalation:<uuid>` | `approval:<uuid>`
│
▼
┌──────────────────────────────────────────┐
│ blocking? ← agent_runs.awaitingInput │ one grouped query
│ ← tasks.status = 'blocked' │ per signal
└──────────────────────────────────────────┘
│
▼
rank(blocking desc, confidence desc [null→0.5], createdAt asc)
Four queries per page, none of them N+1. The synthetic composite id follows the
precedent already set by the unified schedules read model
(packages/agent/src/schedules/schedule-view.types.ts,
whose id is ${sourceType}:${ownerId} and is documented as "synthetic, never a
DB PK").
Why no decisions table. Both backing records are load-bearing today, have
their own writers, their own idempotency keys and their own notification mirrors.
A third table would be a third writer to keep in sync, a third dedupe key, and a
migration of live data. The queue needs a view, and it gets one.
2.2 The resolution seam — extracted, not forked
The chain "close the record → compose the answer → deliver it to the agent →
unblock the Task" exists exactly once today, inside
InboxService.reply().
This plan extracts it into a new leaf service and makes the Inbox a caller, so
the two surfaces cannot drift:
BEFORE AFTER
InboxService.reply() InboxService.reply()
├ routeEscalationReply ├ (unchanged claim + CAS)
│ └ escalations.resolve └ DecisionResolutionService.resolve(...)
└ tryResumeLinkedRun │
└ steering.resume │
▼
DecisionResolutionService
1. close the backing record
(AgentEscalationService.resolve
| AgentApprovalsService.decide)
2. mark remaining open asks `superseded`
3. compose the answer message
4. deliver:
live run → RunSteeringService.steer
parked run → RunSteeringService.resume
neither → record 'none'
5. TaskTransitionService.recheckUnblockFor
6. activity log + analytics
returns DecisionResolutionOutcome
DecisionResolutionService lives in the new packages/agent/src/decisions/
module. It depends on RunSteeringService, TaskTransitionService,
AgentEscalationService, AgentApprovalsService and DecisionAskRepository — all
@Optional() except the ask repository, following the positional-arity convention
this package already documents, so unit tests and the worker RPC context construct
it with one argument and degrade honestly.
Import direction. inbox already imports agents and agent-approvals
(see its constructor). decisions imports agents, agent-approvals and
tasks-domain; inbox imports decisions. Nothing imports inbox. No cycle.
2.3 The ask, and why it rides the existing typed-question union
DecisionAsk.question stores a serialized HitlQuestion
(serializeHitlQuestion / parseHitlQuestion), and DecisionAsk.answer stores a
serialized HitlAnswer, validated by validateHitlAnswer. The five user-facing
ask kinds map onto question kinds like this, and the mapping is enforced by a pure
function so an ill-formed pair is rejected at write time:
DecisionAsk.kind | Permitted HitlQuestion.kind | Reason required when |
|---|---|---|
decision | choice, multi_choice | the chosen option is not defaultOptionId |
approval | approval, confirm | decision === 'rejected' / confirmed === false |
fact | text | never |
access | access (new union member) | never |
action | action (new union member) | never |
Two new members are appended to HitlQuestionKind in
packages/contracts/src/hitl/hitl-question.types.ts:
HitlAccessQuestion—{ kind: 'access', capability: string, toolPattern?: string, connectionHint?: string, grantUrl?: string }, answered byHitlAccessAnswer { kind: 'access', granted: true, verified: boolean }.capabilityandtoolPatternare capability/tool names, never plugin ids (Constitution II), and nothing here ever carries a credential value (Constitution VII).HitlActionQuestion—{ kind: 'action', action: string, evidenceHint?: string }, answered byHitlActionAnswer { kind: 'action', done: true, note?: string }.
Both get parser branches, validateHitlAnswer cases, describeHitlQuestion
cases and unit coverage in the existing
packages/contracts/src/__tests__/
suite. This is an additive union widening (Constitution X): every existing
consumer switches on the members it knows and the canvas renderer already parses
defensively, returning null on an unknown kind.
2.4 Ask materialisation — the queue is complete on day one
Asks are written by the same code path that writes the backing record, so a decision never exists without at least one ask:
AgentEscalationService.record()gains an optionalasks?: DecisionAskInput[]. When absent (every caller today), one derived ask is written:kind: 'decision',question: { kind: 'text', prompt: <decisionNeeded, capped 1000>, context: <summary> }. Reason codes with a better natural shape get a better derived ask:budget-stopandguardrail-refusalandmerge-refusedderivekind: 'approval'withquestion.kind = 'approval'.AgentApprovalsService.createProposal()writes one derived ask:kind: 'approval',question: { kind: 'approval', prompt: title, action: title, risks: riskFlags }.- A backfill inside the P1 migration writes one derived ask for every currently open escalation and pending proposal, in batches of 500.
So P1 ships a fully populated queue with zero agent-side change. P2 lets an agent supply its own typed asks.
2.5 What this epic does not touch
- The approve / reject / approve-all endpoints and their DTOs — byte for byte.
- The escalation list / get / resolve endpoints and their DTOs — byte for byte.
- The Home approval block and its server actions.
- The Inbox controller, its DTOs and its item kinds.
RunSteeringService,TaskTransitionService,RunDispatchGateService— used, never modified, except for a widenedsteercall site.- Any notification default.
3. Data model
3.1 P1 — new table decision_asks
Entity file: packages/agent/src/entities/decision-ask.entity.ts.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | @PrimaryGeneratedColumn('uuid') |
userId | uuid | Owner. Every read is owner-scoped |
decisionType | varchar(16) | 'escalation' | 'approval' |
decisionId | uuid | The backing row's id. Raw column, no @ManyToOne — polymorphic, and the entities-cycle rule this package documents |
position | int default 0 | Render order, 0-based |
kind | varchar(16) | 'decision' | 'approval' | 'fact' | 'access' | 'action' |
question | simple-json | A serialized HitlQuestion |
required | boolean default true | Only required asks gate resolution (FR-21) |
status | varchar(16) default 'open' | 'open' | 'answered' | 'withdrawn' | 'superseded' |
answer | simple-json nullable | A serialized HitlAnswer |
rationale | text nullable | The "why", ≤ 1000 chars, enforced at the service layer |
answeredByUserId | uuid nullable | |
answeredAt | PortableDateColumn nullable | |
withdrawnByUserId | uuid nullable | |
withdrawnAt | PortableDateColumn nullable | |
withdrawalNote | text nullable | What was posted to the agent |
resumedRunId | uuid nullable | The run this answer started, for the undo guard |
dedupKey | varchar(200) nullable, unique | ${decisionType}:${decisionId}:${questionId} — the writers are retry-prone |
tenantId | uuid nullable | Tier A/C, stamped by ScopeStampingSubscriber |
organizationId | uuid nullable | idem |
createdAt / updatedAt | PortableDateColumn |
Indexes:
idx_decision_asks_decisionon(decisionType, decisionId, position)— the detail read.idx_decision_asks_user_statuson(userId, status)— the grouped counts the queue read needs.uq_decision_asks_dedupunique on(dedupKey).
3.2 P1 — additive columns on agent_escalations
| Column | Type | Written by |
|---|---|---|
missionId | uuid nullable | AgentEscalationService.record(), derived from tasks.missionId; backfilled by the migration. Provenance only — it drives the Mission filter and the chip; nothing about a Mission is ever resolved, paused or unblocked from here. taskId already exists on this table |
archivedAt | PortableDateColumn nullable | Archive / bulk archive / orphan sweep |
archivedByUserId | uuid nullable | NULL when the sweeper archived it |
archivedReason | varchar(32) nullable | 'user' | 'bulk' | 'source-gone' |
firstViewedAt | PortableDateColumn nullable | First detail read by a human (FR-14) |
New index idx_agent_escalation_mission_status on (missionId, status).
AgentEscalationStatus gains 'archived' in
packages/contracts/src/agents/escalation.types.ts
and in AGENT_ESCALATION_STATUSES. The column is already varchar(16), so this
needs no migration for width. Every existing read filters status='open' or
status='resolved' explicitly, so archived rows simply drop out.
3.3 P1 — additive columns on agent_action_proposals
The same five columns (missionId, archivedAt, archivedByUserId,
archivedReason, firstViewedAt) plus taskId — six in total. This table has
no taskId today, and the queue's Task filter (spec FR-5) and its
"what is this blocking" line (FR-11) both need one; agent_escalations already
carries it. Two indexes:
idx_agent_action_proposals_task_status on (taskId, status) and
idx_agent_action_proposals_mission_status on (missionId, status).
AgentActionProposalStatus gains 'archived' in
agent-action-proposal.entity.ts
and in AGENT_ACTION_PROPOSAL_STATUSES; the column is varchar(16) already.
taskId is derived from runId → agent_runs.taskId, and missionId one hop
further through tasks.missionId, both at creation and both backfilled the same
way. The Task is what a decision blocks; the Mission is only where the Task came
from.