Licence dropped 60→50 on cairn-enc: one 403 self-revokes forever, silently — retry window, reason surfacing, metrics (pre-launch) #437

Closed
opened 2026-09-06 01:55:24 +00:00 by Cordy · 1 comment
Owner

Incident (2026-09, cairn-enc)

Nikola noticed the dogfood dropped from the 60-seat licence to the free tier; the licence page said "licence revoked — running on the free tier". He recovered it by re-pasting the key (2026-09-03 23:40 — the successful check-in right after his account-page login at 23:37).

Root cause (confirmed)

  • The licence itself was never revoked or expired: server DB shows revoked=0, key valid to 2027-07-29 (the sub is yearly; the monthly one belongs to bao).
  • The client's check-in received a 403 at some point in the window — almost certainly reason unknown during the licence server's account-era redeploys / JSON→SQLite migration (Aug 24–26; frequent restarts in the journal).
  • Client behaviour on 403 revoked/unknown: set Revoked=true → cap drops to 50 immediatelycheckinRequiredLocked returns false → check-ins stop permanently. No self-heal is possible; only a manual Refresh/SetKey clears it. One transient server-side 403 = a silently bricked licence.

Why it took days to notice, and could not be reconstructed

  • The server logs no check-ins at all (no successes, no 403s, no reasons); lastSeen is one overwritten value.
  • Client logs died with ~20 pod restarts (Pis have no persistent journald).
  • The licence page shows free-tier numbers with one terse line; no since-when, no what-happened, no notification.

Build plan (approved by Nikola; UI parts mockup-first)

Client (this repo):

  1. 403 is no longer terminal on first sight. A revoked/unknown refusal keeps the licence inactive (honest) but check-ins continue daily for a revoked-watch window (~14 days, mirroring the #308 expired-watch), so a transient server-side 403 self-heals at the next 200. A genuinely revoked licence gets two weeks of daily 403s, then goes quiet for good.
  2. The drop explains itself. Persist reason + since-when; the Licensing page shows an inactive-state card (revoked / grace exhausted / expired variants — mockup first); an admin bell notification fires on the transition; loud log lines on every state change.
  3. Metrics + alert. Export licensed-state/seat-cap gauges; homelab-config gains CairnLicenseDropped (key installed ∧ not licensed) so the next drop pages within minutes.
  4. TDD red first: a 403-unknown followed by a next-day 200 must recover without manual action (impossible today).

Server: cairn-license-server companion issue — log every check-in with id/outcome/reason, persist last-refusal per licence.

Airgapped deployments are unaffected by the whole class: no check-in URL, no 403, no self-revocation.

## Incident (2026-09, cairn-enc) Nikola noticed the dogfood dropped from the 60-seat licence to the free tier; the licence page said **"licence revoked — running on the free tier"**. He recovered it by re-pasting the key (2026-09-03 23:40 — the successful check-in right after his account-page login at 23:37). ## Root cause (confirmed) - The licence itself was never revoked or expired: server DB shows `revoked=0`, key valid to 2027-07-29 (the sub is **yearly**; the monthly one belongs to bao). - The client's check-in received a **403** at some point in the window — almost certainly reason `unknown` during the licence server's account-era redeploys / JSON→SQLite migration (Aug 24–26; frequent restarts in the journal). - Client behaviour on 403 revoked/unknown: set `Revoked=true` → cap drops to 50 **immediately** → `checkinRequiredLocked` returns false → **check-ins stop permanently**. No self-heal is possible; only a manual Refresh/SetKey clears it. One transient server-side 403 = a silently bricked licence. ## Why it took days to notice, and could not be reconstructed - The server logs **no check-ins at all** (no successes, no 403s, no reasons); `lastSeen` is one overwritten value. - Client logs died with ~20 pod restarts (Pis have no persistent journald). - The licence page shows free-tier numbers with one terse line; no since-when, no what-happened, no notification. ## Build plan (approved by Nikola; UI parts mockup-first) **Client (this repo):** 1. **403 is no longer terminal on first sight.** A `revoked`/`unknown` refusal keeps the licence inactive (honest) but check-ins continue daily for a revoked-watch window (~14 days, mirroring the #308 expired-watch), so a transient server-side 403 self-heals at the next 200. A genuinely revoked licence gets two weeks of daily 403s, then goes quiet for good. 2. **The drop explains itself.** Persist reason + since-when; the Licensing page shows an inactive-state card (revoked / grace exhausted / expired variants — mockup first); an admin bell notification fires on the transition; loud log lines on every state change. 3. **Metrics + alert.** Export licensed-state/seat-cap gauges; homelab-config gains `CairnLicenseDropped` (key installed ∧ not licensed) so the next drop pages within minutes. 4. TDD red first: a 403-unknown followed by a next-day 200 must recover without manual action (impossible today). **Server:** cairn-license-server companion issue — log every check-in with id/outcome/reason, persist last-refusal per licence. Airgapped deployments are unaffected by the whole class: no check-in URL, no 403, no self-revocation.
Cordy closed this issue 2026-09-06 02:46:41 +00:00
Author
Owner

Shipped in v0.6.133 (PR #439, merged; dogfoods live).

Decision record:

  • Revocation is a watch, not a verdict: a 403 revoked/unknown drops the licence to the free tier immediately (unchanged) but keeps daily check-ins alive for 14 days (revokedWatchWindow); the first 200 restores the licence with no admin action. Past the watch, check-ins stop for good — same bounded-phone-home shape as the expired watch (#308). Legacy state with the bare Revoked flag re-enters the watch on load.
  • Licence state persists a capped Transitions history (dropped/restored, reason revoked/grace/expired) — the forensic trail this incident lacked. The bell derives admin-only entries from it (one per state change, click lands on Licensing).
  • Licence page: one state-driven dropcard per the approved license-inactive-states mockup (refused / unreachable / expired, shared calm line, Check in now); seat meter dims and names the suspended cap. i18n ×4.
  • /metrics: cairn_license_installed / cairn_license_licensed / cairn_license_seat_cap — absent when unwired, so absent() keeps meaning. homelab-config now alerts CairnLicenseDropped (installed==1 and licensed==0, 5m, critical): the silent month this incident got is no longer possible.
  • Status API adds inactiveReason / inactiveSince / retryUntil / licSeats.

TDD: TestTransientRefusalSelfHeals replayed the incident red on the runner (server hits stayed 1); TestForbiddenRevokedStillStops — which asserted the incident-causing behaviour — became TestForbiddenRevokedEntersWatch. Full suite green.

Live verify: cairn-enc + cairn-openbao on v0.6.133; files.c0rdyceps.ch/metrics shows installed 1 / licensed 1 / seat_cap 60 (the 60-seat key counting again).

Follow-ups tracked separately: cairn-license-server#40 (log every check-in, persist refusal history, 503-not-403 when storage is unavailable — the server-side half of never letting a redeploy answer "unknown"), #438 (Remove licence button, mockup first).

Shipped in **v0.6.133** (PR #439, merged; dogfoods live). **Decision record:** - Revocation is a **watch, not a verdict**: a 403 revoked/unknown drops the licence to the free tier immediately (unchanged) but keeps daily check-ins alive for **14 days** (`revokedWatchWindow`); the first 200 restores the licence with no admin action. Past the watch, check-ins stop for good — same bounded-phone-home shape as the expired watch (#308). Legacy state with the bare `Revoked` flag re-enters the watch on load. - Licence state persists a capped **Transitions** history (`dropped`/`restored`, reason `revoked`/`grace`/`expired`) — the forensic trail this incident lacked. The bell derives admin-only entries from it (one per state change, click lands on Licensing). - Licence page: one state-driven dropcard per the approved license-inactive-states mockup (refused / unreachable / expired, shared calm line, Check in now); seat meter dims and names the suspended cap. i18n ×4. - `/metrics`: `cairn_license_installed` / `cairn_license_licensed` / `cairn_license_seat_cap` — absent when unwired, so `absent()` keeps meaning. homelab-config now alerts `CairnLicenseDropped` (installed==1 and licensed==0, 5m, critical): the silent month this incident got is no longer possible. - Status API adds `inactiveReason` / `inactiveSince` / `retryUntil` / `licSeats`. **TDD:** `TestTransientRefusalSelfHeals` replayed the incident red on the runner (server hits stayed 1); `TestForbiddenRevokedStillStops` — which asserted the incident-causing behaviour — became `TestForbiddenRevokedEntersWatch`. Full suite green. **Live verify:** cairn-enc + cairn-openbao on v0.6.133; `files.c0rdyceps.ch/metrics` shows installed 1 / licensed 1 / seat_cap 60 (the 60-seat key counting again). Follow-ups tracked separately: cairn-license-server#40 (log every check-in, persist refusal history, 503-not-403 when storage is unavailable — the server-side half of never letting a redeploy answer "unknown"), #438 (Remove licence button, mockup first).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#437
No description provided.