Darstellung
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.
| Clock | Meaning | Value |
|---|---|---|
| Refresh interval | How often we ask | refresh_after from the payload, ~24 h, plus jitter |
| Offline grace | How long a validly signed payload stays usable while the API is unreachable | 7 days past expires_at |
| Business grace | Grace for non-payment | zero |
Rules:
- A signed payload is trusted while
now <= expires_at. - Past
expires_at,LicenseStateKind::OfflineGracekeeps paid entitlements for seven more days. This is a transport concession, not a billing one. - After that,
OfflineExpireddegrades to Free. Data untouched. - A signed payload saying
plan: freetakes 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_invalidand 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:
| Option | Content |
|---|---|
ad_form_license_identity | credentials |
ad_form_license_payload | the signed bytes plus signature, kid, algorithm |
ad_form_license_runtime | checked_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.