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 viaspendTSU(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.ts—authorizeTicket/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:
- Lease broker no controller (
internal/leasebroker): serviço HTTP que mediaauthorize/redeempara o cloud usando o cloudclient pinado + JWT do controller. Opt-in viaZTP_PREM_LEASE_BROKER_ADDR+ZTP_PREM_LEASE_BROKER_TOKEN; fail-closed sem token; mapeia 402 do cloud → 402. - 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 emlib/license/tsu-estimate. - 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-ticketsvarre ticketsactivecujoexpires_atpassou 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-ledgerexpõestaleActiveTickets(ticketsactivevencidos 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 é oactualTsu, 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 tiersdefense/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.