Security: admin-plane webhook category boundary + conservative offender cleanup (v0.1.25.51, closes #209)#210
Conversation
…ffender cleanup (v0.1.25.51, closes #209) The 0.1.25.50 fix closed the tenant-plane INJECTION path for admin-only webhook event_categories. #209 is the admin-plane CARRIER source: WebhookAdminController.create (POST /v1/admin/webhooks?tenant_id=X) and .update (PATCH) applied NO category validation, so an operator / admin-on-behalf-of could place admin-only categories (api_key/policy/webhook/system) on a subscription owned by concrete tenant X. Because matchesEventType treats categories as an ADDITIVE union, X - which owns the row's endpoint URL + signing secret - then received admin-only governance/security telemetry for its tenant. (a) Admin write-path validation: - New shared WebhookCategoryBoundaryValidator (@component), the single source of truth for the boundary; both the tenant controller (refactored off its two private methods) and the admin controller call it, so the checks and both planes cannot drift. Boundary derives from EventCategory.isTenantAccessible(). - Admin plane validates only when the target is a CONCRETE tenant (validateForTarget no-ops on __system__). Target tenant: create -> the tenant_id param; update -> the STORED subscription's tenant_id (WebhookUpdateRequest has no tenant_id). Admin-only entry on a non-__system__ target -> 400 INVALID_REQUEST. - __system__ carve-out: admin-only categories remain allowed there (legitimate system-wide monitoring; not tenant-owned). CAPABILITY REMOVAL (recorded): 0.1.25.51 changes a previously-allowed behavior. 0.1.25.50 explicitly tested/documented admin-plane admin-only categories as 201/200 ("the boundary is tenant-plane-only, not a global tightening"). Verified by grep that those two WebhookAdminControllerTest cases were the ONLY place this was asserted - an incidental tested-as-allowed behavior, not a separate product feature. Both rewritten to assert the new 400 (concrete tenant) / 201-200 (__system__). (d2) One-time cleanup - CONSERVATIVE (disable, not strip): - Startup ApplicationRunner (WebhookCategoryBoundaryReconciler) -> WebhookRepository.reconcileTenantCategoryBoundary(dryRun). Chosen over a maintenance endpoint (a new endpoint would fail the OpenAPI contract-diff check); mirrors the repo's @scheduled audit-sweep / CommandLineRunner precedent. - Per non-__system__ row: a row carrying admin-only categories, or a legacy empty-both match-ALL row, is DISABLED - NOT silently stripped. A concrete-tenant admin-category row may be legit-but-misconfigured operator monitoring; silently rewriting its selectors would break it with no signal. Disabling stops delivery immediately (dispatch matches only ACTIVE), is reversible, preserves the row + offending categories for review, loudly logged. Already-DISABLED offenders skipped -> idempotent. - Config: reconcile-on-startup (default true), reconcile-dry-run (default false; true = REPORT offenders in logs, mutate nothing). Disabled in test properties so it never races per-test seeding. MIGRATION (in CHANGELOG/AUDIT, corrected per review): genuine per-tenant admin monitoring must move to a __system__-owned subscription filtered by event_categories, selecting the tenant CLIENT-SIDE on the envelope tenant_id. NOT a scope_filter: admin lifecycle events are null-scoped (verified: ApiKeyController emits scope=null) and matchesScope excludes null scopes from any non-blank filter, so a scope_filter would deliver none. gov spec PR #129 (v0.1.25.40, pending) makes the boundary normative and is corrected the same way. Tests: WebhookCategoryBoundaryValidatorTest (boundary + system carve-out + null no-ops); admin controller - admin-only category/type on a real tenant -> 400 (create + update, service never called), on __system__ -> 201/200, tenant-accessible -> ok; reconciler integration (real Redis) over a seeded mix -> correct DISABLED terminal states with categories intact, __system__/legit/types-only/already-disabled untouched, dry-run reports-without-mutating, idempotent re-run; reconciler component unit (flag on/off, dry-run passthrough, failure-swallowed). Build: mvn -B verify green - 1,590 tests (192 model + 564 data + 834 api), coverage gates met. Version/revision 0.1.25.50 -> 0.1.25.51. README alignment stays governance v0.1.25.39 (.40 / cycles-protocol#129 still pending). Closes #209.
…e data coverage Codex security review of PR #210 found the create/update gate sound but several LIVE bypasses that must close before this ships as a security fix. All verified against source, then fixed; then data-module coverage restored to >=0.95 (the CI gate that failed at 0.93). HIGH 1. Replay bypassed the boundary. WebhookService.replay filtered historical events only by the request's optional type filter + the subscription's scope_filter — never by the subscription's OWN event_types/event_categories, and no status check. Fix: replay now intersects with the subscription's own selectors via WebhookRepository.matchesEventType (made public static, shared with live dispatch — spec replayEvents: "all event types the subscription is subscribed to") AND is a no-op on a non-ACTIVE subscription (a DISABLED offender receives nothing). Pre-existing bug, fixed here. 2. Reconciled offenders could be reactivated unrepaired. A status-only {"status":"ACTIVE"} PATCH passed null selector arrays. Fix: both controllers validate the EFFECTIVE resulting selectors (stored ∪ request) — reactivating a concrete-tenant row that still holds admin-only selectors → 400. 3. Reconciler ignored admin-only event TYPES. Fix: offender = admin-only TYPE or CATEGORY (action DISABLED_ADMIN_SELECTORS, field offendingSelectors). 4. __system__ empty-both rows escaped cleanup. Fix: the system carve-out exempts admin selectors ONLY; empty-both is checked for all owners. MEDIUM 5. Blank tenant_id got an undocumented system exemption. Fix: new WebhookSubscription.isSystemOwner (null/omitted or literal __system__ only; blank = concrete) — the SINGLE predicate used by both the validator (api) and reconciler (data), closing finding 6 (validator/reconciler disagreement on null owner) too. 7. Reconciler robustness. Rewritten SMEMBERS+sequential GET/SET → SSCAN cursor batching (bounded memory) + atomic compare-and-set Lua write (never clobbers a concurrent update; CAS miss = counted failure, retried) + background daemon thread (startup/readiness NEVER blocked) + exponential-backoff retry on incomplete pass + loud ERROR alert on exhausted retries. Readiness decision: do NOT hard-block readiness on migration completion — a migration bug must not brick the service, the write-path gate already stops NEW offenders, legacy offenders remain DISABLED-pending until a clean pass. reconcile now returns ReconcileResult(repaired, failures). MINOR 8. Migration wording: api_key/webhook/system events are null-scoped (client-side tenant_id filtering); policy events are scope-filterable. Tests: replay-outside-selectors blocked + replay-on-DISABLED no-op; status-only reactivation of unrepaired offender → 400 (tenant + admin; __system__ still reactivatable); admin-only event TYPE on concrete tenant → 400 (write) + reconciler-flagged; __system__ empty-both disabled; null-owner system-exempt for admin selectors but empty-both-checked; blank tenant_id create with admin category → 400; reconciler CAS-miss → incomplete+retry, corrupt-row failure, SSCAN multi-batch paging, whole-pass failure incomplete, dry-run report-only, retry-until-complete, give-up-and-alert. Coverage: data-module unit-job LINE 0.9562 (gate 0.95; was 0.93 — the earlier reconciler coverage came only from the Testcontainers integration test that the CI unit job excludes; new branches now covered by mocked-Jedis unit tests). Full build: 1,621 tests (192 model + 579 data + 850 api), all module coverage gates green. #129 (gov v0.1.25.40) merged so the contract check passes.
Codex REVISE-MAJOR — live bypasses closed (f5f4116)All findings verified against source before fixing; then the data-module coverage gate (the one that failed at 0.93) was restored. HIGH
MEDIUM
MINOR
TestsReplay-outside-selectors blocked + replay-on-DISABLED no-op; status-only reactivation of an unrepaired offender → 400 (tenant + admin plane; Build / coverage
|
…trip cleanup (#209) Codex round 2 flagged the #209 boundary as timing-dependent: the reconciler is fail-open and live dispatch did no ownership check, so an un-reconciled concrete-tenant offender still delivered admin-only events. Make the guarantee fail-closed at DELIVERY and reconsider the cleanup action. HIGH — fail-closed dispatch boundary (the durable guarantee): - WebhookDispatchService now takes WebhookCategoryBoundaryValidator and, in BOTH live dispatch and replay (dispatchToSubscription), skips an event when the subscription is concrete-tenant-owned (isSystemOwner==false) AND the event is admin-only. Per-event filter (mixed subs still get tenant-accessible events; counted boundary_skipped). dispatchToSubscription returns boolean so replay counts only real deliveries. Reconciler action — STRIP, not disable: since dispatch already blocks the leak, admin-only selectors are non-functional, so strip them (keep tenant-accessible), empty-both fallback -> disable. Null-owner rows migrated to __system__ (+ index). MEDIUM — bulk RESUME routed through effective-selector validation; replay status re-checked at dispatch time (TOCTOU). MINOR — replay null-owner NPE fixed via isSystemOwner; reconciler moved to a managed daemon ExecutorService with @PreDestroy shutdown (interrupts backoff). Docs (CHANGELOG/AUDIT/javadoc) reframed: dispatch boundary is the security mechanism, reconciler is best-effort storage hygiene that STRIPS. Tests: dispatch-side per-event filtering (live+replay), bulk-RESUME blocked, replay delivered-count + status re-check, reconciler strip/normalize actions, daemon shutdown interrupts backoff, exhausted-retry ERROR alert, real-Redis strip/normalize/concurrency/multi-batch/blank-owner + Lua CAS no-clobber. Build green: api unit 854, data unit 534 (LINE 0.9565), data integ 582, api integ 861.
Round 2 — fail-closed dispatch boundary + strip cleanupAddresses the round-2 REVISE-MAJOR: the boundary was timing-dependent (reconciler is fail-open; live dispatch did no ownership check). Each finding verified against source, then fixed. Full foreground build, all gates green. HIGH — enforce the boundary AT DELIVERY (fail-closed)The durable confidentiality guarantee is now a per-event dispatch filter, not the reconciler. Reconciler action decision — STRIP (not disable)Because dispatch now blocks the leak, admin-only selectors on a concrete-tenant row are already non-functional. The reconciler STRIPS them (keeping tenant-accessible selectors) so storage matches the already-enforced delivery behavior — no behavior change, no collateral on legitimate deliveries, no signal lost (dispatch, not the ACTIVE flag, is the guarantee). Actions: Null-owner decision — reconciler MIGRATES null →
|
#209) Codex round-3 findings (admin-service scope; the events-worker enforcement is handled separately in that repo). All verified against source, then fixed. P2 — replay cap-then-filter under-delivery: replay fetched one page capped at max_events then filtered post-hoc, so matching events past the cap were dropped. Now pages the window chronologically in bounded batches and applies every filter (request types, subscription selectors, scope_filter, ownership boundary) DURING pagination, collecting up to max_events DELIVERABLE events, delivered in order. A 20k scan ceiling bounds cost on sparse windows (logs, never silently truncates). Finding 1 — dispatch boundary now blocks when the event TYPE or the independent CATEGORY is admin-only, and blocks an unclassifiable (both-null) record for a concrete owner. Added isAdminOnly(EventCategory). Finding 2 — null-owner normalize is now atomic: the CAS SET and the SADD webhooks:__system__ run in one Lua op. Index-membership repair (SISMEMBER) runs independent of status, so a DISABLED null-owner row is normalized and a system row missing from the index is re-added (new INDEXED_SYSTEM_MEMBER action). Finding 3 — /test: documented, narrow invariant EXCEPTION (spec defines the ping as system.webhook_test; synthetic owner-triggered probe, no governance telemetry, bypasses the dispatch queue). Spec-side exception flagged to coordinator. Finding 4 — createDelivery returns actual enqueue success; dispatchToSubscription and the live dispatch metric propagate it so replay events_queued is honest. Finding 5 — test hardening: CAS test evals the real production script; 600-row SSCAN test asserts >1 cursor; re-read test asserts findById drove the skip; new tests for category/unclassifiable blocking, disabled-null-owner + unindexed-system repair (unit + real Redis), /test envelope, enqueue-failure->false. seed() helper mirrors production save() (per-tenant index). Build green: api unit 863, data unit 538 (LINE 0.9567), data integ 588, api integ 870.
Round 3 — replay pagination + boundary/atomicity/honesty hardeningAdmin-service scope only. The largest round-3 item (enforcing the boundary in the separate P2 — replay cap-then-filter under-delivery
Finding 1 (LOW) — category + unclassifiable
Finding 2 (MEDIUM) — normalize atomicity + disabled/unindexedThe null-owner SET (CAS Lua) and Finding 3 (MEDIUM) — /test bypass. Decision: DOCUMENTED EXCEPTION
It is a narrow, explicit exception: owner-triggered synthetic probe, payload Finding 4 (LOW) — honest boolean
Finding 5 — test hardening
Note on rounds 1/2 findingsReport findings 1, 2, 4 (replay status short-circuit + dispatch re-check, strip-not-preserve reconciler, null-safe Build (foreground, all gates green)
|
…209) Codex round 4: the round-3 replay pagination rewrite traded the old under-delivery for three cursor bugs on the millisecond-scored event index — (HIGH) equal-timestamp members skipped when the ASC cursor advanced by score+1; (MEDIUM) a hydration-thinned short page misread as range exhaustion; (MEDIUM) duplicate pages when a cursor member had vanished (zscore null). Fix — approach B: fetch ONE bounded, ordered, de-duplicated event-id list for the [from,to] window (single ZRANGEBYSCORE, EventRepository.listEventIdsInRange) then hydrate + filter in fixed batches over that list (hydrateByIds drops missing/corrupt/unknown-enum rows, fail-closed). The ZSET gives the exact ordered member set, so there is no score cursor to skip or duplicate and hydration drops can't look like exhaustion. Chosen over extending list()'s cursor (approach A) because it does NOT touch list() — every existing caller (~30 sites) is unchanged. Preserved: chronological delivery, max_events counts DELIVERABLE events (after all filters + ownership boundary), scan-ceiling warning. The scan ceiling is now configurable (webhook.replay.max-scan, default 20000) so it is exercisable. Tests: removed the mocked paging-mechanics unit tests (couldn't catch these); added WebhookReplayPaginationIntegrationTest (real Redis / real EventRepository) covering all six scenarios — equal-ms tail matches, interior hydration drops, vanished member no-duplicate, scan-ceiling warning, cross-page chronological order, max_events counts deliverable. Added data-module unit tests for the two new EventRepository methods; re-mocked remaining replay unit tests. Build green: api unit 861, data unit 543 (LINE 0.9572), api integ 874, data integ 593.
Round 4 — replay pagination cursor bugs → approach BClassification / reconciler / Approach chosen: (B), and whyI took approach (B): fetch the ordered member-id list up front, then hydrate + filter in batches over that fixed list.
The three bugs (fixed)
Implementation
TestsThe mocked paging-mechanics unit tests (which supplied idealized pages and couldn't catch any of these) were removed. New
Also added data-module unit tests for Build (foreground, all gates green)
|
Codex round 5 (test-only): two WebhookReplayPaginationIntegrationTest guards didn't reproduce the bugs they cover, so they'd pass against round-4 too. No production change. - Hydration-thinning: was 10 members / 3 deleted (all survivors hydrate in the first fetch). Rewritten to seed 1500 filler (the old list() limit*3 candidate window), evict 1100 interior rows (ZSET members remain) so the first window hydrates to 400 (< 500), then 30 deliverable matches at positions 1500+. Round-4's "page.size() < 500 -> stop" delivers 0; approach B delivers all 30. - Vanished-cursor: was 5 members / 1 row deleted. The old zscore(cursor)==null duplicate class is STRUCTURALLY ELIMINATED by approach B (full id list fetched once, no per-page re-query). Rewritten to 600 members (2 hydration batches), 4 rows evicted across both batches incl. the 500-boundary -> each cleanly skipped, no duplicate, later members still delivered (596 queued). Pins the new structural guarantee. Other four scenario tests unchanged. Gates green: api unit 861, data unit 543 (LINE 0.9572), api integration 874, data integration 593.
Round 5 — strengthen two replay regression tests (test-only)Production fix confirmed sound; no production changes this round. Two 1. Hydration-thinning — now reproduces the "short page misread as exhaustion" bugOld: 10 members, delete 3 → all 7 survivors hydrate in the first fetch, so round-4 passes too (no distinction). New (
Round-4's 2. Vanished-cursor — the duplicate class is STRUCTURALLY ELIMINATEDAssessment: approach B fetches the full ordered id list once ( New (
The old 5-member/1-row test could not span batches or exercise a re-query path. The other four scenario tests were left unchanged (codex-clean). Build (foreground, all gates green)
|
|
External review (codex): SHIP after 6 rounds. Summary of the review arc: round 1 caught replay/reactivation/type bypasses; round 2 the fail-open reconciler timing; round 3 that the actual delivery worker was outside the boundary entirely (→ the separate cycles-server-events#110 last-mile fix); round 4 three replay-pagination cursor bugs (equal-timestamp skip, hydration-thinned-as-exhaustion, vanished-cursor duplicate) → fixed via approach B (fetch ordered id-set up front, hydrate in batches; list() untouched); round 5–6 hardened the regression tests to genuinely reproduce those conditions. Final state — enqueue-side boundary + fail-closed predicate, strip-based reconciler (SSCAN, atomic CAS+index, managed daemon), replay filtering during pagination, /test documented exception, honest enqueue counts. 1,600+ tests, coverage gates met. PARKED ready-to-merge per the hold. Coordinated with cycles-server-events#110 (last-mile delivery boundary) and cycles-protocol#130 (governance v0.1.25.41 / INVARIANT 2). Suggested merge order: spec #130 → admin #210 → events #110, then a coordinated release. |
#209) Reviewer P2: replay capped the candidate scan at replayMaxScan and, if matching events existed beyond the ceiling, returned a SUCCESSFUL response with events_queued < max_events plus only a server-side WARN — the caller reads that as "all matching events in my window" though the search was truncated. Fix (fail-fast, caller-visible; chosen over a truncation flag to avoid a response-schema change): collectDeliverableReplayEvents fetches replayMaxScan+1 candidate ids to detect overflow; if the ceiling is reached AND fewer than max_events DELIVERABLE events were found within the scanned set, it throws GovernanceException(INVALID_REQUEST, 400) BEFORE the dispatch loop — no partial enqueue, replay lock released by the finally. Message instructs the caller to narrow from/to. If max_events WAS filled within the ceiling that is the explicit pagination cap (not truncation) and replay proceeds. Error code: INVALID_REQUEST (400) — LIMIT_EXCEEDED is the 429 rate-limit code; INVALID_REQUEST/400 matches the existing webhook validators. Tests: rewrote the round-5 scan-ceiling test (which codified the silent partial) into replay_windowExceedsScanCeiling_incomplete_returns400_nothingEnqueued (60 > ceiling 50, max_events unfilled -> 400, nothing enqueued, lock released), added replay_windowExceedsCeiling_butMaxEventsFilledWithin_succeeds (cap filled within ceiling -> success). Six approach-B correctness tests intact. Spec: replayEvents already declares a 400 (ErrorResponse), so this is within its declared responses; the over-large-window SEMANTIC is undocumented — recommend a prose clause for governance PR #130 (reported to coordinator). Build green: api unit 861, data unit 543 (LINE 0.9572), api integ 875, data integ 593.
Reviewer round — over-large replay window is now a caller-visible 400 (un-parks the PR)Valid P2. Replay capped the candidate scan at Fix — fail-fast, caller-visibleChosen over a truncation flag (no response-schema change; uses existing error semantics). Precise semantics — only the genuinely-incomplete case fails:
Error code chosen
Tests
Spec finding (for the coordinator to route)Governance Build (foreground, all gates green)
|
|
External review (codex): SHIP — scan-ceiling fix confirmed. Replay now fails caller-visibly (400 INVALID_REQUEST, nothing enqueued) when the [from,to] window exceeds the server scan limit and can't be satisfied, instead of silently returning a partial replay; a normal max_events-filled result still succeeds. Governance PR #130 documents the semantic (replayEvents SCAN LIMIT clause). Re-PARKED ready-to-merge per the hold. |
Reviewer REVISE-MINOR: the scan-limit fix framed max_events as a RESUMABLE
pagination cap ("lower max_events to page", "advance from"), but ReplayResponse
has no continuation position — a partial can't be resumed losslessly (distinct
timestamps: caller never learns the last replayed timestamp; same-ms events:
inclusive from repeats or skips). The guidance was unusable.
Decision (chosen over adding a cursor — no schema change, lossless): REPLAY IS
ALL-OR-NARROW. A successful replay delivers EVERY deliverable event in [from,to];
never a partial with an implied "continue".
collectDeliverableReplayEvents: fetch replayMaxScan+1 candidate ids; if
ids.size() > replayMaxScan (not fully scannable) -> 400. Else collect all
deliverable, one past max_events; if collected > max_events -> 400. Else deliver
all, chronological. Both 400s thrown BEFORE any enqueue (no partial side effects;
lock released by finally). Replaces the prior "ceiling reached AND < max_events
-> 400".
Error code INVALID_REQUEST (400). Messages:
- "replay window too large: it exceeds the replay scan limit of N events; narrow
the from/to range"
- "replay window contains more than max_events (N) deliverable events; narrow the
from/to range, or raise max_events (up to 1000)"
Removed all "lower max_events to page" / "there may be more" continuation
guidance; max_events is a window bound, not a cursor.
Tests: replaced the two ceiling tests + the max_events-cap test. New integration
(real Redis): window > scan limit -> 400; window > max_events deliverable -> 400;
same-ms cluster of 15 with max_events 10 -> 400, then raise to 20 -> one replay
delivers all 15 exactly once (reviewer's same-timestamp concern); window <=
max_events incl same-ms cluster -> all delivered once, non-matching filtered.
Four approach-B correctness tests intact. Unit: >max_events -> 400 nothing
dispatched, lock released; <=max_events -> all delivered in order.
Spec: replayEvents already declares 400 (ErrorResponse) — schema-conformant; the
all-or-narrow semantic + messages must match governance PR #130 (reported).
Build green: api unit 862, data unit 543 (LINE 0.9572), api integ 877, data integ 593.
Reviewer round — replay is ALL-OR-NARROW (un-parks the PR)Valid REVISE-MINOR. The scan-limit fix left a broken claim: it framed Decision: all-or-narrow (chosen over adding a cursor — no schema change, lossless)A successful replay delivers EVERY deliverable event in Final logic (
|
#209) Codex confirm: all-or-narrow LOGIC is correct; one P2 DOC-ONLY — stale pagination-framing comments contradicting the new behavior. No behavior change (one local var rename page->batch; no test change). Swept the whole replay path case-insensitively (pagin|paging|page|cursor|continue|advance from|there may be more|stops and logs|truncat|max_events); zero survivors. - WebhookService.replay comment: "collect ... by paging ... DURING pagination" -> all-or-narrow (collect EVERY deliverable; over-large window -> 400 before enqueue). - REPLAY_PAGE_SIZE javadoc: "page size while paging" -> "Hydration BATCH size ... NOT a pagination unit: the whole window is scanned within one replay". - replayMaxScan javadoc: "collection stops and logs — NOT a silent truncation" -> "returns a 400 (window too large — narrow from/to), NOT a silent partial". - collectDeliverableReplayEvents local page -> batch. - WebhookDispatchService.isBlockedByOwnershipBoundary javadoc: "while paginating ... max_events budget ... the cap would count" -> "selecting deliverable events ... deliverable total drives the completeness check". - EventRepository.listEventIdsInRange javadoc: "replay pages hydration over" -> "replay hydrates fixed BATCHES over". Kept (confirmed correct): the all-or-narrow negations; the approach-B contrast describing the OLD list() cursor hazards it AVOIDS; the unrelated cursor-paginated listByTenant/listAll/listDeliveries APIs and the `continue` loop keyword. Gates green: api unit 862, data unit 543 (LINE 0.9572), model 192.
Doc-only — replay comments aligned to all-or-narrow (zero-survivor sweep)Codex-confirmed: the all-or-narrow logic is correct; this fixes the P2 doc-only residual (stale pagination-framing comments). No behavior or test change — one local variable rename ( Fixed
Confirmed correct (kept — not stale)
No behavior/comment mismatch beyond wording was found. Build (all gates green)
|
|
External review (codex): SHIP — replay is ALL-OR-NARROW (complete-or-400, no continuation), and the residual pagination-cap comments are corrected (zero survivors). Final: enqueue-side + fail-closed boundary, strip reconciler, replay filtering-during-scan with all-or-narrow completeness (400 + narrow-window on over-large windows, no silent partial). Matches governance #130's replayEvents clause. Re-PARKED ready-to-merge per the hold. |
…ort enqueue (#209) Two reviewer findings, one commit. F3 (P2) — enforce max_events min:1/max:1000 (was silently clamped). Added @min(1) @max(1000) (jakarta.validation) to ReplayRequest.maxEvents and removed the Math.min(maxEvents, 1000) clamp in WebhookService — out-of-range is now rejected with 400 (spec declares minimum:1/maximum:1000). @Valid was already on the replay @RequestBody (WebhookAdminController), so no controller change. Omitting max_events still defaults to 100 (@Min/@max pass for null; service fallback). Controller tests: 0 -> 400, 1001 -> 400 (service not called), omitted -> 202. F1 (P1) — best-effort enqueue is the INTENDED, spec-aligned behavior (governance #130): selection is complete, events_queued reports the ACCEPTED count and may be < selected on transient backend failure; replay is not idempotent. No behavior change — made it observable + documented: - loud WARN when queued < selected (replay_id, subscription_id, selected, queued) so a degraded backend is operator-visible. - reframed replay comments/javadoc + ReplayResponse.eventsQueued to state best-effort-with-count is intended and events_queued may be < selected; retry may duplicate (random delivery IDs). - new test replay_partialEnqueue_reportsAcceptedCount_andLogsWarn_bestEffort (3 selected, backend rejects 1 -> events_queued=2 + PARTIAL-enqueue WARN). Did NOT add fail-on-partial/atomic enqueue (rejected in favor of best-effort spec alignment). Gates green: model 192, data unit 543 (LINE 0.9572), api unit 866, api integ 881, data integ 593.
Reviewer round — replay max_events validation (F3) + best-effort enqueue documented (F1)Both findings folded into one commit. Still HELD. F3 (P2) — enforce
|
|
External review (codex): SHIP — max_events now enforces @min(1)/@max(1000) (400 on 0/1001, no silent clamp; omitted defaults to 100), and best-effort enqueue is confirmed intended + observable (partial-enqueue WARN, honest docs, events_queued = accepted count). Matches governance #130's narrowed replay clause. Re-PARKED ready-to-merge per the hold. |
…come (#209) Reviewer P2: the best-effort partial-enqueue WARN fired for EVERY events_queued<selected, but dispatchToSubscription returned false for several reasons and only one is a degradation — so a benign concurrent-disable mid-replay raised a false "backend degraded" alarm. Fix (structured result, not just log wording): - WebhookDispatchService.dispatchToSubscription now returns DispatchOutcome { ENQUEUED, INACTIVE, BLOCKED, ENQUEUE_FAILED } instead of a boolean; per-branch behavior unchanged (ownership guard -> BLOCKED; deleted/ non-ACTIVE at the dispatch-time re-check -> INACTIVE; LPUSH/save fail -> ENQUEUE_FAILED; else ENQUEUED). - Only production caller is WebhookService.replay (live dispatch() uses createDelivery directly, unchanged). Replay counts ENQUEUED as events_queued and tallies each category. - Categorized shortfall: enqueue_failed>0 -> WARN "DEGRADED dispatch backend" with per-category counts; shortfall ONLY INACTIVE/BLOCKED -> INFO "intended, NOT degradation". Tests: renamed partial test -> replay_partialEnqueue_backendFailure_logsDegradedWarn (ENQUEUE_FAILED -> DEGRADED WARN, enqueued=2 enqueue_failed=1); added replay_partialEnqueue_concurrentDisable_logsBenignInfo_notDegradedWarn (INACTIVE -> events_queued=2, benign INFO, NO degraded WARN). Updated WebhookDispatchServiceTest (10 assertions boolean->DispatchOutcome incl. deleted->INACTIVE, LPUSH-fail-> ENQUEUE_FAILED, blocked->BLOCKED) and the replay integration mock (->ENQUEUED). Gates green: model 192, data unit 543 (LINE 0.9572), api unit 867, api integ 882, data integ 593.
Reviewer round — categorize the replay shortfall (structured dispatch outcome)Valid P2 (un-parks). The partial-enqueue WARN fired for every The enum
Caller changesThe only production caller is Categorized shortfall loggingWhen
(The per-event outer catch — e.g. an unexpected Tests
Build (foreground, all gates green)
Note for spec reconciliation (#130): |
…NACTIVE (#209) Codex P2 on the DispatchOutcome classification: the findById re-read in dispatchToSubscription was wrapped in a blanket catch(Exception) -> INACTIVE. But WebhookRepository.findById throws GovernanceException.webhookNotFound (ErrorCode.WEBHOOK_NOT_FOUND, 404) ONLY for a genuinely-missing row and wraps Redis/deserialization/other failures in a plain RuntimeException. So a real backend outage during the re-read was misclassified INACTIVE -> replay emitted the benign INFO and HID the failure. Fix: catch GovernanceException and map to INACTIVE ONLY when getErrorCode()==WEBHOOK_NOT_FOUND (genuine delete, intended lifecycle); any other GovernanceException and any other Exception (Redis/deser/unexpected) -> ENQUEUE_FAILED (degradation -> degraded WARN), each logged. The paused/disabled INACTIVE trigger (non-ACTIVE on a successful re-read) is unchanged. Not-found signal caught: GovernanceException with ErrorCode.WEBHOOK_NOT_FOUND. Tests: renamed deleted case -> dispatchToSubscription_subscriptionDeletedMidReplay_ inactive (WEBHOOK_NOT_FOUND -> INACTIVE); added dispatchToSubscription_reReadBackendError_enqueueFailed_notInactive (RuntimeException wrapping JedisConnectionException -> ENQUEUE_FAILED) and dispatchToSubscription_reReadNonNotFoundGovernanceError_enqueueFailed. Replay-level WARN-vs-INFO covered by the existing pair. Gates green: model 192, data unit 543 (LINE 0.9572), api unit 869, api integ 884, data integ 593.
Codex round — a re-read backend error must not be misclassified as INACTIVEValid P2 on the classification I just added. Source verified
Not-found signal caught: Fix
Tests
Build (foreground, all gates green)
|
|
External review (codex): SHIP — the DispatchOutcome classification now returns INACTIVE only for a genuine WEBHOOK_NOT_FOUND; Redis/deserialization failures during the dispatch-time re-read map to ENQUEUE_FAILED (degraded WARN), so a real backend outage is no longer hidden as a benign lifecycle stop. Re-PARKED ready-to-merge per the hold. |
… .48) Release prep for v0.1.25.51: prod + full-stack compose self-pin bumped 0.1.25.50 → 0.1.25.51; full-stack bundled pins moved to cycles-server-events 0.1.25.23 (last-mile webhook ownership boundary, #209 — MUST be deployed before this admin version begins blocking at enqueue) and cycles-server 0.1.25.48 (POST /v1/events TENANT_CLOSED guard). The pom <revision> (0.1.25.51) and CHANGELOG [0.1.25.51] entry already landed in PR #210. Compose files parse clean.
Closes #209. Security fix (v0.1.25.51): closes the admin-plane CARRIER source for admin-only webhook
event_categories, and conservatively cleans up pre-existing offenders.The carrier source
The 0.1.25.50 fix closed the tenant-plane injection path. But
WebhookAdminController.create(POST /v1/admin/webhooks?tenant_id=X) and.update(PATCH) applied no category validation, so an operator / admin-on-behalf-of could place admin-only categories (api_key,policy,webhook,system) on a subscription owned by a concrete tenant X. SincematchesEventTypetreats categories as an ADDITIVE union, X — which controls that row's endpoint URL and signing secret — then received admin-only governance/security telemetry for its tenant.(a) Admin write-path validation
WebhookCategoryBoundaryValidator(@Component) — the single source of truth for the boundary. Both the tenant controller (refactored off its two private methods) and the admin controller call it, so the type-/category-level checks and both planes cannot drift; the boundary derives fromEventCategory.isTenantAccessible().validateForTargetno-ops on__system__). Target tenant: create → thetenant_idparam; update → the STORED subscription'stenant_id(WebhookUpdateRequesthas notenant_id; ownership is the subscription's, validated againstprior.getTenantId()). Admin-only entry on a non-__system__target → 400INVALID_REQUEST.__system__carve-out: admin-only categories remain allowed there (legitimate system-wide monitoring; not tenant-owned).Governance spec revision v0.1.25.40 (pending, runcycles/cycles-protocol#129) makes this normative.
Capability removal (recorded)
This changes a previously-allowed behavior. 0.1.25.50 explicitly tested/documented admin-plane admin-only categories as 201/200 ("the boundary is tenant-plane-only, not a global tightening"). Verified by grep those two
WebhookAdminControllerTestcases were the only place this was asserted — an incidental tested-as-allowed behavior, not a separately documented product feature. Both are rewritten to assert the new 400 (concrete tenant) / 201-200 (__system__). This is an intentional security correction: a tenant-owned row exposes URL + secret to the tenant.(d2) One-time cleanup — conservative (disable, not strip)
A startup
ApplicationRunner(WebhookCategoryBoundaryReconciler) →WebhookRepository.reconcileTenantCategoryBoundary(dryRun). Chosen over a maintenance endpoint because a new admin endpoint would fail the OpenAPI contract-diff check (undocumented surface); mirrors the repo's@Scheduledaudit-sweep /CommandLineRunnerprecedent.Per non-
__system__row: a row carrying admin-only categories, or a legacy empty-both match-ALL row, is DISABLED — not silently stripped. Per review: a concrete-tenant admin-category row may be a legit-but-misconfigured operator monitoring subscription; silently rewriting its selectors would break it with no signal. Disabling stops delivery immediately (dispatch matches onlyACTIVE), is reversible, preserves the row + offending categories for review, and is loudly logged (subscription_id / tenant_id / offending categories + migration hint). Already-DISABLEDoffenders are skipped → idempotent.Config:
webhook.category-boundary.reconcile-on-startup(defaulttrue),webhook.category-boundary.reconcile-dry-run(defaultfalse;true= REPORT offenders in logs without mutating, for review before the disabling pass).Migration (corrected per review — no
scope_filter)Genuine per-tenant admin monitoring must move to a
__system__-owned subscription (create with notenant_id) filtered byevent_categoriesto the admin classes; because__system__is in every tenant's dispatch union it receives those events for ALL tenants, so select the target tenant client-side on the envelope'stenant_id. Do NOT use ascope_filter: admin lifecycle events are null-scoped (verified:ApiKeyControlleremitsscope=null) andmatchesScopeexcludes null scopes from any non-blank filter, so ascope_filterwould deliver none of them. The__system__row stays operator-owned (URL + secret). gov #129 is being corrected the same way.Tests
WebhookCategoryBoundaryValidatorTest— boundary,__system__carve-out, null no-ops.__system__→ 201/200; tenant-accessible → ok.WebhookCategoryBoundaryReconcileIntegrationTest) — seeded mix (admin-category offender, admin-only-category offender, legit,__system__, empty-both, types-only, already-disabled) → correctDISABLEDterminal states with categories left intact, non-offenders untouched, dry-run reports without mutating, idempotent re-run.mvn -B verifygreen — 1,590 tests (192 model + 564 data + 834 api), coverage gates met. Version 0.1.25.50 → 0.1.25.51. README spec-alignment stays governance v0.1.25.39 (.40 / #129 still pending on origin/main).