diff --git a/docs/extending/quota-reset-trigger.md b/docs/extending/quota-reset-trigger.md
new file mode 100644
index 00000000..4d6a3979
--- /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 7e77f677..60cc554b 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 00000000..3e068580
--- /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 00000000..56c7475d
--- /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);
+ }
+ }
+}