Root cause: decide() was advisory but user charges were reduced from its output;
recordGrant() later might absorb less, so Timmy could absorb the gap silently.
Fix architecture (serve="free" path — fully-free jobs + sessions):
- decide() now runs _atomicPoolDebit() inside a FOR UPDATE transaction
- Pool is debited at decision time for serve="free" decisions
- Work starts immediately after, so no window for pool drain between debit and use
- If work fails → releaseReservation() returns sats to pool
Fix architecture (serve="partial" path — partial-subsidy jobs):
- decide() remains advisory for "partial" (no pool debit at decision time)
- This prevents pool drain from users who get a partial offer but never pay
- For jobs: reservePartialGrant() atomically debits pool at work-payment-confirmation
time (inside advanceJob), before work begins
- For sessions: reservePartialGrant() called after synchronous work completes,
using actual cost capped by advisory absorbSats
recordGrant() now takes (pubkey, requestHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual) returned to pool atomically
- Audit log and daily counter reflect actual absorbed amount
- Pool balance was already decremented by decide() or reservePartialGrant()
Result: In ALL paths, pool debit happens atomically before charges are reduced.
User charge reduction and pool debit are always consistent — Timmy never operates
at a loss due to concurrent pool depletion.
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin: estimateRequestCost already includes infra+margin; convert directly
- absorbedSats capped at actual cost post-execution (Math.min)
Fix 3 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs run paid accounting: actual sats, refund, pool credit, deferred grant
Fix 4 — Defer ALL grant recording to post-work execution (jobs.ts)
- Fully-free path: removed recordGrant from eval time; now called in runWorkInBackground
- For isFree jobs: absorbCap = actual post-execution cost (calculateActualChargeSats)
- For partial jobs: grant deferred from invoice creation to after work completes
Fix 5 — Atomic, pool-bounded grant recording with row locking (free-tier.ts)
- SELECT ... FOR UPDATE locks pool row inside transaction
- actualAbsorbed = Math.min(absorbSats, poolBalance) — pool can never go negative
- Pool balance update is plain write (lock already held)
- Daily absorption: SQL CASE expression atomically handles new-day reset
- Audit log and identity counter both reflect actualAbsorbed, not requested amount
- If pool is empty at grant time, transaction returns without writing
Fix 6 — Remove fire-and-forget (void) from all recordGrant() call sites
- All three call sites now use await; grant failures propagate correctly
- Removed unused createHash import from free-tier.ts
Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts)
- Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts
Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work
- Was called at invoice creation — economic DoS vulnerability
- Now deferred to runWorkInBackground via new `partialAbsorbSats` param
- Fully-free jobs still record grant at eval time (no payment involved)
Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile
- freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork()
- Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin
(calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd
- absorbedSats capped at actual cost post-execution to prevent over-absorption
Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts)
- isFreeExecution = workAmountSats === 0 (not job.freeTier)
- Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path:
actual sats, refund eligibility, pool credit, and deferred grant recording
Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts)
- Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value
- Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB
→ reset counter on new day, increment atomically otherwise
- No more application-layer read-modify-write for either counter
Fix 6 — Remove fire-and-forget from all recordGrant() call sites
- Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1)
- Grant persistence failures now propagate correctly instead of silently diverging
- Removed unused createHash import from free-tier.ts
1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts
- Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd
- Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts
2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground
- Previously called at invoice creation for partial path — economic DoS vulnerability
- Now deferred to after work completes via new `partialAbsorbSats` param in runWorkInBackground
- Fully-free jobs still record grant at eval time (no payment involved)
3. Sessions pre-gate: estimate → decide → execute → reconcile (with double-margin bug fix)
- Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called
- Fixed: estimateRequestCost already includes infra+margin via calculateWorkFeeUsd,
so convert estimatedCostUsd directly to sats — no second calculateActualChargeUsd call
- absorbedSats capped at actual cost post-execution (Math.min) to prevent over-absorption
4. Atomic pool deduction in recordGrant (free-tier.ts)
- Replaced non-atomic read-then-write pattern with SQL GREATEST expression inside transaction
- UPDATE timmyConfig SET value = GREATEST(value::int - absorbSats, 0)::text RETURNING value
- Audit log (freeTierGrants) receives actual post-deduct value from DB; no oversubscription
- Removed unused createHash import from free-tier.ts
## What was built
### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns
### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
— all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
— checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring
### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
balance debit. Session balance only reduced by `chargeSats`; absorbed portion
comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.
### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
(if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets
### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot
## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)