Skip to content

L2 Pré-autorização (tickets) — guia de adoção (ADR-0104)

O L2 fecha o risco de fraude do consumo on-prem movendo a cobrança para o cloud: o TSU é debitado antes do teste rodar (no authorize), e o módulo só pode consumir até a quota que pagou. O auto-reporte vira reconciliação (refund do não-usado), não a fonte da cobrança. Assim, um cliente que controla o host nunca consome mais do que pagou, independentemente do que reporte.

O que já está implementado (cloud + cliente)

  • Tabela usage_tickets (migration 0020).
  • POST /api/usage/authorize — debita via spendTSU (atômico) e emite ticket; 402 se sem saldo.
  • POST /api/usage/redeem — reconcilia (idempotente), refunda o remanescente não-usado.
  • src/lib/usage/tickets.tsauthorizeTicket / redeemTicket.
  • Go: cloudclient.Authorize / RedeemTicket + usage.TicketLease (gating local).

Adoção implementada — gate no Dashboard via lease broker (transparente)

Descoberta de arquitetura: os agentes de maior consumo (k6, Playwright) são Node.js e não falam com o cloud — eles passam pelo gate único authorize() do Dashboard no run-start (/api/k6/runs/start, /api/runs/start, /api/test-runs/preflight). E o Dashboard não tem o license JWT (só o controller tem, com SPKI pinning). Por isso a adoção foi feita assim, sem espalhar o JWT nem tocar os agentes:

  1. Lease broker no controller (internal/leasebroker): serviço HTTP que media authorize/redeem para o cloud usando o cloudclient pinado + JWT do controller. Opt-in via ZTP_PREM_LEASE_BROKER_ADDR + ZTP_PREM_LEASE_BROKER_TOKEN; fail-closed sem token; mapeia 402 do cloud → 402.
  2. Gate do Dashboard (lib/license/gate.authorize): antes de permitir a run, chama o broker (lib/license/cloud-lease) para pré-autorizar (debita o ledger real). 402 → nega a run. No-op quando o broker não está configurado (preserva o modo Wave-1 local). TSU estimado em lib/license/tsu-estimate.
  3. Transparente para os agentes: k6 e Playwright não mudam — o gate já é o chokepoint por onde toda run passa (cobre os três caminhos de uma vez).

Config (Dashboard env): ZTP_PREM_CLOUD_LEASE_URL (URL do broker — localhost no single-node, ClusterIP Service no multi-node) + ZTP_PREM_LEASE_BROKER_TOKEN; ZTP_PREM_CLOUD_LEASE_FAILMODE=closed para negar em falha do broker (default: fail-open/degraded, para não derrubar o lab por infra do broker); ZTP_PREM_PW_LEASE_SECONDS (default 60) — teto do lease por run de Playwright (o redeem clampa nele; runs mais longas que o teto são sub-cobradas — calibre com a duração típica do ciclo).

Fase 1 = use-it-or-lose-it (sem redeem): o ticket é debitado no authorize e expira; solidez máxima. Fase 2 (implementada): o run-start persiste o usage_ticket_id na run; o run-complete (k6/runs/complete, runs/complete) redime o ticket via cloud-lease.cloudRedeem → broker → /api/usage/redeem, reportando o consumo real (estimado da duração real) para refundar o não-usado. A estimativa (tsu-estimate.ts) usa os multiplicadores de intensidade canônicos; resta afinar a escala vus→TSU com o pricing real.

Ciclo de vida do ticket — expiração e hand-back (auditoria 2026-06-10)

authorize (débito cloud-side)
   ├── run OK ──→ run-complete redime ──→ refund do não-usado     [caminho feliz]
   ├── deny local pós-lease (HSM/spend) ──→ redeem actualTsu=0    [hand-back, M4]
   └── run-complete nunca chega (crash) ──→ cron expire-tickets   [forfeit, H2]
                                            (expires_at + 24h de grace → expired)
  • Hand-back (M4): se o gate do Dashboard nega a run DEPOIS do lease já debitado (HSM gate, falha do spend local), ele devolve o ticket na hora via redeem com actualTsu=0 — refund total, pois a run nunca começou.
  • Expiração (H2): o cron horário /api/cron/expire-tickets varre tickets active cujo expires_at passou há mais de 24h (grace)expired. Um redeem tardio legítimo dentro do grace ainda refunda; depois dele o lease é forfeitado.
  • Por que a expiração NÃO refunda automaticamente: auto-refund reabriria a fraude que o L2 fechou — um host adversário rodaria o teste, suprimiria o run-complete e esperaria o dinheiro voltar. O forfeit é a postura segura; o stranded_tsu é logado (usage.tickets.expired) e um crash legítimo é refundado manualmente pelo ops (admin grant), com o log como evidência.
  • Observabilidade: reconcile-ledger expõe staleActiveTickets (tickets active vencidos além do grace) — deve ser 0 com o cron saudável; um valor crescente significa sweeper parado ou dinheiro estrangulando.

Caminho alternativo — módulo Go que reporta direto

Para um módulo que rode no controller-side e gateie o próprio loop, o helper usage.TicketLease (Go):

Padrão de adoção (Go)

lease := usage.NewTicketLease(client, fingerprint, "k6-art")

// 1. Pré-autorize ANTES de rodar. Sem saldo → pause (não rode).
if err := lease.Acquire(ctx, estimateTSU); err != nil {
    if cloudclient.IsQuotaExceeded(err) {
        // pausar + sinalizar "top-up required" ao operador
    }
    return err
}

// 2. Consuma dentro da quota paga. Consume() RECUSA ao estourar a quota.
for batch := range work {
    if !lease.Consume(batch.TSU) {        // quota pré-paga esgotada
        if err := lease.Acquire(ctx, refill); err != nil { break } // renova
        if !lease.Consume(batch.TSU) { break }
    }
    run(batch)
}

// 3. Reconcilie ao terminar — refunda o não-usado.
_, _ = lease.Redeem(ctx)

Dimensionamento do lease

  • Lease grande por execução: menos round-trips, mais TSU "preso" até o redeem.
  • Leasing incremental (recomendado): peça blocos pequenos conforme avança; ao terminar, simplesmente pare de pedir. Minimiza TSU preso e o refund.

Offline / grace

Reusar a janela de grace do heartbeat: permitir que um lease já pré-autorizado continue rodando durante uma falha de conectividade com o cloud, reconciliando no reconnect. Sem lease válido + fora da grace → o módulo não roda (fail-closed).

Política de refund

  • Default allowRefund=true: refunda o remanescente (o único valor auto-reportado é o actualTsu, e só afeta o refund deste ticket, limitado pela quota).
  • allowRefund=false (use-it-or-lose-it): zero caminho auto-reportado — solidez máxima; recomendado para tiers defense/contratos que exijam.

Garantia de solidez

A fraude máxima possível cai de "todo o consumo auto-reportado" para, no pior caso com refund habilitado, "o remanescente não-usado de um ticket" — e com allowRefund=false, para zero. O teto de cada execução é a quota que o cliente já pagou no authorize (débito cloud-side, não falsificável). A expiração com forfeit (acima) preserva essa garantia até no caminho de abandono: suprimir o run-complete não devolve o lease — perde-o.