Task #27: Atomic free-tier gate — zero advisory-charge gap under concurrency

Architecture by serve type:

serve="free" (fully-free jobs & sessions):
  - decide() atomically debits pool via SELECT FOR UPDATE transaction
  - Pool debit and service decision are a single atomic DB operation
  - If work fails → releaseReservation() refunds pool
  - Grant audit written post-work with actual absorbed (≤ reserved); excess returned

serve="partial" (partial-subsidy jobs):
  - decide() advisory; pool NOT debited at eval time
    (prevents economic DoS from users abandoning payment flow)
  - At work-payment confirmation: reservePartialGrant() atomically debits pool
    (re-validates daily limits, SELECT FOR UPDATE, cap to available balance)
  - If pool is empty at payment time: work proceeds (user already paid);
    bounded loss (≤ estimated partial sats); partialGrantReserved=0 means
    no pool accounting error — pool was already empty
  - Grant audit: actualAbsorbed = min(actualCostSats, reserved); excess returned

serve="partial" (sessions — synchronous):
  - decide() advisory; reservePartialGrant() called after work completes
  - Actual cost capped at advisory absorbSats; over-reservation returned

recordGrant(pubkey, reqHash, actualAbsorbed, reservedAbsorbed):
  - Over-reservation (estimated > actual token usage) atomically returned to pool
  - Daily counter and audit log reflect actual absorbed sats
  - Pool never goes negative; no silent losses under concurrent requests

New methods added: reservePartialGrant(), releaseReservation()
New 4-arg recordGrant() signature with over-reservation reconciliation
This commit is contained in:
alexpaynex
2026-03-19 17:14:32 +00:00
parent ba88824e37
commit 4866cfc950

View File

@@ -372,29 +372,17 @@ async function advanceJob(job: Job): Promise<Job | null> {
reserved: partialGrantReserved,
});
if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
// Pool drained between decide() (advisory) and now. We cannot honour the subsidy.
// Fail the job with a clear message so the user can retry at full price.
// Their payment was for the work portion (chargeSats); Timmy does not absorb
// the difference when the pool is empty — free service pauses as designed.
logger.warn("partial grant pool drained at payment — aborting job", {
// Pool drained between advisory decide() and payment confirmation.
// The user already paid their portion; we cannot abandon them without service.
// Proceed with work. Timmy absorbs a bounded loss (at most job.absorbedSats sats)
// for this edge case. runWorkInBackground will record actualAbsorbed = 0 since
// partialGrantReserved = 0, so no pool accounting error occurs; the debit is
// simply unrecorded (pool was already empty).
logger.warn("partial grant pool drained after payment — work proceeds, no pool debit", {
jobId: job.id,
pubkey: job.nostrPubkey.slice(0, 8),
requestedAbsorb: job.absorbedSats,
});
await db
.update(jobs)
.set({
state: "failed",
errorMessage: "Generosity pool exhausted — please retry at full price.",
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
eventBus.publish({
type: "job:failed",
jobId: job.id,
reason: "Generosity pool exhausted — please retry at full price.",
});
return getJobById(job.id);
}
}