Gateway Module — `compute-metering/`
1. Purpose
The compute-metering/ module captures per-job compute spend (token counts + model only, never payload) from both edges — the CC-shell transcript parser and the Hermes/VPS model-call site — and costs it two ways: a billing figure (all tokens × the single "pricing model of record" rate, the cost-plus cap) and an internal figure (each captured model's actual rate on the two-tier Fable/Opus fleet). The headline security control is the .strict() capture envelope (AK-421 §04.1, the payload-projection invariant): only the allow-listed dimensions { counts, model, edge, job ref, timestamp } are accepted; any prompt/messages/content field is rejected at the trust boundary so payload can never leak into the billing table. All money is computed in exact-decimal integer micro-USD (BigInt), never a JS float. AK-434 Increment 2 added per-dispatch and LISA-central (main-loop) cost read-models.
2. File Inventory
| File | Lines | Responsibility |
|---|---|---|
config.ts | 224 | DEFAULT_PRICE_TABLE (multi-model, id-keyed), resolvePriceTable() env-override resolver |
index.ts | 33 | Layer factory createComputeMeteringLayer(db) → { repo, router, priceTable } |
repository.ts | 203 | compute_capture table DDL + capture/cost queries |
routes.ts | 126 | 1 POST capture + 3 GET cost endpoints |
service.ts | 322 | Pure costing fns (micro-USD arithmetic, per-model + dispatch + central) |
types.ts | 305 | .strict() capture Zod + cost query Zod + all cost read-model interfaces + price-table shape |
| Total | 1213 |
3. Public API Surface
REST Endpoints
Mounted at /api/compute-capture (index.ts line 766). No auth exemption for this prefix, so:
| Method | Path | Auth | Body / Query | Response | Side effects |
|---|---|---|---|---|---|
| POST | /api/compute-capture | Bearer (write only) | ComputeCaptureInputSchema (.strict()) | 201 { data: ComputeCaptureRow } | INSERT into compute_capture |
| GET | /api/compute-capture/cost?job_ref=… | Bearer | ComputeCostQuerySchema | { data: ComputeCostResult } | none |
| GET | /api/compute-capture/cost/by-dispatch?dispatch_id=N | Bearer | ComputeCostByDispatchQuerySchema (coerced int) | { data: ComputeDispatchCostResult } — billing + internal split + per-model lines | none |
| GET | /api/compute-capture/cost/central | Bearer | none | { data: ComputeCentralCostResult } — main-loop (dispatch_id IS NULL) complement + unattributed_fail_closed_count | none |
Security note (gray-fox F-1 CRITICAL, documented in code): these cost routes MUST NOT mount under /api/dashboard/*, because the dashboard read gate serves every GET /api/dashboard/* without a caller Bearer — if the gateway is exposed publicly that would make raw cost figures world-readable. The dashboard's compute tab fetches them with the injected read-only token it already carries. Responses carry raw two-rate costs only — no ×1.50 markup; margin materialises exclusively in the out-of-scope invoice calculator (gray-fox F-6).
MCP Tools
None. Capture is machine-to-machine edge→gateway REST traffic (AK-421 §06 Q6) — the .strict() schema is the sole validation surface, with no MCP layer.
4. Internal API
createComputeMeteringLayer(db): { repo, router, priceTable }— resolves the price table once at construction.createComputeMeteringRepository(db, priceTable): ComputeMeteringRepository—capture,costForJob,costForDispatch,costForCentral; exportsCOMPUTE_CAPTURE_DDL.service.tspure fns:parseUsdToMicro,renderMicroToUsd,selectRecordEntry(BY model id, never positional — raiden ADV),computeJobCost,computeDispatchCost,computeCentralCost.config.ts:DEFAULT_RECORD_MODEL_ID,DEFAULT_PRICE_TABLE,resolvePriceTable(rawEnv?).
5. Background Services
None. Capture is push-driven from the edges; cost is request-driven.
6. Data Contracts
ComputeCaptureInputSchema—.strict(). Fields:job_ref(required),job_timing_id(nullish FK seam to the not-yet-built TEE),dispatch_id(nullish FK →agent_dispatch(id); NULL for main-loop, populated for dispatched work),attribution_fail_closed(nullish bool — TRUE when a subagent capture's structural join couldn't resolve a dispatch; distinct from a genuine main-loop NULL),edge(cc_transcript|hermes),model,input_tokens,output_tokens,cache_read_tokens/cache_write_tokens(nullish),captured_at(optional; server-stamped if omitted). Do not relax.strict()without re-opening the §04 security review.ComputeCostQuerySchema/ComputeCostByDispatchQuerySchema— job_ref / coerced positive-int dispatch_id.- Cost read-model interfaces:
ComputeCostResult,InternalModelCostLine,ComputeDispatchCostResult,ComputeCentralCostResult(extends withunattributed_fail_closed_count). All money as exact-decimal USD strings. - Price table: multi-model,
model_id-keyed (PriceTableEntrywithinput/output/cache_read/cache_write_cost_per_millionstrings, optionalcache_write_1h_cost_per_million,deployableflag). Unknown captured model id → costed at the record rate (conservative cap) + flaggedunknown_model_rate(gray-fox F-5, never $0/skipped).
7. Dependencies
- Gateway modules consumed:
shared/(formatZodError); FK reference (nullable) to dispatch'sagent_dispatchtable. - External libraries:
better-sqlite3,express,zod. - Environment variables:
COMPUTE_PRICE_TABLE_JSON(optional override of the deployable price table — the §09 configurability path).
8. Test Coverage
| Layer | File | Cases |
|---|---|---|
| Service + routes + repository | test/compute-metering.test.ts (838 L) | 42 it blocks |
| Legacy boot guard | test/zz-compute-metering-legacy-boot.test.ts (36 L) | boot-wiring regression guard |
Covers exact-decimal arithmetic, .strict() rejection of payload fields, two-rate split, unknown-model fallback, per-dispatch attribution, and central-lane fail-closed counting.
9. Known Limitations
types.tsdocstring drift (flagged, not a bug): thePriceTableConfig.record_model_iddocstring says "Increment 1 default: Opus 4.8", butconfig.tsDEFAULT_RECORD_MODEL_IDis now'claude-fable-5'(the Increment-2 flip). The docstring is stale relative to the deployed default — a MEDIUM documentation gap for the campaign tail.- 1-hour cache-write tier is carried in the shape but not applied: the capture envelope has a single
cache_write_tokenscount and does not distinguish 5m from 1h at source, so all cache-write tokens are costed at the 5-minute rate (deliberate, conservative — under-states only for rare 1h-TTL writes). job_timing_idis a nullable seam to a TEE (Temporal Estimation Engine) that does not yet exist; always NULL in Increment 1.
10. Change History
| Date | Dispatch | Summary |
|---|---|---|
| 2026-07-04 | 2297 | Initial module spec (smoke-clone-3, LisaOS audit campaign Phase 4) |
| — | (AK-434) | Increment 2 — two-rate cost read-model, per-dispatch + central views, Fable-5 record flip |
| — | (AK-421) | Increment 1 — module created; .strict() capture envelope |