Skip to content

Commit eb6db8b

Browse files
authored
Merge pull request #231 from runcycles/fix/runtime-spec-conformance-tenant-closed-and-matcher
Runtime spec conformance: scope-filter matcher parity + TENANT_CLOSED Rule 2 guard (v0.1.25.47)
2 parents e254666 + f860d78 commit eb6db8b

21 files changed

Lines changed: 1903 additions & 40 deletions

File tree

AUDIT.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,181 @@
55

66
---
77

8+
### 2026-07-10 — v0.1.25.47: TENANT_CLOSED Rule 2 guard on reservation mutations
9+
10+
Adopts the governance spec's CASCADE SEMANTICS Rule 2 (terminal-owner
11+
mutation guard, `cycles-governance-admin-v0.1.25.yaml`) on the runtime
12+
plane: once the owning tenant's CLOSED flip is durable, reservation
13+
create/commit/release/extend return 409 `TENANT_CLOSED` (Mode B
14+
invariant (a): a mutation observed after the flip MUST NOT succeed, even
15+
before the cascade touches the child or revokes keys).
16+
17+
What the guard actually closes, layer by layer (audited during
18+
implementation): `ApiKeyRepository.validate` ALREADY reads `tenant:<id>`
19+
fresh per request and 401s tenant keys of SUSPENDED/CLOSED tenants at
20+
the auth filter — so for tenant-key HTTP traffic the post-flip window
21+
was already shut (with 401, the pending runtime spec revision's "a
22+
closed tenant usually surfaces on this plane as 401", not the Rule 2
23+
409). Two real gaps remained, both now closed: (1) **admin-key
24+
mutations** — the runtime's admin-on-behalf-of auth (`X-Admin-API-Key`;
25+
allowlist GET list/single + POST release) carries no tenant-status
26+
check, so an admin release on a closed tenant SUCCEEDED, mutating
27+
budgets post-flip; it now returns 409 `TENANT_CLOSED`. (2) **the
28+
auth-check→script race** — the filter reads tenant status at auth time
29+
and the CLOSED flip can land between that read and the Lua execution;
30+
only an in-script check is atomic with the mutation. The guard also
31+
covers any future path that reaches the repository without the
32+
tenant-key filter.
33+
34+
Design decisions:
35+
- **Guard placement — inside the Lua scripts.** The codebase's precedent
36+
for status guards (BUDGET_FROZEN/BUDGET_CLOSED in reserve.lua) is
37+
in-script, and only in-script placement is atomic with the budget
38+
mutations (Redis executes scripts serially): a Java pre-check would
39+
leave a flip-vs-mutation race, and piggybacking on
40+
`getTenantConfig()` would inherit its 60 s Caffeine cache
41+
(`cycles.tenant-config.cache-ttl-ms`), violating invariant (a)'s
42+
"durable to readers" requirement. Cost: one extra Redis `GET
43+
tenant:<id>` + `cjson.decode` inside each mutation script — no extra
44+
network round-trip.
45+
- **Owning-tenant resolution.** reserve.lua uses the auth-derived tenant
46+
already in ARGV[10]; commit/release/extend read the `tenant` field from
47+
the reservation hash (the authoritative owner; reserve.lua has always
48+
written it) — added to the scripts' existing HMGETs, no new commands.
49+
- **No tenant record ⇒ no restriction** (runtime-only deployments), same
50+
contract as the admin plane's `TerminalOwnerMutationGuard`. A PRESENT
51+
record that cannot be decoded into an object with a string `status`
52+
**fails closed** (500 INTERNAL_ERROR, no mutation) — matching the admin
53+
plane's TenantRepository, which propagates parse failures rather than
54+
treating a corrupt governance record as an open tenant (codex round 2;
55+
the first cut fell through open on decode failure). Round 3 extended
56+
fail-closed to unknown status STRINGS via a whitelist (CLOSED → 409;
57+
ACTIVE/SUSPENDED → proceed; anything else → 500): the governance
58+
TenantStatus enum is a closed set and the cascade revision explicitly
59+
introduces no new status values as a wire-compat guarantee, so an
60+
unknown status (e.g. "CLOZED", lowercase "closed") cannot be a
61+
legitimate future value under the current contract — it is corruption.
62+
Pinned per op with "CLOZED" and lowercase "closed" records in the
63+
malformed matrix.
64+
- **Precedence.** Same-key idempotent replay first (a replay re-observes
65+
a pre-flip mutation — not a new mutation; Rule 2(b) idempotency;
66+
mirrors how the budget status guards sit after replay), then the
67+
closed-tenant guard, then every reservation-state/expiry/budget check —
68+
honoring the spec's "regardless of that child's own current status"
69+
(precedence sentence added to spec PR runcycles/cycles-protocol#125
70+
ERROR SEMANTICS). Codex round 2 resolved the first cut's accepted edge:
71+
commit.lua/release.lua previously returned RESERVATION_FINALIZED for a
72+
different-key attempt on a finalized reservation before the guard ran;
73+
the replay branches were narrowed to true same-key replays so
74+
different-key attempts fall through to the guard, and release.lua's
75+
post-guard state check widened from `== "COMMITTED"` to `~= "ACTIVE"`
76+
to keep the open-tenant RESERVATION_FINALIZED response identical.
77+
- **Scope.** Exactly the four reservation mutations Rule 2 names get the
78+
409 guard. `GET`/list stay available on closed tenants (spec:
79+
post-mortem reads). The non-persisting evaluations (dry_run +
80+
`/v1/decide`) were initially left unguarded; an external review round
81+
(round 4) flagged that a post-flip dry_run could stamp a SIGNED ALLOW
82+
attestation for a request whose live execution MUST fail — resolved
83+
per the amended spec PR runcycles/cycles-protocol#125: a FRESH
84+
evaluation on a CLOSED tenant now returns 200 decision=DENY with
85+
reason_code=TENANT_CLOSED (new `Enums.ReasonCode` value, typed,
86+
mirroring the documented DecisionReasonCode vocabulary) via a single
87+
shared gate (`evaluateTenantStatusGate`) called from both evaluation
88+
paths, after replay handling and before any budget read. The gate
89+
reads `tenant:<id>` fresh (never the 60 s config cache) and applies
90+
the same fail-closed whitelist as the Lua guards — malformed record
91+
(undecodable / non-object / missing or non-string status / unknown
92+
status string) → 500 INTERNAL_ERROR BEFORE evidence stamping (the
93+
server cannot attest against corrupt governance state; no
94+
reserve/decide evidence row and no error-evidence row is written,
95+
consistent with the existing convention that evidence is emitted only
96+
for decisions actually reached — INTERNAL_ERROR is likewise excluded
97+
from EVIDENCE_DENIAL_CODES). Cached pre-close replays keep their
98+
original payload. `POST /v1/events` also mutates budgets and Rule 2's list is
99+
"non-exhaustive" — flagged as an open spec question rather than guarded
100+
ahead of the spec. `TENANT_CLOSED` error-evidence emission was initially
101+
deferred "until spec v0.1.25.13 lands"; resolved in review round 5 —
102+
spec PR runcycles/cycles-protocol#125 added TENANT_CLOSED to the
103+
evidence ErrorResponseMirror (cycles-evidence-v0.2.yaml 0.2.1) and both
104+
PRs merge together, so `TENANT_CLOSED` is now IN
105+
`EVIDENCE_DENIAL_CODES`. Rationale: the set's criterion is "decision
106+
reached and denied" and it already contains the governance-state
107+
denials BUDGET_FROZEN/BUDGET_CLOSED; a mutation-surface 409
108+
TENANT_CLOSED is the direct sibling of BUDGET_CLOSED (owner-level
109+
instead of ledger-level), so excluding it was inconsistent — the signed
110+
denial receipt is exactly what a closed-tenant enforcement event should
111+
produce. Error-evidence emission applies to the mutation-surface 409s
112+
(persisting create, commit, release; reservation_id hoisted on
113+
commit/release). /v1/decide never 409s for a closed tenant — it (and
114+
dry_run create) returns 200 DENY/TENANT_CLOSED and emits its normal
115+
decide/reserve decision evidence via the round-4 gate; extend is not
116+
an evidence endpoint and emits nothing, same as every other code
117+
(pinned by test). SUSPENDED tenants: existing runtime semantics
118+
live in the AUTH layer only (tenant keys 401 — pre-existing, unchanged,
119+
pinned); the mutation-layer guard is deliberately CLOSED-only per
120+
Rule 2 / spec v0.1.25.13.
121+
- **Wire.** `Enums.ErrorCode` gains `TENANT_CLOSED` (additive; runtime
122+
spec revision v0.1.25.13, runcycles/cycles-protocol#125 — mirrors the pre-existing
123+
governance code).
124+
125+
Tests: `TenantClosedGuardIntegrationTest` (Testcontainers Redis, real
126+
Lua). All four ops are exercised at the repository layer — a repository
127+
call is exactly "a request already past auth", i.e. the
128+
filter-check→script race — with no-partial-mutation assertions (budget
129+
`reserved`/`remaining`/`spent`, reservation state, `expires_at`
130+
unchanged). HTTP layer: admin-key release on a closed tenant → 409
131+
TENANT_CLOSED with the full ErrorResponse envelope (previously 200 —
132+
the reachable hole); tenant-key mutation on a closed tenant → 401
133+
pinned (pre-existing auth behavior, unchanged); admin GET + list on a
134+
closed tenant → 200 (Rule 2 read access). Plus record-absent / ACTIVE
135+
pass-through, SUSPENDED (repo-level ops proceed — mutation guard is
136+
CLOSED-only; auth-layer 401 pinned as pre-existing), cross-tenant
137+
isolation, and replay-across-the-flip semantics. The 409 HTTP calls use
138+
a non-validating client until spec v0.1.25.13 merges (the shared
139+
validating client checks response enums against cycles-protocol@main);
140+
response shape is asserted explicitly. Unit tests: handleScriptError
141+
token mapping, `tenantClosed` factory, GlobalExceptionHandler 409
142+
envelope (+ no-evidence pin).
143+
144+
Codex review round 2 (both applied against real-Lua tests): (1)
145+
fail-closed on malformed tenant records — all four guards return
146+
INTERNAL_ERROR (500, message preserved through a new explicit
147+
handleScriptError case) when a present `tenant:<id>` row fails
148+
cjson.decode, decodes to a non-object, or lacks a string `status`;
149+
pinned per op with malformed / non-object (string, number) /
150+
missing-status shapes and no-partial-mutation assertions. (2)
151+
TENANT_CLOSED precedence over RESERVATION_FINALIZED for non-replay
152+
mutations (see the Precedence bullet); pinned: closed tenant +
153+
finalized reservation + different key → 409 TENANT_CLOSED on commit,
154+
release, and extend; same-key replay still returns the original
155+
response; open tenant + different key still RESERVATION_FINALIZED
156+
(no-regression pin). Codex also confirmed the matcher port is
157+
byte-identical to admin main and that the in-script `GET tenant:<id>`
158+
matches the repo's existing standalone-Redis posture — no changes.
159+
160+
### 2026-07-10 — v0.1.25.47: webhook scope-filter matcher parity with the admin plane
161+
162+
The admin server fixed its `scope_filter` matcher for spec conformance
163+
(cycles-server-admin PR #206); the runtime dispatch matcher
164+
(`EventEmitterRepository.matchesScope`) already had the trailing-`*`/exact
165+
split but lacked two of the admin refinements, so the two planes could
166+
disagree on the same (filter, scope) pair — a subscription could receive an
167+
event live (runtime dispatch) that admin-plane replay would skip, or vice
168+
versa. Ported both refinements 1:1 so the matchers are byte-identical:
169+
(1) blank/whitespace-only event scope is unscoped — excluded from any
170+
scope-filtered subscription (previously bare `*`, an empty-prefix
171+
`startsWith`, matched a blank `""` scope); (2) trailing-`*` filters require
172+
a non-empty child segment after the prefix (`tenant:a/*` no longer matches
173+
the degenerate `tenant:a/`; spec text is "all scopes *under*" the base).
174+
The matcher was made `public static` (mirroring the admin's) and the
175+
admin's full matcher test table — null/blank filters, bare `*`, trailing
176+
`*` child/base/sibling/empty-segment cases, exact match, literal
177+
mid-string `*`, case sensitivity, blank-scope edges — was ported into
178+
`EventEmitterRepositoryTest`, pinning both planes to the same
179+
(filter, scope, expected) table. One dispatch-level test pins the
180+
blank-scope refinement end-to-end through `emit()`. No wire or storage
181+
change; delivery selection shifts only on the two edge cases.
182+
8183
### 2026-07-04 — full-stack prod compose: stop host-publishing events management port (no version bump)
9184

10185
`docker-compose.full-stack.prod.yml` published the events worker's 9980 to

CHANGELOG.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,112 @@ changes to request/response bodies or Lua-script semantics would require a
1414
minor bump. "Internal signature changes" (e.g. Java method parameters) are
1515
called out but are not breaking to API clients.
1616

17+
## [0.1.25.47] — 2026-07-10
18+
19+
### Added
20+
21+
- **Governance Rule 2 terminal-owner guard on reservation mutations
22+
(`TENANT_CLOSED`).** Once the owning tenant's `status=CLOSED` flip is
23+
durable in the shared Redis (written by the governance plane's tenant
24+
close), the four reservation mutations — create (`POST /v1/reservations`),
25+
commit, release, extend — are rejected with HTTP 409 and
26+
`error=TENANT_CLOSED` (standard ErrorResponse envelope). This implements
27+
the governance spec's CASCADE SEMANTICS Rule 2 / Mode B invariant (a)
28+
(`cycles-governance-admin-v0.1.25.yaml`): a mutation observed after the
29+
flip MUST NOT succeed, even in the race window before the close cascade
30+
drains the reservation or revokes the tenant's API keys. The check runs
31+
inside the reserve/commit/release/extend Lua scripts (like the existing
32+
`BUDGET_FROZEN`/`BUDGET_CLOSED` guards), so it is atomic with the budget
33+
mutations — a post-flip request can never partially succeed — and it is
34+
not subject to the tenant-config cache TTL. Runtime spec revision:
35+
v0.1.25.13 adds `TENANT_CLOSED` to the runtime ErrorCode enum
36+
(runcycles/cycles-protocol#125).
37+
- **What changes in practice:** tenant-key requests on a closed tenant
38+
were already rejected with 401 at the auth filter (which reads tenant
39+
status per request) — that behavior is unchanged. The new 409 closes
40+
the two paths auth could not: **admin-key mutations** (an
41+
admin-on-behalf-of `release` on a closed tenant previously succeeded;
42+
it now returns 409 `TENANT_CLOSED`) and the **auth-check→script race**
43+
(a request that passed auth just before the flip can no longer mutate
44+
just after it).
45+
- **No tenant record ⇒ no restriction:** runtime-only deployments without
46+
a governance plane are unaffected. A present-but-malformed tenant record
47+
(undecodable JSON, non-object, missing `status`, or a status outside
48+
the closed `ACTIVE|SUSPENDED|CLOSED` TenantStatus enum — e.g. `"CLOZED"`
49+
or lowercase `"closed"`) **fails closed**: 500 `INTERNAL_ERROR`, no
50+
mutation — a corrupt governance record is never treated as an open
51+
tenant.
52+
- **Precedence:** same-key idempotent replays return their original
53+
response; any other mutation on a closed tenant is `TENANT_CLOSED` even
54+
when the reservation is already finalized/expired (takes precedence over
55+
`RESERVATION_FINALIZED`/`RESERVATION_EXPIRED`, per the spec revision's
56+
ERROR SEMANTICS). Open-tenant error responses are unchanged.
57+
- **Reads unaffected:** `GET /v1/reservations/{id}` and the list endpoint
58+
keep working on closed tenants (spec: post-mortem read access via admin
59+
keys; tenant keys remain subject to the existing auth-layer 401).
60+
- **SUSPENDED unchanged:** the mutation guard is CLOSED-only; the
61+
pre-existing auth-layer 401 for suspended tenants is untouched.
62+
- **Idempotent replays unaffected:** replaying a mutation that succeeded
63+
before the flip still returns its original response.
64+
- **Signed denial receipts:** a mutation-surface 409 `TENANT_CLOSED`
65+
(persisting create, commit, release) emits an `error` CyclesEvidence
66+
envelope and stamps `cycles_evidence` on the response, like the other
67+
budget/lifecycle denial codes (it is the owner-level sibling of
68+
`BUDGET_CLOSED`; declared in the evidence ErrorResponseMirror,
69+
cycles-evidence-v0.2.yaml 0.2.1). `/v1/decide` and `dry_run` create
70+
never 409 for a closed tenant — they return 200 `decision=DENY` with
71+
`reason_code=TENANT_CLOSED` and emit their normal `decide`/`reserve`
72+
decision evidence (see the next bullet). Extend is not an evidence
73+
endpoint and is unchanged.
74+
- **Non-persisting evaluations answer truthfully instead of 409ing:** a
75+
FRESH `dry_run` or `POST /v1/decide` evaluation on a CLOSED tenant
76+
returns 200 `decision=DENY` with `reason_code=TENANT_CLOSED` (new
77+
DecisionReasonCode value, spec revision v0.1.25.13) — previously a
78+
post-flip dry_run could produce a signed ALLOW attestation for a
79+
request whose live execution must fail. Malformed tenant records fail
80+
these evaluations closed (500, before any evidence is stamped); absent
81+
records and ACTIVE/SUSPENDED tenants evaluate exactly as before, and
82+
cached pre-close replays keep their original payload.
83+
84+
### Fixed
85+
86+
- **Webhook `scope_filter` matching now byte-identical to the admin plane's
87+
spec-conformant matcher** (cycles-server-admin PR #206). Two refinements to
88+
the runtime dispatch matcher (`EventEmitterRepository.matchesScope`):
89+
- *Blank scope is unscoped:* an event whose scope is blank/whitespace-only
90+
is now treated like a null-scope event — excluded from every
91+
scope-filtered subscription. Previously the bare `*` filter (empty-prefix
92+
`startsWith`) delivered blank-scope events.
93+
- *Trailing-`*` filters match children only:* `tenant:acme-corp/*` now
94+
requires a non-empty remainder after the prefix — the degenerate
95+
empty-child scope `tenant:acme-corp/` no longer matches (the spec says
96+
"all scopes **under** acme-corp"). Unchanged: the bare base scope
97+
`tenant:acme-corp` never matched a `…/*` filter on this plane.
98+
99+
Everything else is unchanged (null/blank filter matches all events
100+
including unscoped ones; no trailing `*` = exact, case-sensitive match).
101+
The runtime and admin matchers are now pinned to the same table of
102+
(filter, scope, expected) test cases so live dispatch and admin-plane
103+
dispatch/replay cannot drift.
104+
105+
### Compatibility
106+
107+
- `Enums.ErrorCode` gains `TENANT_CLOSED` (additive; mirrors the governance
108+
code of the same name — runtime spec revision v0.1.25.13, runcycles/cycles-protocol#125),
109+
and `Enums.ReasonCode` gains `TENANT_CLOSED` (additive; same spec revision —
110+
the 200-DENY reason for non-persisting evaluations on closed tenants).
111+
New 409 behavior appears only for tenants a governance plane has closed;
112+
deployments without tenant records see no change. The reserve/commit/
113+
release/extend Lua scripts gain a read-only tenant-status check before
114+
their mutations (one extra Redis `GET` inside the script per mutation);
115+
no key layouts change. `commit.lua`/`extend.lua` read the additional
116+
`tenant` field from the reservation hash (written by `reserve.lua` since
117+
the beginning); reservations without it skip the guard.
118+
- Webhook delivery-selection change only for the two matcher edge cases
119+
(blank event scopes against `*`, empty-child scopes against `…/*`).
120+
Normal scopes and filters are unaffected. No other wire, Redis, event,
121+
or evidence schema change.
122+
17123
## [0.1.25.46] — 2026-07-04
18124

19125
### Added

OPERATIONS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ counters is:
6060
- Budget denials: `BUDGET_EXCEEDED`, `OVERDRAFT_LIMIT_EXCEEDED`,
6161
`DEBT_OUTSTANDING`
6262
- Budget state: `BUDGET_FROZEN`, `BUDGET_CLOSED`
63+
- Tenant state: `TENANT_CLOSED` (owning tenant closed — governance
64+
Rule 2 terminal-owner guard on reservation create/commit/release/extend)
6365
- Reservation state: `RESERVATION_EXPIRED`, `RESERVATION_FINALIZED`
6466
- Request issues: `IDEMPOTENCY_MISMATCH`, `UNIT_MISMATCH`,
6567
`MAX_EXTENSIONS_EXCEEDED`, `NOT_FOUND`

0 commit comments

Comments
 (0)