/**
 * WS5.2 R7a — per-account spend-slice ORCHESTRATION (the LEASE-SLICED ceiling, owned by the
 * fenced single-writer). This is the cohesive control-plane unit on top of the durable
 * `AccountFollowMeGrantLedger` (AccountFollowMeGrants.ts), kept PURE + injectable so the
 * distributed math is unit-testable without a live mesh.
 *
 * Spec bounds enforced here (docs/specs/ws52-account-follow-me-security.md §R7a, §S5, §I5):
 *
 *  (a) FENCED single-writer issuance. Slices are issued ONLY by the current `FencedLease` holder;
 *      a non-holder issuing is refused (`not-lease-holder`). Issuance is epoch-fenced: a renew
 *      stamped with a stale lease epoch is void (`stale-lease-epoch`). On a holder FAILOVER the new
 *      holder constructs `SliceIssuer` over the SAME durable ledger and re-derives outstanding
 *      slices BEFORE issuing — the ledger's sum-of-leases bound holds across the handoff with no
 *      double-allocation (proven in AccountFollowMeGrants).
 *
 *  (b) `maxSpend` is denominated in provider quota-FRACTION (0..1), reusing AccountQuotaSnapshot.
 *      The issuer takes the ceiling as a fraction; per-slice consumption is the durable grant amount.
 *
 *  (c) The requester-side renewal CONTROL PLANE is rate-capped + coalesced + P19-breaker-protected,
 *      so N VMs on one hot account produce O(per-account-cap) renewal RPCs — never an O(N) herd:
 *        - per-(account,machine) in-flight COALESCING: a VM with an outstanding renewal does not
 *          start a second (`coalesced-in-flight`);
 *        - per-account renewal RATE CAP with EXPONENTIAL backoff after each refusal/failure;
 *        - a P19 sustained-FAILURE breaker: after `breakerThreshold` consecutive failures the VM
 *          FAILS CLOSED TO ITS OWN ACCOUNT (`breaker-open`) instead of retry-storming a slow/
 *          partitioned/unreachable holder. The breaker re-closes after `breakerCooldownMs`.
 *
 *  (d) FIRST-SLICE-under-partition: a VM that never received a slice is treated identically to a
 *      VM whose slice is exhausted — it FAILS CLOSED TO ITS OWN ACCOUNT until it obtains its first
 *      slice (the `decideAccountUse` consultation below).
 *
 * INVARIANT (load-bearing): fail-closed-to-OWN-account on EVERY uncertainty — no slice, exhausted,
 * partition, breaker open, no mandate, ledger error. A borrowed account is NEVER overspent past its
 * grant. Everything stays dark behind `multiMachine.accountFollowMe` at the wiring layer.
 */
import { AccountFollowMeGrantLedger, } from './AccountFollowMeGrants.js';
/**
 * The HOLDER-side issuer. Runs ON the fenced lease holder (the `slice-renew` handler wires this).
 * Refuses unless this machine genuinely holds the lease right now, then delegates the sum-of-leases
 * accounting to the durable ledger (single source of truth, re-derived on every call → failover-safe).
 */
export class SliceIssuer {
    cfg;
    ledger;
    now;
    constructor(store, cfg) {
        this.cfg = cfg;
        this.now = cfg.now ?? Date.now;
        this.ledger = new AccountFollowMeGrantLedger(store, this.now);
    }
    /** Outstanding committed slices for an account (re-derived from the durable ledger). */
    outstandingFor(accountId) {
        return this.ledger.outstandingFor(accountId);
    }
    /**
     * Issue (or re-issue) a slice in response to a renew request. FENCED: refuses unless this machine
     * holds the lease at the current epoch. The slice is stamped with the CURRENT lease epoch, so it is
     * void at a later epoch (consume/renew checks via the ledger). The ledger enforces the sum-of-leases
     * ceiling and single-use grant ids.
     */
    issueForRenew(req, ctx) {
        // (a) FENCED single-writer: only the live lease holder may issue. A non-holder (e.g. a stale
        //     ex-holder mid-failover) refuses — the safe direction (no double-allocation).
        if (!this.cfg.holdsLease()) {
            return { ok: false, reason: 'not-lease-holder' };
        }
        const leaseEpoch = this.cfg.currentLeaseEpoch();
        const result = this.ledger.issue({
            grantId: req.grantId,
            mandateId: req.mandateId,
            accountId: req.accountId,
            targetFingerprint: req.requestingMachineFp,
            amount: req.amount,
            ceiling: ctx.ceiling,
            leaseEpoch,
            expiresAt: req.expiresAt,
        });
        if (!result.ok)
            return { ok: false, reason: result.reason };
        return {
            ok: true,
            grantId: result.grant.grantId,
            amount: result.grant.amount,
            leaseEpoch: result.grant.leaseEpoch,
            expiresAt: result.grant.expiresAt,
        };
    }
}
/**
 * The per-machine requester-side renewal control plane for ONE account. Pure state-machine: a caller
 * asks `shouldAttempt()` before sending a `slice-renew` RPC, marks `beginAttempt()` when it sends,
 * and reports `recordOutcome()` with the result. It enforces coalescing, the rate cap with
 * exponential backoff, and the P19 breaker — so the transport above it can be a thin RPC.
 */
export class SliceRenewalControl {
    minInterval;
    backoffMul;
    maxInterval;
    breakerThreshold;
    breakerCooldownMs;
    now;
    inFlight = false;
    lastAttemptAt = -Infinity;
    /** Current backoff interval (grows on refusal/failure, resets on success). */
    currentInterval;
    consecutiveFailures = 0;
    breakerOpenedAt = null;
    constructor(cfg = {}) {
        this.minInterval = cfg.minRenewIntervalMs ?? 5000;
        this.backoffMul = cfg.backoffMultiplier ?? 2;
        this.maxInterval = cfg.maxRenewIntervalMs ?? 300000;
        this.breakerThreshold = cfg.breakerThreshold ?? 3;
        this.breakerCooldownMs = cfg.breakerCooldownMs ?? 60000;
        this.now = cfg.now ?? Date.now;
        this.currentInterval = this.minInterval;
    }
    /** Is the P19 breaker currently open (a probe not yet allowed)? */
    breakerOpen() {
        if (this.breakerOpenedAt === null)
            return false;
        return this.now() - this.breakerOpenedAt < this.breakerCooldownMs;
    }
    /**
     * May this VM send a renewal RPC for the account right now? Refuses (fail-closed at the call site)
     * when a renewal is already in flight (coalescing), the rate cap has not elapsed, or the breaker is
     * open. The caller treats any `proceed:false` as "use my OWN account this turn."
     */
    shouldAttempt() {
        if (this.inFlight)
            return { proceed: false, reason: 'coalesced-in-flight' };
        if (this.breakerOpen())
            return { proceed: false, reason: 'breaker-open' };
        if (this.now() - this.lastAttemptAt < this.currentInterval) {
            return { proceed: false, reason: 'rate-capped' };
        }
        return { proceed: true };
    }
    /** Mark a renewal RPC as started (coalescing latch). Must be paired with recordOutcome(). */
    beginAttempt() {
        this.inFlight = true;
        this.lastAttemptAt = this.now();
    }
    /**
     * Report the RPC result, advancing the control state:
     *  - issued  → reset backoff + breaker (the channel is healthy again);
     *  - refused → grant-level refusal (e.g. would-exceed-ceiling); back off, but NOT a transport
     *              failure, so it does not advance the breaker (the holder answered);
     *  - failed  → transport failure (timeout/partition/unreachable); back off AND advance the breaker.
     */
    recordOutcome(kind) {
        this.inFlight = false;
        if (kind === 'issued') {
            this.currentInterval = this.minInterval;
            this.consecutiveFailures = 0;
            this.breakerOpenedAt = null;
            return;
        }
        // Back off on any non-success.
        this.currentInterval = Math.min(this.currentInterval * this.backoffMul, this.maxInterval);
        if (kind === 'failed') {
            this.consecutiveFailures += 1;
            if (this.consecutiveFailures >= this.breakerThreshold && this.breakerOpenedAt === null) {
                this.breakerOpenedAt = this.now();
            }
        }
    }
}
/**
 * The SELECTION-TIME consultation (R7a(b)/(d), S5, §6.2). Returns whether a candidate BORROWED
 * account may be used this turn, or whether the call must FALL BACK to this machine's OWN account.
 *
 * Fail-closed-to-own-account on EVERY uncertainty: flag off, not a borrowed account, no slice ever
 * received (first-slice-under-partition), exhausted slice, expired slice, or a stale-epoch slice all
 * resolve to `own`. ONLY a live, unexpired, current-epoch slice with remaining budget yields
 * `borrowed`. This is pure — the router/pool wiring calls it and routes accordingly; when off it can
 * never be reached so default behavior is byte-identical.
 */
export function decideAccountUse(q) {
    if (!q.followMeEnabled)
        return { use: 'own', reason: 'follow-me-disabled' };
    if (!q.isBorrowedAccount)
        return { use: 'own', reason: 'not-a-borrowed-account' };
    const s = q.slice;
    // (d) first-slice-under-partition: never received a slice → own account.
    if (!s || !s.grantId)
        return { use: 'own', reason: 'no-slice' };
    // A slice from a superseded lease epoch is void (the holder failed over).
    if (s.leaseEpoch < q.currentLeaseEpoch)
        return { use: 'own', reason: 'stale-lease-epoch' };
    if (s.expiresAt <= q.now)
        return { use: 'own', reason: 'slice-expired' };
    if (!(s.remaining > 0))
        return { use: 'own', reason: 'slice-exhausted' };
    return { use: 'borrowed', remaining: s.remaining, reason: 'live-slice' };
}
//# sourceMappingURL=AccountFollowMeSpendSlice.js.map