Seat lifecycle: seats never free — rolling activity window + visible seats + manual release #428

Closed
opened 2026-09-05 02:33:55 +00:00 by Cordy · 3 comments
Owner

Flagged twice this week (Manuel's licensing question; Nikola 2026-09-05): "how would a seat become free again if a person leaves the company and their user either gets deleted from their OIDC or just stays for multiple months?"

Current behaviour: it never does

license.Manager.SeenUsers is append-only. Admit records a username on first sign-in; nothing ever removes one, and the stored timestamp is first-seen — not even refreshed on later sign-ins. A leaver holds their seat forever, whether their IdP account is deleted or just dormant. Cairn cannot observe IdP deletion (no SCIM/webhooks; OIDC only speaks at sign-in). The Overview footnote — "seats are people who have signed in, not accounts" — quietly means ever, which makes it a ratchet.

How the competition does it

Direct competitors (self-hosted file sync/share):

Product Seat = Freeing a seat Pitfall observed
Seafile Pro activated account (ACTIVATE_AFTER_FIRST_LOGIN optionally ties activation to first login) admin deactivates the account account-state model; LDAP sync auto-deactivating users surprised operators; imports can accidentally activate everyone
Nextcloud Enterprise enabled account (disabled users excluded from subscription count) admin disables the account counting provisioned state across user backends breeds bugs — public issues on wrong/negative user counts, disabled-ratio warnings under LDAP
ownCloud Enterprise activity-defined: metrics distinguish registered / active (lastLogin < 2 weeks) / concurrent automatic — inactivity ages the user out 2-week window is aggressively short for a licensing definition; fine for metrics, flappy for billing

Adjacent industry:

  • GitLab self-managed: billable = not blocked/deactivated; deactivating/blocking releases the seat immediately; ships auto-removal of dormant members; bills true-up on max seats used during the period — a known renewal-surprise generator.
  • GitHub Enterprise: bills dormant users too (org membership = billed); ships dormant-user reports so admins can prune by hand. This is Cairn's current model, and it is the widely-resented one.
  • General SaaS: recurring customer complaints wherever deactivated/departed users keep counting; the accepted best practices are a documented seat definition, visibility into seat holders, and a 90-day audit rhythm.

Fit assessment for Cairn: the Seafile/Nextcloud/GitLab model requires the product to own account state — a "disable user" concept. Cairn deliberately has none ("Cairn does not store passwords and cannot create, disable or rename a person"). The only models that fit an IdP-consuming product are ownCloud's activity window (no account state needed, purely observed sign-ins) and a manual release on the seat record (the seat, not the account — Cairn owns the former, never the latter).

Design (completes the existing philosophy instead of replacing it)

  1. Last-seen semantics. Admit refreshes the user's timestamp on every successful sign-in, throttled to once per ~24h per user so it is not a state write per request. Migration is self-healing: existing first-seen values simply act as stale last-seens; active users blink out at worst once and re-record on next sign-in.
  2. Rolling window count. A seat = a distinct user seen in the last 90 days (fixed policy, deliberately not config — a configurable window is a knob for gaming your own invoice). One counting function; the Seats tile, cap enforcement in Admit, and the seatsUsed reported at check-in all use it — displayed, enforced and billed numbers can never disagree (the Nextcloud bug class dies by construction). Dormant leavers age out automatically; IdP-deleted users age out identically since they can never sign in again.
  3. Seats become visible. The admin Seats tab lists current seat holders with "last seen X ago" — the operator sees dormancy instead of inferring it (GitHub's dormant report, minus the resented billing).
  4. Manual release. An audit-logged "release seat" action per row for immediate offboarding. Gaming is self-defeating: a released user who signs in again simply re-occupies a seat, which is only "cheating" if the person did not actually leave — in which case the count was right.
  5. Copy + docs. Overview footnote becomes "seats are people who signed in within the last 90 days"; licensing-faq seat definition updated to match; CHANGELOG note that windowed counting can only ever lower the reported number vs today.

Deliberately rejected: account-state seats (nothing to deactivate in Cairn), SCIM/IdP webhooks (an integration surface far out of scope for the problem), GitLab-style max-seats true-up (renewal surprises; check-in stays a point-in-time honest report), ownCloud's 2-week window (too flappy for a billing definition).

Decisions to bless before building

  • Window length 90 days, fixed (recommended) — or different / configurable?
  • Does the license server side need the window definition beyond docs? (seatsUsed payload shape is unchanged.)
  • Manual release: admin-only action on the Seats tab, audit event seat-release — confirm.
  • Footnote copy: "seats are people who signed in within the last 90 days" ×4 locales.

Verification

  • TDD (witnessed red, house style): window expiry frees a seat; sign-in refreshes last-seen (and is throttled); released seat re-occupies on next sign-in; cap enforcement uses the windowed count; check-in reports the windowed count; migration treats legacy first-seen values as last-seen.
  • Live: after deploy, Seats tab lists both admins with last-seen; releasing one drops the tile count; their next sign-in restores it.

Related: #426 (admins now occupy seats — this issue governs how any seat is ever released), the licensing dress rehearsal planned for v0.7.

Flagged twice this week (Manuel's licensing question; Nikola 2026-09-05): *"how would a seat become free again if a person leaves the company and their user either gets deleted from their OIDC or just stays for multiple months?"* ## Current behaviour: it never does `license.Manager.SeenUsers` is append-only. `Admit` records a username on first sign-in; nothing ever removes one, and the stored timestamp is **first-seen** — not even refreshed on later sign-ins. A leaver holds their seat forever, whether their IdP account is deleted or just dormant. Cairn cannot observe IdP deletion (no SCIM/webhooks; OIDC only speaks at sign-in). The Overview footnote — "seats are people who have signed in, not accounts" — quietly means *ever*, which makes it a ratchet. ## How the competition does it **Direct competitors (self-hosted file sync/share):** | Product | Seat = | Freeing a seat | Pitfall observed | |---|---|---|---| | Seafile Pro | activated account (`ACTIVATE_AFTER_FIRST_LOGIN` optionally ties activation to first login) | admin deactivates the account | account-state model; LDAP sync auto-deactivating users surprised operators; imports can accidentally activate everyone | | Nextcloud Enterprise | enabled account (disabled users excluded from subscription count) | admin disables the account | counting provisioned state across user backends breeds bugs — public issues on wrong/negative user counts, disabled-ratio warnings under LDAP | | ownCloud Enterprise | **activity-defined**: metrics distinguish registered / active (lastLogin < 2 weeks) / concurrent | automatic — inactivity ages the user out | 2-week window is aggressively short for a licensing definition; fine for metrics, flappy for billing | **Adjacent industry:** - **GitLab self-managed**: billable = not blocked/deactivated; deactivating/blocking releases the seat immediately; ships *auto-removal of dormant members*; bills true-up on **max seats used** during the period — a known renewal-surprise generator. - **GitHub Enterprise**: bills **dormant users too** (org membership = billed); ships dormant-user reports so admins can prune by hand. This is Cairn's current model, and it is the widely-resented one. - General SaaS: recurring customer complaints wherever deactivated/departed users keep counting; the accepted best practices are a documented seat definition, visibility into seat holders, and a 90-day audit rhythm. **Fit assessment for Cairn:** the Seafile/Nextcloud/GitLab model requires the product to *own account state* — a "disable user" concept. Cairn deliberately has none ("Cairn does not store passwords and cannot create, disable or rename a person"). The only models that fit an IdP-consuming product are ownCloud's **activity window** (no account state needed, purely observed sign-ins) and a **manual release on the seat record** (the seat, not the account — Cairn owns the former, never the latter). ## Design (completes the existing philosophy instead of replacing it) 1. **Last-seen semantics.** `Admit` refreshes the user's timestamp on every successful sign-in, throttled to once per ~24h per user so it is not a state write per request. Migration is self-healing: existing first-seen values simply act as stale last-seens; active users blink out at worst once and re-record on next sign-in. 2. **Rolling window count.** A seat = a distinct user seen in the last **90 days** (fixed policy, deliberately not config — a configurable window is a knob for gaming your own invoice). One counting function; the Seats tile, cap enforcement in `Admit`, and the `seatsUsed` reported at check-in all use it — displayed, enforced and billed numbers can never disagree (the Nextcloud bug class dies by construction). Dormant leavers age out automatically; IdP-deleted users age out identically since they can never sign in again. 3. **Seats become visible.** The admin Seats tab lists current seat holders with "last seen X ago" — the operator sees dormancy instead of inferring it (GitHub's dormant report, minus the resented billing). 4. **Manual release.** An audit-logged "release seat" action per row for immediate offboarding. Gaming is self-defeating: a released user who signs in again simply re-occupies a seat, which is only "cheating" if the person did not actually leave — in which case the count was right. 5. **Copy + docs.** Overview footnote becomes "seats are people who signed in within the last 90 days"; licensing-faq seat definition updated to match; CHANGELOG note that windowed counting can only ever *lower* the reported number vs today. **Deliberately rejected:** account-state seats (nothing to deactivate in Cairn), SCIM/IdP webhooks (an integration surface far out of scope for the problem), GitLab-style max-seats true-up (renewal surprises; check-in stays a point-in-time honest report), ownCloud's 2-week window (too flappy for a billing definition). ## Decisions to bless before building - [ ] Window length 90 days, fixed (recommended) — or different / configurable? - [ ] Does the license *server* side need the window definition beyond docs? (`seatsUsed` payload shape is unchanged.) - [ ] Manual release: admin-only action on the Seats tab, audit event `seat-release` — confirm. - [ ] Footnote copy: "seats are people who signed in within the last 90 days" ×4 locales. ## Verification - TDD (witnessed red, house style): window expiry frees a seat; sign-in refreshes last-seen (and is throttled); released seat re-occupies on next sign-in; cap enforcement uses the windowed count; check-in reports the windowed count; migration treats legacy first-seen values as last-seen. - Live: after deploy, Seats tab lists both admins with last-seen; releasing one drops the tile count; their next sign-in restores it. Related: #426 (admins now occupy seats — this issue governs how any seat is ever released), the licensing dress rehearsal planned for v0.7.
Author
Owner

Decisions blessed (Nikola, 2026-09-05):

  • Window: 90 days, fixed. Hard-coded policy, not config.
  • Server side: A — docs only. Deliberate, with reasoning recorded: the seatWindowDays metadata field provides nothing to the customer and creates a visible "sends usage data" surface in a product sold on sovereignty and privacy. Seafile and ownCloud send nothing back to their vendors; purchased seat counts are already known on our side from the sale. "Technically no one gains anything, but on the human level we might lose trust before even landing." The upgrade-cliff cost (seatsUsed dropping unexplained at the version boundary, incomparable history) is accepted knowingly.
  • Manual release: yes — admin-only action on the Seats tab, audit event seat-release; released users re-occupy on next sign-in.
  • Footnote copy: "seats are people who signed in within the last 90 days, not accounts" ×4 locales.

Open question surfaced by the same reasoning (not blocking this issue): the existing check-in already reports seatsUsed (#89). The sovereignty argument applies to it identically — dropping it would place Cairn fully in the Seafile/ownCloud no-usage-telemetry camp, leaving check-in as pure licence validation (renewals informed by the customer's own Seats tab instead). Nikola to decide separately.

Next: Seats tab UI mockup (house rule: mockup before build), then TDD implementation.

**Decisions blessed (Nikola, 2026-09-05):** - [x] **Window: 90 days, fixed.** Hard-coded policy, not config. - [x] **Server side: A — docs only.** Deliberate, with reasoning recorded: the `seatWindowDays` metadata field provides nothing to the customer and creates a visible "sends usage data" surface in a product sold on sovereignty and privacy. Seafile and ownCloud send nothing back to their vendors; purchased seat counts are already known on our side from the sale. *"Technically no one gains anything, but on the human level we might lose trust before even landing."* The upgrade-cliff cost (seatsUsed dropping unexplained at the version boundary, incomparable history) is accepted knowingly. - [x] **Manual release: yes** — admin-only action on the Seats tab, audit event `seat-release`; released users re-occupy on next sign-in. - [x] **Footnote copy:** "seats are people who signed in within the last 90 days, not accounts" ×4 locales. **Open question surfaced by the same reasoning (not blocking this issue):** the existing check-in already reports `seatsUsed` (#89). The sovereignty argument applies to it identically — dropping it would place Cairn fully in the Seafile/ownCloud no-usage-telemetry camp, leaving check-in as pure licence validation (renewals informed by the customer's own Seats tab instead). Nikola to decide separately. Next: Seats tab UI mockup (house rule: mockup before build), then TDD implementation.
Author
Owner

Shipped and live-verified. v0.6.129 (PR #429, TDD run tdd-428 — red evidence in the run log: window-aging assertions, api compile-red, Seats/ReleaseSeat compile-red, all watched before their implementations landed in the same run), plus two layout patches v0.6.130/v0.6.131 (the admin shell's #adminview .uarow two-column grid wrapped the kebab; fixed with a two-id three-column rule, verified by live style injection before tagging). Both dogfoods run v0.6.131.

What went in:

  • Last-seen semantics: Admit refreshes the holder's timestamp (throttled to one write per day); windowed 90-day counting feeds the Seats tile, the admission cap and both check-in paths. Legacy first-seen entries count as last-seen — the migration is doing nothing (verified live: manu-admin holds no seat because his last sign-in predates v0.6.128's admin recording; he claims one at next sign-in).
  • GET /api/v1/license/seats + POST /api/v1/license/seats/release; release appends audit action seat-release (actor = admin, path = freed holder).
  • Users & access → tabs Access | Seats per the approved mockup; deep link #admin/users/seats; licence meter footer restates the 90-day definition ×4 locales and links to the tab.
  • Docs: licensing-faq "What counts as a seat?" updated; CHANGELOG entries.

Live verification on the enc dogfood (nikola-test session):

  • Access tab byte-identical to the old page under the new tab bar ✓
  • Seats tab: "2 of 60 seats in use" + window sentence; nikola-test Active (last-seen throttling visible: "2 hours ago" despite fresh sign-ins), nikola-test2 Idle (23 days) ✓
  • Kebab → "Release seat…" → confirm dialog (copy per mockup) → released nikola-test2 (user-approved): count dropped to 1 of 60, row gone, list refreshed ✓
  • Audit log: 04:39 · nikola-test · seat-release · nikola-test2
  • Licence page footer + "manage seats in Users & access" link lands on the Seats tab ✓

Follow-up nit: the audit query page's Action dropdown doesn't list seat-release (events appear under "Any action"; the dropdown is a hardcoded list).

Still open on this issue: the parked question of dropping seatsUsed from the check-in payload entirely (Nikola's call), and Nikola's own look at the live tab before closing.

Shipped and live-verified. **v0.6.129** (PR #429, TDD run `tdd-428` — red evidence in the run log: window-aging assertions, api compile-red, Seats/ReleaseSeat compile-red, all watched before their implementations landed in the same run), plus two layout patches **v0.6.130/v0.6.131** (the admin shell's `#adminview .uarow` two-column grid wrapped the kebab; fixed with a two-id three-column rule, verified by live style injection before tagging). Both dogfoods run v0.6.131. What went in: - Last-seen semantics: `Admit` refreshes the holder's timestamp (throttled to one write per day); windowed 90-day counting feeds the Seats tile, the admission cap and both check-in paths. Legacy first-seen entries count as last-seen — the migration is doing nothing (verified live: manu-admin holds no seat because his last sign-in predates v0.6.128's admin recording; he claims one at next sign-in). - `GET /api/v1/license/seats` + `POST /api/v1/license/seats/release`; release appends audit action `seat-release` (actor = admin, path = freed holder). - Users & access → tabs **Access | Seats** per the approved mockup; deep link `#admin/users/seats`; licence meter footer restates the 90-day definition ×4 locales and links to the tab. - Docs: licensing-faq "What counts as a seat?" updated; CHANGELOG entries. Live verification on the enc dogfood (nikola-test session): - Access tab byte-identical to the old page under the new tab bar ✓ - Seats tab: "2 of 60 seats in use" + window sentence; nikola-test Active (last-seen throttling visible: "2 hours ago" despite fresh sign-ins), nikola-test2 Idle (23 days) ✓ - Kebab → "Release seat…" → confirm dialog (copy per mockup) → released nikola-test2 (user-approved): count dropped to **1 of 60**, row gone, list refreshed ✓ - Audit log: `04:39 · nikola-test · seat-release · nikola-test2` ✓ - Licence page footer + "manage seats in Users & access" link lands on the Seats tab ✓ Follow-up nit: the audit query page's Action dropdown doesn't list `seat-release` (events appear under "Any action"; the dropdown is a hardcoded list). Still open on this issue: the parked question of dropping `seatsUsed` from the check-in payload entirely (Nikola's call), and Nikola's own look at the live tab before closing.
Author
Owner

Closing — every thread resolved:

  • Parked question decided (Nikola, 2026-09-05): seatsUsed dropped from the check-in payload. v0.6.132 (PR #434, TDD with the red watched on the runner) posts {licenseId} alone; seat usage never leaves the instance. The licence server was verified beforehand to require only licenseId. Live-verified: Refresh licence on the enc dogfood at 05:32 — check-in OK against license.swisscairn.ch with the slimmed payload, key intact.
  • Wording precision pass (customer trust): the phone-home promise is keyed to the licence, not the seat count — "no licence installed ⇒ never phones home; any installed licence, above or below 50 seats ⇒ monthly check-in, licence id and nothing else" (per #89, Nikola's recorded decision from v0.3.23). Fixed everywhere it was coupled to "≤50 users": Cairn README, ARCHITECTURE §8.1, licensing-faq, PUBLIC-SECTOR (which still claimed an "anonymous seat count" is sent), the FreeSeats comment (PR #436) — and on cairn-site, including the factually false pricing bullet "at or under 50 users never contact us — not even with a key installed" (cairn-site PR #20). Historical CHANGELOG entries (v0.3.2) carry supersession notes rather than rewrites (PR #435).
  • Follow-up tracked separately: #433 (audit Action dropdown).

Full arc: v0.6.129 seat lifecycle (90-day window, last-seen refresh, Seats tab, audited release), v0.6.130/131 layout fixes, v0.6.132 payload slim — all live on both dogfoods, docs coherent across handbook, ARCHITECTURE, site, and wikis.

Closing — every thread resolved: - **Parked question decided (Nikola, 2026-09-05): `seatsUsed` dropped from the check-in payload.** v0.6.132 (PR #434, TDD with the red watched on the runner) posts `{licenseId}` alone; seat usage never leaves the instance. The licence server was verified beforehand to require only `licenseId`. **Live-verified**: Refresh licence on the enc dogfood at 05:32 — check-in OK against license.swisscairn.ch with the slimmed payload, key intact. - **Wording precision pass (customer trust)**: the phone-home promise is keyed to the licence, not the seat count — "no licence installed ⇒ never phones home; any installed licence, above or below 50 seats ⇒ monthly check-in, licence id and nothing else" (per #89, Nikola's recorded decision from v0.3.23). Fixed everywhere it was coupled to "≤50 users": Cairn README, ARCHITECTURE §8.1, licensing-faq, PUBLIC-SECTOR (which still claimed an "anonymous seat count" is sent), the `FreeSeats` comment (PR #436) — and on **cairn-site**, including the factually false pricing bullet "at or under 50 users never contact us — not even with a key installed" (cairn-site PR #20). Historical CHANGELOG entries (v0.3.2) carry supersession notes rather than rewrites (PR #435). - Follow-up tracked separately: #433 (audit Action dropdown). Full arc: v0.6.129 seat lifecycle (90-day window, last-seen refresh, Seats tab, audited release), v0.6.130/131 layout fixes, v0.6.132 payload slim — all live on both dogfoods, docs coherent across handbook, ARCHITECTURE, site, and wikis.
Cordy closed this issue 2026-09-05 05:38:01 +00:00
Sign in to join this conversation.
No labels
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#428
No description provided.