Tracks every feature in the PRD against what is actually in the codebase.
Last audited: 2026-09-03 · Baseline: 12 shared · 142 backend unit · 37 backend e2e · 815 frontend
Status is evidence-based, not aspirational. A row is DONE only when the code
exists, is reachable from the running app, and has a test. A file existing is not
evidence; a stub, a TODO, or a service method with no caller is PARTIAL at best.
✅ DONE |
Implemented, reachable in the app, tested |
🟡 PARTIAL |
Some of it exists — the row says exactly what is missing |
⬜ TODO |
Not started |
💀 DEAD |
Code exists and is tested, but nothing calls it — invisible to a user |
DEAD is called out separately because it is the most dangerous state: it looks
finished in the source and in the test count, and does nothing in the product.
Every task runs this gate before its status changes to ✅. Each step is here because skipping it caused a real failure in this project, not as ceremony.
pnpm --filter backend exec tsc --noEmit -p tsconfig.json
pnpm --filter frontend exec tsc --noEmit -p tsconfig.app.jsonUse pnpm exec, never npx. npx does not resolve workspace binaries — it
silently downloaded an unrelated tsc package from the registry and reported
success.
pnpm test # shared + backend unit + frontend
pnpm run test:e2e # backend e2e — separate config, NOT included aboveRun both. A change was committed here after unit tests alone and broke an e2e
spec that pinned the same data. The frontend suite must go through ng test
(pnpm --filter frontend run test) — bare vitest cannot work, because Angular's
builder generates the TestBed bootstrap.
pnpm run build # shared → backend → frontend, in that orderCatches things tests miss: shared specs leaking into dist, SSR prerender
failures, template type errors.
pnpm run dev # backend :3000, frontend :4200Open the feature and use it. Tests did not catch the aurora-scarcity violation
(two gradients in one viewport), and a dead ng serve behind a live
concurrently supervisor presented exactly as "the backend API is broken."
| Change touches | Verify |
|---|---|
| AI / Copilot | DevTools → Network: the Gemini key appears only in requests to generativelanguage.googleapis.com, never to our origin |
| Data / API | The route is authenticated and RBAC is enforced server-side — sign in as arjun@actuo.demo (member) and confirm the action is refused |
| WebMCP | Works in flag-enabled Chrome (chrome://flags/#enable-webmcp-testing) and still works with the flag off |
| Money / totals | The number is right on a dataset larger than one page (100 rows) — truncation shows a wrong figure, not an obvious gap |
| UI | Both themes, phone and desktop widths, keyboard reachable |
| Anything deployed | pnpm run verify:deploy <url>. Local green does not mean deployed correct — SSR fell back to CSR in production while every test passed, and the page looked fine |
Only if the change alters a rule, a command, or a non-obvious constraint that would otherwise be rediscovered the hard way.
Explain why, not just what. Note anything you could not verify.
pkill -f "concurrently/dist/bin" # supervisor FIRST, or it respawns children
pkill -f "nest start"; pkill -f "ng serve"
lsof -nP -iTCP:3000,4200 -sTCP:LISTEN # must print nothingKilling children while the supervisor lives leaves a half-dead stack that looks
alive. Always verify after — pkill's exit status is not proof.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Email/password, argon2id | 0 | ✅ | backend/src/auth/auth.service.ts; hash asserted in auth.e2e-spec.ts |
| JWT access + rotating refresh | 0 | ✅ | Single-use jti, reuse revokes the whole family |
| Organizations | 0 | 🟡 | Created at signup only. No create/join/switch path |
| Invite by email | 1 | ⬜ | No route, no email transport |
| Roles owner/admin/member | 0 | ✅ | membership_role enum + roles.guard.ts |
| RBAC at the API layer | 0 | ✅ | Role re-read from memberships, never from the token. 17 e2e cases incl. a smuggled role claim |
| Session management screen | 2 | ⬜ | refresh_tokens stores user_agent and revokeAllForUser() exists, but there is no list/revoke endpoint or UI |
Verify: sign in as member and as owner; confirm an owner-only route returns 403 for the member from the API, not merely hidden in the UI.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Manual entry | 0 | ✅ | pages/add-expense/add-expense.ts |
| Tags | 2 | ⬜ | No column, no DTO field |
| Receipt upload + OCR | 2 | ⬜ | receipt_url column exists; nothing writes it. No Storage, no OCR |
| Recurring templates | 1 | ⬜ | recurring_templates table is absent from the migration |
| CSV bulk import | 2 | ⬜ | Export exists; import does not |
| Soft delete | 0 | ✅ | Partial indexes where deleted_at is null |
| Audit trail | 0 | ✅ | GET /api/audit-log (owner/admin) plus a Change history panel in Settings, beside the renamed Tool calls panel. The copy states the difference: audit_log is what changed, tool_call_log is what an agent did |
| Expense actions in the UI | 0 | ✅ | Submit / approve / reject / reimburse / reopen / delete on every row, offered from mayPerformOn in @actuo/shared — the same function ExpensesService.transition enforces with, so a button that would 403 is never rendered. Decisions take an optional note inline; delete is two-step |
Verify: create an expense, confirm it appears in the list and in audit_log.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Per-category budgets | 1 | ✅ | budgets.service.ts:status unions budgeted and spent categories |
| Per-team budgets | 2 | ⬜ | No team entity exists |
| Threshold alerts (80%) | 1 | ✅ | isNearBudget in budget-rollup.ts (≥80% utilization), dashboard notice, "Nearing budget" badge in the list, atWarningThreshold in get_budget_status tool output |
| Rollover vs reset | 1 | ✅ | rollover checkbox in the form. Carry logic in budgets.service.ts:status(): effective = declared + max(0, prevDeclared − prevSpent). One prior month only, unspent is carried, overspend is never debt. 4 tests pin behaviour. UI shows carry: "₹50,000 + ₹8,000 carried" |
| Budget creation/edit UI | 1 | ✅ | Upsert form on the Budgets page for owner/admin — POST new, PATCH existing. Edit button on each row. Rollover checkbox, carry amount display when > 0 |
Verify: with a category over budget, confirm the bar turns danger-toned and the figure matches a hand-check against the expense rows. At ≥80%, confirm "Nearing budget" badge appears and the dashboard notice fires.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Five statuses + state machine | 1 | ✅ | expense-state-machine.ts; all 20 illegal transitions tested |
| submit → approve/reject | 1 | ✅ | UI controls on every row. Self-approval is banned in one shared rule (NOT_ON_OWN_ACTIONS) that the server enforces and the UI reads, so the button is not offered either |
| Multi-step chains | 2 | ⬜ | One decision ends the flow |
| Comment thread | 2 | 🟡 | approvals.comment is written on a decision; there is no read path, no thread, and no way to comment without deciding |
Verify: submit as member, approve as owner, confirm the member cannot approve their own expense.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Original + converted amounts stored | 1 | ✅ | Columns exist. core/expense/amount.ts owns the rule: sumSpend() adds only base-currency rows and reports the rest |
| Live FX + daily cache | 1 | ✅ | backend/src/fx/ reads ECB rates from api.frankfurter.dev and caches them in fx_rates (migration 0003). Keyed on the date asked for, not the date the rate is from, or every weekend lookup would miss forever; the only entry that can go stale is today's while it still stands in for an earlier day. FxService.rateOn returns null rather than throwing, so an unreachable publisher cannot stop an expense being filed |
| Historical rate lock | 1 | ✅ | expenses.fx_rate + fx_rate_date, written with converted_amount at the expense's own date. fx_rate_date is not expense_date — the ECB publishes once per working day, so a Saturday expense locks Friday's rate, and the row prints the rate's own date. An edit re-locks on amount, currency or date. Verified live: AWS filed 2026-08-29 (a Saturday) carries the 2026-08-28 rate |
| Embedded converter (advisory) | 1 | ✅ | converter/currency-converter.ts frames a separate converter app on /convert, /agent, a dashboard card and foreign-currency expense rows. /convert and the dashboard open on arrival — the dashboard because a cross-origin tool lives only as long as the document that registered it, so a collapsed frame on the landing screen means the Copilot cannot convert until someone clicks. One frame at a time, CONVERTER_URL from GET /api/config |
The exclusion rules were what made FX cheap to land.
sumSpend()returns{total, excluded}andsumByCategory()returnsunconverted— a row counts only when it has a base-currency value, and the ones that do not are counted and stated rather than added at face value. That held, so foreign rows re-entered every total the momentconverted_amountstarted being filled:core/expense/amount.tsneeded no logic change at all, only a comment and a line of copy that had gone out of date.unconvertedCountstill exists and still reachesget_budget_status; it is now normally 0, and means "no rate could be locked" rather than "FX does not exist".The seed's foreign rows were worse than excluded — they were quietly wrong. They shipped with a
converted_amountat hand-written rates (₹87/$ for Figma, ₹94/€ for Sentry) that no publisher ever quoted, so they counted toward every total with nothing to explain them.--restatereplaced all five with dated ECB rates, and picked up two real rows (AWS $200, Starbucks $45) that had no conversion at all. Applied 2026-09-03: 7 rows locked, 0 failures, books up ₹25,136, and the dashboard's 14-day figure hand-checks to ₹54,669 against its rows.The embedded converter does not change any of that, deliberately. It is a reference a person reads, framed from a separate origin; it writes nothing, and
CurrencyConverterhas nooutput()and nopostMessagelistener, so there is no channel a converted figure could travel back through. That absence is the enforcement, andcurrency-converter.spec.tsasserts it directly — along with the two surface specs proving that opening the lookup moves neither the row's amount nor the dashboard total.amount.spec.tsis untouched by this work, which is the point: if a change here needed to edit it, the change would be wrong.
Verify: file expenses in two currencies and confirm the dashboard total is not a naive sum. A foreign row shows what it was converted from and on which day's rate; a row on a weekend names the preceding working day.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Trend line | 1 | ✅ | Hand-rolled SVG in spend-pace.ts, no chart library |
| Spend pace / forecast | 1 | ✅ | Straight-line projection, on-track/watch/over |
| Spend by category | 1 | ✅ | GET /api/analytics/summary returns byCategory with per-category spend and share. Also get_spend_summary WebMCP tool |
| Month-over-month deltas | 1 | ✅ | monthOverMonthDelta in AnalyticsSummary, percentage change vs prior month (null when no prior data). Exposed in get_spend_summary tool output |
| Team vs individual | 2 | ⬜ | — |
| CSV export | 0 | ✅ | Chunked, cancellable, complete across pages. The file is fetched with the session bearer and saved via saveBlob (core/reports/report-download.ts) — generate_report returns a job id and a bounded preview, never the download URL, which is an authenticated route no chat link can reach. Two ways to get the file: the Download button on the tool call card, and the download_report tool for agents |
| PDF export | 2 | ⬜ | pdf is now rejected rather than silently answered with CSV. shared/src/report-format-contract.spec.ts pins the tool schema and the backend DTO to the same list |
/api/analytics/* |
1 | ✅ | backend/src/analytics/ module with GET /api/analytics/summary. Returns current/prior month totals, MoM delta, per-category breakdown, excluded counts. 7 unit tests |
Table exists and is seeded. No repository, module, route, bell UI, or email transport. Phase 2.
Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Phase 3) is left.
| Item | Phase | Status | Notes |
|---|---|---|---|
| Floating widget, orb → panel | 0 | ✅ | Full-screen sheet on mobile |
| Gemini function calling, BYOK client-side | 0 | ✅ | Key never reaches our origin; enforced by key-privacy.spec.ts |
| Thought signatures preserved | 0 | ✅ | Replayed verbatim; required by Gemini 3 or the second tool turn 400s |
| Tool-call cards | 0 | ✅ | Name, summary, expandable IO, read-only vs mutating dot |
| Confirmation before mutating tools | 0 | ✅ | In-chat card. PRD says "native dialog"; in-chat was chosen deliberately |
| Key-setup flow when no key | 0 | ✅ | Opens into setup rather than failing silently |
Embeddable via one <script> |
3 | ⬜ | It is an Angular component inside the app shell |
| Cross-origin tool use | 0 | ✅ | The converter is framed from CONVERTER_URL with allow="tools", and ConverterSession owns discovery for all four surfaces. The synthetic partner page and its :4201 server are gone: dev and production now frame the same independently deployed converter, so there is no cross-origin path that is only exercised in one of them. Verified 2026-09-03 in Chrome 151 with the flag, first from localhost:4200 — all seven of its tools discovered over a real origin boundary, badges correct (4 read-only / 3 mutating), and executeTool(convertCurrency, {amount:200,from:'EUR',to:'INR'}) returning 200 EUR = 22,018.00 INR with the embedded widget moving to match — and then from the deployed Actuo at /agent, which closes the last gap: two genuinely public origins, neither serving the other |
| Item | Phase | Status | Notes |
|---|---|---|---|
| Gemini key management | 0 | ✅ | Masked, show/hide, Test key, two-step clear, non-persistent-storage warning |
| Model selector | 0 | ✅ | Served from /api/config so churn needs no rebuild; retired models kept and labelled |
| Org settings | 2 | 🟡 | Displays name and currency; no edit route |
| Category management | 2 | 🟡 | Read-only list; no create/rename/delete |
| Approval rules | 2 | ⬜ | — |
| Audit log viewer | 2 | ✅ | ToolCallAudit POSTs every invocation. Verified live: a Copilot-path tool call appears under Agent, an Add Expense submit under Human |
| Aspect | Status | Notes |
|---|---|---|
| Declarative API (annotated form) | ✅ | Add Expense: toolname/tooldescription/toolparamdescription/toolautosubmit, agentInvoked + respondWith, no JS registration |
Imperative registerTool |
✅ | Eight tools, per-tool AbortController lifetime |
| Writes drive the visible page | ✅ | submit_expense, approve_expense and set_budget navigate to the page that owns the action and hand the work to it, so the user watches it happen — the form fills field by field, the row's badge changes in place, the budget bar moves. page-driven-tools.ts injects no ApiClient, so there is no path back to posting behind the page. Rendezvous in webmcp/page-actions.ts |
| Budget editing | ✅ | set_budget creates or updates through the Budgets form. The route (POST/PATCH /budgets) and the form both already existed; only the tool was missing, so an agent could read a budget and never change one |
| Agent navigation | ✅ | navigate_to moves the browser between the seven authenticated pages, so an agent driving Actuo from outside does not have to read the DOM and guess where to click. Its enum descriptions are the map of the app, read straight off getTools(). APP_DESTINATIONS is pinned against the real router config in both directions by tools/navigate-destinations-contract.spec.ts |
| JSON Schema inputs | ✅ | One definition in shared/src/tools.ts, used by client and server |
| Dynamic / state-gated tools | ✅ | The shell polls on sign-in and after every mutating call. Verified live: approve_expense present in getTools() as owner with 3 pending, absent as member, and every tool retired on sign-out |
Cancellation (AbortSignal) |
✅ | Client aborts, polls stop, server abandons the job mid-fetch and mid-format |
| Cross-origin tools | ✅ | See §6.8. Needs a genuinely second origin — same-origin descriptors are filtered out, which is what made the earlier in-repo page unprovable. It is now a separately built, independently deployed app Actuo does not own, and it is proven from the deploy itself, not only from localhost |
| Security annotations | ✅ | readOnlyHint on the reads, driving the shell's re-poll and the /agent panel. untrustedContentHint on search_expenses and approve_expense — the two that surface another person's free text — and on the converter's tools, whose results carry third-party rate data; shown as a badge on the tool-call card |
getTools() discovery |
✅ | Drives the cross-origin path and the /agent panel; re-runs on toolchange. The Copilot still reads its own registry for local tools, deliberately — see "the tool registry decision" |
executeTool() + manual debug panel |
🟡 | executeTool() done, and /agent renders Copilot.crossOriginTools and the registry's invocationLog(). Still read-only: there is no form to invoke a tool by hand with arbitrary arguments |
Open question:
generate_reportis annotatedreadOnlyHint: truebut creates a server-side job. Defensible, but decide it deliberately.
Before this, every tool posted straight to /api/* and nothing on screen
moved: ToolRegistry.observe() refreshes only Session.pendingApprovals, whose
only consumer is ToolSession gating a tool, and every page's resource()
params is signal-free so nothing could re-trigger a load. An agent approving
an expense left the user looking at a row that still read "Submitted".
navigate_to is deliberately not readOnlyHint: it reads and writes no
data, but it changes what the user is looking at, and that is what the flag
tells a client. It is the same category the embedded converter's own UI-moving
tools sit in, which /agent already renders as Mutating. The one consequence
is that app.ts exempts it by name from the pending-approval re-poll every
other mutating tool triggers — navigation cannot change the queue, and an agent
walking the app would otherwise fire a search per hop.
download_report is the companion tool that saves a finished report to disk. It
exists because a file has no other route out for a client that can only call
tools: the download route needs the session bearer header, so no URL — in chat or
handed to an agent — can fetch it. A tool's execute() runs inside the page, in
the authenticated session, so the page does the fetch and the browser save on the
agent's behalf. readOnlyHint: false (a file lands on the user's machine) with no
confirmation (asking to download is the confirmation).
@angular/service-worker installed and enabled on the production build only.
frontend/public/manifest.webmanifest, icons generated by
scripts/generate-brand-assets.mjs (192, 512, maskable, apple-touch), and
ngsw-config.json.
dataGroups is empty on purpose — caching /api would show stale money and
would undercut the promise that every read goes through an authenticated route.
navigationUrls no longer needs a /partner-demo/** exclusion — the converter
is a different origin, which the service worker never sees.
PwaService holds the deferred beforeinstallprompt and online/offline state;
the shell renders an install banner and an offline banner from it. Verified live
on the production build: one activated worker, /api absent from ngsw.json,
and the offline banner appearing and clearing on the network events.
| Item | Status | Notes |
|---|---|---|
| robots.txt | ✅ | Gated routes disallowed |
| sitemap.xml | ✅ | Public route only, <loc> absolute via the __PUBLIC_ORIGIN__ stamp |
| Structured data | ✅ | Real application/ld+json SoftwareApplication |
| llms.txt | ✅ | Accurate tool inventory and permission model |
| OG / Twitter | ✅ | 1200×630 og.png generated from the brand tokens, plus og:url, og:image:alt, twitter:image and a canonical link |
| SSR on public pages | 🟡 | app.routes.server.ts prerenders **, including authenticated routes, which hydrate client-side (correct for a gated view, accidental rather than chosen). Was broken on the deploy until 2026-09-03, and not for the documented reason: Angular downgrades to CSR on any untrusted x-forwarded-* header, and Render sends x-forwarded-for; a host-allowlist miss is a 400 instead. frontend/src/server.ts now trusts the full proxy set |
| noindex on gated views | ✅ | data.robots per route, applied by SeoService on every navigation; a route that declares nothing defaults to noindex. Verified live: the tag flips going from / to /expenses |
| Item | Status | Notes |
|---|---|---|
| RBAC server-side | ✅ | — |
| Rate limiting on auth | ✅ | In-process fixed window; per-instance only |
| Paginated, indexed queries | ✅ | One shared page cap; callers needing all rows paginate |
| Migrations + seed | ✅ | Applied to the hosted project; 0002 idempotent |
| Supabase call timeouts | ✅ | 8s deadline — a stall used to hang the request forever |
Unit tests for tool execute() |
✅ | — |
| Structured logging / error tracking | ⬜ | Nest logger only; no Sentry-tier reporting |
| CI | ✅ | .github/workflows/ci.yml runs the full Definition of Done gate on push and PR, and has run on GitHub — five successful runs, most recently on PR #4 |
| Single-process deploy | ✅ | server.mjs; the routing contract it depends on is pinned by routing-contract.e2e-spec.ts. It also mounts common/canonical-redirect.ts, which 308s pages on any non-canonical hostname — placed after Nest so /api can never reach it, and before the Angular handler so it also covers the static files that skip Angular's host check. It self-disables when PUBLIC_ORIGIN's host is not in NG_ALLOWED_HOSTS, because that combination would 308 the alias to a host Angular 400s: every page down behind a health check that still returns 200 |
| Item | Status | Notes |
|---|---|---|
| Public deployed URL | ✅ | Live at https://actuo.programmersingh.dev, with https://actuo.onrender.com kept as an alias that 308s to it. server.mjs composes Nest under /api with the Angular SSR handler from a committed Dockerfile; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README Why a Dockerfile). Attaching the custom domain broke it in the documented way — every page 400'd on the new host until it was added to NG_ALLOWED_HOSTS — and in one that was not caught by any check: the SEO stamp is baked at build time, so the new domain served a sitemap, canonical and og:image all naming the old origin. verify:deploy now asserts the stamped origin matches the URL it is checking |
| README | ✅ | Root README.md: what is WebMCP-specific and where, the flag setup, what works without it, and the deploy steps. Workspace READMEs are still starter boilerplate |
| Demo video | ⬜ | The last box left. The script is the "What to look at" list in README.md, and the SSR fix it was waiting on has landed — the deployed site server-renders, so a recording made now records the real thing |
| Source with clear tool definitions | ✅ | shared/src/tools.ts |
Every Phase 0 row is green, real FX landed on 2026-09-03, the deploy is live on its own domain, and cross-origin is proven from that deploy. What is left is the rest of Phase 1, and the video.
- Budgets depth — three open §6.3 rows, one surface.
POST /budgetsinserts and there is no PATCH, so a budget can be set once and not changed; the form hides categories that already have one rather than offering a guaranteed 409. Threshold alerts (80%) and rollover-vs-reset are the other two, andbudgets.spec.tsalready guards the rollover checkbox against returning without its behaviour. /api/analytics/*— no controller; the dashboard derives everything client-side. Standalone spend-by-category and a month-over-month delta tile are the visible half.- Recurring expenses —
recurring_templatesis in PRD §8.7 and absent from the migrations, so it needs0004. - Org invites — the last Phase 1 row, and the only one needing an external
service (Resend, plus a
sync: falsesecret inrender.yaml). - Demo video — the last §12 checkbox. The script is the "What to look at"
list in
README.md. - Everything else is Phase 2–3: receipt OCR, notifications, multi-step approval chains, comment threads, teams, tags, CSV import, PDF export, session management, and packaging the Copilot as a standalone script.
- The Firebase App Hosting backend may still be connected with auto-rollouts,
in which case it fails on every push. Deleting it is
firebase apphosting:backends:delete actuo --project actuo-2f1f3. App Hosting itself is no longer the target — see README Why a Dockerfile — and the issue that killed it is firebase-tools#10435, closed as not planned. /agentis a sixth tab on mobile. The labels fit (widest is "Dashboard" at ~54px in a 65px slot at 390px, measured), but it is tight, and this was verified by measurement rather than at a real 390px viewport.- No CSP header is set anywhere yet. When one lands it will need
frame-srcfor the converter origin (CONVERTER_URL) — one origin now, not two — or every converter surface breaks silently: an iframe blocked by CSP renders empty with no error the page can see. It also has to be written against the canonical host, since that is the only origin that serves pages now. - Brand assets are generated, not designed.
scripts/generate-brand-assets.mjsproduces the icons and og card from the palette with ImageMagick. They are clean but plain, and regenerating after a palette change is manual.