Skip to content

feat(spec,middleware): specify unsubscribe and enforce the subscription lease - #99

Open
ucekmez wants to merge 1 commit into
ci/test-reference-implementationfrom
feat/subscription-lease-lifecycle
Open

feat(spec,middleware): specify unsubscribe and enforce the subscription lease#99
ucekmez wants to merge 1 commit into
ci/test-reference-implementationfrom
feat/subscription-lease-lifecycle

Conversation

@ucekmez

@ucekmez ucekmez commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 7 of a stacked series. Base is #98. Not for merge without review.

Two halves of the same hole in §10.

1. unsubscribe existed everywhere except the normative spec

Where Evidence
Middleware DELETE /eep/subscribe/:id, operationId: "unsubscribe"
Layer 3 schema ws-message.jsonunsubscribe / unsubscribed
IETF draft "Absolute https URL for subscribe / unsubscribe"
Fixtures README claims subscription/ holds "subscribe/unsubscribe request and response shapes"
SPECIFICATION.md zero occurrences; §10's diagram had no cancellation path and no terminal state but rejected

A subscription protocol with no specified way to stop a subscription is the first thing a new implementer hits.

2. The WebSub lease was advertised and never honoured

Intent verification sends hub.lease_seconds=2592000. Nothing defined expiry, renewal, or hub.mode=unsubscribe. EEP borrowed WebSub's challenge handshake without its lifecycle, so the value was decorative: subscriptions were immortal, and an abandoned delivery_url received traffic forever with no defined way to garbage-collect it.

What changed

Spec

  • §10 gains expired and cancelled terminal states, a state table, and an explicit rule that non-active subscriptions are not delivered to.
  • §10.1 Cancellation204, idempotent, terminal, id not reusable, delivery_secret discarded, in-flight deliveries not retried. Idempotency is the load-bearing part: returning 404 on a repeat DELETE tells a retrying client its own successful cancellation failed.
  • §10.2 Lease lifetimehub.lease_seconds is binding; expires_at on every representation; renewal is re-subscription with intent verification, preserving the subscription_id; and a publisher that will not enforce a lease MUST NOT advertise one.
  • Unsubscribe verification — an unauthenticated WebSub-style unsubscribe must complete a hub.mode=unsubscribe challenge. Without it, a caller could cancel someone else's delivery by guessing a subscription_id.
  • Six subscription.* lifecycle events added to the §9 catalog (they were being emitted but were not in the catalog).

Schemalease_seconds on subscription.request.json (optional, 300–31536000). Types regenerated.

Both middleware packages

  • Grant a lease at creation, clamping a requested value into policy rather than rejecting the subscription, and report expires_at.
  • WebhookDispatcher refuses to deliver once the lease has elapsed — checked at delivery time rather than by a sweeper, so the rule holds in deployments with no background job. An unenforced lease is no lease.
  • Cancellation is idempotent and emits subscription.cancelled with a reason, replacing the non-catalogued subscription.deleted.

Fixtures — a lease request and a below-minimum lease.

Scope

  • Spec / schema only
  • TypeScript package(s)
  • Python package(s)
  • Tests / CI
  • Docs / examples

Checklist

  • I read CONTRIBUTING.md and CODE_OF_CONDUCT.md.
  • Tests added or updated where appropriate.
  • Breaking change? Yes, two — flagged for CHANGELOG:
    1. DELETE on an unknown id now returns 204 instead of 404 (idempotency, §10.1).
    2. The lifecycle event is now subscription.cancelled, not subscription.deleted — the old name was in neither the §9 catalog nor any schema.
      Subscriptions created after this change also expire after 30 days by default where previously they never did. That is the point of the change, but it is a behaviour change for existing deployments and belongs in release notes.
  • Documentation updated for user-visible behavior.

Verification

Suite Result
tests/ 172 passed
@eep-dev/middleware 121 passed (was 107)
eep-middleware-python 52 passed, 100% coverage (gate satisfied)
@eep-dev/compliance-cli 73 passed
tests/cross-impl/test_conformance_fixtures.py 21 passed
compliance-cli --fixtures 19 vectors, 0 failed
codegen-schema-types --check no drift
check-draft-schema-parity

Notes for reviewers

Clamping rather than rejecting a requested lease_seconds is a judgement call. The lease is the publisher's to grant, so a subscriber asking for something unreasonable should still end up with a working subscription and an honest expires_at, rather than a 400 it has to interpret. The schema still bounds the field so the request is checkable — the negative fixture pins that.

Renewal is specified but not implemented. §10.2 says re-subscribing with the same source_did/event_types/delivery_url extends the existing subscription and preserves the id; the middleware currently creates a new subscription. I did not want to bundle a de-duplication change into this PR, and the spec text is what implementors need first. Happy to follow up, or to soften §10.2 to SHOULD if you'd rather not have the gap.

subscription.expiring is SHOULD, not MUST, and is not emitted by the middleware yet — it needs a scheduler, which this in-process dispatcher does not have.

…on lease

Two halves of the same hole in §10.

**Unsubscribe existed everywhere except the normative spec.** The
middleware implemented `DELETE`, `ws-message.json` defined `unsubscribe`
/ `unsubscribed`, the IETF draft referenced it, and the conformance
fixtures README claimed to hold vectors for it. SPECIFICATION.md
contained the string zero times, and the §10 lifecycle diagram had no
cancellation path and no terminal state other than `rejected`. A
subscription protocol with no specified way to stop a subscription is
the first thing a new implementer hits.

**The WebSub lease was advertised and never honoured.** Intent
verification sends `hub.lease_seconds=2592000`, but nothing defined
expiry, renewal, or `hub.mode=unsubscribe`. EEP borrowed WebSub's
challenge handshake without its lifecycle, so the value was decorative:
subscriptions were immortal and an abandoned `delivery_url` received
traffic forever with no defined way to garbage-collect it.

Spec:
- §10 lifecycle gains `expired` and `cancelled` as terminal states, a
  state table, and an explicit rule that non-`active` subscriptions are
  not delivered to.
- §10.1 Cancellation: `204`, idempotent, terminal, id not reusable,
  `delivery_secret` discarded, in-flight deliveries not retried.
  Idempotency matters — returning 404 on a repeat DELETE tells a
  retrying client its own successful cancellation failed.
- §10.2 Lease lifetime: `hub.lease_seconds` is binding, `expires_at` is
  reported on every representation, renewal is re-subscription with
  intent verification and preserves the `subscription_id`, and a
  publisher that will not enforce a lease MUST NOT advertise one.
- Unsubscribe verification: an unauthenticated WebSub-style unsubscribe
  MUST complete a `hub.mode=unsubscribe` challenge, otherwise a caller
  could cancel someone else's delivery by guessing a `subscription_id`.
- Six `subscription.*` lifecycle events added to the §9 catalog.

Schema: `lease_seconds` on `subscription.request.json` (optional,
300..31536000), types regenerated.

TypeScript and Python middleware:
- Grant a lease at creation, clamping a requested value into policy
  rather than rejecting the subscription, and report `expires_at`.
- `WebhookDispatcher` refuses to deliver once the lease has elapsed. The
  check is at delivery time rather than in a sweeper, so the rule holds
  in deployments with no background job — an unenforced lease is no
  lease.
- Cancellation is idempotent and emits `subscription.cancelled` with a
  reason, replacing the non-catalogued `subscription.deleted`.

Conformance fixtures for a lease request and a below-minimum lease.

Refs: EEP audit 2026-08 findings A2, A3
Signed-off-by: Ugur Cekmez <ucekmez@gmail.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 19:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants