hango: multi-tenant agent harness in Go
Overview
hango is a multi-tenant agent harness server written in Go from scratch: API-key auth, durable runs, SSE streaming, middleware-composed harness features (planning, virtual filesystem, subagents, summarization, memory, skills, human-in-the-loop). Built feature by feature as a learning roadmap whose final state is production-ready. Every phase leaves a binary that builds, vets and tests green, at least one integration test against real Postgres where it touches the store, and a learning doc at docs/learning/NN-<slug>.md comparing hango’s choice against goclaw and langchain deepagents.
Locked decisions
- Go 1.27, standard-library
net/http(http.ServeMuxplus a small router ininternal/server, no web framework), SSE only (no WebSocket). Package cap is 20 plusinternal/tool/builtin, enumerated exactly:cmd/hango,migrations, andinternal/{app,config,server,auth,store,store/pg,provider,provider/anthropic,provider/openai,agent,tool,tool/builtin,run,fs,usage,telemetry,safego,telegram,claudecli}.scripts/is excluded because it holds no importable code. Adding a twenty-first package requires an amendment to this line before the code is written. - Ids are UUID v7 from the Go 1.27 stdlib
uuidpackage (uuid.NewV7);uuid.Nilis a function and is always called, never compared as a value. - Dependency direction is one-way:
internal/agentimports nothing frominternal/run.agentdefinesEventSinkandGate;runimplements them and the composition root wires them. Any design needing the reverse direction is wrong. - Run execution is owned, leased and resumable. A worker starts a run only through a conditional claim (
UPDATE ... WHERE state='queued' RETURNING) that setsowner_idandlease_expires_at; every state transition is conditional on state and owner; reconciliation reclaims only expired leases and requeues at a turn boundary. Restart and drain requeue, they do not fail. pause_turnis unreachable and therefore an error. hango registers no Anthropic server tools, sopause_turnsurfacesErrUnsupportedStop. There is no resume counter anywhere.anthropic-sdk-gov1.74 behind hango’s ownprovider.Provider; normalizedMessage/Block/Eventmodel designed so an OpenAI adapter synthesizes block start/stop.- PostgreSQL only via pgx v5, hand-written SQL, goose embedded migrations. No SQLite, no sqlc, no ORM. Messages are append-only rows
(session_id, seq, role, jsonb blocks). - Logical tenancy:
tenant_id uuid NOT NULLon every tenant-owned table, tenant-leading index, scoped store built from context that fails closed, cross-tenant read test per table from the first table onward. RLS is a late backstop, never a replacement — and enabling it in phase 16 is an explicit refactor ontoWithTenantTxplus a smallBYPASSRLSsystem pool for the five paths that precede a tenant scope. - Intra-tenant visibility: a
membersees and mutates only what they created;ownerandadminsee and mutate everything in their tenant. Enforced by a per-route authorization matrix plus a sharedvisibility(scope)store predicate, tested route by route and table by table. - hango owns identity: tenants, tenant-scoped users (owner/admin/member), api_keys owned by a user (SHA-256 hash + lookup prefix). No passwords, no login UI, bootstrap admin key from config.
- Runs are durable rows executed by an in-process scheduler lane worker; SSE subscribes to the persisted event log with
Last-Event-IDresume; cancel endpoint; graceful drain. Single replica in v1 (in-process rate limiter and run registry, documented). - Middleware architecture: one ordered, data-driven slice with
BeforeRun,WrapModelCall,WrapToolCall,ToolProvider,PromptContributor. Every harness feature after phase 7 is a middleware. The system prompt is assembled inside the chain at model-call time as[]provider.Blockfrom the contributors in stack order, which is what makes cache breakpoints and memory ordering expressible as block indices. Middleware state ismap[string]json.RawMessagemarshalled by its owner, so it survives persistence and resume.ToolreturnsResult{ForLLM, ForUser, IsError}; tool errors become tool results, never panics into the loop; repeat-call detection. No authored base system prompt: policy lives in tool descriptions, the system prompt stays short and dynamic. - Audit everything: every mutating API request and every approval decision writes an append-only `audit_events` row carrying request, trace and span ids; audit rows, run events, spans and logs join on `trace_id`.
- Observability stack: OTel in-process (one TracerProvider/MeterProvider/LoggerProvider,
otelslogas the defaultsloghandler) → one OTLP egress to an OpenTelemetry Collector → fan-out to Grafana LGTM (Tempo/Loki/Prometheus) and Langfuse Cloud; GenAI andlangfuse.*attribute names are hango-local constants, prompt bodies off by default and stripped in the collector. Observability is a foundation requirement: it ships in phase 1 and every phase instruments itself so any failure is locatable tenant → agent → session → run → turn → model call → tool call.
Goals
| # | Goal | Priority |
|---|---|---|
| 1 | Production-ready multi-tenant harness server, single binary, docker compose up; cross-tenant access impossible by construction, proven per table | P1 |
| 2 | Durable, cancellable, resumable runs with SSE streaming; provider abstraction proven by a second (OpenAI-compatible) adapter | P1 |
| 3 | Harness features composed as middleware: planning, filesystem, subagents, context management, memory, skills, HITL | P1 |
| 4 | Every layer observable and every failure locatable in Grafana and Langfuse; usage accounting and quotas | P1 |
| 5 | A learning doc per phase: concept, hango vs goclaw vs deepagents, how to debug that layer | P1 |
Phases
Effort is stated as implementation + learning doc, because the learning doc is a deliverable of every phase, not an afterthought.
| # | Phase | Impl | Doc | Effort | Depends on |
|---|---|---|---|---|---|
| 1 | Foundation, server skeleton, observability spine | 15h | 2h | 17h | — |
| 2 | Persistence and tenancy core | 16h | 2h | 18h | 1 |
| 3 | Provider abstraction and Anthropic adapter | 12h | 3h | 15h | 1 |
| 4 | Agents, sessions, messages | 10h | 2h | 12h | 2 |
| 5 | Tools, agent loop, durable runs | 17h | 3h | 20h | 3, 4 |
| 6 | Streaming, cancellation, drain | 14h | 2h | 16h | 5 |
| 7 | Middleware chain and planning tool | 11h | 2h | 13h | 6 |
| 8 | Filesystem backend and large-result offload | 13h | 2h | 15h | 7 |
| 9 | Subagents | 10h | 2h | 12h | 8 |
| 10 | Context management and summarization | 11h | 2h | 13h | 8 |
| 11 | Long-term memory and skills | 7h | 2h | 9h | 9, 10 |
| 12 | OpenAI-compatible provider and provider registry | 8h | 2h | 10h | 5, 8, 10 |
| 13 | Usage, quotas, rate limits | 9h | 2h | 11h | 9, 10, 12 |
| 14 | Dashboards, alerts, debugging runbook | 10h | 2h | 12h | 13, 18, 19 |
| 15 | Human-in-the-loop | 12h | 2h | 14h | 9, 14 |
| 16 | Production hardening and RLS backstop | 13h | 3h | 16h | 15 |
| 17 | Evaluation loop | 8h | 2h | 10h | 16 |
| 18 | Telegram channel | 15h | 2h | 17h | 6, 13 |
| 19 | Claude Code CLI runtime for the owner | 12h | 2h | 14h | 6, 8, 13 |
| 20 | Telegram media, pairing and approvals | 23h | 3h | 26h | 15, 16, 18 |
| Total | 246h | 44h | 290h |
Build order
Build order: 1 → 3 → 2 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 18 → 19 → 14 → 15 → 16 → 20 → 17.Phase 3 depends only on phase 1, so it runs before phase 2. A real model then streams into Tempo and Langfuse at about 31h instead of 49h, and the observability spine has something meaningful to show early. Phase numbers, scope and dependencies do not change. Phases 18 and 19 run right after phase 13, so the family can use hango through Telegram before the dashboard, approval and hardening work; neither adds a migration, so migrations 00001–00011 keep their numbers. Phase 20 runs after phase 16, so its migration 00012_telegram.sql follows the RLS migration and creates its tables with row-level security from the start, and phase 17’s evals can include an image task. build_plan_html.go reads this line, and the page’s “Next up” follows it.
Phase 5 stops twice for review: 5a (harness core: tools and the loop against provider.Fake) and 5b (durability: runs, claim, lease, reclaim, worker, endpoints). Its phase file lists which steps and criteria belong to each checkpoint.
Acceptance criteria
- Server boots from config, graceful shutdown, health endpoints.
- Tenant resolved from API key; every store and tool call scoped to the tenant. Cross-tenant access impossible by construction, covered by tests.
- Users within a tenant can create and run agents.
- Agent loop with tool calling and streaming over HTTP.
- Persisted sessions and messages.
- Subagents, todo/planning tool, per-tenant virtual filesystem, long-term memory.
- OpenTelemetry traces and structured logs, correlated in Grafana and Langfuse.
-
go test ./...green,go vetclean. -
docker compose upbrings up server and database. -
hango eval runscores a fixed task set through real runs, pushes the scores to Langfuse, andhango eval compareshows whether a change helped.
Learning docs
Each phase writes docs/learning/NN-<slug>.md (concept, design choices, trade-offs, how goclaw and deepagents do the same thing, why hango diverges, how to debug that layer) and updates docs/learning/README.md and docs/system-architecture.md. Every doc under docs/ starts with front matter holding title and weight (a learning doc’s weight is its phase number) so the Hugo site in site/ orders it; see docs/docs-site.md.
Follow-ups out of scope
- Bring-your-own provider credentials per tenant (deferred from phase 12). Requirements that work must satisfy: a
provider_credentialstable with AES-GCM ciphertext whose AAD binds(tenant_id, provider)so a row cannot be replayed into another tenant; a key id per row with primary/secondary keys for rotation; resolution through the existingCredentialLookupseam with no in-memory caching of decrypted secrets; an SSRF allowlist plus post-resolution private-IP rejection for tenant-suppliedbase_url; metadata-only responses;owner/adminonly. Operator-set server credentials remain the default path. - Anthropic server tools and opaque raw-block passthrough, which together are what would make
pause_turnreachable and resumable. Until thenpause_turnisErrUnsupportedStop. - LLM gateway with per-user quotas. One place that owns provider credentials, routes each call to a provider, and enforces quotas per user as well as per tenant, so family members cannot use up each other’s budget and more providers can be added without touching the agent loop. It builds on the phase-12 registry and the phase-13 usage records. Until then the family shares one tenant quota.
- MCP bridge (MCP servers exposed as
tool.Toolimplementations); multi-replica deployment (shared rate limiter, event fan-out — note the run ownership lease is already multi-replica-safe); sandboxed execution tool (Sandboxbackend withExecute); parallel tool execution within a batch; chat channels other than Telegram, Telegram group chats (goclaw design kept in phase 20, with a configurable history limit), Telegram webhook mode and multi-bot, web chat UI (planned after Telegram) and an admin web page, both in Svelte 5 and talking to the same HTTP API, desktop app, i18n, 20+ providers.
Red Team Review
Four adversarial reviews (security adversary, failure-mode analyst, assumption destroyer, scope and complexity critic) produced 41 findings against the 16 phase files, grouped below into 15 themes plus four decisions the user made directly. Every group was accepted. Reports: plans/reports/redteam-260922-0126-{security-adversary,failure-mode-analyst,assumption-destroyer,scope-complexity-critic}.md.
| # | Finding | Severity | Disposition | Applied To |
|---|---|---|---|---|
| D1 | BYO per-tenant provider credentials (table, AES-GCM, rotation, 3 endpoints) is scope beyond the contract and an unbounded security surface | High | Accept (user decision) | phase 12 (scope cut to a CredentialLookup seam over operator config, base URL validated https-or-loopback), phase 16 (RLS table list, deployment guide), plan.md follow-ups |
| D2 | No intra-tenant authorization model: a member can read and destroy every other user’s data | Critical | Accept (user decision) | phase 2 (route matrix, intra-tenant test cases), phase 4 (sessions.created_by, agent/session/message matrix, 404 not 403), phase 5 and 6 (runs and events), phase 8 (files API), phase 15 (approvals) |
| D3 | Restart or drain while running loses work or executes a run twice; no ownership lease | Critical | Accept (user decision) | phase 5 (owner_id, lease_expires_at, conditional claim, heartbeat, resume_turn, reclaim sweep, tool idempotency), phase 6 (drain requeues, HANGO_DRAIN_TIMEOUT ≥ one turn), phase 16 (deployment guide) |
| D4 | pause_turn cannot round-trip hango’s block model, and hango registers no server tools, so the resume path is unreachable code | Critical | Accept (user decision) | phase 3 (ErrUnsupportedStop), phase 5 (MaxResumes deleted, fixtures and criteria updated), plan.md locked decisions and follow-ups |
| F1 | RLS as designed denies every plain-pool query, starting with API-key authentication | Critical | Accept | phase 16 (system role with BYPASSRLS on a second small pool with five enumerated callers, WithTenantTx refactor of every tenant-scoped call site, never across an LLM call, full suite green under forced RLS, auth path exercised post-RLS) |
| F2 | No atomic run claim and non-transactional approval/expiry transitions: runs can execute twice | Critical | Accept | phase 5 (conditional claim, conditional transitions, enqueue dedup, two-worker race test), phase 15 (decision and expiry each transition in their own transaction; two concurrent final approvals ⇒ one execution) |
| F3 | On approval resume, RepairToolCalls destroys the batch it is meant to resume; creator self-approval; unvalidated edit arguments | Critical | Accept | phase 15 (decisions loaded before BeforeRun, repair skips decided ids, resume re-drives the pending batch through the tool chain, exactly-once criterion, owner/admin-only and never the creator, edit validated against Parameters() and the when matcher, approval.decided published by the handler) |
| F4 | Subagent lane semaphore is acquired re-entrantly: self-deadlock at depth ≥ 2 | Critical | Accept | phase 9 (non-blocking TryAcquire at depth > 0 with a capacity-refusal tool result, sequential tool batches in v1, parallel-task criterion and test deleted, depth cap kept) |
| F5 | internal/agent and internal/run form an import cycle; Agent.Run changes signature in phase 9 with its callers unlisted | Critical | Accept | plan.md locked decisions (one-way rule), phase 5 (RunOptions, final signature, call sites enumerated, EventSink owned by agent), phase 9 (injected agent.Gate, no signature change) |
| F6 | The load-bearing “Memory after PromptCache” ordering cannot exist: prompt fragments are strings assembled outside the chain | Critical | Accept | phase 7 (PromptContributor returns []provider.Block, assembly inside ModelCall, order asserted by index), phase 10 (breakpoint by block index, summary-message breakpoint), phase 11 (memory is RunHook + PromptContributor, WrapModelCall claim removed) |
| F7 | Accumulate(seq) cannot serve streaming and discards the partial message the cancel path needs | High | Accept | phase 3 (stateful Accumulator with Push/Message/Partial/Usage/Stop), phase 5 and 6 (emit per event while accumulating; persist the partial on failure), phase 12 (parity test uses the same accumulator) |
| F8 | Run durability gaps: unpaginated SSE replay, replay-phase drops, seq allocated outside the insert, unhandled Emit errors, queued runs with no liveness path | High | Accept | phase 6 (replay catch-up loop, byte-capped replay stage, seq inside the insert tx, documented Emit error policy with stream_degraded, DB poller, hango.run.oldest_queued_age, runs.last_event_seq in the DDL, turn.aborted before retries, HANGO_SSE_WRITE_TIMEOUT and HANGO_MAX_QUEUED_RUNS_PER_SESSION in config), phase 14 (alerts) |
| F9 | Compaction can split a tool_use/tool_result pair; the budget omits system prompt and tool definitions | High | Accept | phase 10 (boundary snap-back, post-compaction validator, adversarial fixture, EstimateRequest, optional TokenCounter over Messages.CountTokens, second overflow degrades to truncation, summary-message cache breakpoint, counters written at the turn boundary, sessions.summary dropped), phase 8 (EstimateRequest introduced with the estimator) |
| F10 | State.Private map[string]any has no persistence codec: typed middleware state silently resets on resume | High | Accept | phase 7 (map[string]json.RawMessage, per-middleware accessors, explicit missing-key path with a span event, round-trip test, per-batch scratch cleared at batch end) |
| F11 | Broadcaster topic lifetime contradicts HITL suspension; the goroutineleak profile cannot see these leaks | High | Accept | phase 6 (reference-counted topics, documented subscriber bound, hango.broadcaster.topics, goroutine-count and topic-count assertions, cancel-registry removal points), phase 16 (goroutineleak only in the soak test) |
| F12 | Agent-scoped filesystem exposes every session’s transcript tenant-wide and makes /memories/ a persistent prompt-injection store | High | Accept | phase 8 (per-session prefixes excluded from glob/grep/API, agent-id context key owned by internal/fs, files-API agent ownership check, version column with conflict results, privileged reserved-prefix writes, false parallel-mutation mitigation deleted), phase 9 (risk row corrected), phase 11 (allowed_tools may only narrow), phase 15 (default interrupt rule) |
| F13 | Observability hygiene: unimplemented per-tenant trace opt-out, pattern-based content stripping, route-less HTTP metrics, no auth rate limit, unrevocable bootstrap key, missing GenAI constants, collapsed cache-write pricing | High | Accept | phase 1 (5 missing GenAI constants, metrics_names.go, content_attrs.go, http.route labeler, collector strip list on both legs), phase 2 (tenants.trace_llm_content, pre-auth limiter, coalesced last_used_at, one-shot bootstrap key), phase 3 (both content gates tested), phase 13 (5m/1h cache-write pricing), phase 14 (retrofit and regex test deleted, alerts added), phases 15 and 16 (dashboard/alert updates) |
| F14 | Unjustified scope: CompositeBackend, two bespoke load/soak driver programs, undecided test-database and integration-test wiring | High | Accept | phase 11 (CompositeBackend dropped, reserved prefixes in path.go, learning note kept), phase 16 (TestConcurrentRuns/TestSoak replace scripts/, p95 budget written first), phase 2 (per-package test databases via HANGO_TEST_DB_DSN, go test ./... runs integration, -short skips) |
| F15 | Internal inconsistencies: wrong dependencies, disagreeing effort tables, duplicate step numbers, unread contracts, wrong package cap, stdlib uuid usage | Medium | Accept | plan.md (effort table reconciled with learning-doc line items, package cap stated as 18 + tool/builtin, UUID v7 locked), phase 12 ([5, 8, 10]), phase 13 ([9, 10, 12]), phases 3 and 5 (step numbering), phase 5 (Result.Meta deleted), phase 8 (FileStore rename), phase 9 (InterruptConfig defined), phase 10/12 (PromptCaching consumed, ParallelTools deleted), migrations renumbered 00009-00011 |
Severity breakdown across the 41 underlying findings: 11 Critical, 22 High, 8 Medium. Accepted: 41 (100%). Rejected: 0. Four items were resolved by explicit user decision (D1-D4) rather than by the reviewers’ proposed remedy.
Files modified: plan.md and all sixteen phase files. Net effort change: 180h → 216h (+36h), of which +35h is the learning-doc line item that was previously unbudgeted; implementation effort moved 180h → 181h as scope cuts (BYO credentials, CompositeBackend, load/soak drivers, phase-14 metrics retrofit: −11h) roughly offset new work (run ownership and leases, RLS refactor, SSE durability, authorization matrix, filesystem hardening: +12h).
Whole-Plan Consistency Sweep
Re-read plan.md and all sixteen phase files after the edits, then grepped every plan file for the terms the changes could have stranded.
| Check | Result |
|---|---|
provider_credentials, HANGO_ENCRYPTION_KEY*, internal/auth/crypt.go | Only in the deliberate D1 deferral notes (phase 12), the RLS table-list comment (phase 16) and the plan.md follow-up. No live requirement, file, step or criterion. |
MaxResumes, pause_turn resume | Only in the phase-5 struct comment recording the removal. All pause_turn mentions now resolve to ErrUnsupportedStop. |
CompositeBackend | Only in deepagents comparison prose and the phase-11 “rejected option” learning note. No hango file, step or criterion. |
scripts/loadtest, scripts/soaktest | Only in the phase-16 sentence stating they are not built. Makefile targets now wrap go test. |
goroutineleak gates | Only in the phase-16 soak test and the two notes explaining why it is not used elsewhere. Phase 6 uses count-based assertions. |
Accumulate(, Accumulate as a function | Gone. Phase 3 defines Accumulator; phases 5, 6 and 12 reference it. Remaining word “Accumulate” appears twice as ordinary prose (“accumulate usage”, “accumulate strictly by Index”). |
Result.Meta | Deleted from the type; only the comment recording the deletion remains. Phase 8 records the offload path on the span instead. |
sessions.summary | Column removed from the phase-10 migration with a comment naming its replacement. No reader anywhere. |
FileStoreFactory | Renamed to store.FileStore in phase 8 (two sites) with the rename noted. |
hango.tenant.trace_llm (old attribute) | Replaced everywhere by hango.tenant.trace_llm_content, declared in phase 1, populated in phase 2, enforced and tested in phase 3, consumed by the collector config in phase 1. |
State.Private map[string]any | Now map[string]json.RawMessage in phase 7; the only remaining map[string]any are tool.Parameters() (a JSON Schema object, correct) and goclaw comparison prose. |
SystemPromptFragment / Chain.PromptFragments | Replaced by SystemBlocks in phases 7, 10 and 11, including the phase-7 “Modify” list. |
EstimateMessages | Replaced by EstimateRequest in phases 8 and 10, including the phase-10 “Modify” list. |
LaneGate | Replaced by agent.Gate plus the internal/run semaphore in phase 9. |
MarkRunning | Removed: the conditional claim is the transition to running (phase 5). |
| Phase dependencies | Frontmatter and the plan.md table agree for all sixteen phases, including the corrected 12 → [5, 8, 10] and 13 → [9, 10, 12]. The DAG is acyclic and every phase’s dependencies precede it numerically. |
| Effort | Frontmatter totals match the plan.md table row by row (16, 16, 15, 12, 19, 16, 13, 15, 12, 13, 9, 10, 11, 10, 13, 16) and sum to 216h, matching the plan.md frontmatter. Every phase’s learning-doc outline ends with its time budget, and impl + doc equals the phase total. |
| Migration numbering | Renumbered after the phase-12 migration was dropped: 00001-00008 unchanged, 00009 usage and quotas (13), 00010 pending approvals (15), 00011 RLS (16). No gap and no reordering relative to phase order. |
| Duplicate step numbers | Fixed in phase 3 (two “12"s) and phase 5 (two “13"s); every phase’s implementation steps are now strictly increasing. |
| Package cap | plan.md now states 18 packages plus internal/tool/builtin, enumerated; the enumeration matches every package named across the phase files, and scripts/ no longer appears as a code location. |
| Cross-cutting test tables | The isolation table (phase 2) is extended by phases 4, 5, 6, 8, 9, 13, 15 and now carries both a cross-tenant and an intra-tenant case per table; the authorization matrix (phase 2) is extended by phases 4, 5, 8 and 15. |
| Locked decisions vs phases | Every locked decision in plan.md is realized in at least one phase, and no phase contradicts one: one-way package dependency (5, 9), ownership lease (5, 6, 16), pause_turn as error (3, 5), intra-tenant visibility (2, 4, 5, 8, 15), chain-time prompt assembly (7, 10, 11), RLS as a refactored backstop (16), UUID v7 (2). |
Unresolved contradictions: 0.
Validation Log
Session 1 — 2026-09-22
Trigger: post-red-team validation interview during /ak:plan --hard. Four questions asked, four answered. Three confirmed the plan as written; one (Q3) replaced a proposed minimal scope with a broader requirement and is propagated through the phase files below.
Q1 — Architecture: Go module path and CI provider
Question: “Go module path? Phase 1 assumes github.com/phihdn/hango and phase 16 assumes GitHub Actions for CI.”
Options: github.com/phihdn/hango (Recommended) | Different path.
Answer: github.com/phihdn/hango.
Rationale: fixes every import path in the plan and the CI provider in phase 16. No plan change.
Q2 — Trade-offs: where LLM-level traces go during development
Question: “Where do LLM-level traces go during development?”
Options: Langfuse Cloud Hobby (Recommended) | Self-host Langfuse from phase 1 | Grafana only for now.
Answer: Langfuse Cloud Hobby.
Rationale: confirms the phase-1 default. Self-hosting stays behind the compose profile, so the decision is reversible without touching application code.
Q3 — Scope: audit trail of admin mutations
Question: “The security review asked for an audit trail of admin mutations (tenant, user, key, quota, approval changes). It is not in the contract.”
Options: Add a minimal audit_events table in phase 2 (Recommended) | Defer to follow-ups.
Answer (custom input, verbatim): “I would like to audit/trace everything. As I said, I want to know what happened when something went wrong”.
Rationale: broader than the recommended option. Every mutation that passes through the API and every approval decision is audited, and each row is correlated to its trace id so the audit trail, the trace, the run event log and the logs all join on one key.
Q4 — Assumptions: phase 3’s working increment
Question: “Phase 3 (provider adapter) has no HTTP surface. Its binary proof is a hango probe command that needs a live Anthropic key, plus fake-provider tests in go test. Acceptable as the phase’s working increment?”
Options: Yes, probe command plus tests (Recommended) | No, add a minimal chat endpoint.
Answer: Yes.
Rationale: a minimal chat endpoint added in phase 3 would be thrown away in phase 5. No plan change.
Confirmed Decisions
| # | Decision | Effect on the plan |
|---|---|---|
| Q1 | Module path github.com/phihdn/hango, CI on GitHub Actions | None — confirms phases 1 and 16 as written |
| Q2 | Langfuse Cloud Hobby for LLM traces in development | None — confirms the phase-1 default; self-host remains a compose profile |
| Q3 | Audit everything: every mutating request and every approval decision | New locked decision; audit_events and the audit middleware land in phase 2; phases 4, 5, 6, 8, 13, 15 declare their actions; phase 14 adds panels, a runbook step and alerts; phase 16 adds the RLS policy and the retention follow-up |
| Q4 | Phase 3 ships hango probe plus fake-provider tests, no HTTP surface | None — confirms phase 3 as written |
Action Items
| # | Action | Where | Status |
|---|---|---|---|
| A1 | Add the audit_events DDL to the existing 00001_identity migration, leaving every other migration number untouched | phase 2 | Applied |
| A2 | Add the audit middleware, SetAuditAction, the AuditStore and the read-only GET /v1/audit-events route with its authorization row | phase 2 | Applied |
| A3 | Register hango.audit.events and hango.audit.write_failures through the phase-1 metrics_names.go mechanism, and add the audit-vs-trace-vs-log learning bullet | phase 2 | Applied |
| A4 | Add audit_events to the cross-tenant isolation table and the read endpoint to the authorization matrix | phase 2 | Applied |
| A5 | Declare each phase’s audit actions in its implementation steps and assert them in its success criteria | phases 4, 5, 6, 8, 13, 15 | Applied |
| A6 | Audit the sweeper’s approval expiries as actor_kind=system, action approval.expire | phase 15 | Applied |
| A7 | Add the Audit panel group to hango-api, the audit step to the runbook, and the denied-rate and write-failure alert rules | phase 14 | Applied |
| A8 | Include audit_events in the RLS enumeration with insert-and-select-only rights, and record the partition-or-archive retention follow-up | phase 16 | Applied |
| A9 | Reconcile effort: phase 2 +2h, phases 5, 14 and 15 +1h each; update the phases table and both frontmatter totals | plan.md, phases 2, 5, 14, 15 | Applied |
Impact on Phases
| Phase | Change | Effort |
|---|---|---|
| 2 | audit_events DDL in 00001_identity, AuditStore, audit middleware and SetAuditAction, GET /v1/audit-events, two metrics, authorization row, isolation case, three risk rows, four success criteria | 16h → 18h |
| 4 | Handlers declare agent.create, agent.update, agent.delete, session.create, session.delete; message append deliberately declares none | 12h (unchanged) |
| 5 | Run creation declares run.create and records agent, session and lane in detail | 19h → 20h |
| 6 | Cancel declares run.cancel; the cancel criterion asserts the audit row | 16h (unchanged) |
| 8 | Files API declares file.write and file.delete, path in detail, never content | 15h (unchanged) |
| 13 | Quota handler declares quota.update with previous and new limits in detail | 11h (unchanged) |
| 14 | Audit panel group on hango-api, runbook step 8, two new alert rules (ten → twelve) | 10h → 11h |
| 15 | approval.decide from the handler including denials, approval.expire from the sweeper, learning bullet, success criterion | 13h → 14h |
| 16 | audit_events in the RLS enumeration with insert-and-select-only rights, retention follow-up in the deployment guide, OWASP evidence cites audit actions | 16h (unchanged) |
Net effort: 216h → 221h (+5h). Implementation 181h → 186h; learning docs unchanged at 35h.
Whole-Plan Consistency Sweep (Session 1)
Re-grepped every plan file for audit, the action names, the endpoint, the metric names and the migration numbers after the edits.
| Check | Result |
|---|---|
| Table name | audit_events is the only spelling anywhere; it is created once, in migrations/00001_identity.sql (phase 2), and referenced by phases 4, 5, 6, 8, 13, 14, 15 and 16 and by the plan.md locked decision. |
| Action names | The closed set declared in phase 2 is tenant.create, user.create, api_key.create, api_key.revoke, agent.create, agent.update, agent.delete, session.create, session.delete, run.create, run.cancel, file.write, file.delete, quota.update, approval.decide, approval.expire. Every action named in phases 4, 5, 6, 8, 13 and 15 is in that set, and no phase introduces one that is not. The similar-looking approval.requested and approval.decided are SSE event kinds from phase 15, not audit actions, and remain distinct. |
| Endpoint | GET /v1/audit-events appears in phase 2 only — the requirement, the authorization matrix, the handler file and the success criterion. No phase adds a write, update or delete route for audit rows. |
| Metric names | hango.audit.events and hango.audit.write_failures are declared in the phase-2 Instrumentation line, which registers them through the internal/telemetry/metrics_names.go mechanism phase 1 introduced. Phase 1 lists no metric names explicitly, so nothing needed to change there. Phase 14’s dashboard and alert additions use exactly those two names. |
| Middleware seam | SetAuditAction(c, action, targetKind, targetID) is defined once (phase 2 architecture) and called by name in phases 4, 5, 6, 8, 13 and 15. No phase invents a second way to record an action. |
| Append-only property | Stated in phase 2 (store exposes Append and List only) and enforced at the database level in phase 16 (insert-and-select-only rights for hango_app, plus the test). No phase deletes or updates an audit row, and retention is an explicit v1 non-goal with a named follow-up. |
| Migration numbering | Unchanged: 00001-00011 exactly as before, with audit_events added to the body of 00001 rather than taking a new number. Phase 16’s RLS migration is still 00011. |
| Effort | Frontmatter totals are 16, 18, 15, 12, 20, 16, 13, 15, 12, 13, 9, 10, 11, 11, 14, 16 and sum to 221h, matching the plan.md table Total and the plan.md frontmatter. The Impl column sums to 186h and the Doc column to 35h. Phases 2, 5, 14 and 15 have their per-phase “Time budget” lines updated to match their new frontmatter. |
| Phase dependencies | Untouched by this session; the DAG is unchanged and still acyclic. |
| Locked decisions vs phases | The new “audit everything” decision is realized in phases 2, 4, 5, 6, 8, 13, 14, 15 and 16, and no phase contradicts it. |
Unresolved contradictions: 0.
Review Session — 2026-10-13
Trigger: the learning-flow review in plans/reports/review-261013-1855-roadmap-learning-flow-and-gaps.md. The user chose to keep testcontainers (RV1 option O2), adopt the 1 → 3 → 2 → 4 → 5 build order (RV7) and add an evaluation phase (RV9 option O2). All of RV1–RV9 were applied.
| # | Change | Where |
|---|---|---|
| RV1 | Per-package main_test.go starts or reuses one named testcontainers container and creates its own database; HANGO_TEST_DB_DSN is an optional override for CI; no non-test testing.go, so the production binary does not link testcontainers | phase 2 |
| RV2 | The worker builds its scope from the creator’s current role and status and fails the run with creator_inactive when the creator is disabled or gone; the empty role is invalid and visibility(scope) fails closed on it | phases 2, 4, 5 |
| RV3 | Tenant creation, bootstrap and platform routes acting on a tenant write inside that tenant’s scope, so the system pool keeps exactly five callers | phases 2, 16 |
| RV4 | Phase 11 depends on [9, 10] | phase 11, plan.md |
| RV5 | internal/run/reconcile.go and its test moved from Modify to Create | phase 5 |
| RV6 | Ten stale text spots corrected | docs/roadmap.md, phases 6, 11, 12, 13, 14 |
| RV7 | Build order section added; the page’s “Next up” follows it | plan.md, docs/roadmap.md, build_plan_html.go, plan-template.html |
| RV8 | Review checkpoints 5a and 5b inside phase 5 | phase 5 |
| RV9 | Phase 17, evaluation loop, 8h + 2h, no new package | phase 17, plan.md, docs/roadmap.md |
Net effort: 221h → 231h (+10h, all phase 17). Implementation 186h → 194h, learning docs 35h → 37h.
Decision Session — 2026-10-14
Trigger: the first deployment is for the owner’s family, and the comparison in plans/reports/research-261014-1634-aicoworker-goclaw-hango-comparison.md. The user chose a Telegram channel for the family and a Claude Code CLI runtime limited to the owner. Web chat comes later. Requests disguised as Claude Code with a subscription OAuth token stay out of scope.
| # | Change | Where |
|---|---|---|
| DS1 | Phase 18, Telegram channel: long polling, private chats, a static Telegram-to-hango user map, streamed replies by message edits, run creation shared with the HTTP handler | phase 18, plan.md, docs/roadmap.md |
| DS2 | Phase 19, Claude Code CLI runtime: claude -p as a child process for agents with provider = "claude_cli", owner-only checks at agent save, run creation and claim, tools over a loopback MCP bridge, usage excluded from tenant quotas | phase 19, plan.md, docs/roadmap.md |
| DS3 | Package cap 18 → 20 plus internal/tool/builtin (internal/telegram, internal/claudecli) | plan.md |
| DS4 | Build order places 18 and 19 after 13 | plan.md, docs/roadmap.md |
| DS5 | The phase-19 MCP bridge is hand-written with the standard library; the official Go SDK is not added | phase 19 |
| DS6 | Phase 14 depends on 18 and 19 and adds a hango-channels dashboard and three alerts for Telegram and the Claude CLI | phase 14, plan.md |
| DS7 | Phase 18 adds Markdown-to-HTML replies with a plain-text fallback, edit-failure fallback, retry_after on every call, 409 back-off with a poll watchdog, command registration, repeated typing and reply-to context (+5h) | phase 18 |
| DS8 | Phase 20 brings the Telegram channel near goclaw and OpenClaw: photos, albums, documents, voice notes with transcription, files out, debounce, pairing codes, group chats with mention gating, approval buttons and status reactions; it adds the provider image block and migration 00012, and runs after phase 16 | phase 20, plan.md, docs/roadmap.md |
| DS9 | An LLM gateway with per-user quotas is a follow-up; the family shares one tenant quota until then | plan.md |
| DS10 | Phase 20 groups follow goclaw: group pairing, one group principal and one shared session per group or forum topic, open/allowlist/disabled policy, require_mention with strict/yield modes, wake words, pending group history, writers, and an environment → group → topic config merge; forum topics move from follow-ups into phase 20 (+5h) | phase 20, plan.md, docs/roadmap.md |
| DS11 | Voice transcription uses any OpenAI-compatible endpoint (OpenAI or Groq Whisper) with a HANGO_STT_LANGUAGE hint, vi for the family, NFC-normalized transcripts and a gated Vietnamese fixture test (+1h) | phase 20 |
| DS12 | Group chats are deferred: each person uses a private chat for now, and the goclaw group design moves to a deferred section of phase 20 with HANGO_TELEGRAM_GROUP_HISTORY_LIMIT (default 50, 0 off) overridable per group and topic; the group-principal question stays open until groups return (−9h) | phase 20, phase 18, plan.md, docs/roadmap.md |
| DS13 | The HTTP layer uses the standard-library http.ServeMux with a small router in internal/server (Handle, Group, an error adapter, DecodeJSON, a status-recording writer) instead of Echo v5; SetAuditAction takes *http.Request, and span names and http.route come from r.Pattern (+1h) | phase 1, phase 2, plan.md, docs/tech-stack.md, docs/roadmap.md |
| DS14 | The web chat and an admin web page are follow-ups built with Svelte 5 on top of the existing HTTP API; neither is a v1 phase, so effort is unchanged | plan.md, docs/roadmap.md, docs/tech-stack.md |
Net effort: 231h → 290h (+59h). Implementation 194h → 246h, learning docs 37h → 44h.