Files
timmy-tower/artifacts/api-server/src/lib/free-tier.ts

371 lines
14 KiB
TypeScript
Raw Normal View History

Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
import { randomUUID } from "crypto";
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
import { db, timmyConfig, freeTierGrants, nostrIdentities, type TrustTier } from "@workspace/db";
Task #27: Apply 3 required fixes for cost-routing + free-tier gate 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
2026-03-19 16:47:51 +00:00
import { eq, sql } from "drizzle-orm";
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
import { makeLogger } from "./logger.js";
import { trustService } from "./trust.js";
const logger = makeLogger("free-tier");
// ── Env-var helpers ────────────────────────────────────────────────────────────
function envInt(name: string, fallback: number): number {
const raw = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
}
function envFloat(name: string, fallback: number): number {
const raw = parseFloat(process.env[name] ?? "");
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
}
// ── Daily free budget per trust tier (sats) ───────────────────────────────────
// Override via env vars. "new" tier gets 0 — must earn trust first.
const DAILY_BUDGET_NEW = envInt("FREE_TIER_BUDGET_NEW", 0);
const DAILY_BUDGET_ESTABLISHED = envInt("FREE_TIER_BUDGET_ESTABLISHED", 50);
const DAILY_BUDGET_TRUSTED = envInt("FREE_TIER_BUDGET_TRUSTED", 200);
const DAILY_BUDGET_ELITE = envInt("FREE_TIER_BUDGET_ELITE", 1_000);
// ── Generosity pool ───────────────────────────────────────────────────────────
// Pool starts at POOL_INITIAL_SATS on first boot. Grows by POOL_CREDIT_PCT of
// every paid work invoice. Never drops below 0.
const POOL_INITIAL_SATS = envInt("FREE_TIER_POOL_INITIAL_SATS", 10_000);
const POOL_CREDIT_PCT = envFloat("FREE_TIER_POOL_CREDIT_PCT", 10);
const POOL_KEY = "generosity_pool_sats";
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
const DAY_MS = 24 * 60 * 60 * 1000;
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
// ── Types ─────────────────────────────────────────────────────────────────────
export interface FreeTierDecision {
serve: "free" | "partial" | "gate";
absorbSats: number;
chargeSats: number;
}
// ── FreeTierService ───────────────────────────────────────────────────────────
export class FreeTierService {
// Daily sat budget for each trust tier.
dailyBudgetForTier(tier: TrustTier): number {
switch (tier) {
case "elite": return DAILY_BUDGET_ELITE;
case "trusted": return DAILY_BUDGET_TRUSTED;
case "established": return DAILY_BUDGET_ESTABLISHED;
case "new":
default: return DAILY_BUDGET_NEW;
}
}
// Read the current pool balance; initialise to POOL_INITIAL_SATS on first call.
async getPoolBalance(): Promise<number> {
const rows = await db
.select()
.from(timmyConfig)
.where(eq(timmyConfig.key, POOL_KEY))
.limit(1);
if (rows[0]) return parseInt(rows[0].value, 10) || 0;
// First boot — seed the pool
await db
.insert(timmyConfig)
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
.onConflictDoNothing();
logger.info("generosity pool initialised", { initialSats: POOL_INITIAL_SATS });
return POOL_INITIAL_SATS;
}
/**
* Decide whether to serve a request for free, partially free, or at full cost.
*
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
* For serve="free": the pool is atomically debited for absorbSats immediately.
* This is correct because work starts right away no window where the reservation
* can be abandoned. If work fails, call releaseReservation() to refund the pool.
*
* For serve="partial": the decision is advisory pool is NOT debited yet.
* The user still needs to pay their portion before work starts. Pool debit happens
* later via reservePartialGrant() once payment is confirmed, so the pool is never
* depleted by a user who never pays.
*
* For serve="gate": no action taken (no pool debit).
*
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
* Returns:
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
* serve="free" absorbSats=estimatedSats (already debited), chargeSats=0
* serve="partial" 0 < absorbSats < estimatedSats (advisory), chargeSats = remainder
* serve="gate" absorbSats=0, chargeSats=estimatedSats (full charge)
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
*/
async decide(pubkey: string | null, estimatedSats: number): Promise<FreeTierDecision> {
const gate: FreeTierDecision = { serve: "gate", absorbSats: 0, chargeSats: estimatedSats };
if (!pubkey || estimatedSats <= 0) return gate;
const identity = await trustService.getIdentityWithDecay(pubkey);
if (!identity) return gate;
const dailyBudget = this.dailyBudgetForTier(identity.tier);
if (dailyBudget === 0) return gate;
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
const todayAbsorbed =
(Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS
? 0
: identity.satsAbsorbedToday;
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed);
if (dailyRemaining === 0) {
logger.info("free-tier: daily budget exhausted", {
pubkey: pubkey.slice(0, 8),
tier: identity.tier,
todayAbsorbed,
dailyBudget,
});
return gate;
}
const poolBalance = await this.getPoolBalance();
if (poolBalance <= 0) {
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
logger.warn("free-tier: pool empty", { pubkey: pubkey.slice(0, 8) });
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
return gate;
}
const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats);
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
if (canAbsorb <= 0) return gate;
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
if (canAbsorb >= estimatedSats) {
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
// Fully free — atomically debit the pool now, work starts immediately after this.
const debited = await this._atomicPoolDebit(canAbsorb);
if (debited <= 0) {
// Pool drained between balance read and debit (concurrent request)
logger.warn("free-tier: pool drained between check and debit", { pubkey: pubkey.slice(0, 8) });
return gate;
}
logger.info("free-tier: reserved free (pool debited)", {
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
pubkey: pubkey.slice(0, 8),
tier: identity.tier,
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
absorbSats: debited,
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
});
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
return { serve: "free", absorbSats: debited, chargeSats: 0 };
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
}
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
// Partial: advisory only — pool debit deferred until payment confirmed.
logger.info("free-tier: partial subsidy (advisory, pool debit deferred)", {
pubkey: pubkey.slice(0, 8),
tier: identity.tier,
absorbSats: canAbsorb,
chargeSats: estimatedSats - canAbsorb,
});
return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb };
}
/**
* Atomically debit the generosity pool inside a transaction with row-level lock.
* Returns the actual amount debited (may be less than requested if pool is low).
* Returns 0 if pool has insufficient funds.
*/
private async _atomicPoolDebit(requestedSats: number): Promise<number> {
if (requestedSats <= 0) return 0;
let actualDebited = 0;
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
await db.transaction(async (tx) => {
// Ensure pool row exists
await tx
.insert(timmyConfig)
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
.onConflictDoNothing();
// Lock pool row for the duration of this transaction
const locked = await tx.execute(
sql`SELECT value::int AS balance FROM timmy_config WHERE key = ${POOL_KEY} FOR UPDATE`,
);
const poolBalance = (locked.rows[0] as { balance: number } | undefined)?.balance ?? 0;
if (poolBalance <= 0) return; // pool empty, actualDebited stays 0
actualDebited = Math.min(requestedSats, poolBalance);
const newPoolBalance = poolBalance - actualDebited;
const now = new Date();
await tx
.update(timmyConfig)
.set({ value: String(newPoolBalance), updatedAt: now })
.where(eq(timmyConfig.key, POOL_KEY));
});
return actualDebited;
}
/**
* Atomically reserve and debit the pool for a partial grant at payment confirmation time.
* Called in advanceJob() when work payment is confirmed, BEFORE starting work.
*
* Returns the actual sats debited (may be less than requested if pool drained since decide()).
* If returns 0, pool is now empty caller should recalculate charge or gate the request.
*/
async reservePartialGrant(requestedAbsorbSats: number, pubkey: string): Promise<number> {
if (requestedAbsorbSats <= 0) return 0;
// Re-check daily limits before debiting (advisory decide() could be stale)
const identity = await trustService.getIdentityWithDecay(pubkey);
if (!identity) return 0;
const dailyBudget = this.dailyBudgetForTier(identity.tier);
const todayAbsorbed =
(Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS
? 0
: identity.satsAbsorbedToday;
const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed);
const canAbsorb = Math.min(requestedAbsorbSats, dailyRemaining);
if (canAbsorb <= 0) return 0;
const debited = await this._atomicPoolDebit(canAbsorb);
logger.info("free-tier: partial grant reserved at payment time", {
pubkey: pubkey.slice(0, 8),
requestedAbsorbSats,
canAbsorb,
debited,
});
return debited;
}
/**
* Release a pool reservation when a request is rejected, fails, or is not executed.
* Returns `absorbSats` to the generosity pool.
* Only call if decide() debited the pool (serve="free" path).
*/
async releaseReservation(absorbSats: number, reason: string): Promise<void> {
if (absorbSats <= 0) return;
const now = new Date();
await db
.update(timmyConfig)
.set({
value: sql`(value::int + ${absorbSats})::text`,
updatedAt: now,
})
.where(eq(timmyConfig.key, POOL_KEY));
logger.info("free-tier: reservation released", { absorbSats, reason });
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
}
/**
* Credit the generosity pool from a paid interaction.
* Called after a work invoice is paid and work completes successfully.
* creditSats = paidSats * POOL_CREDIT_PCT / 100
*/
async credit(paidSats: number): Promise<void> {
if (paidSats <= 0) return;
const creditSats = Math.floor(paidSats * POOL_CREDIT_PCT / 100);
if (creditSats <= 0) return;
const now = new Date();
await db
.insert(timmyConfig)
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
.values({ key: POOL_KEY, value: String(creditSats), updatedAt: now })
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
.onConflictDoUpdate({
target: timmyConfig.key,
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
set: {
value: sql`(timmy_config.value::int + ${creditSats})::text`,
updatedAt: now,
},
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
});
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
logger.info("generosity pool credited", { paidSats, creditSats });
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
}
/**
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
* Record the audit log entry and update daily absorption counters AFTER work completes.
*
* The pool was already debited (by decide() for free jobs, by reservePartialGrant()
* for partial jobs). This call only writes the audit row and updates daily absorption.
*
* `actualAbsorbed` = actual cost in sats (may be less than reservedAbsorbed due to
* actual token usage being lower than estimate).
* `reservedAbsorbed` = amount debited from pool at reservation time.
* Any over-reservation (reservedAbsorbed - actualAbsorbed) is released back to pool.
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
*/
async recordGrant(
pubkey: string,
requestHash: string,
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
actualAbsorbed: number,
reservedAbsorbed: number,
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
): Promise<void> {
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
const toRecord = Math.max(0, actualAbsorbed);
const overReserved = Math.max(0, reservedAbsorbed - toRecord);
if (toRecord <= 0 && overReserved <= 0) return;
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
const now = new Date();
await db.transaction(async (tx) => {
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
// Return over-reservation to pool (estimated > actual cost)
if (overReserved > 0) {
await tx
.update(timmyConfig)
.set({
value: sql`(value::int + ${overReserved})::text`,
updatedAt: now,
})
.where(eq(timmyConfig.key, POOL_KEY));
Task #27: Complete cost-routing + free-tier gate — all critical fixes applied 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
2026-03-19 16:59:11 +00:00
}
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
if (toRecord > 0) {
// Update identity daily absorption (CASE handles new-day reset atomically)
await tx
.update(nostrIdentities)
.set({
satsAbsorbedToday: sql`
CASE
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
THEN ${toRecord}
ELSE sats_absorbed_today + ${toRecord}
END
`,
absorbedResetAt: sql`
CASE
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
THEN ${now}::timestamptz
ELSE absorbed_reset_at
END
`,
updatedAt: now,
})
.where(eq(nostrIdentities.pubkey, pubkey));
const poolRows = await tx.select().from(timmyConfig).where(eq(timmyConfig.key, POOL_KEY)).limit(1);
const poolBalanceAfter = poolRows[0] ? parseInt(poolRows[0].value, 10) : 0;
await tx.insert(freeTierGrants).values({
id: randomUUID(),
pubkey,
requestHash,
satsAbsorbed: toRecord,
poolBalanceAfter,
});
}
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
});
Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap 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.
2026-03-19 17:08:43 +00:00
logger.info("free-tier grant recorded", {
pubkey: pubkey.slice(0, 8),
actualAbsorbed: toRecord,
reservedAbsorbed,
overReserved,
});
Task #27: Cost-routing + free-tier gate ## 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)
2026-03-19 16:34:05 +00:00
}
/**
* Pool status snapshot for health/metrics endpoints.
*/
async poolStatus(): Promise<{
balanceSats: number;
budgets: Record<TrustTier, number>;
}> {
const balanceSats = await this.getPoolBalance();
return {
balanceSats,
budgets: {
new: DAILY_BUDGET_NEW,
established: DAILY_BUDGET_ESTABLISHED,
trusted: DAILY_BUDGET_TRUSTED,
elite: DAILY_BUDGET_ELITE,
},
};
}
}
export const freeTierService = new FreeTierService();