v0.1.25.51 — tenant webhook admin-category boundary (#209, deploy after events 0.1.25.23)
Security release — tenant-owned webhook subscriptions can no longer carry or receive admin-only event classes (issue #209). The admin-plane half of a coordinated three-layer fix (with cycles-server-events 0.1.25.23 and governance spec v0.1.25.40/.41). Enforces governance WEBHOOK SUBSCRIPTION INVARIANT 2: a subscription owned by a concrete tenant (tenant_id present and != "__system__") must not carry or be delivered admin-only selectors/events (api_key.* / policy.* / webhook.* / system.*).
Defense in depth — three layers:
- Write-path gate: the admin webhook create/update paths now validate
event_categories(not justevent_types) against the tenant-accessible boundary for concrete-tenant targets — closing the injection vector where a tenant could smuggle admin-only categories, and the admin-on-behalf-of / admin-plane path that could place them on a tenant-owned row.__system__targets are exempt. SharedWebhookCategoryBoundaryValidatorso the two planes can't drift. - Dispatch boundary (the durable guarantee): live dispatch AND replay skip, per event, any admin-only event to a concrete-tenant subscription — fail-closed on type OR category OR unclassifiable record, independent of reconciler state or stored-selector correctness. A mixed subscription still receives its tenant-accessible events. (The last-mile enforcement at HTTP-send time is in cycles-server-events 0.1.25.23.)
- Startup reconciler: SSCAN-batched, atomic CAS writes, background daemon with retry — strips admin-only selectors from existing concrete-tenant offender rows (disables empty-both rows), and migrates null-owner rows to
__system__. Areconcile-dry-runmode reports without mutating.
Also: the empty-both webhook match-ALL update door is closed; the tenant/admin webhook boundary is normative and conformance-binding (INVARIANT 2). Replay hardening: correct pagination (no equal-timestamp skips, no duplicates), all-or-narrow completeness (a replay delivers every deliverable event in its window or returns 400 to narrow — never a silent partial), max_events validated 1–1000, and structured dispatch classification (a real backend failure logs a degraded warning; an intended concurrent-disable/guard logs benign info). /test synthetic probe is a documented, narrow invariant exception.
⚠ Deploy ordering (operational, important): deploy and fully roll out cycles-server-events 0.1.25.23 FIRST (the last-mile boundary) before this version. This admin version begins blocking admin-only selectors at the write/enqueue path; the events worker enforces the boundary at HTTP send for anything already queued or retried. If admin is deployed before the events fleet is fully upgraded, a mixed-version window exists where an old (0.1.25.22) worker can still send a queued violating delivery. The full-stack compose in this release pins events 0.1.25.23.
Operators — audit/remediate existing rows: the reconciler auto-strips offenders on startup (dry-run available); the 0.1.25.50/.51 CHANGELOG includes a redis-cli/jq audit recipe. Migration for legit per-tenant admin monitoring: use a __system__-owned subscription with client-side tenant filtering on the envelope tenant_id (admin lifecycle events are null-scoped, so a scope_filter cannot select them per tenant; only policy.* is scope-filterable). Residual provenance/ownership follow-up tracked as #209's linked issue.
Compatibility: BEHAVIOR CHANGE — admin create/update requests provisioning admin-only categories/types on a concrete-tenant subscription now return 400 (previously 201/200); replay windows exceeding the scan limit or max_events deliverable count now return 400 (narrow the window). No Redis/wire-schema change. Governance spec v0.1.25.40/.41 (runcycles/cycles-protocol). Version bump 0.1.25.50 → 0.1.25.51.
1,600+ tests green; JaCoCo ≥95% on all modules; external security review (codex) SHIP across the full fix. Full details: CHANGELOG.md [0.1.25.51], AUDIT.md, PR #210.