Skip to content

Phase 6 — AI features (claim flags, forecasting, natural-language reports) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: Three advisory features, in the spec’s order: transparent rules that flag unusual claim lines for an approver, seasonal-baseline spend forecasts with empirical ranges, and a deterministic (Tier 0) natural-language search box over the existing report engine. No model, no provider, nothing leaves the deployment.

Architecture: a new platform/insight package with three leaves (claimflags, forecast, nlreport) and a small shared part (the per-feature mode, the run record, feedback). Analysis reads inside a SET TRANSACTION READ ONLY transaction and writes only the package’s own tables in a separate, named transaction. A flag is a row beside the claim, never a state on it; nothing in the package can approve, reject, post, route or edit.

Tech Stack: Go, pgx v5, Postgres (goose), gin, shopspring/decimal via money.Amount.

Spec: docs/superpowers/specs/2026-10-03-ai-features-design.md. Owner decisions D1-D13 are the recommended option in every row of its section 9 (recorded in commit fd1c6263): Tier 0 only, no external model until counsel and a tax agent have read the privacy section (D2, D3).

  • G-LLM — D2/D3. Tier 1/2 of natural-language reports (a local or external model, consent screen, redaction, rate and cost budgets, spec task 8) is not planned and not built. It needs a provider, a region, contract terms and credentials that cannot be verified here.
  • G-CONSENT — counsel and a tax agent read spec section 6 and section 11A.1. Blocks nothing in this plan (no data leaves), but the PDPA wording stays unreviewed.
  • G-RELEASE — D6. Moving an org shadow to live is an administrator’s call from the evaluation view. This plan ships the switch and the audit; it sets no precision floor.
  • Migration range: 00990–00999 (this plan uses 00990–00991). Task 1 declares it in internal/kernel/db/migrationranges_test.go.
  • A float never touches money. Amounts are money.Amount; percentiles are nearest-rank over decimals; ratios are decimal.Decimal; evidence JSON carries amounts as strings. A guard scans platform/insight for float32/float64.
  • Every table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, policy org_isolation; tenant foreign keys are composite on org_id. Every constraint is named; every function pins search_path.
  • Retention: claim_flag (status is the one amended column) and the other insight tables never grant DELETE to the application role.
  • Vocabulary values are strings: mode, severity, status, reason code, feedback verdict, trigger, outcome.
  • Every user-facing reason is translation rows (entity_kind = 'claim_flag_reason', entity_id = md5('claim_flag_reason:' || code)::uuid, field sentence, en and ms), composed with the stored evidence. The flag row stores no prose.
  • Advisory: a flag never gates submission or approval and adds no state, step or approver. Claimants hold none of the new permissions (D4).
  • The contract is api/openapi.yaml; routes behind httpx.Protected; no handler reads an organization from a header.
  • Env vars: every variable read appears in .env.example.
  • Tests: one package at a time with -p 1 (Docker is fragile on this VM); the full go test -p 1 ./... and the web suite once, at the end. Each new guard is watched failing once and the message recorded in the execution record below.
  • Commits: one per task with the session’s attribution line. Merging and pushing are the owner’s.
  • AI-1 One mode per feature. The spec has enabled_* flags and one mode; this plan folds them into claimflags_mode, forecast_mode, nlreport_mode, each off (default, D13) / shadow / live.
  • AI-2 Rule parameters are code constants in v1, versioned by method_version (claimflags/v1). The spec wants administrator-editable per-org parameters; that is a follow-up (an editable table plus audit), not a hidden constant. Stated as open.
  • AI-3 Flags attach to a claim line. One row per (line, reason) that is open, dismissed or confirmed; a re-run that changes the evidence writes a new row and marks the old superseded (never an in-place edit of what an approver saw). A rule that no longer fires supersedes its open row.
  • AI-4 R1 is extended, not forked. expenses.PossibleDuplicates (same document bytes) stays computed on read and untouched. The new duplicate_receipt_details rule (same normalized supplier, same expense date, same base amount, another live claim) stores only a count, never the other claim (R21).
  • AI-5 “Live” lines are lines of claims whose workflow state’s semantic is none of draft, rejected, cancelled, read by semantic, as duplicate.go does.
  • AI-6 Shadow hides from approvers. In shadow the flags are written and only holders of insight.flags.review or insight.settings.manage can read them; live shows them to insight.flags.read.
  • AI-7 Percentile is nearest-rank: rank = ceil(p·n/100) over the sorted decimals. Deterministic, no interpolation.
  • AI-8 Forecast method choice is a fixed rule on history length: under 6 months no forecast; 6–11 months trailing 3-month mean; 12 or more seasonal-naive. The range is the p10–p90 band of the method’s own back-test errors, and is omitted (low confidence) under 6 back-test points.
  • AI-9 NL Tier 0 resolves only what is in the closed catalog (report keys and their allowed period phrases) and asks otherwise; it never guesses an account or project.

Task 1 — Migration 00990, range, permissions 00991

Section titled “Task 1 — Migration 00990, range, permissions 00991”

Files: db/migrations/00990_insight.sql, db/migrations/00991_insight_owner_grants.sql, internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go, internal/platform/insight/migration00990_test.go

  • Tables insight_org_setting, insight_run, claim_flag, insight_feedback; RLS, named constraints, composite tenant FKs, retention grants; claim_flag_reason translation seed for the six reason codes in en and ms.
  • Tests (each watched failing once): claim_flag refuses a second open flag for the same line and reason (presence: a different reason is accepted); DELETE is not granted on the four tables; a reason code with no ms row fails the translation guard (presence: all codes enumerated > 0).
  • Verify: go test -p 1 ./internal/kernel/db/... ./internal/platform/insight/....

Task 2 — Rules (pure) and the decimal percentile

Section titled “Task 2 — Rules (pure) and the decimal percentile”

Files: internal/platform/insight/claimflags/{rules,percentile}.go and tests

  • R2 split_under_threshold, R3 round_amount_cluster, R5 amount_outlier_claimant, R6 amount_outlier_org, R7 new_merchant_high_amount, R1-variant duplicate_receipt_details as pure functions of a line and its history, each returning a Flag{Reason, Severity, Evidence} or nothing.
  • Tests: each rule fires on a hand-built case and stays silent on the case one step short (presence before absence); minimum-history and minimum-count guards produce insufficient_history, not a flag; percentile on known data.

Task 3 — Service: read, evaluate, persist, kill switch

Section titled “Task 3 — Service: read, evaluate, persist, kill switch”

Files: internal/platform/insight/{setting,run,claimflags_service}.go, internal/platform/insight/claimflags/load.go and tests

  • Settings read/write (audited); Analyse (read-only tx: load line, history, thresholds from workflow.Latest), Persist (own tx: supersede-or-insert, insight_run, audit); Subscribe on workflow.EventTransitioned for a claim leaving draft (post-commit, swallowed).
  • Tests on a provisioned org through expenses.AddLine: shadow writes a flag, off writes nothing (G10, presence in the same test), a flag never changes the claim’s state or its audit (G9), a re-run with the same evidence writes nothing, a changed one supersedes.

Files: internal/modules/insight/, api/openapi.yaml, cmd/api/main.go, internal/app/app.go, wiring tests, web/src/lib/i18n/locales/{en,ms,zh}/team.json

  • insight.flags.read, insight.flags.review, insight.settings.manage (Owner by default through 00991; a claimant role has none). Routes: list flags of a claim, dismiss, confirm, feedback, get and set the modes.
  • Tests: 403 without each key; a claimant reads no flags; shadow flags hidden from insight.flags.read and shown to review; dismiss does not delete.

Files: internal/platform/insight/guard_test.go

  • G1 imports (and a selector allowlist for workflow), G2 write SQL only on own tables, G4 no net/http in claimflags or forecast, G6 no float. Each reports files scanned > 0 and finds a planted fixture before asserting absence; each watched failing once.

Files: db/migrations/00992_forecast.sql, internal/platform/insight/forecast/*, routes

  • Baselines and back-tested range (AI-8) as pure functions over monthly series; a loader that reads actuals through reporting movements; forecast_run + forecast_point with the ledger watermark; commitments only where a source verifies by month, else actuals-only with the gap stated.

Task 7 — Natural-language reports, Tier 0

Section titled “Task 7 — Natural-language reports, Tier 0”

Files: internal/platform/insight/nlreport/*, routes

  • Closed catalog from the report registry, deterministic parser (AI-9), a Proposal that must validate through the existing export allow-list, preview only; confirm is the ordinary report endpoint.
  • Full go test -p 1 ./... and web suite once; tick docs/ROADMAP.md accurately; execution record below.

Tier 1/2 models, the evaluation harness numbers and views, the flags UI (approval screen, flags list, settings screen), rules R4, R8, R9, administrator-editable rule parameters, audit-export inclusion of flags (D12), any cross-org benchmark.

Built on branch worktree-agent-ace1b70cc2a1aa0fc, four commits on main 4ff55eb8. Tasks 1-7 landed in a different grouping than the list above; Task 8 is the close-out. Nothing was merged or pushed.

What differs from the plan as written

  • No separate 00991 migration per concern. 00990 (schema, reason and dismissal translation rows), 00991 (Owner grants for the three keys), 00992 (forecast tables). The range declared is 990-999.
  • Reason codes are not a table. Their translation rows are keyed by md5('claim_flag_reason:' || code)::uuid, derived identically in SQL and in Go (insight.TranslationID); a test pins the two together and fails if a code has no en or ms row.
  • stat.Percentile moved to its own package platform/insight/stat when forecasting needed it; claimflags calls it.
  • INSIGHT_LLM_PROVIDER is not read. No model tier exists, so a variable that can only hold none would be dead configuration. insight_run.provider is constrained to 'none' in the schema instead; a Tier 1/2 migration relaxes it. The one new variable is INSIGHT_ENABLED (the deployment-wide kill switch).
  • Report-search “explanation” is not generated server-side. The answer carries report_key, params and note codes; the client composes the preview sentence from its locale files (translation-row rule). The question is audited by SHA-256 and length, not kept (D9’s reasoning applied to the question).
  • The ledger watermark is max(journal_entry.posted_seq), a one-line read of the ledger table, not through reporting (which has no such function). Actuals themselves are read only through reporting.ProfitAndLoss.
  • Forecast shadow semantics: off 409 feature_off; shadow only for insight.flags.review or insight.settings.manage; live for reports.read (D10).

Guards, each watched failing once (message as printed)

Guard Sabotage Red message
kill switch (G10) removed the mode check in AnalyseClaim off: no run was recorded expected 1, actual 2
RLS (G12) DISABLE ROW LEVEL SECURITY on claim_flag SELECT count(*) FROM claim_flag as another organization should be zero, was 1
standing flag index index predicate made unmatchable An error is expected but got nil
status-only trigger trigger narrowed to UPDATE OF id An error is expected but got nil
translation (G15) an ms row renamed claim_flag_reason "amount_outlier_org" has no ms translation
G1 import scratch file importing platform/accounting zz_scratch.go: imports .../platform/accounting
G1 selector workflow.Transition uses workflow.Transition, not in the read allowlist
G1 export runner export.Run in nlreport uses export.Run, not in the read allowlist
G1 savedreport import in insight imports .../platform/savedreport
G2 write SQL INSERT INTO journal_line zz_scratch.go: writes journal_line
G4 outbound net/http in claimflags, then in nlreport imports net/http
G6 float var amount float64 zz_scratch.go: float64
G9 structural platform/expenses importing insight a platform package imports insight
G3 read-only an UPDATE inside the forecast analysis cannot execute UPDATE in a read-only transaction (SQLSTATE 25006)
forecast mode mode check made unmatchable off by default: expected error, got nil
forecast idempotency stored-run lookup disabled unexpected error on the repeat run
range logic MinBacktest 6 to 2; p90 to p80 TestTooFewBacktestPointsGiveNoRange; rank ceil(0.90*12) = 11
claim read check skipped in requireReadable expected 404, actual 200 (the flag body)
shadow visibility condition removed approver’s list Should be empty
deployment switch Disabled ignored forecast and flag lists Should be empty
G5 validation Validate calls removed from Parse balance sheet travel last month proposed an account on a balance sheet; the property test names operations balance sheet
.env.example INSIGHT_ENABLED commented out these variables are read but absent from .env.example: [INSIGHT_ENABLED]
rule edge Cmp(p95) <= 0 to < 0 at the p95 is not above it

Not run: a migration Down test (the group register has one; this plan did not add it), the Playwright specs.

Follow-up: web screens and flags at the decision (2026-10-04)

Section titled “Follow-up: web screens and flags at the decision (2026-10-04)”

Built on the same branch after the record above; the “Not in this plan” list shrinks by the flags UI and the shown-flags audit entry.

  • Web (web/src/features/insight/, strings in locales/{en,ms,zh}/insight.json): ClaimFlagsPanel in the claim drawer (read key; dismiss and confirm need the review key; feedback needs read), FlagsListPage (/insight/flags), InsightSettingsPage (/admin/insight), ForecastPage (/reports/forecast), ReportSearchBox on the reports page. Nav entries for the three pages. Tests per screen; the web suite was run once at the end (157 files, 851 tests green), pnpm lint has 0 errors.
  • New endpoint, not in the original plan: GET /insight/flags (review key; status open/all, reason filter; newest first, 200 max; each flag only if its claim is readable by the caller). The spec’s “flags list” screen (2.4) had no cross-claim read to stand on. One visibility rule (handlers.visibility: deployment switch, mode, shadow-only-for-reviewers, read-or-review key) now serves the claim list, the new list and the hook below.
  • Flags shown at the decision (spec 1.4): the expenses transition handler asks an optional expenses.Deps.FlagsShown hook (wired to insight.FlagsShown in cmd/api) for the open flag ids the decider could see, before the move, and writes expense_claim / flags_shown after it (after, because of the audit-chain lock order). The hook is a read; it can fail a request, never change a decision. expenses does not import insight.
  • Not done, and why: (a) flags in the audit-trail export (D12): no audit-trail export exists in the repo to add them to. (b) R4, R8, R9: the spec defines them only conditionally (receipt timestamps and a calendar it says it did not verify; R9 “only with shadow evidence”), so none was built rather than invent parameters. (c) A browser pass at phone width, and the one-click “wrong translation” feedback for report search (no endpoint stores it). (d) The forecast screen offers an account filter only, not analytic accounts.
Guard Sabotage Red message
flags shown on the audit entry the write condition made unreachable PRESENCE: the decision's audit entry names what was shown expected 1, actual 0
flags list claim read check the readable filter made always true a flag never reveals a claim: Should be empty (the flag body listed)
flags-shown visibility the read-or-review key check removed a member holding none of the keys was shown a flag id: Should be empty
claim panel key gate the query enabled for everyone, panel not gated expected 1 to be +0 (the flags endpoint was asked by a member without the key)
dismiss needs a reason the button’s disabled removed dismisses with a reason, confirms, and records feedback red
flags list route key review key replaced by read key says the feature is off, and does not offer the page to a member without the review key red
forecast no-range case range drawn whenever points exist RangeError: not a decimal amount: null
report-search preview the “filter not applied” note disabled; confirm made to drop as_of Unable to find an accessible element with the role "note"; expected '?as_of=2026-10-04' to contain 'as_of=2026-09-30'

Go packages run one at a time with -p 1: modules/expenses, modules/insight, platform/insight/..., internal/gen, internal/app, cmd/api, kernel/layering green. One earlier run of platform/insight failed ten tests at once while other containers were starting (Docker contention); the same package passed alone, twice.

Follow-up: unblocked leftovers (2026-10-04, branch phase6-ai-leftovers)

Section titled “Follow-up: unblocked leftovers (2026-10-04, branch phase6-ai-leftovers)”

Six commits on main ee92160f; nothing merged or pushed. Scope: only what the spec defines and no provider decision blocks.

  • Analytic-account filter on the forecast screen. One analytic account (the screen offers one, like the custom report builder), sent as analytic_account_ids: [id]; with none chosen the field is absent. Web test asserts both bodies.
  • Nightly jobs (platform/insight/jobs.go, registered in cmd/worker beside the other daily jobs, maintenance queue, RunOnStart, River leader election as for the hr jobs; INSIGHT_ENABLED=false makes both do nothing; no new env var). insight_forecast_nightly: whole-chart series, horizon 3, for every organization whose forecast mode is not off (RunForecast’s own mode check, ErrFeatureOff is not a failure), as the system (created_by_member_id NULL), insight_run.trigger_kind = 'scheduled', idempotent on the ledger watermark. insight_flags_nightly: AnalyseClaim over every claim that has an open flag; Persist with the scheduled trigger returns before recording a run when nothing was superseded or raised, so an unchanged night writes no row.
    • Differs from the spec’s wording: the spec says the nightly job “recomputes the peer statistics”. No statistics table exists (each analysis derives them from the history, analyse.go ponytail note), so the job re-analyses; there is nothing stored to recompute. Per-account and per-analytic series are not forecast nightly, only on demand.
  • Evaluation harness, as far as the spec defines it. Built: precision per rule (insight.FlagPrecision, GET /insight/evaluation/flags, a card on /admin/insight for holders of insight.flags.review; en/ms/zh), and a scorer for a fixed synthetic en/ms question set (nlreport/questionset_test.go: exact-match, clarification and invalid rates; en 11/11 exact with 5 asked and 1 refused by validation, ms 4/4). Ruling AI-10 (mine, to be confirmed): precision = confirmed / (confirmed + dismissed), every dismissal counted against the rule whatever its reason, because the spec’s “dismissed-as-not-useful” does not say which reasons are excluded; known_duplicate arguably means the flag was right but already known. The per-reason counts and the feedback verdicts are returned so the owner can recompute under another reading. No floor, no verdict, no minimum-sample prompt (the spec sets none, D6). Not built, because building them would be inventing: recall against a seeded set (the spec lists categories of seeded case, not contents or sizes); the forecast back-test table (the spec says “per series class” without defining one, and with the v1 methods the baseline comparison is vacuous: the method is seasonal-naive from 12 months and the naive cannot be computed below that).
  • Flags in the audit-trail export (D12): re-checked, still nothing to add them to. audit_log is read by audit.VerifyChain, cmd/migrate verify-audit and two internal joins (reconcile, budget); no audit export or report exists.

Guards, each watched failing once (message as printed)

Guard Sabotage Red message
scheduled pass writes nothing when unchanged early return in Persist made unreachable Should be zero, but was 1: nothing changed: the pass recorded no run
deployment switch on the nightly forecast Disabled check removed Should be zero, but was 1: mode on but the deployment switch is off
precision denominator Labelled computed from confirmed only expected: 2, actual: 1 (labelled)
evaluation hidden when off mode check made unmatchable off shows nothing: Should be empty, the six rule rows listed
forecast analytic filter the analytic field dropped from the request expected { account_id: null, horizon: 3 } to deeply equal { account_id: null, …(2) }
precision shows no label as a dash the null branch disabled Unable to find an element with the text: No labels yet
question-set scorer the “imbangan duga” phrase mapped to the balance sheet expected score{n:4, exact:4}, actual exact:3

A review catch worth recording: the web no-float drift test (src/drift/nofloat.test.ts) rejected Number(precision) in the first version of the card; the percentage is now read off the decimal string.

Verified: go test -p 1 on platform/insight/..., modules/insight, cmd/worker, kernel/layering; web src/features/insight and src/drift; tsc --noEmit, eslint and prettier on the touched web files. Full suites (once, at the end): go test -p 1 ./... ran 151 packages; the first pass failed modules/insight (a real miss: the route table that pins each route’s key did not list the new route; fixed, a pinned guard working as designed) and six packages (company, expenses, identity/liveness, identity/photo, payables, taxreturn) with the Docker start-up failures this plan already records (not yet accepting connections, container waits of 10 minutes while another agent’s suite ran); those six and modules/insight then passed alone. The whole suite was not re-run as one green pass after the fix. Web: 157 files, 853 tests green, tsc clean, eslint 0 errors. Not verified: the jobs under a real River worker process (the tests call Work directly; cmd/worker/queues_test.go is unchanged and does not exercise the new kinds); no browser pass on the new filter and card; en/ms/zh strings not eyeballed (zh and ms are mine, unreviewed by a speaker); Playwright specs not run.