From 021a8d1a15e3a617d94b9c22a82809a40a167c97 Mon Sep 17 00:00:00 2001 From: Adam Frisby Date: Sun, 13 Sep 2026 14:27:01 +0000 Subject: [PATCH] Add fail-closed reset-credit consume trigger (5/5) Implements the gated trigger that spends a banked quota-reset credit on a shouldSpend=true advisor verdict: off-by-default flag, dry-run-by-default with a separate AllowLiveSpend decision, deterministic idempotency keys persisted before dispatch, persisted per-period cap (1 credit/$80 per 30d), per-attempt kill-switch, balance unknown/stale/empty refusals, reconcile- on-ambiguity instead of blind retry, serialized consume path, per-decision audit trail, and an injected transport so no test can reach the live endpoint. Covered by 21 verification tests plus operator docs. CodeyBox-Prompt-Revision: 2 Co-Authored-By: CodeyBox --- docs/extending/quota-reset-trigger.md | 72 ++ src/CodeyBox.Core/CodeyBox.Core.csproj | 1 + src/CodeyBox.Core/ResetCreditTrigger.cs | 1073 +++++++++++++++++ .../CodeyBox.Tests/ResetCreditTriggerTests.cs | 530 ++++++++ 4 files changed, 1676 insertions(+) create mode 100644 docs/extending/quota-reset-trigger.md create mode 100644 src/CodeyBox.Core/ResetCreditTrigger.cs create mode 100644 tests/CodeyBox.Tests/ResetCreditTriggerTests.cs diff --git a/docs/extending/quota-reset-trigger.md b/docs/extending/quota-reset-trigger.md new file mode 100644 index 000000000..4d6a39791 --- /dev/null +++ b/docs/extending/quota-reset-trigger.md @@ -0,0 +1,72 @@ +# Quota Reset-Credit Consume Trigger + +The consume trigger (`ResetCreditConsumeTrigger` in `CodeyBox.Core`) is the +action half of the Quota Reset Advisor (5/5). The advisor (3/5) and notifier +(4/5) are report-only by design — this trigger is the only component that may +call `POST {base}/wham/rate-limit-reset-credits/consume`, and each call costs +approximately **80 USD, irreversibly**. + +Because a defect here spends real money, the trigger is fail-closed at every +step. When a design choice trades safety against capability, it chooses +safety: prefer refusing to act over acting on incomplete information. + +## Gate chain + +A trigger attempt proceeds only when **all** of these hold, in order: + +1. The advisor verdict is `shouldSpend=true`. Any hold (or missing advice) refuses. +2. `Enabled` is true. Default **false** — off means no request under any circumstance. +3. The kill-switch is disengaged. Re-read on **every** attempt, so engaging it + blocks the next attempt with no restart. +4. The decision was not already consumed (idempotent replay — no new request). +5. `available_count` is readable, fresh (within `MaxBalanceAgeSeconds`), and positive. + An unknown, stale, or zero balance refuses. +6. The per-period cap (default: 1 credit / $80 USD per 30 days) is not yet reached, + checked against persisted history — a restart cannot reset the budget. +7. `AllowLiveSpend` is true. Default **false**: enabling the feature alone only + reaches dry-run, which logs the full intended request (including the redeem + key and which credit would be consumed) and issues nothing. Live spend needs + this second, separate decision. + +## Idempotency + +`redeem_request_id` is derived deterministically (SHA-256) from the authorising +decision — agent, optimal window, deadline, credit spend-by, and reason — and +persisted **before** the request is issued. Every retry for that decision reuses +the same key, so a transport failure, timeout, or crash between dispatch and +response can never consume twice. An ambiguous failure is reconciled against +the balance read endpoints (a decremented or unreadable balance is treated as +consumed) rather than retried blind. The consume path is serialised, so +concurrent attempts for one decision issue a single request. + +## Configuration reference + +All keys are hot-reloadable — changes take effect on the next attempt without +a host restart. This is load-bearing for the kill-switch, which must never be +captured at startup. + +| Key | Type | Default | Description | +|---|---|---|---| +| `Enabled` | bool | `false` | Master switch. Off means no request under any circumstance. | +| `AllowLiveSpend` | bool | `false` | Second decision required for a live call. False = dry-run (log, don't issue). | +| `KillSwitch` | bool | `false` | While engaged, no request is issued regardless of any other setting. | +| `MaxCreditsPerPeriod` | number | `1` | Hard cap per period, enforced against persisted history. | +| `PeriodDays` | number | `30` | Rolling window the cap is enforced over. | +| `MaxBalanceAgeSeconds` | number | `900` | A balance older than this is stale and refuses. | + +The cap is always expressed in both credits and money (`1 credit ($80 USD) +per 30d`) in configuration surfaces, logs, and audit records. + +## Audit + +Every attempt — spend or refusal — appends an audit record carrying what the +advisor reported, which gate allowed or refused, the idempotency key, the +outcome, and the running period-to-date spend, so a consumed credit is +attributable afterwards. + +## Testing rule + +No automated test may reach the live endpoint. The trigger holds no HTTP +client of its own: the consume call sits behind the injected +`IResetCreditConsumeTransport`, and tests exercise the decision logic against +a fake. A test suite that can spend credit by being run is unacceptable. diff --git a/src/CodeyBox.Core/CodeyBox.Core.csproj b/src/CodeyBox.Core/CodeyBox.Core.csproj index 7e77f6770..60cc554ba 100644 --- a/src/CodeyBox.Core/CodeyBox.Core.csproj +++ b/src/CodeyBox.Core/CodeyBox.Core.csproj @@ -5,6 +5,7 @@ + diff --git a/src/CodeyBox.Core/ResetCreditTrigger.cs b/src/CodeyBox.Core/ResetCreditTrigger.cs new file mode 100644 index 000000000..3e0685804 --- /dev/null +++ b/src/CodeyBox.Core/ResetCreditTrigger.cs @@ -0,0 +1,1073 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CodeyBox.Core; + +/// +/// Cost of one consumed rate-limit reset credit, in USD. The provider charges +/// irreversibly per POST wham/rate-limit-reset-credits/consume call. +/// Surfaced as a constant (not config) so every log line and cap description +/// prices the same value; the configurable part is how many credits per period +/// may be spent (). +/// +public static class ResetCreditPricing +{ + /// Irreversible cost of a single consumed credit, in whole USD. + public const int CostPerCreditUsd = 80; +} + +/// +/// Operator config for the banked reset-credit consume trigger (5/5). The trigger +/// acts on a shouldSpend=true verdict from the reset-optimality advisor +/// (4/5) by calling the provider's consume endpoint — an irreversible ~80 USD +/// spend per call. Every field is re-read on each trigger attempt (via the +/// injected options provider), so the kill-switch and every other knob take +/// effect without a host restart. +/// +public sealed record ResetCreditTriggerOptions +{ + /// + /// Master switch. Default false: when absent or false no consume request is + /// issued under any circumstance, regardless of advisor verdicts. + /// + public bool Enabled { get; init; } + + /// + /// Second, separate decision required for a live spend. Default false, which + /// means dry-run: the intended request is logged in full and no HTTP request + /// is issued. Enabling alone never authorises a live call. + /// + public bool AllowLiveSpend { get; init; } + + /// + /// Kill-switch. Read on every trigger attempt (never captured at startup): + /// while engaged, no request is issued regardless of any other setting. + /// + public bool KillSwitchEngaged { get; init; } + + /// + /// Hard cap on credits consumed per , enforced against + /// persisted history so a restart cannot reset the budget. Default 1 — the + /// smallest useful value. Zero blocks all spends. + /// + public int MaxCreditsPerPeriod { get; init; } = 1; + + /// Rolling window the cap is enforced over. Default 30 days. + public TimeSpan Period { get; init; } = TimeSpan.FromDays(30); + + /// + /// Maximum age of an available_count reading the trigger will spend + /// against. An older reading is treated as stale and refused. Default 15 minutes. + /// + public TimeSpan MaxBalanceAge { get; init; } = TimeSpan.FromMinutes(15); + + /// + /// Human-readable cap priced in both credits and money, e.g. + /// "1 credit ($80 USD) per 30d". Used in logs and audit records. + /// + public string CapDescription => + string.Create( + CultureInfo.InvariantCulture, + $"{MaxCreditsPerPeriod} credit{(MaxCreditsPerPeriod == 1 ? string.Empty : "s")} " + + $"(${MaxCreditsPerPeriod * ResetCreditPricing.CostPerCreditUsd} USD) per {FormatPeriod(Period)}"); + + /// Binds trigger options from a configuration section. Absent keys keep safe defaults (off / dry-run). + public static ResetCreditTriggerOptions FromConfiguration(IConfigurationSection section) + { + if (section is null) + return new ResetCreditTriggerOptions(); + + var defaults = new ResetCreditTriggerOptions(); + return new ResetCreditTriggerOptions + { + Enabled = ReadBool(section, "Enabled", defaults.Enabled), + AllowLiveSpend = ReadBool(section, "AllowLiveSpend", defaults.AllowLiveSpend), + KillSwitchEngaged = ReadBool(section, "KillSwitch", ReadBool(section, "KillSwitchEngaged", defaults.KillSwitchEngaged)), + MaxCreditsPerPeriod = ReadInt(section, "MaxCreditsPerPeriod", defaults.MaxCreditsPerPeriod, minimum: 0), + Period = ReadPeriodDays(section, "PeriodDays", defaults.Period), + MaxBalanceAge = ReadMaxBalanceAge(section, defaults.MaxBalanceAge), + }; + } + + private static bool ReadBool(IConfigurationSection section, string key, bool fallback) + { + var raw = section[key]; + if (string.IsNullOrWhiteSpace(raw)) + return fallback; + return bool.TryParse(raw, out var parsed) ? parsed : fallback; + } + + private static int ReadInt(IConfigurationSection section, string key, int fallback, int minimum) + { + var raw = section[key]; + if (string.IsNullOrWhiteSpace(raw)) + return fallback; + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + return fallback; + return parsed < minimum ? minimum : parsed; + } + + private static TimeSpan ReadPeriodDays(IConfigurationSection section, string key, TimeSpan fallback) + { + var raw = section[key]; + if (string.IsNullOrWhiteSpace(raw)) + return fallback; + if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + return fallback; + return TimeSpan.FromDays(Math.Min(parsed, 3650)); + } + + private static TimeSpan ReadMaxBalanceAge(IConfigurationSection section, TimeSpan fallback) + { + var raw = section["MaxBalanceAgeSeconds"]; + if (string.IsNullOrWhiteSpace(raw)) + return fallback; + if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) || parsed < 0) + return fallback; + return TimeSpan.FromSeconds(Math.Min(parsed, TimeSpan.FromDays(7).TotalSeconds)); + } + + private static string FormatPeriod(TimeSpan period) + { + if (period.TotalDays >= 1 && period.TotalDays % 1 == 0) + return $"{period.TotalDays:0}d"; + if (period.TotalHours >= 1) + return $"{period.TotalHours:0}h"; + return $"{period.TotalMinutes:0}m"; + } +} + +/// Balance reading backing a spend decision: the provider's available_count with its observation time. +/// Banked credits available, or null when unreadable. +/// When the count was observed, or null when unknown (treated as stale). +public readonly record struct ResetCreditBalance(int? AvailableCount, DateTimeOffset? SampledAt); + +/// Request for one consume call. RedeemRequestId is the idempotency key. +public sealed record ResetCreditConsumeRequest +{ + /// Caller-supplied idempotency key, derived deterministically from the authorising decision. + public required string RedeemRequestId { get; init; } + + /// Optional specific credit to consume. Null spends the provider default (soonest-expiring). + public string? CreditId { get; init; } +} + +/// Result of one consume call. +public sealed record ResetCreditConsumeResult +{ + /// True when the provider consumed a credit for this idempotency key. + public required bool Consumed { get; init; } + + /// Provider echo of the consumed credit, when supplied. + public string? CreditId { get; init; } + + /// Provider message, when supplied. Never contains secrets. + public string? Message { get; init; } +} + +/// +/// Injected transport for the consume path. Production uses +/// ; every automated test uses a +/// fake. The trigger holds no other route to the network, so a test suite can +/// never spend a credit — including under misconfiguration or a leaked live +/// credential — unless a test explicitly constructs the HTTP transport. +/// +public interface IResetCreditConsumeTransport +{ + /// Reads the current banked-credit balance with its observation time. + Task ReadBalanceAsync(CancellationToken ct); + + /// + /// Consumes one credit. Implementations MUST send + /// as the provider idempotency key so a retry with the same key cannot consume twice. + /// + Task ConsumeAsync(ResetCreditConsumeRequest request, CancellationToken ct); +} + +/// Transport failure carrying no provider verdict — the outcome is ambiguous and must be reconciled, never retried blind. +public sealed class ResetCreditTransportException : Exception +{ + /// Creates a transport failure with the given message. + public ResetCreditTransportException(string message) + : base(message) + { + } + + /// Creates a transport failure wrapping its cause. + public ResetCreditTransportException(string message, Exception inner) + : base(message, inner) + { + } +} + +/// +/// Live HTTP transport for the reset-credit endpoints. Built only by production +/// wiring with an explicitly provided and bearer-token +/// provider; tests never construct it. The base address is restricted to the +/// provider's exact backend host — any other value is rejected at construction. +/// +public sealed class HttpResetCreditConsumeTransport : IResetCreditConsumeTransport +{ + /// Exact provider backend origin. Requests to any other host are refused. + public const string AllowedBaseAddress = "https://chatgpt.com/backend-api"; + + private const int MaxResponseChars = 64 * 1024; + + private readonly HttpClient _http; + private readonly Func _bearerTokenProvider; + private readonly string _baseAddress; + + /// Creates the live transport. Throws for any base address outside the provider origin. + /// Caller-owned HTTP client (lifetime belongs to the caller). + /// Returns the current bearer token, or null when unconfigured. + /// Must be exactly the provider backend origin. + public HttpResetCreditConsumeTransport( + HttpClient http, + Func bearerTokenProvider, + string baseAddress = AllowedBaseAddress) + { + ArgumentNullException.ThrowIfNull(http); + ArgumentNullException.ThrowIfNull(bearerTokenProvider); + ArgumentNullException.ThrowIfNull(baseAddress); + if (!string.Equals(baseAddress.TrimEnd('/'), AllowedBaseAddress, StringComparison.Ordinal)) + throw new ArgumentOutOfRangeException(nameof(baseAddress), baseAddress, $"Reset-credit transport refuses non-provider host."); + _http = http; + _bearerTokenProvider = bearerTokenProvider; + _baseAddress = baseAddress.TrimEnd('/'); + } + + /// + public async Task ReadBalanceAsync(CancellationToken ct) + { + var token = _bearerTokenProvider(); + if (string.IsNullOrWhiteSpace(token)) + return new ResetCreditBalance(null, null); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, _baseAddress + "/wham/usage"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return new ResetCreditBalance(null, null); + var body = await ReadCappedAsync(response.Content, ct).ConfigureAwait(false); + if (body is null) + return new ResetCreditBalance(null, null); + var count = ParseAvailableCount(body); + return count is null + ? new ResetCreditBalance(null, null) + : new ResetCreditBalance(count, DateTimeOffset.UtcNow); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw new ResetCreditTransportException("Reset-credit balance read failed.", ex); + } + } + + /// + public async Task ConsumeAsync(ResetCreditConsumeRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.RedeemRequestId)) + throw new ArgumentOutOfRangeException(nameof(request), "RedeemRequestId is required."); + var token = _bearerTokenProvider(); + if (string.IsNullOrWhiteSpace(token)) + throw new ResetCreditTransportException("No bearer token configured for reset-credit consume."); + + try + { + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _baseAddress + "/wham/rate-limit-reset-credits/consume"); + httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + var payload = new Dictionary { ["redeem_request_id"] = request.RedeemRequestId }; + if (!string.IsNullOrWhiteSpace(request.CreditId)) + payload["credit_id"] = request.CreditId; + httpRequest.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var response = await _http.SendAsync(httpRequest, ct).ConfigureAwait(false); + var body = await ReadCappedAsync(response.Content, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new ResetCreditTransportException($"Consume endpoint returned {(int)response.StatusCode}."); + return new ResetCreditConsumeResult { Consumed = true, CreditId = request.CreditId, Message = Truncate(body) }; + } + catch (ResetCreditTransportException) + { + throw; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw new ResetCreditTransportException("Reset-credit consume call failed with an ambiguous outcome.", ex); + } + } + + private static int? ParseAvailableCount(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("rate_limit_reset_credits", out var credits) + || credits.ValueKind != JsonValueKind.Object + || !credits.TryGetProperty("available_count", out var count) + || count.ValueKind != JsonValueKind.Number + || !count.TryGetInt32(out var value)) + return null; + return value; + } + catch (JsonException) + { + return null; + } + } + + private static async Task ReadCappedAsync(HttpContent content, CancellationToken ct) + { + await using var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var buffer = new char[MaxResponseChars + 1]; + var total = 0; + int chunk; + do + { + chunk = await reader.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct).ConfigureAwait(false); + total += chunk; + } + while (chunk > 0 && total < buffer.Length); + if (total > MaxResponseChars) + return null; + return new string(buffer, 0, total); + } + + private static string? Truncate(string? value) + { + if (string.IsNullOrEmpty(value)) + return value; + const int max = 512; + return value.Length <= max ? value : value.Substring(0, max); + } +} + +/// Machine-readable outcome of one trigger attempt. Every attempt — spend or refusal — is audited. +public enum ResetCreditTriggerDecision +{ + /// Advisor did not report optimal (or no advice): never spend. + RefusedAdvisorHold, + /// Feature flag off: no request under any circumstance. + RefusedFeatureDisabled, + /// Kill-switch engaged: blocked without restart semantics. + RefusedKillSwitch, + /// Balance unreadable: unknown balance is not permission to spend. + RefusedBalanceUnknown, + /// Balance reading too old to spend against. + RefusedBalanceStale, + /// No banked credit available (count not positive). + RefusedBalanceEmpty, + /// Per-period cap already reached. + RefusedCapExceeded, + /// Transport failure with no ambiguous consumption: retry with the same key is allowed later. + RefusedTransportFailed, + /// Feature enabled but live spend not authorised: intended request logged, nothing issued. + DryRun, + /// A credit was consumed for this idempotency key. + Consumed, + /// Repeat trigger for an already-consumed decision: no new request. + AlreadyConsumed, + /// Ambiguous failure reconciled as consumed via the read endpoints: no blind retry. + ReconciledConsumed, +} + +/// Outcome of one call. +public sealed record ResetCreditTriggerOutcome +{ + /// What the trigger decided. + public required ResetCreditTriggerDecision Decision { get; init; } + + /// Human-readable reason naming the gate that allowed or refused. + public required string Reason { get; init; } + + /// Idempotency key for the authorising decision. Present on every outcome, including refusals. + public required string RedeemRequestId { get; init; } + + /// True when a live consume request was issued on this call. + public required bool RequestIssued { get; init; } + + /// True when a credit was consumed (this call or a reconciled prior attempt). + public required bool Consumed { get; init; } + + /// Credits consumed in the current period after this decision. + public required int PeriodSpend { get; init; } + + /// Configured cap, echoed for attribution. + public required int PeriodCap { get; init; } +} + +/// +/// Audit record for one trigger decision. Persisted for spends AND refusals so a +/// consumed credit — or a decision not to spend — is attributable afterwards. +/// +public sealed record ResetCreditTriggerAuditRecord +{ + /// When the decision was made. + public required DateTimeOffset OccurredAt { get; init; } + + /// Agent the authorising advice concerned (empty when no advice was present). + public required string Agent { get; init; } + + /// Advisor reason name driving the attempt (empty when no advice was present). + public required string AdviceReason { get; init; } + + /// What the advisor reported (ShouldSpend bit), when advice was present. + public bool? AdvisorShouldSpend { get; init; } + + /// Gate that allowed or refused: the name. + public required string Gate { get; init; } + + /// Idempotency key for the decision. + public required string RedeemRequestId { get; init; } + + /// True when a live request was issued on this attempt. + public required bool RequestIssued { get; init; } + + /// True when the decision leaves a credit consumed. + public required bool Consumed { get; init; } + + /// Running total spent in the current period after this decision. + public required int PeriodSpend { get; init; } + + /// Configured cap at decision time. + public required int PeriodCap { get; init; } + + /// Human-readable detail (gate, balance, money). Never contains secrets or tokens. + public required string Detail { get; init; } +} + +/// +/// Persisted trigger state: idempotency keys, consumption history (the cap +/// budget), and the audit trail. Backed by a file so a restart cannot reset +/// the budget; every mutating step is atomic under an async gate. +/// +public interface IResetCreditTriggerStore +{ + /// Returns the persisted redeem key for a decision fingerprint, if any. + Task FindRedeemKeyAsync(string decisionFingerprint, CancellationToken ct); + + /// Persists a fingerprint-to-key mapping BEFORE the consume request is issued. Idempotent. + Task PersistRedeemKeyAsync(string decisionFingerprint, string redeemRequestId, CancellationToken ct); + + /// True when a consumption is already recorded for this redeem key. + Task IsConsumedAsync(string redeemRequestId, CancellationToken ct); + + /// Records a consumption. Re-recording the same key is a no-op (at-most-once). + Task RecordConsumptionAsync(string redeemRequestId, DateTimeOffset consumedAt, CancellationToken ct); + + /// Counts consumptions within the rolling ending at . + Task PeriodSpendAsync(DateTimeOffset now, TimeSpan period, CancellationToken ct); + + /// Appends an audit record. The trail is bounded; oldest entries are dropped first. + Task AppendAuditAsync(ResetCreditTriggerAuditRecord record, CancellationToken ct); + + /// Returns persisted audit records, newest last. + Task> ListAuditsAsync(CancellationToken ct); +} + +/// +/// File-backed . State is re-read from +/// disk on every operation and written atomically (temp file + move), so a new +/// instance over the same path observes the same budget — a restart cannot +/// reset the cap. Concurrent attempts within one process are serialised by an +/// async gate; the trigger additionally serialises its own consume path. +/// +public sealed class FileResetCreditTriggerStore : IResetCreditTriggerStore +{ + /// Maximum audit records retained. Bounds the state file against unbounded growth. + public const int MaxAuditRecords = 1000; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly string _filePath; + private readonly SemaphoreSlim _gate = new(1, 1); + + /// Creates a file-backed store. The parent directory must exist. + /// Absolute path of the JSON state file. + public FileResetCreditTriggerStore(string filePath) + { + ArgumentNullException.ThrowIfNull(filePath); + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentOutOfRangeException(nameof(filePath), "Store file path is required."); + _filePath = filePath; + } + + /// + public async Task FindRedeemKeyAsync(string decisionFingerprint, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(decisionFingerprint); + var state = await LoadAsync(ct).ConfigureAwait(false); + return state.Keys.TryGetValue(decisionFingerprint, out var key) ? key : null; + } + + /// + public async Task PersistRedeemKeyAsync(string decisionFingerprint, string redeemRequestId, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(decisionFingerprint); + ArgumentNullException.ThrowIfNull(redeemRequestId); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var state = await LoadLockedAsync(ct).ConfigureAwait(false); + state.Keys[decisionFingerprint] = redeemRequestId; + await SaveLockedAsync(state, ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public async Task IsConsumedAsync(string redeemRequestId, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(redeemRequestId); + var state = await LoadAsync(ct).ConfigureAwait(false); + return state.Consumptions.Any(c => string.Equals(c.RedeemRequestId, redeemRequestId, StringComparison.Ordinal)); + } + + /// + public async Task RecordConsumptionAsync(string redeemRequestId, DateTimeOffset consumedAt, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(redeemRequestId); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var state = await LoadLockedAsync(ct).ConfigureAwait(false); + if (state.Consumptions.Any(c => string.Equals(c.RedeemRequestId, redeemRequestId, StringComparison.Ordinal))) + return; + state.Consumptions.Add(new PersistedConsumption { RedeemRequestId = redeemRequestId, ConsumedAt = consumedAt }); + await SaveLockedAsync(state, ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public async Task PeriodSpendAsync(DateTimeOffset now, TimeSpan period, CancellationToken ct) + { + var state = await LoadAsync(ct).ConfigureAwait(false); + var cutoff = now - period; + return state.Consumptions.Count(c => c.ConsumedAt > cutoff); + } + + /// + public async Task AppendAuditAsync(ResetCreditTriggerAuditRecord record, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(record); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var state = await LoadLockedAsync(ct).ConfigureAwait(false); + state.Audits.Add(PersistedAudit.FromRecord(record)); + while (state.Audits.Count > MaxAuditRecords) + state.Audits.RemoveAt(0); + await SaveLockedAsync(state, ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public async Task> ListAuditsAsync(CancellationToken ct) + { + var state = await LoadAsync(ct).ConfigureAwait(false); + return state.Audits.Select(a => a.ToRecord()).ToList(); + } + + private async Task LoadAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await LoadLockedAsync(ct).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private async Task LoadLockedAsync(CancellationToken ct) + { + if (!File.Exists(_filePath)) + return new PersistedState(); + // Fail-closed: a present-but-unreadable state file surfaces its error + // instead of degrading to an empty budget. An empty budget would reset + // the per-period cap (fail-open on an $80 spend path); throwing blocks + // the attempt with no spend, and the host logs and retries next tick. + await using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); + using var reader = new StreamReader(stream, Encoding.UTF8); + var json = await reader.ReadToEndAsync(ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException($"Reset-credit trigger state file is empty: {_filePath}"); + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException($"Reset-credit trigger state file deserialised to null: {_filePath}"); + } + + private async Task SaveLockedAsync(PersistedState state, CancellationToken ct) + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var tempPath = _filePath + ".tmp"; + var json = JsonSerializer.Serialize(state, JsonOptions); + await File.WriteAllTextAsync(tempPath, json, Encoding.UTF8, ct).ConfigureAwait(false); + File.Move(tempPath, _filePath, overwrite: true); + } + + private sealed class PersistedState + { + public int Version { get; set; } = 1; + public Dictionary Keys { get; set; } = new(StringComparer.Ordinal); + public List Consumptions { get; set; } = new(); + public List Audits { get; set; } = new(); + } + + private sealed class PersistedConsumption + { + public string RedeemRequestId { get; set; } = string.Empty; + public DateTimeOffset ConsumedAt { get; set; } + } + + private sealed class PersistedAudit + { + public DateTimeOffset OccurredAt { get; set; } + public string Agent { get; set; } = string.Empty; + public string AdviceReason { get; set; } = string.Empty; + public bool? AdvisorShouldSpend { get; set; } + public string Gate { get; set; } = string.Empty; + public string RedeemRequestId { get; set; } = string.Empty; + public bool RequestIssued { get; set; } + public bool Consumed { get; set; } + public int PeriodSpend { get; set; } + public int PeriodCap { get; set; } + public string Detail { get; set; } = string.Empty; + + public static PersistedAudit FromRecord(ResetCreditTriggerAuditRecord record) => new() + { + OccurredAt = record.OccurredAt, + Agent = record.Agent, + AdviceReason = record.AdviceReason, + AdvisorShouldSpend = record.AdvisorShouldSpend, + Gate = record.Gate, + RedeemRequestId = record.RedeemRequestId, + RequestIssued = record.RequestIssued, + Consumed = record.Consumed, + PeriodSpend = record.PeriodSpend, + PeriodCap = record.PeriodCap, + Detail = record.Detail, + }; + + public ResetCreditTriggerAuditRecord ToRecord() => new() + { + OccurredAt = OccurredAt, + Agent = Agent, + AdviceReason = AdviceReason, + AdvisorShouldSpend = AdvisorShouldSpend, + Gate = Gate, + RedeemRequestId = RedeemRequestId, + RequestIssued = RequestIssued, + Consumed = Consumed, + PeriodSpend = PeriodSpend, + PeriodCap = PeriodCap, + Detail = Detail, + }; + } +} + +/// +/// The banked reset-credit consume trigger (5/5). Acts on a +/// shouldSpend=true advisor verdict by calling the provider consume +/// endpoint — an irreversible ~80 USD spend per call — behind a chain of +/// fail-closed gates. Safety properties: +/// +/// Disabled by default; off means no request under any circumstance. +/// Dry-run by default; a live call needs the second AllowLiveSpend decision. +/// Deterministic idempotency key per authorising decision, persisted BEFORE the request. +/// Per-period cap enforced against persisted history (restart-safe). +/// Kill-switch re-read on every attempt (no restart needed). +/// Unknown, stale, or non-positive balances refuse. +/// Ambiguous failures reconcile via the read endpoints instead of retrying blind. +/// The consume path is serialised: concurrent attempts cannot spend twice. +/// +/// +public sealed class ResetCreditConsumeTrigger +{ + private readonly Func _optionsProvider; + private readonly IResetCreditConsumeTransport _transport; + private readonly IResetCreditTriggerStore _store; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + private readonly SemaphoreSlim _consumeGate = new(1, 1); + + /// + /// Creates the trigger. All collaborators are injected — including the + /// options provider (a delegate so hot-reloaded values take effect without + /// a restart) and the transport (a fake under test, so no test can reach + /// the live endpoint). + /// + /// Returns current options on every attempt. Must never be null-returning. + /// Consume transport. Tests inject a fake. + /// Persisted idempotency/cap/audit store. + /// Clock. Defaults to system. + /// Logger. Defaults to null logger. + public ResetCreditConsumeTrigger( + Func optionsProvider, + IResetCreditConsumeTransport transport, + IResetCreditTriggerStore store, + TimeProvider? clock = null, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(optionsProvider); + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(store); + _optionsProvider = optionsProvider; + _transport = transport; + _store = store; + _clock = clock ?? TimeProvider.System; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Attempts one trigger for an advisor verdict. Serialised against + /// concurrent attempts; every path (spend or refusal) writes an audit record. + /// + /// Latest advisor verdict for the agent. Null/ShouldSpend=false refuses. + public async Task TryTriggerAsync(ResetSpendAdvice? advice, CancellationToken ct = default) + { + await _consumeGate.WaitAsync(ct).ConfigureAwait(false); + try + { + return await TryTriggerLockedAsync(advice, ct).ConfigureAwait(false); + } + finally + { + _consumeGate.Release(); + } + } + + /// + /// Derives the stable decision fingerprint for an advisor verdict. Pure: + /// the same authorising decision always yields the same fingerprint, across + /// restarts, so retries reuse one idempotency key. + /// + public static string ComputeDecisionFingerprint(ResetSpendAdvice advice) + { + ArgumentNullException.ThrowIfNull(advice); + var window = advice.OptimalWindow; + var canonical = string.Join( + "|", + "v1", + advice.Agent.Trim().ToLowerInvariant(), + window?.OpensAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) ?? "-", + window?.ClosesAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) ?? "-", + advice.DecisionDeadline?.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) ?? "-", + advice.NextCreditExpiresAt?.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) ?? "-", + advice.Reason.ToString()); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Derives the idempotency key for a decision fingerprint. Pure and stable. + /// + public static string ComputeRedeemRequestId(string decisionFingerprint) + { + ArgumentException.ThrowIfNullOrWhiteSpace(decisionFingerprint); + return "rcr_" + decisionFingerprint; + } + + private async Task TryTriggerLockedAsync(ResetSpendAdvice? advice, CancellationToken ct) + { + var options = _optionsProvider(); + ArgumentNullException.ThrowIfNull(options, nameof(options)); + var now = _clock.GetUtcNow(); + + if (advice is null || !advice.ShouldSpend) + { + var key = advice is null ? "rcr_refused_no_advice" : ComputeRedeemRequestId(ComputeDecisionFingerprint(advice)); + return await RefuseAsync( + advice, options, now, key, + ResetCreditTriggerDecision.RefusedAdvisorHold, + advice is null + ? "Advisor produced no verdict — refusing: no authorising decision." + : $"Advisor holds ({advice.Reason}) — refusing: trigger is reachable only on shouldSpend=true.", + ct).ConfigureAwait(false); + } + + var redeemKey = ComputeRedeemRequestId(ComputeDecisionFingerprint(advice)); + + if (!options.Enabled) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedFeatureDisabled, + "Feature flag is off — refusing: off means no request under any circumstance.", + ct).ConfigureAwait(false); + } + + if (options.KillSwitchEngaged) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedKillSwitch, + "Kill-switch is engaged — refusing regardless of any other setting.", + ct).ConfigureAwait(false); + } + + if (await _store.IsConsumedAsync(redeemKey, ct).ConfigureAwait(false)) + { + var spend = await _store.PeriodSpendAsync(now, options.Period, ct).ConfigureAwait(false); + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.AlreadyConsumed, + "Decision already consumed — idempotent replay: no new request.", + requestIssued: false, consumed: true, periodSpend: spend, ct).ConfigureAwait(false); + } + + ResetCreditBalance balance; + try + { + balance = await _transport.ReadBalanceAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedBalanceUnknown, + $"Balance read threw ({ex.GetType().Name}) — refusing: an unknown balance is not permission to spend.", + ct).ConfigureAwait(false); + } + + if (balance.AvailableCount is not { } count) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedBalanceUnknown, + "Balance is unreadable (available_count missing) — refusing: an unknown balance is not permission to spend.", + ct).ConfigureAwait(false); + } + + if (balance.SampledAt is not { } sampledAt || now - sampledAt > options.MaxBalanceAge) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedBalanceStale, + $"Balance reading is stale (sampled {(balance.SampledAt is { } s ? s.ToString("u") : "unknown")}, max age {options.MaxBalanceAge}) — refusing.", + ct).ConfigureAwait(false); + } + + if (count <= 0) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedBalanceEmpty, + $"Balance is {count} — refusing: no banked credit to spend.", + ct).ConfigureAwait(false); + } + + var periodSpend = await _store.PeriodSpendAsync(now, options.Period, ct).ConfigureAwait(false); + if (periodSpend >= options.MaxCreditsPerPeriod) + { + return await RefuseAsync( + advice, options, now, redeemKey, + ResetCreditTriggerDecision.RefusedCapExceeded, + $"Cap reached ({periodSpend}/{options.MaxCreditsPerPeriod} credits, " + + $"${periodSpend * ResetCreditPricing.CostPerCreditUsd} of ${options.MaxCreditsPerPeriod * ResetCreditPricing.CostPerCreditUsd} USD per {options.CapDescription}) — refusing.", + ct, periodSpendOverride: periodSpend).ConfigureAwait(false); + } + + if (!options.AllowLiveSpend) + { + _logger.LogInformation( + "Reset-credit trigger dry-run: would POST {{base}}/wham/rate-limit-reset-credits/consume " + + "redeem_request_id={RedeemRequestId} credit_id={CreditId} balance={Balance} agent={Agent} " + + "spend={Spend}/{Cap} ({CapDescription}). No request issued (AllowLiveSpend=false).", + redeemKey, + "server-default (soonest-expiring)", + count, + advice.Agent, + periodSpend, + options.MaxCreditsPerPeriod, + options.CapDescription); + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.DryRun, + $"Dry-run: intended POST wham/rate-limit-reset-credits/consume redeem_request_id={redeemKey} " + + $"credit_id=server-default (soonest-expiring) balance={count} agent={advice.Agent} " + + $"spend={periodSpend}/{options.MaxCreditsPerPeriod} ({options.CapDescription}). No request issued.", + requestIssued: false, consumed: false, periodSpend: periodSpend, ct).ConfigureAwait(false); + } + + await _store.PersistRedeemKeyAsync(ComputeDecisionFingerprint(advice), redeemKey, ct).ConfigureAwait(false); + + var request = new ResetCreditConsumeRequest { RedeemRequestId = redeemKey, CreditId = null }; + try + { + var result = await _transport.ConsumeAsync(request, ct).ConfigureAwait(false); + if (!result.Consumed) + { + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.RefusedTransportFailed, + "Provider reported no consumption — recording nothing; a later retry reuses the same key.", + requestIssued: true, consumed: false, periodSpend: periodSpend, ct).ConfigureAwait(false); + } + + await _store.RecordConsumptionAsync(redeemKey, now, ct).ConfigureAwait(false); + var after = await _store.PeriodSpendAsync(now, options.Period, ct).ConfigureAwait(false); + _logger.LogInformation( + "Reset-credit trigger consumed 1 credit (${CostUsd} USD) redeem_request_id={RedeemRequestId} agent={Agent} spend={Spend}/{Cap} ({CapDescription}).", + ResetCreditPricing.CostPerCreditUsd, redeemKey, advice.Agent, after, options.MaxCreditsPerPeriod, options.CapDescription); + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.Consumed, + $"Consumed 1 credit (${ResetCreditPricing.CostPerCreditUsd} USD) redeem_request_id={redeemKey} agent={advice.Agent} spend={after}/{options.MaxCreditsPerPeriod} ({options.CapDescription}).", + requestIssued: true, consumed: true, periodSpend: after, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return await ReconcileAmbiguousFailureAsync(advice, options, now, redeemKey, count, ex, periodSpend, ct).ConfigureAwait(false); + } + } + + /// + /// Reconciles a transport failure, timeout, or crash-window ambiguity against + /// the read endpoints instead of retrying blind. A decremented or unreadable + /// balance is treated as consumed (fail-closed against double-spend); an + /// provably unchanged balance leaves the key unconsumed so a later retry + /// with the SAME key is safe. + /// + private async Task ReconcileAmbiguousFailureAsync( + ResetSpendAdvice advice, + ResetCreditTriggerOptions options, + DateTimeOffset now, + string redeemKey, + int preCount, + Exception failure, + int periodSpendBefore, + CancellationToken ct) + { + ResetCreditBalance probe; + try + { + probe = await _transport.ReadBalanceAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + probe = new ResetCreditBalance(null, null); + } + + if (probe.AvailableCount is { } post && post >= preCount && probe.SampledAt is { } sampled && now - sampled <= options.MaxBalanceAge) + { + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.RefusedTransportFailed, + $"Consume call failed ({failure.GetType().Name}) but the balance is provably unchanged " + + $"({preCount} -> {post}) — recording nothing; a later retry reuses redeem_request_id={redeemKey}.", + requestIssued: true, consumed: false, periodSpend: periodSpendBefore, ct).ConfigureAwait(false); + } + + await _store.RecordConsumptionAsync(redeemKey, now, ct).ConfigureAwait(false); + var after = await _store.PeriodSpendAsync(now, options.Period, ct).ConfigureAwait(false); + var why = probe.AvailableCount is null + ? "the post-failure balance is unreadable" + : $"the balance moved ({preCount} -> {probe.AvailableCount})"; + _logger.LogWarning( + "Reset-credit trigger ambiguous failure reconciled as consumed: {Failure} ({Why}); redeem_request_id={RedeemRequestId}. No blind retry.", + failure.GetType().Name, why, redeemKey); + return await AuditAndReturnAsync( + advice, options, now, redeemKey, ResetCreditTriggerDecision.ReconciledConsumed, + $"Consume call failed ({failure.GetType().Name}) and {why} — treating the outcome as consumed " + + $"for redeem_request_id={redeemKey}. No blind retry. Spend={after}/{options.MaxCreditsPerPeriod} ({options.CapDescription}).", + requestIssued: true, consumed: true, periodSpend: after, ct).ConfigureAwait(false); + } + + private async Task RefuseAsync( + ResetSpendAdvice? advice, + ResetCreditTriggerOptions options, + DateTimeOffset now, + string redeemKey, + ResetCreditTriggerDecision decision, + string reason, + CancellationToken ct, + int? periodSpendOverride = null) + { + var spend = periodSpendOverride ?? await _store.PeriodSpendAsync(now, options.Period, ct).ConfigureAwait(false); + _logger.LogInformation("Reset-credit trigger refused ({Decision}): {Reason}", decision, reason); + return await AuditAndReturnAsync(advice, options, now, redeemKey, decision, reason, requestIssued: false, consumed: false, periodSpend: spend, ct).ConfigureAwait(false); + } + + private async Task AuditAndReturnAsync( + ResetSpendAdvice? advice, + ResetCreditTriggerOptions options, + DateTimeOffset now, + string redeemKey, + ResetCreditTriggerDecision decision, + string reason, + bool requestIssued, + bool consumed, + int periodSpend, + CancellationToken ct) + { + var record = new ResetCreditTriggerAuditRecord + { + OccurredAt = now, + Agent = advice?.Agent ?? string.Empty, + AdviceReason = advice?.Reason.ToString() ?? string.Empty, + AdvisorShouldSpend = advice?.ShouldSpend, + Gate = decision.ToString(), + RedeemRequestId = redeemKey, + RequestIssued = requestIssued, + Consumed = consumed, + PeriodSpend = periodSpend, + PeriodCap = options.MaxCreditsPerPeriod, + Detail = reason.Length <= 2000 ? reason : reason.Substring(0, 2000), + }; + await _store.AppendAuditAsync(record, ct).ConfigureAwait(false); + return new ResetCreditTriggerOutcome + { + Decision = decision, + Reason = reason, + RedeemRequestId = redeemKey, + RequestIssued = requestIssued, + Consumed = consumed, + PeriodSpend = periodSpend, + PeriodCap = options.MaxCreditsPerPeriod, + }; + } +} diff --git a/tests/CodeyBox.Tests/ResetCreditTriggerTests.cs b/tests/CodeyBox.Tests/ResetCreditTriggerTests.cs new file mode 100644 index 000000000..56c7475db --- /dev/null +++ b/tests/CodeyBox.Tests/ResetCreditTriggerTests.cs @@ -0,0 +1,530 @@ +using CodeyBox.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CodeyBox.Tests; + +/// +/// Verification tests for the banked reset-credit consume trigger (5/5). +/// Every test drives the real against a +/// fake transport and a file-backed store — no test can reach the live endpoint. +/// +public sealed class ResetCreditTriggerTests : IDisposable +{ + private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-01T10:00:00Z"); + private static readonly DateTimeOffset Deadline = DateTimeOffset.Parse("2026-09-10T00:00:00Z"); + + private readonly string _tempDir; + private bool _disposed; + + public ResetCreditTriggerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "rc-trigger-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try { Directory.Delete(_tempDir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + [Fact] + public async Task FlagOff_NoRequestIssuedForSpendAdvice() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = false, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedFeatureDisabled, outcome.Decision); + Assert.False(outcome.RequestIssued); + Assert.False(outcome.Consumed); + Assert.Equal(0, transport.ConsumeCalls); + await AssertAuditAsync(store, outcome.RedeemRequestId, expectKey: true); + } + + [Fact] + public async Task FlagAbsent_DefaultsOff_NoRequestIssued() + { + var defaults = new ResetCreditTriggerOptions(); + Assert.False(defaults.Enabled); + Assert.False(defaults.AllowLiveSpend); + + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(3, Now) }; + await using var trigger = BuildTrigger(() => defaults, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedFeatureDisabled, outcome.Decision); + Assert.Equal(0, transport.ConsumeCalls); + } + + [Fact] + public async Task EnabledWithoutLiveSpend_LogsIntendedRequestWithoutIssuing() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = false }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.DryRun, outcome.Decision); + Assert.False(outcome.RequestIssued); + Assert.False(outcome.Consumed); + Assert.Equal(0, transport.ConsumeCalls); + Assert.False(string.IsNullOrWhiteSpace(outcome.RedeemRequestId)); + Assert.Contains(outcome.RedeemRequestId, outcome.Reason, StringComparison.Ordinal); + var audits = await store.ListAuditsAsync(CancellationToken.None); + var audit = Assert.Single(audits); + Assert.Equal(nameof(ResetCreditTriggerDecision.DryRun), audit.Gate); + Assert.Equal(outcome.RedeemRequestId, audit.RedeemRequestId); + } + + [Fact] + public void ConsumePath_HasNoRouteToRealTransportUnderTest() + { + var triggerType = typeof(ResetCreditConsumeTrigger); + var httpFields = triggerType + .GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public) + .Where(f => f.FieldType == typeof(HttpClient)); + Assert.Empty(httpFields); + + var ctors = triggerType.GetConstructors(); + var ctor = Assert.Single(ctors); + var paramTypes = ctor.GetParameters().Select(p => p.ParameterType).ToList(); + Assert.Contains(typeof(IResetCreditConsumeTransport), paramTypes); + Assert.DoesNotContain(typeof(HttpClient), paramTypes); + } + + [Fact] + public void LiveTransport_RefusesNonProviderHost() + { + using var http = new HttpClient(); + Assert.Throws( + () => new HttpResetCreditConsumeTransport(http, () => "token", "https://evil.example/api")); + } + + [Fact] + public void LiveTransport_DefaultsAreMisconfigurationSafe() + { + var options = new ResetCreditTriggerOptions(); + Assert.False(options.Enabled); + Assert.False(options.AllowLiveSpend); + Assert.False(options.KillSwitchEngaged); + Assert.Equal(1, options.MaxCreditsPerPeriod); + Assert.Contains("$80", options.CapDescription, StringComparison.Ordinal); + Assert.Contains("1 credit", options.CapDescription, StringComparison.Ordinal); + } + + [Fact] + public async Task RepeatedTriggerForOneDecision_ReusesKeyAndConsumesOnce() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + var advice = SpendAdvice("codex", Now, Deadline); + + var first = await trigger.TryTriggerAsync(advice, CancellationToken.None); + var second = await trigger.TryTriggerAsync(advice, CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.Consumed, first.Decision); + Assert.Equal(ResetCreditTriggerDecision.AlreadyConsumed, second.Decision); + Assert.Equal(first.RedeemRequestId, second.RedeemRequestId); + Assert.Equal(1, transport.ConsumeCalls); + Assert.Equal(1, transport.ServerConsumptions); + } + + [Fact] + public async Task TransportFailure_ResultsInAtMostOneConsumption() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport + { + Balance = new ResetCreditBalance(2, Now), + ConsumeBehaviour = FakeConsumeTransport.Behaviour.Throw, + }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + var advice = SpendAdvice("codex", Now, Deadline); + + var outcome = await trigger.TryTriggerAsync(advice, CancellationToken.None); + + Assert.True(outcome.Decision is ResetCreditTriggerDecision.ReconciledConsumed or ResetCreditTriggerDecision.RefusedTransportFailed); + Assert.True(transport.ServerConsumptions <= 1); + var retry = await trigger.TryTriggerAsync(advice, CancellationToken.None); + Assert.True(transport.ServerConsumptions <= 1); + Assert.Equal(outcome.RedeemRequestId, retry.RedeemRequestId); + } + + [Fact] + public async Task Timeout_ResultsInAtMostOneConsumption() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport + { + Balance = new ResetCreditBalance(2, Now), + ConsumeBehaviour = FakeConsumeTransport.Behaviour.Timeout, + }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + var advice = SpendAdvice("codex", Now, Deadline); + + var outcome = await trigger.TryTriggerAsync(advice, CancellationToken.None); + + Assert.True(outcome.Decision is ResetCreditTriggerDecision.ReconciledConsumed or ResetCreditTriggerDecision.RefusedTransportFailed); + Assert.True(transport.ServerConsumptions <= 1); + } + + [Fact] + public async Task CrashBetweenDispatchAndResponse_ResultsInAtMostOneConsumption() + { + var clock = new FakeTriggerClock(Now); + var server = new FakeConsumeServer(); + var crashing = new FakeConsumeTransport(server) { Balance = new ResetCreditBalance(2, Now), ConsumeBehaviour = FakeConsumeTransport.Behaviour.CrashAfterServerConsume }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + var storePath = StorePath(); + var store1 = new FileResetCreditTriggerStore(storePath); + var trigger1 = new ResetCreditConsumeTrigger(() => options, crashing, store1, clock, NullLogger.Instance); + var advice = SpendAdvice("codex", Now, Deadline); + + var crashed = await trigger1.TryTriggerAsync(advice, CancellationToken.None); + + server.ApplyConsumptionToBalance(crashing); + var restarted = new FakeConsumeTransport(server) { Balance = crashing.Balance }; + var store2 = new FileResetCreditTriggerStore(storePath); + var trigger2 = new ResetCreditConsumeTrigger(() => options, restarted, store2, clock, NullLogger.Instance); + var retried = await trigger2.TryTriggerAsync(advice, CancellationToken.None); + + Assert.Equal(crashed.RedeemRequestId, retried.RedeemRequestId); + Assert.Equal(1, server.Consumptions); + Assert.Equal(ResetCreditTriggerDecision.Consumed, retried.Decision); + Assert.True(retried.Consumed); + } + + [Fact] + public async Task CapEnforced_AcrossProcessRestart() + { + var clock = new FakeTriggerClock(Now); + var server = new FakeConsumeServer(); + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true, MaxCreditsPerPeriod = 1, Period = TimeSpan.FromDays(30) }; + var storePath = StorePath(); + + var t1 = new FakeConsumeTransport(server) { Balance = new ResetCreditBalance(5, Now) }; + var trigger1 = new ResetCreditConsumeTrigger(() => options, t1, new FileResetCreditTriggerStore(storePath), clock, NullLogger.Instance); + var first = await trigger1.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + Assert.Equal(ResetCreditTriggerDecision.Consumed, first.Decision); + + var t2 = new FakeConsumeTransport(server) { Balance = new ResetCreditBalance(4, Now) }; + var store2 = new FileResetCreditTriggerStore(storePath); + var trigger2 = new ResetCreditConsumeTrigger(() => options, t2, store2, clock, NullLogger.Instance); + var second = await trigger2.TryTriggerAsync(SpendAdvice("codex", Now, Deadline.AddDays(1)), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedCapExceeded, second.Decision); + Assert.False(second.RequestIssued); + Assert.Equal(0, t2.ConsumeCalls); + Assert.Equal(1, server.Consumptions); + Assert.Contains("$80", second.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task ConcurrentAttempts_IssueAtMostOneRequest() + { + var clock = new FakeTriggerClock(Now); + var server = new FakeConsumeServer(); + var transport = new FakeConsumeTransport(server) { Balance = new ResetCreditBalance(5, Now), ConsumeDelay = TimeSpan.FromMilliseconds(50) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true, MaxCreditsPerPeriod = 8, Period = TimeSpan.FromDays(30) }; + await using var trigger = BuildTrigger(() => options, transport, clock, out _); + var advice = SpendAdvice("codex", Now, Deadline); + + var tasks = Enumerable.Range(0, 16) + .Select(_ => trigger.TryTriggerAsync(advice, CancellationToken.None)) + .ToList(); + var outcomes = await Task.WhenAll(tasks); + + Assert.Equal(1, server.Consumptions); + Assert.Equal(1, transport.ConsumeCalls); + var keys = outcomes.Select(o => o.RedeemRequestId).Distinct(StringComparer.Ordinal).ToList(); + Assert.Single(keys); + Assert.Contains(outcomes, o => o.Decision == ResetCreditTriggerDecision.Consumed); + Assert.All(outcomes.Where(o => o.Decision != ResetCreditTriggerDecision.Consumed), o => Assert.Equal(ResetCreditTriggerDecision.AlreadyConsumed, o.Decision)); + } + + [Fact] + public async Task KillSwitch_TakesEffectWithoutRestart() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true, KillSwitchEngaged = false }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + options = options with { KillSwitchEngaged = true }; + var blocked = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedKillSwitch, blocked.Decision); + Assert.Equal(0, transport.ConsumeCalls); + + options = options with { KillSwitchEngaged = false }; + var allowed = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.Consumed, allowed.Decision); + Assert.Equal(1, transport.ConsumeCalls); + var audits = await store.ListAuditsAsync(CancellationToken.None); + Assert.Contains(audits, a => a.Gate == nameof(ResetCreditTriggerDecision.RefusedKillSwitch)); + } + + [Theory] + [InlineData(null, "unknown")] + [InlineData(0, "empty")] + [InlineData(-1, "empty")] + public async Task BadBalance_RefusesWithRecordedReason(int? count, string _) + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(count, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.True( + outcome.Decision is ResetCreditTriggerDecision.RefusedBalanceUnknown or ResetCreditTriggerDecision.RefusedBalanceEmpty, + $"unexpected {outcome.Decision}"); + Assert.False(outcome.RequestIssued); + Assert.Equal(0, transport.ConsumeCalls); + await AssertAuditAsync(store, outcome.RedeemRequestId, expectKey: true); + } + + [Fact] + public async Task StaleBalance_RefusesWithRecordedReason() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now - TimeSpan.FromHours(2)) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true, MaxBalanceAge = TimeSpan.FromMinutes(15) }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedBalanceStale, outcome.Decision); + Assert.False(outcome.RequestIssued); + Assert.Equal(0, transport.ConsumeCalls); + await AssertAuditAsync(store, outcome.RedeemRequestId, expectKey: true); + } + + [Fact] + public async Task AdvisorHold_RefusesAndAudits() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var outcome = await trigger.TryTriggerAsync(HoldAdvice("codex", Now), CancellationToken.None); + + Assert.Equal(ResetCreditTriggerDecision.RefusedAdvisorHold, outcome.Decision); + Assert.Equal(0, transport.ConsumeCalls); + await AssertAuditAsync(store, outcome.RedeemRequestId, expectKey: true); + } + + [Fact] + public async Task EveryDecision_ProducesAuditWithKeyAndSpend() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = false, AllowLiveSpend = false }; + await using var trigger = BuildTrigger(() => options, transport, clock, out var store); + + var refused = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + options = options with { Enabled = true }; + var dryRun = await trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None); + + var audits = await store.ListAuditsAsync(CancellationToken.None); + Assert.Equal(2, audits.Count); + foreach (var audit in audits) + { + Assert.False(string.IsNullOrWhiteSpace(audit.RedeemRequestId)); + Assert.False(string.IsNullOrWhiteSpace(audit.Gate)); + Assert.True(audit.PeriodSpend >= 0); + Assert.True(audit.PeriodCap >= 0); + Assert.False(string.IsNullOrWhiteSpace(audit.Detail)); + } + Assert.Equal(refused.RedeemRequestId, audits[0].RedeemRequestId); + Assert.Equal(dryRun.RedeemRequestId, audits[1].RedeemRequestId); + Assert.Equal(refused.PeriodSpend, audits[0].PeriodSpend); + } + + [Fact] + public async Task CorruptStateFile_FailsClosedWithoutSpending() + { + var clock = new FakeTriggerClock(Now); + var transport = new FakeConsumeTransport { Balance = new ResetCreditBalance(2, Now) }; + var options = new ResetCreditTriggerOptions { Enabled = true, AllowLiveSpend = true }; + var path = StorePath(); + await File.WriteAllTextAsync(path, "{not valid json", CancellationToken.None); + var trigger = new ResetCreditConsumeTrigger(() => options, transport, new FileResetCreditTriggerStore(path), clock, NullLogger.Instance); + + await Assert.ThrowsAnyAsync(() => trigger.TryTriggerAsync(SpendAdvice("codex", Now, Deadline), CancellationToken.None)); + + Assert.Equal(0, transport.ConsumeCalls); + } + + [Fact] + public void DecisionFingerprint_IsDeterministicPerDecision() + { + var a = SpendAdvice("codex", Now, Deadline); + var b = SpendAdvice("codex", Now, Deadline); + var c = SpendAdvice("codex", Now, Deadline.AddDays(1)); + + Assert.Equal( + ResetCreditConsumeTrigger.ComputeDecisionFingerprint(a), + ResetCreditConsumeTrigger.ComputeDecisionFingerprint(b)); + Assert.NotEqual( + ResetCreditConsumeTrigger.ComputeDecisionFingerprint(a), + ResetCreditConsumeTrigger.ComputeDecisionFingerprint(c)); + Assert.Equal( + ResetCreditConsumeTrigger.ComputeRedeemRequestId(ResetCreditConsumeTrigger.ComputeDecisionFingerprint(a)), + ResetCreditConsumeTrigger.ComputeRedeemRequestId(ResetCreditConsumeTrigger.ComputeDecisionFingerprint(b))); + } + + private static ResetSpendAdvice SpendAdvice(string agent, DateTimeOffset now, DateTimeOffset closesAt) => new() + { + Agent = agent, + EvaluatedAt = now, + ShouldSpend = true, + Reason = ResetAdviceReason.SpendBeforeDeadline, + Rationale = "test spend", + OptimalWindow = new ResetSpendWindow(now, closesAt), + DecisionDeadline = closesAt, + NextCreditExpiresAt = closesAt, + }; + + private static ResetSpendAdvice HoldAdvice(string agent, DateTimeOffset now) => new() + { + Agent = agent, + EvaluatedAt = now, + ShouldSpend = false, + Reason = ResetAdviceReason.BurnFirst, + Rationale = "test hold", + UsableQuotaPct = 42, + }; + + private static async Task AssertAuditAsync(IResetCreditTriggerStore store, string redeemKey, bool expectKey) + { + var audits = await store.ListAuditsAsync(CancellationToken.None); + var audit = Assert.Single(audits); + if (expectKey) + Assert.False(string.IsNullOrWhiteSpace(audit.RedeemRequestId)); + Assert.Equal(redeemKey, audit.RedeemRequestId); + Assert.True(audit.PeriodSpend >= 0); + } + + private string StorePath() => Path.Combine(_tempDir, Guid.NewGuid().ToString("N") + ".json"); + + private TriggerHandle BuildTrigger( + Func provider, + FakeConsumeTransport transport, + TimeProvider clock, + out IResetCreditTriggerStore store) + { + store = new FileResetCreditTriggerStore(StorePath()); + return new TriggerHandle(new ResetCreditConsumeTrigger(provider, transport, store, clock, NullLogger.Instance)); + } + + private sealed class TriggerHandle(ResetCreditConsumeTrigger inner) : IAsyncDisposable + { + public Task TryTriggerAsync(ResetSpendAdvice? advice, CancellationToken ct) + => inner.TryTriggerAsync(advice, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class FakeTriggerClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan delta) => _now += delta; + } + + /// + /// Fake provider transport. The embedded is the + /// fake provider: it dedupes by redeem key, so a retry with the same key can + /// never consume twice — mirroring the real endpoint's idempotency contract. + /// + private sealed class FakeConsumeTransport : IResetCreditConsumeTransport + { + public enum Behaviour { Success, Throw, Timeout, CrashAfterServerConsume } + + private readonly FakeConsumeServer _server; + + public FakeConsumeTransport() => _server = new FakeConsumeServer(); + public FakeConsumeTransport(FakeConsumeServer server) => _server = server; + + public ResetCreditBalance Balance { get; set; } = new(null, null); + public Behaviour ConsumeBehaviour { get; set; } = Behaviour.Success; + public TimeSpan ConsumeDelay { get; set; } = TimeSpan.Zero; + public int ConsumeCalls { get; private set; } + public int ServerConsumptions => _server.Consumptions; + + public Task ReadBalanceAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (ReadThrows) + throw new ResetCreditTransportException("fake balance read failed"); + return Task.FromResult(Balance); + } + + public bool ReadThrows { get; set; } + + public async Task ConsumeAsync(ResetCreditConsumeRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + ConsumeCalls++; + if (ConsumeDelay > TimeSpan.Zero) + await Task.Delay(ConsumeDelay, ct).ConfigureAwait(false); + switch (ConsumeBehaviour) + { + case Behaviour.Throw: + throw new ResetCreditTransportException("fake transport failure"); + case Behaviour.Timeout: + throw new TimeoutException("fake timeout"); + case Behaviour.CrashAfterServerConsume: + _server.Consume(request.RedeemRequestId); + throw new ResetCreditTransportException("fake crash between dispatch and response"); + default: + _server.Consume(request.RedeemRequestId); + return new ResetCreditConsumeResult { Consumed = true, CreditId = request.CreditId }; + } + } + } + + /// Fake provider side: idempotent consumption ledger keyed by redeem key. + /// A retry with an already-consumed key replays success without a second + /// consumption — the real endpoint's idempotency contract. + private sealed class FakeConsumeServer + { + private readonly HashSet _consumedKeys = new(StringComparer.Ordinal); + private readonly object _lock = new(); + public int Consumptions + { + get { lock (_lock) return _consumedKeys.Count; } + } + + public void Consume(string redeemKey) + { + lock (_lock) _ = _consumedKeys.Add(redeemKey); + } + + public void ApplyConsumptionToBalance(FakeConsumeTransport transport) + { + if (transport.Balance.AvailableCount is { } count && transport.Balance.SampledAt is { } sampled) + transport.Balance = new ResetCreditBalance(Math.Max(0, count - _consumedKeys.Count), sampled); + } + } +}