Skip to content

spec(governance): allow category-only webhook subscriptions (v0.1.25.39)#127

Merged
amavashev merged 2 commits into
mainfrom
spec/gov-category-only-webhook-subs-v0.1.25.39
Jul 11, 2026
Merged

spec(governance): allow category-only webhook subscriptions (v0.1.25.39)#127
amavashev merged 2 commits into
mainfrom
spec/gov-category-only-webhook-subs-v0.1.25.39

Conversation

@amavashev

Copy link
Copy Markdown
Contributor

Summary

Governance spec revision v0.1.25.39: reconciles the spec to the shipped-and-released reference admin behavior (cycles-server-admin 0.1.25.50) by allowing category-only webhook subscriptionsevent_types: [] with a non-empty event_categories ("subscribe to all budget.* by category"). Additive/relaxing on the wire; semantic_base stays 0.1.25.9.

Reviewer finding this fixes

The v0.1.25.38 changelog claimed the empty-both "match-ALL" state was unreachable because event_types carried minItems: 1 on all three webhook schemas. That was wrong for the update path: the shipped admin WebhookUpdateRequest.eventTypes has no bean-validation annotation, so PATCH accepts event_types: []. The real guarantee is a server-side empty-both guard. Decision (Option 1): category-only subscriptions are a legitimate feature, and the spec's minItems: 1 on the UPDATE schema is the stale side.

Verified against admin source (cycles-server-admin main)

  • WebhookCreateRequest.eventTypes@NotEmpty (create still requires non-empty event_types).
  • WebhookUpdateRequest.eventTypes → no annotation (PATCH accepts []).
  • WebhookService.update empty-both guard on both planes: rejects a resulting state with both event_types and event_categories empty — 400 INVALID_REQUEST "Subscription must retain at least one event_type or event_category" (comment cites governance v0.1.25.38).
  • WebhookSubscription.eventTypes model field has no @JsonInclude(NON_NULL) → always serialized, including as [], so keeping the spec field required-present is correct.

Exact schema diff

Schema.field Before After
WebhookUpdateRequest.event_types minItems: 1, optional (no required list) minItems removed, optional; SELECTOR CLEARING note (MAY be [] for category-only, provided event_categories non-empty)
WebhookSubscription.event_types in required, minItems: 1 minItems removed, stays in required (required-present, MAY be empty)
WebhookCreateRequest.event_types in required, minItems: 1 UNCHANGED (matches shipped @NotEmpty create; not relaxed — that would be a server code change)

Normative invariant text (verbatim)

Added to WebhookSubscription.event_types and the createTenantWebhook prose:

SUBSCRIPTION SELECTOR INVARIANT (NORMATIVE): a persisted subscription MUST always match at least one event selector — at least one of event_types / event_categories MUST be non-empty. A create or update whose resulting state would leave BOTH empty MUST be rejected with 400 INVALID_REQUEST. This invariant is what makes category-only subscriptions valid and what makes the empty-both "match ALL event classes" state unreachable (the earlier assumption that event_types minItems: 1 alone guaranteed this was incorrect for the update path — the guarantee is this server-side invariant, now normative).

WebhookUpdateRequest SELECTOR CLEARING note (verbatim):

SELECTOR CLEARING (NORMATIVE): unlike WebhookCreateRequest (where event_types is required and non-empty), an update MAY set event_types to an empty array to convert the subscription to CATEGORY-ONLY — PROVIDED event_categories is (or remains) non-empty. The resulting persisted state is still subject to the SUBSCRIPTION SELECTOR INVARIANT (WebhookSubscription.event_types): an update whose resulting state would leave BOTH event_types and event_categories empty MUST be rejected with 400 INVALID_REQUEST. Because this is a partial update, the resulting state combines the provided fields with the existing subscription's unchanged fields …

WebhookSubscription.required decision

event_types stays in required. A persisted category-only subscription carries event_types: [] — an empty array still satisfies required presence (it was minItems that forbade empty), and the reference admin model serializes the field unconditionally (no @JsonInclude(NON_NULL)). So the field is required-present-but-possibly-empty; only the minItems: 1 was removed. required: [subscription_id, tenant_id, url, event_types, status, created_at] is unchanged.

Also in this revision

  • Corrects the v0.1.25.38 changelog empty-both note (its minItems: 1 reasoning was wrong for the update path; the guarantee is the server-side guard, now spec-normative).
  • OPEN DESIGN note (not fixed here): the create/update asymmetry — create requires non-empty event_types, update allows clearing to category-only — is real and matches shipped behavior. Allowing category-only on create too is a candidate future enhancement (coordinated server + spec change), deliberately out of scope.
  • info.summary / SPEC FAMILY CONTEXT document-revision self-references bumped to 0.1.25.39; summary chronicle extended.

Validation

  • YAML parse OK; validate_changelogs.py green (v0.1.25.39 heading matches info.version).
  • merged/cycles-openapi-admin-merged.yaml regenerated (byte-stable second run — merge-check passes; source metadata picks up v0.1.25.39 dynamically). Protocol merged artifact unaffected.
  • Spectral runs in CI.

Reconciles the spec to shipped-and-released reference admin behavior
(cycles-server-admin 0.1.25.50). A reviewer finding on the v0.1.25.38
work exposed that its empty-both note was wrong: it claimed
WebhookUpdateRequest.event_types carried minItems:1, but the shipped
admin update DTO has NO bean-validation on event_types (only
WebhookCreateRequest.event_types is @notempty), so PATCH accepts
event_types:[] and the server's only gate is an empty-both guard
(rejects a resulting state with BOTH event_types and event_categories
empty, 400 "must retain at least one event_type or event_category").
Decision (Option 1): category-only subscriptions are a legitimate
feature; the spec's minItems:1 on the UPDATE schema is the stale side.

Verified against admin source (cycles-server-admin main):
- WebhookCreateRequest.eventTypes: @notempty (create still requires
  non-empty event_types) — spec unchanged, matches shipped create.
- WebhookUpdateRequest.eventTypes: no annotation — PATCH accepts [].
- WebhookService.update empty-both guard on BOTH planes.
- WebhookSubscription.eventTypes model field has no
  @JsonInclude(NON_NULL) → always serialized, including as [] —
  so required-present holds for a persisted category-only subscription.

governance v0.1.25.39 (schema diff):
- WebhookUpdateRequest.event_types: REMOVE minItems:1 (already optional
  — no required list on the update schema). New SELECTOR CLEARING note:
  MAY be set to [] to convert to category-only provided event_categories
  is/remains non-empty.
- WebhookSubscription.event_types: REMOVE minItems:1; KEEP the field in
  WebhookSubscription.required (an empty array satisfies required
  presence; required-non-empty is what minItems enforced and is now
  wrong for category-only). Description records the ALWAYS-PRESENT /
  MAY-be-empty rule.
- WebhookCreateRequest.event_types: UNCHANGED (required + minItems:1) —
  matches shipped create; NOT relaxed (that'd be a code change).
- SUBSCRIPTION SELECTOR INVARIANT (NORMATIVE) added to
  WebhookSubscription.event_types and the createTenantWebhook prose: a
  persisted subscription MUST match >=1 selector (>=1 of event_types /
  event_categories non-empty); create or update leaving BOTH empty MUST
  be rejected 400 INVALID_REQUEST. This — not minItems:1 — is what makes
  the empty-both match-ALL state unreachable.

Also:
- Corrects the v0.1.25.38 changelog empty-both note (its minItems:1
  reasoning was wrong for the update path; guarantee is the server-side
  guard, now spec-normative).
- OPEN DESIGN note: create/update asymmetry (create requires event_types,
  update allows category-only) is real + matches shipped behavior;
  allowing category-only on CREATE too is a candidate future enhancement,
  deliberately out of scope (coordinated server+spec change).
- info.summary / SPEC FAMILY CONTEXT self-references bumped to 0.1.25.39;
  summary chronicle extended.

Additive/relaxing on the wire — removing minItems:1 only widens what
validates, gated by the selector invariant; no field/operation/status
removed or renamed. semantic_base stays 0.1.25.9. Changelog entry added;
spec-index pins bumped (base_governance -> 0.1.25.39, spec_index ->
0.1.38); admin merged artifact regenerated (byte-stable second run;
source metadata picks up 0.1.25.39 dynamically). Validation: YAML parse
OK, validate_changelogs.py green. Spectral runs in CI.
….1.25.38 entry

Codex review editorial fix on PR #127: the corrected v0.1.25.38
changelog entry pointed at "the CORRECTION in v0.1.25.39 below", but
in newest-first (Keep a Changelog) order the .39 entry sits ABOVE .38.
Changed "below" to "above". No other changes.
@amavashev

Copy link
Copy Markdown
Contributor Author

Editorial fix (6bf0286)

Codex's one editorial finding applied: the corrected v0.1.25.38 changelog entry said "see the CORRECTION in v0.1.25.39 below", but in newest-first (Keep a Changelog) order the .39 entry sits above .38 — changed "below" → "above". validate_changelogs.py green.

Open question (considered and declined): schema-level enforcement of the empty-both invariant

Codex noted (non-blocking) that an anyOf could make the SUBSCRIPTION SELECTOR INVARIANT machine-enforceable in the schema rather than prose-only. Declined for this revision, for the record:

  • OpenAPI/JSON Schema can't cleanly express "at least one of two arrays is non-empty." An anyOf over minItems: 1 on each of event_types / event_categories is awkward, and — more importantly — it cannot capture the create/update asymmetry this revision hinges on (create requires non-empty event_types; update permits event_types: [] for a category-only subscription). A single cross-field anyOf on the shared WebhookSubscription shape would flatten that distinction.
  • The invariant is a resulting-state rule that also spans a partial update (provided fields combined with the existing subscription's unchanged fields) — not something a static request-body schema can evaluate. The reference server enforces it in code (WebhookService empty-both guard), which is the correct enforcement point.
  • Normative prose is how the rest of these cross-field webhook invariants are expressed in this spec (e.g. the tenant-accessible boundary added in v0.1.25.38), so prose keeps the family consistent.

So the invariant stays normative prose, enforced server-side; no schema anyOf added.

@amavashev

Copy link
Copy Markdown
Contributor Author

External review (codex): SHIP after the editorial fix. Normative behavior verified to match cycles-server-admin 0.1.25.50 exactly (codex ran representative schema validations: category-only valid, create-empty invalid, missing invalid, update-empty valid); wire-compat confirmed (relaxing minItems only widens accepted cardinality); no regressions in the merged artifacts. The anyOf machine-enforcement suggestion was considered and declined (recorded above). Reconciles the spec to the shipped category-only webhook behavior.

@amavashev
amavashev merged commit ca4ff57 into main Jul 11, 2026
5 checks passed
@amavashev
amavashev deleted the spec/gov-category-only-webhook-subs-v0.1.25.39 branch July 11, 2026 11:54
@amavashev
amavashev restored the spec/gov-category-only-webhook-subs-v0.1.25.39 branch July 11, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant