v0.1.25.47 — TENANT_CLOSED Rule 2 guard + webhook matcher parity
Governance conformance release: the runtime half of the tenant-close cascade (Rule 2 terminal-owner guard) plus webhook scope-filter matcher parity with the admin plane.
Added
- 409
TENANT_CLOSEDon the four reservation mutations (create, commit, release, extend) once the owning tenant'sstatus=CLOSEDflip is durable in the shared Redis. Implemented inside the reserve/commit/release/extend Lua scripts — atomic with the budget mutations and immune to the tenant-config cache TTL. Implements governance CASCADE SEMANTICS Rule 2 / Mode B invariant (a); runtime spec revision v0.1.25.13 (runcycles/cycles-protocol#125). Closes the two paths the auth layer could not: admin-on-behalf-ofreleaseon a closed tenant (previously succeeded; now 409) and the auth-check→script race window. Tenant-key requests on closed tenants keep their existing per-request 401. - Truthful non-persisting evaluations: a fresh
dry_runcreate orPOST /v1/decideon a CLOSED tenant returns 200decision=DENYwithreason_code=TENANT_CLOSED(new DecisionReasonCode value) instead of what could previously be a signed ALLOW attestation for a request whose live execution must fail. Cached pre-close replays keep their original payload. - Fail-closed governance reads: a present-but-malformed tenant record (undecodable JSON, non-object, missing
status, or a status outside the closedACTIVE|SUSPENDED|CLOSEDenum) fails the request 500INTERNAL_ERRORwith no mutation. Absent tenant record (runtime-only deployments) ⇒ no guard, unchanged behavior. - Signed denial receipts: mutation-surface 409
TENANT_CLOSED(persisting create, commit, release) emits anerrorCyclesEvidence envelope and stampscycles_evidence, like the other budget/lifecycle denial codes (declared in evidence ErrorResponseMirror, cycles-evidence-v0.2.yaml 0.2.1).TENANT_CLOSEDtakes precedence overRESERVATION_FINALIZED/RESERVATION_EXPIREDfor non-replay attempts; same-key idempotent replays return their original response.
Fixed
- Webhook
scope_filtermatching is now byte-identical to the admin plane's spec-conformant matcher (cycles-server-admin #206): a blank/whitespace event scope is treated as unscoped (excluded from every scope-filtered subscription — a bare*filter no longer matches a blank""scope), and a trailing-/*filter requires a non-empty child segment (tenant:a/*no longer matches the degeneratetenant:a/). Both planes' matchers now pin the same test table.
Compatibility
- Additive
ErrorCodeandDecisionReasonCodevalue (TENANT_CLOSED), per runtime spec v0.1.25.13. Deployments without a governance plane see no behavior change; closed tenants previously surfaced only as auth-layer 401s or budget-state errors. - Matcher change affects runtime webhook dispatch only for blank-scope events and empty-child trailing-wildcard filters.
- No HTTP wire-shape, Redis schema, or event-wire change. The Lua mutation scripts gain one in-script
GET tenant:<id>+ decode per call — no additional network round trip.
1,058 tests green including integration + contract validation against spec v0.1.25.13; JaCoCo ≥95% line coverage on all modules; six external review rounds. Full details: CHANGELOG.md [0.1.25.47], AUDIT.md 2026-07-10 entries, PR #231.