09 · Hub v2

The hub humans review work in

Live at hubv2.apphappenventures.com — NestJS API + Next.js 15 on ECS Fargate (us-east-2). The unified human-in-loop queue shipped: reviewers approve, edit and send AI drafts from one screen, reading a single DynamoDB table (hv-hub-v2-development-tasks). Generation, review and send have all cut over from the VPS/n8n onto AWS.

Shipped

The unified queue

The tasks-v2 review screen over DynamoDB, source-agnostic: sales (Gabrielle) + Robin in one surface, with the 6 reviewer actions and account-card grouping. Google OAuth gated to @happenventures.com.

Live

Cutover done

Sales generation moved off the VPS → /generate → SQS → task_consumer Lambda → DynamoDB. Approve & Send is live; the message-dispatcher Lambda delivers each draft via the right channel adapter (Gmail / Slack / WhatsApp).

Gate

Contact Governor

GOV-01 ALLOW / DEFER / BLOCK / DROP gate runs in the consumer with a per-contact touch log + frequency caps. Live in dev, fail-closed.

Open risks · post-cutover

Integrity before UX

Risk #1

Dual send-worker

A legacy n8n “AWS Send Worker (Maestro) — DRY-RUN” is still active, polling the same approved queue as the live AWS send path. The AWS message-dispatcher sends for real (SEND_DRY_RUN=false) and, as last verified 2026-08-07, the Governor sendDevelopmentMode=false — so email reaches the real client today (setting sendDevelopmentMode=true would redirect every email to internal test inboxes instead; the older maestro_sender Lambda is inert: dry-run + EventBridge DISABLED). The live symptom is the n8n worker marking the shared task status=failed; but because email is already live, the deeper hazard is a double-send to the real client if the n8n worker also delivers — retire it.

Resolved

VPS retired

Generation cut over fully: DynamoDB shows 100% of recent drafts from the AWS consumer (maestro-consumer), zero from the VPS, and no live cron, Lambda or code path still hits 187.77.29.73. The box has since been retired (#152) — it is no longer a single point of failure.

Risk #3

As-built docs lag code

The Governor governance docs declared four pieces “Phase 2 pending” that are in fact built and deployed (held tasks, resurfacer, send/approve hooks, live reads). A reader trusting only the docs underestimates the real state.

Stack · verified in code

What's actually running

API

NestJS 11

Prefix /api. Domains: queue (incl. the source-agnostic QueueActionsService), governor, ai-tasks, hubspot, tickets, meetings.

Web

Next.js 15 + React 19

App Router, Tailwind v4, TanStack Query, Zustand. The tasks-v2 review screen is the queue UI.

LLM

Anthropic Claude

claude-sonnet-4-6, SSE streaming for chat. The Maestro + Hermes agents reason with Bedrock Claude.

Data

DynamoDB single-table

hv-hub-v2-development-tasks — one table for TASK / GOVDECISION / TOUCH / TOUCHACCT / CONFIG / DEAL items. Robin + sales tasks both live here (source distinguishes the bot). HubSpot / Airtable still read for meetings & deal context.

Pipeline

SQS + Lambda

Generation: /generate → SQS → task_consumer Lambda → DynamoDB. Send: API → SQS outbound-messages → message-dispatcher Lambda → per-channel adapter (Gmail / Slack / WhatsApp). Legacy n8n send worker being retired; the VPS is already retired.

Infra

ECS Fargate us-east-2

ALB + ECR + Terragrunt CD. Cluster hv-hub-v2-development, services hv-hub-v2-{api,web}. CI on GitHub Actions / AWS CodeBuild runners.

tasks-v2 · the review queue

The 6 reviewer actions

All source-agnostic in QueueActionsService — one action surface, source distinguishes the bot. The primary button is renamed per task type (Approve & Send / Apply / Approve category).

ActionWhat it doesResult
Approve & SendTwo-click confirm (#222). Fires the task’s callback_url / drops it into the send path; writes before/after to Audit_Log.“Sent ✓” · auto-advance
EditInline edit-manual → Save & Approve. The original draft is preserved; edited_content is the learning signal. Edit-with-AI is wired: POST /queue/:id/revise returns a Bedrock rewrite as a preview, saved via Approve revised.edited & approved
RejectOptional reason. Status → Rejected.“Rejected ✓”
SnoozeSets snooze_until (days, pinned to America/New_York); the scheduler resurfaces it.“Snoozed ✓”
CommentAppends to the thread (comments_thread); no status change. On the DRV/CMP (Robin) flows a Slack notify is enqueued to the dispatcher; on the sales surface it is state-only.“Done ✓”
EscalateOptional reason (DEV-471) + target. Most types reassign to a teammate (records the original assignee, requires escalateTo); driver_exception / cmp_complaint fire an urgent Slack #amazon alert via the dispatcher.“Done ✓”

Review flow

Deliberate one-at-a-time review — no bulk actions or multi-select by design. The modal header shows “Task N of M” with back/next arrows; after a decision the UI flashes a confirmation and auto-advances to the next task. Comment, Escalate and Reject sit under the ··· overflow. Drafts below the confidence threshold (Governor confidenceThreshold, default 60, authoritative for all playbooks — the per-playbook SSOT threshold is deprecated for gating; the old spec cites 70) route to a separate needs_review manual-triage lane. “Call in Aircall” opens the contact for a click-to-call without resolving the task.

Flow · end-to-end

Generate → gate → review → send

Now live

One queue on DynamoDB

1. n8n “Auto Tasks” crons → POST /generate → SQS → task_consumer Lambda.

2. Consumer calls the Governor /check — only ALLOW writes a draft; DEFER writes a held task; BLOCK/DROP are suppressed.

3. Hub GET /api/queue?assigned_to=me reads DynamoDB; tasks-v2 shows pending + held, grouped by account.

4. Reviewer acts via QueueActionsService → Audit_Log + /record touch + send path.

Still to retire

Owned send path

1. Approved sales drafts drain via GET /api/queue/approved (DynamoDB resolved).

2. message-dispatcher Lambda → channel adapter (Gmail DWD send-as for email), with per-rep daily cap + 14-day recipient cooldown.

3. Today the dispatcher runs SEND_DRY_RUN=false and the Governor sendDevelopmentMode=false (as last verified 2026-08-07) — so email reaches the real client (flip sendDevelopmentMode=true to redirect to the Governor’s developmentTestEmails inboxes); the legacy n8n send worker is still active (dual-worker risk).

4. Cutover: retire the n8n worker so the message-dispatcher is the sole sender — email is already live to real clients (the VPS has already been retired, #152).

HV Hub v2 system architecture
Animated · interactive — where the Hub sits: the review surface over one DynamoDB store, with the Maestro sales pipeline feeding it and the Unified Sender draining it. Open full ↗
Contact Governor · GOV-01

The ALLOW / DEFER / BLOCK / DROP gate

live in dev The gate

One family at a time

NestJS module apps/api/src/governor/. POST /governor/check runs the spec rules in order: hard-suppress → BLOCK; one governing family (WIN > POST-JOB > REVIVE > SC > GROW); frequency caps; per-account cap. Only ALLOW writes a draft.

live in dev Touch log

Caps + channel coordination

POST /governor/record appends confirmed touches. Default caps 1/14d & 2/30d per contact, 1/week per account. Email and Aircall calls share one budget. Keyed by the consumer’s exact dedup id (c_<sha1>).

live in dev Held tasks

Deferred, not dropped

DEFER writes a real status='held' task (defer_until, held_reason) — deliberately not named deferred (a send-retry state). A resurfacer Lambda re-checks held touches when defer_until ≤ now (EventBridge rule disabled until cutover).

config UI Admin page

Editable from the Hub

/{slug}/admin/governor edits caps, windows, tiers, precedence, cooldowns and the playbook→family map (RBAC governor:read / governor:update), stored as a CONFIG#governor#default item. Default fail-mode is fail-closed (#297).

State · settled vs open

Where the big questions landed

QuestionResolutionState
Where does the queue live?One DynamoDB single-table; sales + Robin unified, source distinguishes the bot.settled
“Approved → Send”: n8n or AWS?Migrated to the AWS Unified Sender (message-dispatcher Lambda over outbound-messages); the interim maestro_sender poll Lambda is inert and the legacy n8n worker is being retired.in cutover
Reads: Airtable or AWS?Read & act on DynamoDB (#194 / #221). HubSpot & Airtable read only for live signals.settled
Send caps / cooldown?Per-rep daily cap + 14-day recipient cooldown in the send path; Governor adds per-contact / per-account caps.settled
Contact frequency control?The Contact Governor (GOV-01) gate — central ALLOW / DEFER / BLOCK / DROP.live
Edit-with-AI rewrite?Wired: the Bedrock rewrite is the preview endpoint POST /queue/:id/revise; the reviewer saves it via Approve revised.settled
New task types (action / bookkeeping) + Aircall + new row fields?Scoped in the Tasks Screen spec; consumer doesn’t emit the new fields yet.scoped
Retire the VPS + remove SSOT Enabled (BO-25)?Kill-switch is Status-only; consumer decoupled from Enabled. The VPS has been retired (#152); dropping the now-unused SSOT Enabled field is the only cleanup left.partial
Tasks Screen spec · next scope

What the spec still introduces

shipped

Edit-with-AI rewrite

POST /queue/:id/revise returns an in-place Bedrock rewrite (preview) with the same guardrails; the reviewer saves it via Approve revised. UI panel + endpoint both ship.

task type

Action tasks

“Apply change” on approve (e.g. HubSpot deal-stage move) — needs a new write-back callback executor reusing the robin callback path.

task type

Bookkeeping tasks

“Approve category” for bank/credit-card transactions, likely fed by Frank / FIN flows.

row fields

New task fields

hubspot_deal_id, contact_phone, based_on, risk_flags + bookkeeping fields — the consumer doesn’t write these yet.

integration

Aircall touch budget

Click-to-call exists; a logged call must hit the Governor touch budget (channel='call').

cutover

Retire the dual sender

Retire the legacy n8n send worker so the message-dispatcher is the sole sender — email is already live to real clients (SEND_DRY_RUN=false, Governor sendDevelopmentMode=false; the VPS has already been retired, #152).

Send layer · dual-worker risk

The send path, post-cutover

The old Airtable-polling senders are off. The AWS path is the message-dispatcher Lambda (Unified Sender over outbound-messages) — but a legacy n8n worker still polls the old approved queue.

WorkerWhereActiveSource of truthState
message-dispatcherAWS Lambdaonoutbound-messages SQS (enqueued on approve)Routes by channel; runs SEND_DRY_RUN=false and, as last verified 2026-08-07, the Governor sendDevelopmentMode=false — email reaches the real client (setting sendDevelopmentMode=true redirects every email to the developmentTestEmails inboxes). Per-channel send flags in the Governor config gate each channel (Slack on, WhatsApp off).
AWS Send Worker (Maestro) — DRY-RUNlegacy n8nonsame approved queuePolls the same rows (in dry-run) — today it marks tasks failed; now that email is live to clients it must be retired to avoid a double-send
Consequence: both workers consume the same approved queue. Email is already live to real clients (Governor sendDevelopmentMode=false) and the live sender is the message-dispatcher (the older maestro_sender is inert). The n8n worker is the open hazard: if it also delivers an approved task, the client gets a double send — today it is in dry-run and just marks tasks failed. Send-layer cutover = retire the n8n worker so the dispatcher is the sole sender.
Generation pipeline · as built

Crons POST /generate → SQS → consumer

The cutover (ADR 0001, Path B) kept the n8n “Auto Tasks” crons as the schedule but re-pointed them off the VPS to an AWS API. The generation engine (Maestro) is a Lambda consumer behind SQS — drafts land in DynamoDB, gated by the Governor.

Step 1 · trigger

Crons → /generate

The n8n “Auto Tasks” crons fire on schedule and POST {playbook_id, view_id, mode, limit} to the /generate API — no longer to 187.77.29.73:8788.

Step 2 · queue

Producer → SQS

task-producer enqueues to the ingest SQS queue. A DLQ + CloudWatch alarms (#246) cover failures; SNS relays to Slack #hv-alerts.

Step 3 · draft

task_consumer Lambda

Pulls config from the SSOT, calls the Governor /check, drafts with Bedrock, dedups per contact, and writes pending_review / needs_review / held to DynamoDB.

// n8n "Auto Tasks" cron — re-pointed to AWS at cutover POST https://5pauixfui5.execute-api.us-east-2.amazonaws.com/generate { "playbook_id": "recHqVWeOIL8IV2W9", // SSOT record id "view_id": "viwjBAHqBugdbFrcU", "mode": "orders", "limit": 2, "source_workflow": "auto-tasks-customer-rebooking" } // → task-producer → SQS → task_consumer Lambda // → Governor /check (ALLOW only) → Bedrock draft // → DynamoDB hv-hub-v2-development-tasks (pending_review)

Why a Lambda consumer, not NestJS @Cron

The generation engine is the task_consumer Lambda (Maestro pipeline), decoupled from the Hub API by SQS — it can fail, retry and back-pressure without touching the API that reviewers depend on. The kill-switch is Status-only (per-playbook, read from the SSOT), so a single playbook can be stopped without redeploying. The Hub API owns the read/act surface (/queue + the Governor); the Lambda owns generation.

Next

Close out the cutover

What’s left

The unified queue, the 6 reviewer actions, Approve & Send and the Contact Governor are live in dev. To finish: retire the legacy n8n send worker so the message-dispatcher is the sole sender (email is already live to real clients), wire the Edit-with-AI rewrite endpoint, and add the new task types (action / bookkeeping) + Aircall touch-budget fields from the Tasks Screen spec. The VPS has already been retired (#152). The as-built Governor docs also lag the deployed code — reconcile them.