Skip to content

ADR 0003 — Offline cache and grace periods

Status: accepted Date: 2026-08-21

Context

The plugin must not call the API on every request. It also must not break a paying customer's site when our API has an outage, a DNS problem, or the customer's host blocks outbound HTTP for an hour.

At the same time the platform has no business grace period: once a non-payment is confirmed, entitlements are withdrawn.

Those two requirements pull in opposite directions, and conflating them is the single worst mistake this system could make. Telling a paying customer their subscription lapsed because our API timed out is unacceptable; letting an unpaid customer keep Business features for a week because they firewalled our domain is also unacceptable.

Decision

Two independent clocks, modelled as distinct states rather than one number.

ClockMeaningValue
Refresh intervalHow often we askrefresh_after from the payload, ~24 h, plus jitter
Offline graceHow long a validly signed payload stays usable while the API is unreachable7 days past expires_at
Business graceGrace for non-paymentzero

Rules:

  • A signed payload is trusted while now <= expires_at.
  • Past expires_at, LicenseStateKind::OfflineGrace keeps paid entitlements for seven more days. This is a transport concession, not a billing one.
  • After that, OfflineExpired degrades to Free. Data untouched.
  • A signed payload saying plan: free takes effect immediately. There is no grace on an answer.
  • A transport failure or a 5xx keeps the existing payload and records the failure. It never removes entitlements.
  • A documented credential refusal (installation_token_invalid and friends on a 401/403/404) drops the local credential without a signature, because it can only reduce access.

Seven days is the conservative end of useful: long enough to ride out a real outage or a weekend, short enough that blocking the API is not a viable long-term strategy. Combined with a two-day payload lifetime the effective worst case is nine days of stale entitlement.

LicenseStateKind::is_technical() separates states caused by our side from states caused by billing, and that flag drives the admin wording.

Consequences

Good:

  • An API outage is invisible to customers for a week.
  • A confirmed non-payment takes effect on the next successful check.
  • The admin UI can say "we could not reach the server" and "your subscription ended" as different things, because they are different states.
  • Blocking the API buys at most nine days.

Costs:

  • Up to nine days of stale entitlement in the worst case. Accepted: the alternative is breaking paying customers during our own outages.
  • Nine states instead of four, which is more to reason about. Mitigated by keeping the resolution in one function, LicenseState::resolve().

Storage

Three non-autoloaded options, so the hot path and the cold path have different costs:

OptionContent
ad_form_license_identitycredentials
ad_form_license_payloadthe signed bytes plus signature, kid, algorithm
ad_form_license_runtimechecked_at, next_check_at, last_error

Nothing goes into ad_form_settings: it is autoloaded, user-editable from the settings screen, and its allow-list sanitiser would drop unknown keys.

Rejected

One grace period for both cases. Would make an outage indistinguishable from non-payment, in code and in the message the customer reads.

No offline grace. A one-hour API outage would disable paid features on every installation simultaneously.

Unlimited offline validity. Blocking one domain would be a permanent licence.

Storing state in a transient. Object caches are flushed and not persistent; losing licensing state on a cache flush would cause an API stampede.

Digitale Lösungen. Persönlich. Zukunftssicher.