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).
Gates (owner, before the step they block)
Section titled “Gates (owner, before the step they block)”- 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.
Global Constraints
Section titled “Global Constraints”- Migration range:
00990–00999(this plan uses00990–00991). Task 1 declares it ininternal/kernel/db/migrationranges_test.go. - A float never touches money. Amounts are
money.Amount; percentiles are nearest-rank over decimals; ratios aredecimal.Decimal; evidence JSON carries amounts as strings. A guard scansplatform/insightforfloat32/float64. - Every table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, policyorg_isolation; tenant foreign keys are composite onorg_id. Every constraint is named; every function pinssearch_path. - Retention:
claim_flag(status is the one amended column) and the other insight tables never grantDELETEto 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, fieldsentence,enandms), 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 behindhttpx.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 fullgo 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.
Rulings (where the spec is silent)
Section titled “Rulings (where the spec is silent)”- AI-1 One mode per feature. The spec has
enabled_*flags and onemode; this plan folds them intoclaimflags_mode,forecast_mode,nlreport_mode, eachoff(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,dismissedorconfirmed; a re-run that changes the evidence writes a new row and marks the oldsuperseded(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 newduplicate_receipt_detailsrule (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, asduplicate.godoes. - AI-6 Shadow hides from approvers. In
shadowthe flags are written and only holders ofinsight.flags.revieworinsight.settings.managecan read them;liveshows them toinsight.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_reasontranslation seed for the six reason codes inenandms. - Tests (each watched failing once):
claim_flagrefuses a second open flag for the same line and reason (presence: a different reason is accepted);DELETEis not granted on the four tables; a reason code with nomsrow 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, R3round_amount_cluster, R5amount_outlier_claimant, R6amount_outlier_org, R7new_merchant_high_amount, R1-variantduplicate_receipt_detailsas pure functions of a line and its history, each returning aFlag{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 fromworkflow.Latest),Persist(own tx: supersede-or-insert,insight_run, audit);Subscribeonworkflow.EventTransitionedfor a claim leaving draft (post-commit, swallowed). - Tests on a provisioned org through
expenses.AddLine: shadow writes a flag,offwrites 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.
Task 4 — Permissions, routes, contract
Section titled “Task 4 — Permissions, routes, contract”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 through00991; 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.readand shown to review; dismiss does not delete.
Task 5 — Guards
Section titled “Task 5 — Guards”Files: internal/platform/insight/guard_test.go
- G1 imports (and a selector allowlist for
workflow), G2 write SQL only on own tables, G4 nonet/httpinclaimflagsorforecast, G6 no float. Each reports files scanned > 0 and finds a planted fixture before asserting absence; each watched failing once.
Task 6 — Forecast
Section titled “Task 6 — Forecast”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
reportingmovements;forecast_run+forecast_pointwith 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
Proposalthat must validate through the existingexportallow-list, preview only; confirm is the ordinary report endpoint.
Task 8 — Close-out
Section titled “Task 8 — Close-out”- Full
go test -p 1 ./...and web suite once; tickdocs/ROADMAP.mdaccurately; execution record below.
Not in this plan
Section titled “Not in this plan”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.
Execution record
Section titled “Execution record”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
00991migration per concern.00990(schema, reason and dismissal translation rows),00991(Owner grants for the three keys),00992(forecast tables). The range declared is990-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 noenormsrow. stat.Percentilemoved to its own packageplatform/insight/statwhen forecasting needed it;claimflagscalls it.INSIGHT_LLM_PROVIDERis not read. No model tier exists, so a variable that can only holdnonewould be dead configuration.insight_run.provideris constrained to'none'in the schema instead; a Tier 1/2 migration relaxes it. The one new variable isINSIGHT_ENABLED(the deployment-wide kill switch).- Report-search “explanation” is not generated server-side. The answer carries
report_key,paramsand 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 throughreporting(which has no such function). Actuals themselves are read only throughreporting.ProfitAndLoss. - Forecast shadow semantics: off 409
feature_off; shadow only forinsight.flags.revieworinsight.settings.manage; live forreports.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 inlocales/{en,ms,zh}/insight.json):ClaimFlagsPanelin the claim drawer (read key; dismiss and confirm need the review key; feedback needs read),FlagsListPage(/insight/flags),InsightSettingsPage(/admin/insight),ForecastPage(/reports/forecast),ReportSearchBoxon 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 linthas 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.FlagsShownhook (wired toinsight.FlagsShownincmd/api) for the open flag ids the decider could see, before the move, and writesexpense_claim/flags_shownafter it (after, because of the audit-chain lock order). The hook is a read; it can fail a request, never change a decision.expensesdoes not importinsight. - 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 incmd/workerbeside the other daily jobs, maintenance queue,RunOnStart, River leader election as for the hr jobs;INSIGHT_ENABLED=falsemakes 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,ErrFeatureOffis not a failure), as the system (created_by_member_idNULL),insight_run.trigger_kind = 'scheduled', idempotent on the ledger watermark.insight_flags_nightly:AnalyseClaimover every claim that has an open flag;Persistwith 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.goponytail 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.
- 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,
- Evaluation harness, as far as the spec defines it. Built: precision per rule (
insight.FlagPrecision,GET /insight/evaluation/flags, a card on/admin/insightfor holders ofinsight.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_duplicatearguably 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_logis read byaudit.VerifyChain,cmd/migrateverify-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.