Task Breakdown: Costs, caps and credits
Ordered tasks derived from
plan.md. Each is small enough to land in one pull request and ships with tests per Constitution VI. Every schema task ships its migration in the same pull request per Constitution V.
Epic ID: AW-17-costs-caps
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- Tasks are sequential by default.
(parallel)means it may run alongside its predecessor. - Every task names the exact files to create or modify. An implementer should never have to guess a path.
- "Done when" is stated explicitly for every task and is checkable without reading the diff.
- Add new tasks at the bottom rather than renumbering.
- Phase boundaries are ship boundaries:
developmust be green and deployable at the end of each phase. - Prerequisite: AW-09 P2 is merged. T31 and T32 write into the receipt cost block it creates.
Phase P1 — Separate the meters
Delivers spec FR-1…FR-34, FR-65…FR-74, FR-79…FR-88. Ships no new cap; after P1 the product tells the truth about money without stopping anything new.
P1.1 — Contracts and types
-
T1. Meter contracts. Create
packages/contracts/src/billing/meter.types.tswithUsageMeterId,UsagePayerId,UsageOutcomeId,SpendCapScope,SpendCapState,AddonCode,AddonStatusand every constant listed in plan §3.3 (SPEND_CAP_MIN_CENTS = 100,SPEND_CAP_THRESHOLDS = [75, 90, 100],SPEND_CAP_PROPAGATION_MS = 30_000,AUTO_RECHARGE_MONTHLY_CAP_DEFAULT_CENTS = 10_000,AUTO_RECHARGE_MONTHLY_CAP_MIN_CENTS = 1_000,AUTO_RECHARGE_MONTHLY_CAP_MAX_CENTS = 200_000,AUTO_RECHARGE_MAX_CONSECUTIVE_FAILURES = 3,ADDON_MAX_UNITS_PER_KIND = 25,USAGE_CACHE_FRESHNESS_HOURS = 24,USAGE_EXPORT_MAX_ROWS = 50_000,USAGE_EXPORT_MAX_DAYS = 92,BREAKDOWN_TOP_N = 10,UNCONFIRMED_PAYER_ALERT_RATIO = 0.001). Createpackages/contracts/src/billing/index.ts; modifypackages/contracts/src/index.tsto re-export it. Done when:pnpm --filter @ever-works/contracts buildemits declarations andimport { UsageMeterId } from '@ever-works/contracts'resolves fromapps/api. -
T2 (parallel with T1). Leaf enums in the agent package. Modify
packages/agent/src/entities/_types.tsto addUsageMeter,UsagePayerandUsageOutcomeenums. They live here, not next to an entity, for the same decorator-evaluation cycle reason the file already documents forBudgetOwnerType. Done when: importing any of the three from a sibling entity module does not produceundefinedat decorator time (assert in T4's entity spec).
P1.2 — The usage row learns what it is
-
T3. Usage-event columns. Modify
packages/agent/src/entities/plugin-usage-event.entity.tsto addmeter,payer,outcome,creditsCharged,priceKey,priceVersionandmissionIdexactly as written in plan §3.1, plus the three new@Indexdeclarations (idx_plugin_usage_meter_user_occurred,idx_plugin_usage_pricekey_user_occurred,idx_plugin_usage_mission_occurred). Done when: the entity compiles,missionIdcarries no@ManyToOne(audit rows must outlive a deleted Mission, matching the existingagentId/taskId/runIdcomments), and its doc comment states that the value istasks.missionIdfor the row's Task — neveragents.missionId. -
T4. Migration
AddUsageMeterClassification+ the Mission backfill. Createapps/api/src/migrations/1791170000000-AddUsageMeterClassification.ts. Generate withcd apps/api && pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/AddUsageMeterClassification, then hand-edit to: guard everyADD COLUMNwithhasColumnand every index withhasIndex; not backfillmeter(spec FR-8 — inferring it is exactly the guess the spec forbids); backfillmissionIdfromtasks.missionIdin batches of 5,000 with aWHERE … IS NULLguard so the statement is re-runnable and interruptible.down()drops the three indexes and the seven columns only. Test:packages/agent/src/entities/__tests__/plugin-usage-event.entity.spec.ts— the seven columns exist, the three index names are present, andmissionIdhas no relation decorator. Done when: a fresh database and a database with a partially applied earlier attempt both migrate cleanly, and re-running the migration inserts nothing new.
P1.3 — The price list
-
T5. Credit pricebook. Create
packages/agent/src/subscriptions/billing/credit-pricebook.tsin the same style as the neighbouringcredit-packs.ts:CreditPrice,CREDIT_PRICEBOOK_VERSION = 4,CREDIT_PRICEBOOK_EFFECTIVE_FROM,CREDIT_PRICEBOOK,CREDIT_PRICEBOOK_HISTORY, a purepriceFor(key, units, outcome)that returns0forcachedandfailed, and a puremodelTierFor(entry)that maps aModelCatalogEntry'sinputCostPer1k/outputCostPer1ktofast/balanced/frontier. Entries are exactly the table in spec §6.4. Copy the file header rationale fromcredit-packs.ts: this is a code table on purpose, so a price change ships with a test rather than drifting through an environment variable. Test: createpackages/agent/src/subscriptions/billing/credit-pricebook.spec.ts— every entry has a positive price and a unit;priceForzero-ratescachedandfailed;CREDIT_PRICEBOOK_HISTORYcontains every version from 1 to the current and is frozen; and no price key equals any id in the registered plugin catalogue (Constitution II, enforced by CI rather than by review). Done when: that last assertion fails if someone adds a key named after a plugin. -
T6 (parallel with T5). Expose the pricebook version on the existing pricing view. Modify
packages/agent/src/subscriptions/billing/credits-pricing.tsto addpricebookVersionandpricebookEffectiveFromtoCreditsPricingView. LeavemarginPercentin place and untouched — it still describes the platform's own margin analysis and removing it is out of scope (spec §9, last open question). Test: extendpackages/agent/src/subscriptions/billing/credit-packs.spec.ts's sibling pricing assertions, or addcredits-pricing.spec.tsif none exists. Done when:GET /api/credits/pricingreturns the two new fields.
P1.4 — Classification at capture
-
T7. The classifier. Create
packages/agent/src/usage/usage-meter-classifier.tsexporting a pureclassify(input): { meter, payer, priceKey, priceVersion, creditsCharged }. Inputs are the capability, the operation, the units, the outcome, and the resolved settings source (user/work→workspace;admin/env→platform; anything unresolvable →unconfirmed). The rule is exactly spec §5.1: workspace-owned →modelandcreditsCharged = 0; otherwise →creditspriced from T5;unconfirmed→creditsand flagged. Test: createpackages/agent/src/usage/usage-meter-classifier.spec.ts— the function is total over the cross-product of capability × payer × outcome; no input yields two meters; no input yields none; an unpriced key yieldscreditsCharged = 0and sets the miss flag. Done when: a table-driven test enumerates every capability inPluginUsageCapabilityand asserts a meter for each. -
T8. Stamp it on the single write path. Modify
packages/plugin/src/facades/facade-options.interface.tsto add one optionalmissionIdbeside the existingagentId/taskId/runId, documented as "the Mission of the run's Task (tasks.missionId), resolved once at dispatch — neveragents.missionId". Modifypackages/agent/src/usage/plugin-usage.service.ts—record()calls the classifier, writes the seven new fields (five from the classifier,outcomefrom the caller, andmissionIdpassed straight through —record()never reads thetaskstable itself), and increments the two counters (usage.payer.unconfirmed_ratio,usage.pricebook.miss). A classifier throw is caught: the row is still written withpayer = 'unconfirmed',meter = 'credits', and the counter incremented. A repository throw still returnsnullexactly as today. Modifypackages/agent/src/usage/usage.module.tsto provide the classifier. Test: extendpackages/agent/src/usage/plugin-usage.service.spec.ts. Done when: no facade change is required for a row to carry a meter, and a seeded run whose Task belongs to a Mission produces rows carrying that Mission while a heartbeat run with no Task produces rows withmissionId = null. -
T9. Facades pass outcome and payer; the run adapters pass the Task's Mission. Modify
packages/agent/src/agents/agent-ai-dispatch-facade.tsandpackages/agent/src/agents/agent-plugin-tools-facade.ts— the two adapters that already threadtaskId/runIdintoFacadeOptions— to resolve the run's Task once and thread itsmissionIdalongside them. Heartbeat and chat runs with no Task leave it undefined. Modifypackages/agent/src/facades/search.facade.ts,packages/agent/src/facades/screenshot.facade.ts,packages/agent/src/facades/content-extractor.facade.tsandpackages/agent/src/facades/ai.facade.tsto passoperation,outcomeand theResolvedSetting.sourcethey already hold fromBaseFacadeService.getResolvedSettings. A provider error path recordsoutcome: 'failed'; a cache hit recordsoutcome: 'cached'; a forced-fresh call recordsoutcome: 'ok'withforcedFresh: trueinmetadata. Test: extendpackages/agent/src/facades/__tests__/ai.facade.spec.tsand addpackages/agent/src/facades/__tests__/search.facade.spec.ts— a failing provider call still writes a row, and that row costs 0 credits. Done when: a failed search and a cached page fetch both producecreditsCharged = 0. -
T10 (parallel with T9). The remaining facades. Modify
packages/agent/src/facades/email.facade.ts,packages/agent/src/facades/notification-channel.facade.tsandpackages/agent/src/facades/metrics.facade.tsthe same way. Email and notification-channel sends classify toaddonwithcreditsCharged = 0(spec FR-36 — sends never draw credits); metrics classifies by payer like any other call. Test: extendpackages/agent/src/facades/__tests__/metrics.facade.spec.ts; add an email facade spec asserting a send never produces a credit charge. Done when: no email or notification send can produce a non-zerocreditsCharged.
P1.5 — Settlement stops guessing
- T11. Settle only credits-meter rows.
Modify
packages/agent/src/subscriptions/credits/run-cost-settlement.service.ts: sumcreditsChargedover the Run's rows wheremeter = 'credits'; keep stampingagent_runs.costCentsfrom the full metered rollup (it remains a cost estimate surface, as its comment already says); keep therun:{runId}idempotency key; keep the best-effort posture. Delete the settlement-time provenance re-resolution, thePluginSettingsServicedependency used only for it, and theBYOK_EXEMPTION_UNRESOLVED_BILLS_FULLconstant with its TODO — the flag it asked for now exists on the row. Modifypackages/agent/src/database/repositories/plugin-usage.repository.tsto addgetRunCreditsByPriceKey(runId)andgetRunMeterTotals(runId). Test: rewrite the affected cases inpackages/agent/src/subscriptions/credits/run-cost-settlement.service.spec.ts— a Run made entirely on workspace-owned credentials writes no ledger row; a mixed Run debits only the credits-meter share; a retried settlement writes no second debit. Done when:grep -r BYOK_EXEMPTION_UNRESOLVED_BILLS_FULL packages appsreturns nothing.
P1.6 — Read side
-
T12. Repository aggregations. Modify
packages/agent/src/database/repositories/plugin-usage.repository.tsto addgetSpendByPriceKeyForUser,getSpendByMissionForUser,getSpendByMeterForUser,getWeekSummaryForUserandgetPreMeterResidualForUser(rows withmeter IS NULL). Every one takes the same(userId, from, to, organizationId?)shape as the existing grouped queries and leads with the grouping column so the planner satisfies theGROUP BYfrom the new indexes. Test: extendpackages/agent/src/database/repositories/costs-aggregations.integration.spec.ts. Done when: each new method returns ranked rows and the residual query never foldsmeter IS NULLrows into a named meter. -
T13. Meter and breakdown services. Create
packages/agent/src/subscriptions/credits/meter-summary.service.ts(the three cards- the residual) and
packages/agent/src/subscriptions/credits/spend-breakdown.service.ts(top 10 plusEverything else, per dimension). Both reuse the period parsing already used byusage-summary.service.ts. Modifypackages/agent/src/subscriptions/subscriptions.module.tsto provide both. Test:meter-summary.service.spec.tsandspend-breakdown.service.spec.tsbeside them — the residual is separate, a meter with no data rendersnullrather than0, and theEverything elserow is the exact remainder. Done when: a period with only pre-cutover rows returns all three meters asnull/zero and a non-null residual.
- the residual) and
-
T14. Meters + price-list controllers. Create
apps/api/src/billing/meters.controller.ts(@Controller('api/billing/meters'),GET /with?period=) andapps/api/src/billing/price-list.controller.ts(@Controller('api/billing/price-list'),GET /and?version=). Modifyapps/api/src/billing/billing.module.tsto register both. Test: createapps/api/src/billing/meters.controller.spec.tsandapps/api/src/billing/price-list.controller.spec.ts— all five periods; owner scoping; the price list renders with no payment provider configured; an unknown version 404s. Done when:GET /api/billing/price-listreturns 200 on a deployment with no payment keys. -
T15. Three new cost sections. Modify
apps/api/src/subscriptions/costs.controller.tsto addGET by-tool,GET by-missionandGET by-meteron the existingapi/usage/costsprefix, mirroringby-agent. Modifyapps/web/src/app/api/usage/costs/[section]/route.tsto add the three names to the closed[section]allowlist — nothing else about that route changes. Test: extendapps/api/src/subscriptions/costs.controller.spec.ts; add a case to the web route's own unit spec asserting an unknown section still 404s before reaching the API. Done when:GET /api/usage/costs/by-mission?window=30dreturns ranked Mission rows, each totalling the spend of the Tasks that Mission raised, plus aNot in a Missionrow for spend whose Run had no Task or whose Task names no Mission. -
T16. The Home line endpoint. Modify
apps/api/src/budgets/account-usage.controller.tsto addGET /api/me/usage/this-weekreturning the three figures, the allowance context andstoppedBy(alwaysnulluntil P2). Period is the last 7 days ending now in the caller's profile timezone, falling back to UTC. Test: createapps/api/src/budgets/account-usage.controller.spec.ts(the controller ships without one today) — timezone handling, owner scoping, and a 60-second cache header. Done when: the endpoint returns in under 300 ms p95 against a seeded month of usage. -
T17. Export columns and the refusal. Modify
apps/api/src/subscriptions/credits.controller.ts—GET /api/credits/usage/exportgainsmeter,priceKey,outcome,creditsCharged,priceVersionandmissionId, and refuses with400before streaming when the resolved set exceedsUSAGE_EXPORT_MAX_ROWSor the period exceedsUSAGE_EXPORT_MAX_DAYS. Keep pipingresponse.bodystraight through. Modifyapps/web/src/app/api/credits/usage/export/route.tsonly if a new query param is needed; the allowlist must stay explicit. Test: extendapps/api/src/subscriptions/credits.controller.spec.tsandapps/web/src/app/api/credits/usage/export/route.unit.spec.ts. Done when: a 120-day export is refused with the limit in the message and no bytes written.
P1.7 — Web
-
T18. Shared formatters. Create
apps/web/src/components/settings/billing/spend-format.shared.ts— pure cents/credits/percentage formatters and anunmeasured()helper that renders—with a reason, never0. Same pattern asapps/web/src/components/settings/fleet-cost-ceiling.shared.ts. Test: createapps/web/src/components/settings/billing/spend-format.shared.unit.spec.ts. Done when: the helpers are unit-tested with no React import. -
T19. Meter cards. Create
apps/web/src/components/settings/billing/MeterCards.tsx(server component) per spec §6.2, including the three zero-state sentences and the payments-off copy. Modifyapps/web/src/components/settings/BillingSettings.tsxto mount it above the existing sections. Nothing existing is removed or reordered. Test: createapps/web/src/components/settings/billing/MeterCards.unit.spec.tsx. Done when: no card can render a blended total, and every empty meter renders a sentence. -
T20. Breakdown panels. Create
apps/web/src/components/settings/billing/SpendBreakdown.tsx— three panels fetched withPromise.allSettledso one failing shows its own retry card and never blanks the others. Each row links into the Run list filtered to that dimension and period. Modifyapps/web/src/components/settings/BillingSettings.tsxto mount it. Test: createSpendBreakdown.unit.spec.tsx— one rejected fetch leaves two panels rendered;Everything elseis the exact remainder; the pre-cutover note renders when residual is non-zero. Done when: killing the by-Mission endpoint in a test leaves by-tool and by-Agent intact. -
T21 (parallel with T20). The Breakdown tab on Usage. Modify
apps/web/src/components/settings/usage/UsageTabs.tsxand itsusage-tabs.shared.tsto add a third tabbreakdown, and modifyapps/web/src/app/[locale]/(dashboard)/settings/usage/page.tsxto server-fetch only that tab's endpoints when it is active — the existing "disjoint fetch sets per tab" property must hold for three tabs as it does for two. Test: extendapps/web/src/components/settings/costs/CostsSettings.unit.spec.tsx's sibling tab test, or addUsageTabs.unit.spec.tsx. Done when: opening?tab=breakdownissues none of the Overview or Costs requests. -
T22. Price-list page. Create
apps/web/src/app/[locale]/(dashboard)/settings/billing/price-list/page.tsxandapps/web/src/components/settings/billing/CreditPriceList.tsxper spec §6.4, with the grouped table, the version header, the history note and the conversion footer. Modifyapps/web/src/components/settings/BillingSettings.tsxto link to it from the credits card. Done when: the page renders on a deployment with no payment provider configured. -
T23. The week card. Create
apps/web/src/components/spend/WeekSpendCard.tsx— self-contained client component, polls at 60 s, stops whiledocument.hidden, renders loading / empty / error / stopped states per spec §6.1. Createapps/web/src/app/api/billing/meters/route.ts(proxy, explicit param allowlist). Modifyapps/web/src/components/settings/BillingSettings.tsxto mount the card at the top until AW-19 lands; leave a one-line comment naming AW-19 as the eventual owner. Test: createapps/web/src/components/spend/WeekSpendCard.unit.spec.tsx— polling interval, hidden-tab pause, error state does not blank the card. Done when: with the tab hidden, no network request is issued. -
T24. Receipt cost block. Create
apps/web/src/components/runs/RunCostMeters.tsxrendering the three-meter itemisation of spec §6.8, and modify the AW-09 receipt component to mount it inside its existing Cost block. Modifyapps/api/src/subscriptions/costs.controller.ts(or AW-09's receipt endpoint, whichever owns the payload) to include per-price-key lines, cached and failed counts, the paying account label and the price-list version. Test: createapps/web/src/components/runs/RunCostMeters.unit.spec.tsx— the own-account line, cached and failed counts, "so far" labelling, the reconciliation-mismatch copy, and the aged-out notice. Done when: a Run older than 12 months shows the retention notice and keeps its total.
P1.8 — i18n and end-to-end for P1
-
T25. Message keys, P1 slice. Modify
apps/web/messages/en.jsonto add every key underdashboard.settings.billing.meters,.breakdown,.priceList, plusdashboard.spendWeek,dashboard.runs.receipt.costanddashboard.settings.usage.tabs.breakdown, exactly as listed in plan §8. Done when: every leaf key name is camelCase, no leaf key name contains a literal., and the hydration spec that fails on a missing key passes. -
T26. P1 end-to-end. Create
apps/web/e2e/billing-three-meters.spec.ts(S1, S3, S13, S29),apps/web/e2e/billing-price-list.spec.ts(S2, S28),apps/web/e2e/run-receipt-cost-meters.spec.ts(S4, S5, S15, S16, S17, S24) andapps/web/e2e/home-week-spend.spec.ts(S1). PrefergetByTestIdover*ByRoleon the dense tables; add the new specs toapps/web/e2e/COVERAGE.md. Done when: all four pass locally and in CI with payments disabled.
Phase P2 — Caps that stop
Delivers spec FR-42…FR-64.
P2.1 — Schema
-
T27. Workspace owner type + cap entity + budget meters + auto-recharge ceiling. Modify
packages/agent/src/entities/_types.ts— addBudgetOwnerType.WORKSPACE. Createpackages/agent/src/entities/workspace-spend-cap.entity.tsexactly as in plan §3.1 — noallowOveragecolumn,versionfor optimistic concurrency,PortableDateColumnfor every date,tenantIdandorganizationIdas raw uuid columns with no@ManyToOne(the entity-cycle rule). Modifypackages/agent/src/entities/work-budget.entity.tsandpackages/agent/src/entities/agent-budget.entity.tsto add the nullablemetercolumn (NULL keeps today's "all meters" meaning). Modifypackages/agent/src/entities/billing-profile.entity.tsto addautoRechargeMonthlyCapCents,autoRechargeMonthKeyandautoRechargeMonthSpentCents. Modifypackages/agent/src/entities/index.tsandpackages/agent/src/database/_entities-inventory.tsto register the new entity — this repo has noautoLoadEntities, and an unregistered entity throws on first query. Test: createpackages/agent/src/entities/__tests__/workspace-spend-cap.entity.spec.tsasserting noallowOveragecolumn, portable dates, both scope columns present soapps/api/src/scope/scope-stamping.subscriber.tswill stamp them; extendpackages/agent/src/entities/__tests__/agent-budget.entity.spec.tsfor the newmetercolumn. Done when: all four entity specs pass. -
T28. Migration
AddSpendCapsAndMeterScopedBudgets. Createapps/api/src/migrations/1791170100000-AddSpendCapsAndMeterScopedBudgets.ts.up(): createworkspace_spend_caps; declareuq_workspace_spend_caps_owner_meterin the migration as two partial unique indexes (WHERE "organizationId" IS NULLand its complement) for the same NULL-is-distinct reason documented onwork_budgets, and omit the decorator-level@Indexso the SQLite test driver'ssynchronizedoes not generate a non-partial duplicate; addmetertowork_budgetsandagent_budgets; add the threebilling_profilescolumns; backfillautoRechargeMonthlyCapCents = 10000for every profile withautoRechargeEnabled = trueand a NULL ceiling, so no live auto-recharge is left unbounded and none is silently switched off.down(): drop the table and the five columns. Done when: a database with existing enabled auto-recharge migrates to a $100 ceiling, and re-running the migration changes nothing.
P2.2 — Enforcement
-
T29. Cap service. Create
packages/agent/src/budgets/spend-cap.service.ts— one read model overworkspace_spend_caps,work_budgets,agent_budgetsand the Fleet node ceilings; period arithmetic (calendar month for all scopes, plushour/day/weekfor Agent caps, reusingpackages/agent/src/budgets/budget.service.ts); state computation; strictest-cap resolution; optimistic-concurrency writes. Modifypackages/agent/src/budgets/budgets.module.tsto provide it. Test: createpackages/agent/src/budgets/spend-cap.service.spec.ts— every period unit;ok → warning → stopped; lowering below spend goes straight toexceeded; the strictest of three overlapping caps wins and is named; a staleversionthrows. Done when: the strictest-cap test covers Workspace + Mission + Agent all applying at once. -
T30. Guard extension. Modify
packages/agent/src/budgets/budget-guard.service.ts—checkBudgetgains the Workspace owner type and ameterargument, resolves all applicable caps in one query, refuses withBudgetExceededExceptionnaming the cap that refused, and keeps the existingallowOveragebehaviour for the legacy Work and Agent budgets only. Modify every facade call site listed in plan §1.1 to pass the meter it is about to record. Test: extendpackages/agent/src/budgets/budget-guard.service.spec.ts— a Workspace cap at 100% refuses; a legacy budget withallowOveragestill permits; the refusal names the cap; the check adds under 15 ms against a seeded month. Done when: a benchmark case in the spec asserts the p95 latency bound. -
T31. Run stops, escalations and decisions. Modify
packages/contracts/src/agents/escalation.types.tsto addcredits-exhaustedtoAgentEscalationReasonCodeandAGENT_ESCALATION_REASON_CODES— an addition, never a rename, as the file's own comment requires. Modifypackages/agent/src/agents/run-dispatch-gate.service.tsandpackages/agent/src/agents/run-credits-precheck.tsso a cap refusal parks the Run withbudget-stopand an empty balance parks it withcredits-exhausted— two distinct reasons. Createpackages/agent/src/budgets/spend-decision.writer.ts— writes one decision per (cap, period) using the escalationdedupKeybudget-stop:{capId}:{period}, and one per month for the auto-recharge ceiling. Test: extend the dispatch-gate spec; createspend-decision.writer.spec.tsasserting two concurrent crossings produce exactly one decision. Done when: a Run stopped by a cap and a Run stopped by an empty balance carry different reason codes on the receipt. -
T32. Auto-recharge ceiling. Modify
packages/agent/src/subscriptions/billing/auto-recharge.service.ts— before the compare-and-set claim, rollautoRechargeMonthKeyif stale, refuse whenautoRechargeMonthSpentCents + packPriceCents > autoRechargeMonthlyCapCentswithout contacting the provider, and raise the monthly decision. Increment the counter inapps/api/src/billing/billing-webhook.controller.ts's purchase handler, inside the same transaction that credits the ledger. Test: extendpackages/agent/src/subscriptions/billing/auto-recharge.service.spec.ts— the refusal places no provider call; the counter rolls on a new month; the single-flight guard still holds; three consecutive failures disable. Done when: a test asserts the billing provider mock received zero calls on the refusal path.
P2.3 — API and web
-
T33. Caps API. Create
apps/api/src/budgets/spend-caps.controller.ts(@Controller('api/spend-caps')) withGET /,POST /,PATCH /:id(requiresIf-Match),DELETE /:id, and the DTOs underapps/api/src/budgets/dto/.@Throttle({ long: { limit: 30, ttl: 60_000 } })on every write. Node rows areeditable: falseand every write against one returns400 NODE_CEILING_READ_ONLYwith the Fleet route to use. Modifyapps/api/src/budgets/budgets.module.tsto register it. Test: createapps/api/src/budgets/spend-caps.controller.spec.ts— all five scopes;409duplicate;409 CAP_VERSION_CONFLICTcarrying the current value;400on a Node write; a cross-user id is indistinguishable from a missing one; writes require billing permission. Done when: the cross-user case returns the identical body to the missing-id case. -
T34. Per-Agent cap API, and the legacy read finally tells the truth. Create
apps/api/src/agents/agent-budget.controller.ts(@Controller('api/agents/:agentId/budget')) withGET,PUT,DELETE, calling the repositoryupsert()that has existed unused since the entity shipped. Modifyapps/api/src/agents/agents.controller.tsso its existingGET /api/agents/:id/budgetdelegates to the same service instead of returningcapCents: nullunconditionally — the path and response shape are preserved (Constitution X). Modifyapps/api/src/agents/agents.module.tsto register the new controller. Test: createapps/api/src/agents/agent-budget.controller.spec.ts—PUTthenGETreturns the cap and a non-zero current spend computed from seeded usage rows;DELETEclears it; the legacy path returns the same numbers. Done when:apps/web/src/app/[locale]/(dashboard)/agents/[id]/budgets/page.tsxstops showing "no cap configured" for an Agent that has one. -
T35. Auto-recharge API. Modify
apps/api/src/billing/billing.controller.ts—GET/PUT /api/billing/auto-rechargegainmonthlyCapCents(writable) andmonthlyUsedCents(read-only).PUTwithenabled: trueand no ceiling →400 AUTO_RECHARGE_CAP_REQUIRED; a ceiling outside $10…$2,000 →400. Test: extendapps/api/src/billing/billing.controller.spec.ts. Done when: auto-recharge cannot be enabled without a ceiling through any request shape. -
T36. Cap evaluator job. Create
packages/tasks/src/tasks/trigger/spend-cap-evaluate.task.tsas aschedules.taskat*/10 * * * *calling the cap service over internal RPC; claim each cap row with an atomicUPDATE … WHERE evaluatingAt IS NULL OR evaluatingAt < now() - interval '5 minutes'(it owns a row, so no distributed lock). Modifypackages/tasks/src/tasks/trigger/index.tsto export it,packages/agent/src/tasks/_tasks-symbols.tsto addSPEND_CAP_EVALUATE_DISPATCHER, andpackages/agent/src/tasks/job-runtime.providers.tsto bind it. No call site imports a third-party SDK directly (Constitution IV). Test:packages/agent/src/tasks/spend-cap-evaluate-dispatcher.spec.tsasserting the symbol resolves through the binding factory. Done when: the task appears in the job-runtime registry and a staleevaluatingAtis reclaimed. -
T37. Caps web surface. Create
apps/web/src/app/[locale]/(dashboard)/settings/billing/caps/page.tsx,apps/web/src/components/settings/billing/SpendCapsTable.tsxandapps/web/src/components/settings/billing/SpendCapDialog.tsxper spec §6.5 and §6.6. The dialog is focus-trapped,Esccloses,Entersubmits, and it sendsIf-Match. Modifyapps/web/src/components/settings/BillingSettings.tsxto link to it and to show the stopped banner. Do not add a settings-tree tab — the tree is already 18 items deep. Test: createSpendCapsTable.unit.spec.tsxandSpendCapDialog.unit.spec.tsx. Done when: the Node row cannot be edited and links to the Fleet control, and the legacy budgets are the only rows showingoverage on. -
T38. Auto-recharge ceiling field + the two decision cards. Modify
apps/web/src/components/settings/BillingSettings.tsxto add the monthly-maximum field, its used-this-month readout, and the disabled-after-failures notice. Modifyapps/web/src/components/approvals/ApprovalsQueue.tsxto render the two new decision kinds with their action buttons, disabled with the permission explanation for a viewer without billing permission. Test: createapps/web/src/components/approvals/ApprovalsQueue.unit.spec.tsx(the component ships without one today) covering both decision kinds and the disabled-for-viewer state. Done when: answering a cap decision with "Raise to {amount}" writes the cap and lifts the stop. -
T39. Activity-log entries. Modify
apps/api/src/activity-log/to recordspend_cap_created,spend_cap_updated,spend_cap_deletedandauto_recharge_cap_updatedwith old and new values, attributed to the acting user, and never a card detail. Test: extend the activity-log spec for the four types. Done when: every cap write produces exactly one log entry. -
T40. i18n and end-to-end for P2. Modify
apps/web/messages/en.jsonto adddashboard.settings.billing.caps,.autoRecharge.monthlyCap*,dashboard.approvals.spendand theerrors.billing.*keys from plan §8. Createapps/web/e2e/spend-caps-crud.spec.ts(S6, S19, S20, S21),apps/web/e2e/spend-cap-stops-a-run.spec.ts(S7, S12, S18),apps/web/e2e/auto-recharge-ceiling.spec.ts(S8) andapps/web/e2e/spend-permissions.spec.ts(S25, S26); add them toapps/web/e2e/COVERAGE.md. Done when: all four pass in CI and no leaf key name contains a literal..
Phase P3 — Add-ons
Delivers spec FR-35…FR-41.
-
T41. Add-on entity + migration. Create
packages/agent/src/entities/account-addon.entity.tsas in plan §3.1 (refIdisvarchar(128), notuuid— not every provisioned unit is keyed by a uuid, and a earlier migration in this repo had to widen arefIdfor exactly that reason). Modifypackages/agent/src/entities/index.tsandpackages/agent/src/database/_entities-inventory.ts. Createapps/api/src/migrations/1791170200000-AddAccountAddons.ts— create the table with both indexes, then backfill onependingrow per existing agent inbox (tenant_email_addresses) and per enrolledfleet_nodesrow so nothing is billed until an operator promotes them. Test: createpackages/agent/src/entities/__tests__/account-addon.entity.spec.ts. Done when: the backfill produces onlypendingrows and is re-runnable. -
T42. Add-on service. Create
packages/agent/src/subscriptions/billing/addon.service.ts— pro-ration on add and remove, the 25-per-kind limit, provider quantity updates through the existingBillingProviderseam inpackages/agent/src/subscriptions/billing/billing.provider.ts(never a third-party SDK import), orphan detection, and a hard guarantee that no code path writes to the credit ledger. Modifypackages/agent/src/subscriptions/subscriptions.module.tsto provide it. Test: createpackages/agent/src/subscriptions/billing/addon.service.spec.ts— pro-ration for 28-, 30- and 31-day periods; the limit at 26;seatrefused; an orphan is never billed; no ledger write occurs on any path. Done when: the "no ledger write" assertion uses a strict mock that fails the test on any call. -
T43. Add-ons API. Create
apps/api/src/billing/addons.controller.ts(@Controller('api/billing/addons')) withGET /,GET /preview,POST /,DELETE /:idand DTOs underapps/api/src/billing/dto/. Modifyapps/api/src/billing/billing.module.tsto register it. Test: createapps/api/src/billing/addons.controller.spec.ts— preview maths;409on the unique ref;400 ADDON_LIMIT_REACHED;400 SEAT_MANAGED_ELSEWHERE. Done when:GET /api/billing/addons/preview?code=agent-inbox&quantity=1returns the pro-rated and full amounts for today's date. -
T44. Provisioning hooks. Modify
apps/api/src/email/email.controller.ts's address create and delete paths andapps/api/src/fleet/fleet.controller.ts's enroll and unenroll paths to add and remove the matching add-on line within 60 seconds, best-effort — a failed add-on write must never fail the provisioning call. Test: extend the two controllers' specs — provisioning succeeds even when the add-on service throws, and the reconciler picks the line up afterwards. Done when: deleting an inbox marks its add-onremovedwithout a manual step. -
T45. Reconciler job. Create
packages/tasks/src/tasks/trigger/addon-reconcile.task.tsas aschedules.taskat17 3 * * *, takingDistributedTaskLockService(it owns no row). Modifypackages/tasks/src/tasks/trigger/index.ts,packages/agent/src/tasks/_tasks-symbols.ts(ADDON_RECONCILE_DISPATCHER) andpackages/agent/src/tasks/job-runtime.providers.ts. Test:packages/agent/src/tasks/addon-reconcile-dispatcher.spec.ts. Done when: a vanished unit becomesorphanwithin one run and stops being billed. -
T46. Add-ons web surface. Create
apps/web/src/app/[locale]/(dashboard)/settings/billing/addons/page.tsxandapps/web/src/components/settings/billing/AddonsList.tsxper spec §6.7 — pro-ration preview before confirm, removal confirmation quoting the credit, the read-only seats row linking to the existing seat control, and the orphan row. Modifyapps/web/src/components/settings/billing/MeterCards.tsxso the third card shows real numbers instead of the P1 placeholder. Test: createAddonsList.unit.spec.tsx. Done when: the "never draws credits" sentence appears on the card, the list and the removal confirmation. -
T47. Mission backfill task (parallel with T46). Create
packages/tasks/src/tasks/trigger/usage-mission-backfill.task.tsas a one-shot fan-out for deployments whoseplugin_usage_eventstable is too large to backfill inside the P1 migration window; modify the two dispatcher files as in T36. Done when: running it twice inserts nothing the second time. -
T48. i18n and end-to-end for P3. Modify
apps/web/messages/en.jsonto adddashboard.settings.billing.addons.*. Createapps/web/e2e/addons-lifecycle.spec.ts(S9, S10) and add it toapps/web/e2e/COVERAGE.md. Done when: the spec asserts the credits balance is byte-identical before and after adding and removing an add-on.
Cross-cutting, do not skip
-
T49. Program vocabulary. Modify
docs/specs/features/agent-workspace/README.md§1 to add three rows — Meter, Credit price list, Add-on — with the "do not introduce" column filled in ("bucket","rate card","extra"respectively). Program rule 2 requires this in the same pull request as the epic's first merge. Done when: every noun this epic introduces appears in the program vocabulary table. -
T50. Tracker. Modify
docs/specs/features/agent-workspace/TRACKER.mdto moveAW-17from spec-only to the phase actually merged, at the end of each phase. Done when: the tracker names the merged phase and the pull request. -
T51. Telemetry. Modify the PostHog call sites under
packages/monitoring/(or the analytics dispatcher atpackages/agent/src/activity-log/activity-log-analytics-dispatcher.ts, whichever owns the surface) to emit the eight events in plan §9.1, and register the six operational counters and their alert thresholds from §9.2. Done when:usage.pricebook.misspages on a single occurrence in a staging soak. -
T52. Documentation. Modify
docs/features/credits-and-billing.mdto describe the three meters, the classification rule and the credit price list, and modifydocs/features/budgets-and-usage.mdto describe caps as refusals, the four scopes, and the read-only Node ceiling. Both pages are already listed inapps/docs/sidebarsPlatform.ts, so no sidebar change is needed — if a new page is added instead, it must be listed there or it renders only as an orphan. Also modifydocs/features/settings-map.mdfor the three new Billing sub-routes. Done when: the published docs describe the meters, the price list and the caps, and no page still says a credit is a blended rate.
Definition of done for the epic
Every box in spec §8 is checkable against the merged code, all
three migrations apply cleanly forward on a database seeded from develop, pnpm lint,
pnpm type-check and pnpm test are green, and the nine Playwright specs pass in CI.