// The v0.2.0 milestone version entry — the current, active version on
// the v0.2 line. Wired into `versions/index.ts` as `current: v0_2_0`,
// with `v0_1_0` in `other` so installs on 0.1.0:N can upgrade. Routine
// wrapper updates bump the downstream revision here (`0.2.0:N`) before
// each build/publish; see startos-packaging.md.
//
// Version-string format reminder: ExVer is `<upstream>:<downstream>`.
// The `<upstream>` bump from 0.1.0 → 0.2.0 marks the milestone; the
// `:0` resets the downstream revision counter for the new line. The
// next routine wrapper update on the v0.2 line will be `0.2.0:1`,
// then `:2`, etc.

import { VersionInfo } from '@start9labs/start-sdk'

const RELEASE_NOTES = [
  'Keysat v0.2.0 — first non-alpha milestone. Operator-visible: web admin SPA replaces the StartOS Actions tab for day-to-day work, buyer self-service recovery, opt-in community analytics, and the wire format now agrees byte-for-byte across five language SDKs (Rust, TypeScript, Python, Go, plus the daemon itself).',
  '',
  '**The web admin SPA is the headline.** Daily operator work — creating products, configuring policies and discount codes, searching licenses, suspending/revoking, inspecting machines, registering webhook endpoints, browsing the audit log — happens in the embedded dashboard at /admin/. The StartOS Actions tab is intentionally trimmed to setup-time operations only (Connect/Disconnect BTCPay, Set operator name, Set web UI password, Activate Keysat license, Show credentials). No more "wall of buttons" for everyday tasks.',
  '',
  '**Buyer self-service recovery.** A buyer who lost their license key can re-derive it themselves from (invoice_id, buyer_email) at /recover on the daemon\'s public URL. No support ticket, no operator involvement. Per-IP rate limited (10 req/min), generic-404 on mismatch (does not leak which side of the pair was wrong), audit-logged with the email\'s SHA-256 hash so the log doesn\'t store PII.',
  '',
  '**Webhook delivery DLQ.** The outbound-webhook delivery worker has always retried failed deliveries with exponential backoff up to 10 attempts; failed deliveries past that were silent dead-letters. v0.2 surfaces them: `GET /v1/admin/webhook-deliveries?status=failed` lists them, `POST /v1/admin/webhook-deliveries/:id/retry` re-queues. Surfaced in the SPA on the Webhooks page (defaults to the "Failed" filter so the problem case is what an operator sees first).',
  '',
  '**Opt-in community analytics.** Off by default. When enabled (Overview page in the admin UI), the daemon sends a daily anonymous heartbeat: install_uuid (random, not derived from operator identity), daemon version, tier label, and counts (products / active licenses / settled invoices) floored to the nearest 5 to prevent fingerprinting an operator by their exact license count. Uptime is bucketed (<1d / 1-7d / 1-4w / >4w). Operator name, public URL, store id, API keys, buyer email are NEVER sent — and the test suite asserts none of those strings appear in the heartbeat payload.',
  '',
  '**Five-language SDK parity.** The Go SDK (github.com/keysat-xyz/keysat-client-go) lands alongside this release. Stdlib only — no third-party Go dependencies. All five implementations of the LIC1 wire format (daemon, Rust SDK, TypeScript SDK, Python SDK, Go SDK) pass the same crosscheck vectors at tests/crosscheck/vector.json byte-for-byte across v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.',
  '',
  '**PaymentProvider trait abstraction.** Internally, the four daemon code paths that talked to BTCPay (purchase, webhook, reconcile, tipping) all now go through the abstract PaymentProvider trait. BTCPay-specific concerns (URL rewriting, status-string normalization, metadata enrichment, payment-hash extraction) live inside the BtcpayProvider impl. This unblocks Zaprite (v0.3) — its impl drops in cleanly without touching call sites.',
  '',
  '**Test coverage.** The daemon\'s automated test count grew from ~9 in alpha-iteration :24 to 32 in :47: 9 unit + 12 API integration + 4 SQL migration regression + 4 wire-format crosscheck + 3 webhook-worker integration. Plus the four Go SDK crosscheck tests in the separate Go repo.',
  '',
  '**Upgrade from v0.1.0:N.** Straight drop-in. No new SQLite migrations on the v0.2.0:0 cut itself (those landed individually during the alpha iteration). Existing licenses, invoices, products, policies, and discount codes are untouched. Web UI password, BTCPay connection, operator name, tip-recipient configuration all carry over.',
  '',
  '**What\'s next (v0.3).** Zaprite payment provider for card payments. Recurring subscriptions. In-place tier upgrades for end customers. Multi-currency pricing (USD + sats with auto-conversion at invoice creation).',
].join('\n')

// Routine wrapper-revision changelog. Newest first; each entry is
// what changed since the previous downstream-:N. The `:0` notes are
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
const ROUTINE_NOTES = [
  "0.2.0:62 — **Escape single quotes on the buyer-facing buy page.** The public buy page (`/buy/:slug`) carried its own HTML escaper that omitted the single-quote (`'`) escape the canonical escaper applies, so operator-controlled text rendered into HTML attributes (product name, description, discount code, operator name) was under-escaped. Replaced the forked escaper with the canonical implementation (which escapes `'` as `&#39;` alongside `&<>\"`) and added a unit test covering the single quote. No schema change, no SDK change — straight drop-in over :61.",
  '',
  "0.2.0:61 — **Security hardening for self-license tier enforcement.** The daemon now re-verifies its own self-license against the signed key on every hourly tier refresh, not only at boot. Issuer-applied tier changes — downgrade, suspension, revocation, and the license's own expiry — now take effect on a running daemon within the hour instead of waiting for the next restart. Master and honest downstream instances behave exactly as before. No schema change, no SDK change — straight drop-in over :60.",
  '',
  '0.2.0:60 — **Fix a Zaprite auto-charge silent-lapse on the recurring-renewal money path.** `charge_order_with_profile` already errors on a non-2xx response (those route correctly to WARN + `auto_charge_failed` audit + manual-pay fallback), but on a 2xx `try_auto_charge_zaprite` returned `Ok(true)` regardless of the order\'s actual status — it read `status` only for a log line. So a 200 carrying a non-settled status (a declined or expired charge, or an in-flight PENDING/PROCESSING) suppressed the manual-pay renewal notification and left the worker waiting for an `order.paid` webhook that never arrives: the subscription silently lapsed, the buyer got no pay link, and the operator saw no error. Fixed by classifying the charge response — the auto-charge is treated as successful (and manual-pay suppressed) only when the order is in a recognized settled state (`PAID`/`COMPLETE`/`OVERPAID`, mirroring `get_invoice_status`\'s `Settled` mapping); any other or unrecognized status logs a WARN and falls through to the existing manual-pay `subscription.renewal_pending` path so the buyer can always recover the cycle. Allowlist by design — Zaprite has no documented terminal-failure status string, so an unknown or missing status is treated as not-settled rather than optimistically assumed paid. Adds a unit test on the new `zaprite_charge_settled` helper covering settled / in-flight / failed / unknown statuses. BTCPay subscriptions and any sub without a saved Zaprite profile are unaffected. Also docs-only: flagged the dormant `merchant_profiles.smtp_*` columns (the buyer-email / SMTP plan was dropped — Keysat does not send email). No schema change, no SDK change — straight drop-in over :59.',
  '',
  '0.2.0:59 — **Admin UI: drop the gold button-fill design-contract violations.** Two admin-SPA controls filled with gold, which the brand contract (`design/DESIGN.md`) and the admin-UI pill convention forbid (gold is a marketing accent, never a button fill): the "Featured" tier toggle\'s on-state and the sidebar tier-upgrade CTA. Both now follow the convention — the Featured toggle is navy-filled with a cream pip when on; the upgrade CTA is cream-filled with navy text (the on-brand high-contrast treatment for a primary action on the navy sidebar), and its corner radius is aligned to the 8px button spec. CSS / inline-style only in the embedded `web/index.html` — no schema, no SDK, no behavior change. Straight drop-in over :58. (The matching public-landing fix — the Buy button\'s pill radius set to 8px — ships in the keysat-xyz-landing repo, deployed separately.)',
  '',
  '0.2.0:58 — **Agent-delegable BTCPay connect, gated to sandbox + non-mainnet.** Makes Keysat fully agent-operable for *dev/test setup*: an operator can hand an agent a scoped key that connects a BTCPay payment provider over the API — no master key, no browser click — but only on a sandbox daemon and only for a non-mainnet (regtest/testnet/signet) store. On a production daemon, or for a mainnet store, connecting a provider stays master-only, and disconnect is always master-only. The reasoning: a credential that can repoint where settlement lands is a fund-redirection key, so the capability is deliberately narrow and fails closed. **Gated in three layers:** (1) a daemon-level `KEYSAT_SANDBOX_MODE` flag, read at boot and never settable via any API, is the outer gate — scoped connect is disabled entirely on a production box; (2) `payment_providers:write` is an à-la-carte per-key scope that belongs to no role (not even full-admin), granted explicitly when an operator mints a key; (3) at OAuth-callback time the daemon resolves the target store\'s Bitcoin network from its on-chain receive address and refuses anything not provably non-mainnet, failing closed to mainnet on any ambiguity (no on-chain wallet, unreachable BTCPay, unrecognized address) — it denies rather than guesses. Migrations 0024 (`scoped_api_keys.extra_scopes`) and 0025 (`btcpay_authorize_state.scoped_initiator` + actor hash, to carry the initiator across the browser round-trip) are additive — straight drop-in over :57. The served OpenAPI spec now documents the BTCPay connect/callback/status/disconnect paths and the key-creation `scopes` field, and `/v1/admin/tier` surfaces a read-only `sandbox` flag. Also hardened: the GET authorize-callback now returns the real HTTP status on a denied connect (was a misleading 200 with an error page). Validated end-to-end against a live regtest BTCPay; the docs-onboarding harness (a fresh agent integrating from the published docs alone) converged completed-clean on the full buyer-pays journey. Daemon api test suite is at 65, up from 57. Zaprite connect stays master-only. No SDK change.',
  '',
  '0.2.0:57 —**New `merchant-onboard` scoped-API-key role for least-privilege self-serve onboarding.** A fifth scoped-key role sits between `license-issuer` and `full-admin`, granting read access plus `products:write` + `policies:write` + `licenses:write` — the minimum a merchant (or an integrating agent) needs to stand up a catalog end-to-end over the API: create a product, define its policies/tiers, and issue licenses against them, all without holding the master key. The catalog write *scopes* already existed and were enforced on the endpoints since :55; only a role that expands to them was missing, so this is a `Role`-variant addition, not a scope-model change. `Role::grants` matches the write scopes explicitly (never by `:write` suffix), so the role can never widen into settings / payment-provider / merchant-profile / webhook writes, and every master-only operation (signing-key rotation, payment connect, web-admin password, API-key management, server settings, per-license tier change, DB introspection) stays behind `require_admin` and is structurally unreachable from any scoped key. Existing Creator-tier caps still bound it (5 products / 5 policies per product / 10 active codes). **Caveat:** the role covers catalog setup + manual license issuance fully, but connecting a BTCPay/Zaprite payment provider stays master-only by design, so the buyer-paid purchase flow still needs a one-time operator step. Migration 0023 rebuilds `scoped_api_keys` to widen the role CHECK constraint (SQLite can\'t alter a CHECK in place; the table has no foreign keys, so it\'s a plain copy/drop/rename) — additive, a straight drop-in over :56. Daemon api test suite is at 57, up from 56. No SDK change.',
  '',
  '0.2.0:56 — **Product→merchant-profile write path — multi-profile is now functional end-to-end.** The multi-profile *resolver* has been complete since :52, but products had no way to be *assigned* to a profile, so every product stuck to the auto-created default profile. This cut wires the missing write half. `Product.merchant_profile_id` now threads through all four product SELECTs + `row_to_product`; a new `repo::set_product_merchant_profile` validates the target profile exists first (returns a clean 404 rather than a raw FK 500); it is threaded through `CreateProductReq` (applied as a post-write step) and `UpdateProductReq` (double-`Option` semantics, where `Some(None)` clears a product back to the default profile). The admin SPA shows a merchant-profile `<select>` on the product form only when more than one profile exists, so single-profile operators see no change. No schema migration (highest is still 0022) — straight drop-in over :55. No SDK change.',
  '',
  '0.2.0:55 — **Scoped API keys, an advisory settle-amount tripwire, and multi-arch packaging.** Three things land over :54, with no schema migration (highest is still 0022) — straight drop-in. **(1) Scoped admin API keys.** 58 admin endpoints move from the blanket `require_admin` gate to role-scoped `require_scope` checks, so an operator can mint reduced-privilege keys (for example, read-only access to dashboards and licenses) instead of handing out the master key; 12 sensitive endpoints stay master-only (issuer key, provider connect/disconnect, set-password, API-key CRUD, db-info, operator-name, per-license tier change). The master admin key keeps full access, so existing automation is unaffected. **(2) Advisory settle-amount tripwire** — the follow-up flagged in :54. On settle, `audit_settle_amount` (shared by the webhook and reconcile issue paths) compares the provider-reported paid amount against what was invoiced; on drift it WARN-logs and writes an `invoice.amount_mismatch` audit row, then issues anyway. It is an advisory signal, not a payment gate (a hard gate would fight BTCPay payment tolerance). SAT-denominated invoices only; fiat-subscription renewals and amount-less snapshots are skipped so there are no false positives. **(3) StartOS packaging and multi-arch.** The package now ships as a single universal s9pk built for both `x86_64` and `aarch64` (previously x86-only), so it installs on ARM StartOS hardware. Adds the required `instructions.md`, fixes two dead manifest links (`packageRepo`, `docsUrls`), and clears stale references to the long-retired license enforce mode from the Activate-License and Show-Credentials actions (the daemon always boots at the free Creator tier; activating a license lifts the caps). Daemon test suite is at 54 api tests, up from 47. No SDK change.',
  '',
  '0.2.0:54 — **Security: settle webhooks are now confirmed against the provider before a license is issued.** Previously the settle handler trusted the webhook body\'s claim alone. BTCPay webhooks are HMAC-signed so a forgery there is infeasible, but **Zaprite webhooks carry no signature** — so a forged `order.change`/`status=PAID` POST containing a buyer-visible Zaprite order id could mint a fully-signed license without any payment (the `externalUniqId` "trust anchor" the code comments described was never actually checked on the inbound path). Fixed in `api/webhook.rs::handle_inner`: on any settle event the daemon now re-fetches the authoritative status from the provider\'s own API (`get_invoice_status`) and requires it to actually be `Settled` before persisting the paid status or taking ANY settle-derived action — license issuance, tier-change application, or subscription renewal (the confirmation gate sits ahead of all three). If the provider\'s API is unreachable the handler acks `200` WITHOUT issuing rather than erroring, so a transient provider outage can\'t turn every in-flight webhook into a retry storm; the existing 60-second reconcile loop re-confirms and issues on its next tick (fail-closed on issuance). This only affects operators who enabled the optional Zaprite provider; BTCPay-only operators were never exposed. No schema change, no SDK change — straight drop-in over :53. **Known follow-up**: the confirmation is a binary settled/not-settled check; a literal paid-amount/currency comparison (to reject a provider-reported underpayment) is not yet wired and is tracked separately. Internally this release also adds the first integration-test seam for the real purchase/settle path (`AppState::provider_override`), bringing the daemon test suite to 47 passing with the prior 3 known-failing payment tests resolved.',
  '',
  '0.2.0:53 — **Fix the ambiguous-column bug that broke every paid purchase on :52.** The `:52` merchant-profile model introduced `get_merchant_profile_for_product`, which selects the shared `MERCHANT_PROFILE_COLS` column list (a bare `id, name, …`) while JOINing `products` — but `products` also has an `id`, so SQLite raised `ambiguous column name: id` on every execution. That function runs on every purchase, so **every paid purchase on :52 returned HTTP 500**. Fixed in `db/repo.rs` by replacing the JOIN with an equivalent correlated subquery, keeping `merchant_profiles` the only table in FROM; NULL/missing `merchant_profile_id` behavior is unchanged (no row → caller falls back to the default profile). Also from the same verification pass: added `merchant_profile_provider_resolution_queries_round_trip` covering the previously untested runtime-prepared resolution / CRUD / preference queries, repaired three test call sites for the new `create_invoice` / `create_subscription` params, captured the response body in the `paid_purchase` status assertion, aligned the manifest license to `LicenseRef-Keysat-1.0`, and dropped an unused import. No schema change, no SDK change — straight drop-in over :52.',
  '',
  '0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.',
  '',
  '0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
  '',
  '0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
  '',
  '0.2.0:49 — **Zaprite saved-profile capture: full diagnostic logging + reconciler path.** Sandbox testing of `:47` revealed five recurring subscriptions all settled successfully but with NULL `zaprite_payment_profile_id` — even though Zaprite confirmed the saved card on the contact. Two root causes addressed: (1) `capture_zaprite_payment_profile` had six different early-return-Ok branches (no provider, not Zaprite, downcast fail, no contact_id, no profiles array, no matching profile) that ALL silently returned with no logging, so there was no way to know which branch fired. Every branch now emits a `tracing::info!` or `tracing::warn!` explaining what it found, including a sample of the profiles\' `sourceOrder.externalUniqId` values when no match is found (to detect the timing race where Zaprite\'s profile-attach lags the order.paid webhook). (2) The polling reconciler (which catches missed webhook deliveries) called `issue_license_for_invoice` to recover the license + subscription, but never called `on_invoice_settled` — so a recurring sub created via the reconciler path NEVER got its Zaprite profile captured even though the saved profile was sitting on Zaprite\'s contact. Fixed in `reconcile.rs::ensure_license`: now invokes `on_invoice_settled` after license issuance (and on the idempotency early-return, in case a prior license-exists run missed the hook). The hook is itself idempotent and a no-op for BTCPay subs, so this is safe to call from both webhook and reconciler paths. Together these mean: even if your Zaprite webhook never delivers, the reconciler will pick up the slack within ~60 seconds AND capture the saved profile so auto-charge still works on the next renewal cycle.',
  '',
  '0.2.0:48 — **Thank-you page copy is now provider-aware.** The `/thank-you` landing page (where buyers wait while their license is signed) hardcoded "Your Bitcoin payment was received" + "Lightning settles in seconds; on-chain typically settles in 10–20 minutes" — true for BTCPay-routed purchases, awkward for Zaprite-routed card payments where the buyer never touched Bitcoin. Fixed in `api/mod.rs::thank_you`: read `SETTING_ACTIVE_PROVIDER`, branch the lede copy on it. For Zaprite: "Your payment was received. Card payments confirm in seconds; Bitcoin Lightning also settles in seconds; on-chain Bitcoin typically settles in 10–20 minutes." For BTCPay (and the unconfigured fallback): unchanged Bitcoin-only copy. Also passed the provider kind into the polling JS so the running-status copy (`waitingCopy()`) makes the same distinction at every elapsed-time threshold (2 min, 10 min, slow-block). When the planned multi-provider work lands, this lookup will switch from the singleton setting to the invoice\'s own `payment_provider_id` so the copy matches the rail that actually settled THIS purchase rather than whatever\'s currently active on the daemon.',
  '',
  '0.2.0:47 — **Zaprite recurring purchases now create the contact upfront.** First-time test purchase against a live Zaprite sandbox surfaced the gap: when the order body has `allowSavePaymentProfile: true`, Zaprite\'s API requires an explicit `contactId` and returns `400 contactId is required when allowSavePaymentProfile is true` if you only pass `customerData: { email }`. Their llms.txt docs say contactId is optional in that case; the API itself disagrees, and the API is the source of truth. Fixed in `payment/zaprite/provider.rs`: when about to send `allowSavePaymentProfile: true`, first call a new `client.create_contact(email, name)` helper (`POST /v1/contacts`), then pass the returned id as `contactId` on the subsequent `create_order` call. Three handling paths: (1) recurring + buyer_email present → create contact + save profile, the happy path; (2) recurring + buyer_email MISSING → degrade to one-shot for THIS cycle (buyer gets a license, renewals fall back to manual-pay, warn-logged); (3) non-recurring → unchanged (no contact created, customerData only). Known minor: Zaprite\'s duplicate-email behavior on `POST /v1/contacts` is undocumented, so the same buyer purchasing recurring twice may end up with duplicate contacts in the operator\'s Zaprite dashboard until the multi-provider work introduces a Keysat-side dedup cache.',
  '',
  '0.2.0:46 — **Provider create-invoice failures now surface the underlying cause.** When `provider.create_invoice` failed (Zaprite or BTCPay rejection, network error, currency validation), the buy page rendered only "payment provider create-invoice failed: ZapriteProvider.create_invoice" — the outermost `context()` wrapper — and the actual cause (HTTP status + response body from the upstream) was never logged anywhere either. The trait method returned the anyhow error; only the tower trace layer fired, and it only sees the HTTP status code, not the body. Fixed in `api/purchase.rs`: switch user-facing format from `{e}` to `{e:#}` so the full anyhow chain shows up on the buy page, and add an explicit `tracing::error!` before returning so the same chain lands in daemon logs. Operator-visible: failed checkouts now actually tell you what went wrong ("Zaprite create_order returned HTTP 400: missing payment_methods", etc.) without log-spelunking.',
  '',
  '0.2.0:45 — **Zaprite recurring auto-charge is now wired end-to-end.** Previously, when a buyer paid the first cycle of a recurring policy via Zaprite (Stripe card or any autopay-supporting rail), the renewal worker created a fresh invoice each cycle and waited for the buyer to manually pay it again — even though Zaprite supports saved-card auto-charging via `allowSavePaymentProfile` + `POST /v1/orders/charge`, the wiring was stubbed (the module-level comment in `subscriptions.rs` literally said "Auto-charge ... is NOT in this version"). Three changes close that gap: (1) `api::purchase` sets `allow_save_payment_profile: true` on the first-cycle invoice when the policy is recurring, prompting Zaprite to show the save-card UI at the buyer\'s Stripe checkout; (2) on first-cycle settle, a new `capture_zaprite_payment_profile` helper fetches the buyer\'s Zaprite contact, finds the profile whose `sourceOrder.externalUniqId` matches our invoice, and persists `paymentProfileId` / `method` / `expiresAt` to four new nullable columns on the `subscriptions` table (migration 0019, additive only); (3) the renewal worker now calls `try_auto_charge_zaprite` after creating each renewal order — on success the buyer does nothing (Zaprite settles via the usual `order.paid` webhook); on failure (declined card, expired profile, network) we log + audit + fall through to the existing `subscription.renewal_pending` event so the buyer still has a manual-pay recovery path. Two new operator webhook events: `subscription.auto_charge_initiated` (success) and `subscription.auto_charge_failed` (failure). BTCPay subscriptions and Zaprite subscriptions whose buyer paid with Bitcoin/Lightning or declined the save-card prompt continue to behave exactly as before (manual pay on each renewal); the new auto-charge path is gated entirely on `zaprite_payment_profile_id IS NOT NULL`. NOT YET END-TO-END TESTED against the Zaprite sandbox — the data-model + control flow follows the documented API (`api.zaprite.com/llms.txt`) but exact failure-body shapes for declined cards aren\'t documented; sandbox validation pass recommended before relying on this in production.',
  '',
  '0.2.0:44 — **Admin UI is now usable from a phone.** Previously the admin UI had a single 980px breakpoint that just stacked the 240px sidebar above content, eating ~400px before the operator could reach anything. Three changes: (1) below 720px the sidebar becomes a true off-canvas drawer with a hamburger toggle in the topbar — tap to open, tap the backdrop or any nav link to close, drawer slides in from the left with a translucent dim. (2) Below 640px the stats grid drops from 2-up to 1-up, the topbar tightens (smaller title, operator-id chip hidden since the sidebar already shows who you are), toolbar inputs go full-width instead of being forced to ≥224px, card and button padding tightens to fit narrow screens, and tap targets bump to ~40px tall. (3) Tables now scroll horizontally inside their card instead of clipping rows on narrow screens. Desktop layout is unchanged. Triage flows (glance at status, look up a license, revoke one in a pinch) now work from a phone; form-heavy creates still benefit from a wider screen. CSS + a small JS toggle in the single embedded `web/index.html`.',
  '',
  '0.2.0:43 — **BTCPay authorize success page now says "return to Keysat" instead of "return to StartOS".** The success page is the lightweight HTML BTCPay redirects to after the operator clicks Authorize. With the BTCPay connect flow living inside Keysat\'s admin UI (since the :40-era admin-UI redesign), "return to StartOS" was misdirecting the operator — Keysat\'s own tab is what they came from and what they want to return to. One-line copy change in `success_page()` and the GET fallback path in `btcpay_authorize.rs`; no behavior change.',
  '',
  '0.2.0:42 — **Revert the implicit Patron→Pro entitlement expansion shipped in :41.** Reasoning on revert: the only license affected by the missing-entitlement bug was the master operator\'s own pre-launch self-license, issued under an earlier entitlement scheme. The Patron policy on the master Keysat now lists the correct entitlements, so any fresh Patron license issued today carries them in the signed payload directly. Making `patron` a magic superset at the resolution layer was paying ongoing complexity tax (entitlement-renames have to update a hardcoded list; the gate behavior diverges from what the policy literally says) for a one-shot migration that won\'t recur. Operators with a stuck old-scheme Patron license should re-issue + run "Activate Keysat license" — the new license overwrites `/data/keysat-license.txt` and the daemon picks up the fresh entitlements without a restart. The :41 BTCPay one-click authorize-flow restoration in the admin UI is unchanged.',
  '',
  '0.2.0:41 — **Two fixes: Patron tier now implies the full Pro feature surface, and BTCPay Connect is back to one-click authorize.** Both came from operator-side bugs that the admin-UI redesign exposed.',
  '',
  '**Patron implies Pro at the resolution layer.** Previously, every `tier.has(<pro-entitlement>)` check required the Patron POLICY on the master Keysat to redundantly list every Pro entitlement (`unlimited_products`, `unlimited_policies`, `unlimited_codes`, `recurring_billing`, `zaprite_payments`) — if the operator forgot even one slug on the Patron policy, every Patron customer was silently locked out of that feature. The Zaprite gate caught this in the wild: a Patron license without `zaprite_payments` got an "Upgrade to Pro" CTA on the payment-providers page. Fixed at the right layer: `tier::current()` now expands `patron` into the full Pro entitlement set on read, so a Patron policy can list just `patron` and have everything Pro grants flow through automatically. Existing Patron customers get the implied entitlements without re-issuing a license. Recommended cleanup: also list the entitlements explicitly on the Patron policy itself so the buy-page tier card stays informative — but the gate behavior no longer depends on it.',
  '',
  '**BTCPay Connect: one-click authorize flow restored.** When BTCPay setup moved from a StartOS action (where it was a one-click `Connect BTCPay` that returned a URL the operator opened in their browser to authorize, with the API key / store id / webhook secret auto-detected by Keysat) to the admin UI’s Payment Providers card, it regressed to a four-field paste form asking for Base URL, API key, Store id, and Webhook secret. The daemon-side `/v1/admin/btcpay/connect` endpoint never changed — it still returns an `authorize_url` for BTCPay’s consent page — the form just stopped using it. Rewrote the BTCPay path in the modal: a "Connect BTCPay Server" dialog now has one primary button, "Open BTCPay to authorize". On click, it requests the authorize URL, opens it in a new tab, displays it as copyable text (for operators on a different device than their browser), and polls `/v1/admin/btcpay/status` every 2.5 seconds. The moment BTCPay’s callback lands and the store/webhook are persisted, the modal closes itself and the Payment Providers card re-renders showing BTCPay connected. Zaprite’s connect path is unchanged (Zaprite has no OAuth-style consent endpoint; an API key paste is still required).',
  '',
  '**Upgrade path.** Drop-in. No schema, no SDK change. Operators currently stuck on the four-field BTCPay form get the new one-click button automatically; the manual-fields path is removed for BTCPay since it never actually wrote those fields server-side anyway.',
  '',
  '0.2.0:40 — **Discount-code slot reaper plugs the abandoned-cart leak.** When a buyer clicked Pay with Bitcoin with a discount code applied, the daemon reserved a slot on that code (incrementing `used_count`) BEFORE creating the BTCPay invoice. This is the right pessimistic-lock behavior — prevents two buyers from racing for the last slot of a limited code — but it meant abandoned checkouts only freed the slot when BTCPay later fired `InvoiceExpired`. If that webhook never landed (network blip, daemon offline at the firing moment, misconfigured webhook URL), the slot leaked forever. New 5-minute background reaper closes both holes: scans `discount_redemptions` where status=\'pending\' and the linked invoice is either in a terminal failure state (\'expired\' / \'invalid\') OR has been sitting in \'pending\' for more than 30 minutes, and cancels each one — flipping the redemption to \'cancelled\' and decrementing the code\'s `used_count` so the slot is available again. 30-min threshold covers BTCPay\'s default 15-min invoice expiry plus webhook-delivery buffer. Lives alongside the existing hourly session reaper in `main.rs`. Internal-only; no API or schema change. Operator-visible only in the sense that limited-discount slots no longer drift over time.',
  '',
  '0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
  '',
  '0.2.0:38 — **Admin UI: Create-product Cancel button + modal-overflow fix across all dialogs.** Two operator-reported bugs.',
  '',
  '**Create product: Cancel button.** The "Create a new product" disclosure had a Create button but no way to back out without scrolling up to the chevron. Added a secondary Cancel button alongside Create — collapses the disclosure (returns to the products list) without clearing typed input, so re-expanding picks up where the operator left off.',
  '',
  '**Modal overflow fix.** The Edit-product modal could grow taller than the viewport when a product had a long entitlements catalog, leaving the operator unable to scroll to the Save button. Cause: the modal card lacked `max-height` + `overflow-y`. Added `max-height:90vh; overflow-y:auto` to that card AND to every other dialog card in the admin UI (11 modals total — tier-cap upgrade, force-delete confirm, value-prompt, generic-confirm, license-issued display, BTCPay-connect, scoped-API-key generate, scoped-API-key show-once, edit-product, edit-policy, edit-discount-code). Same fix, applied defensively everywhere so this class of bug can\'t recur as content grows.',
  '',
  '0.2.0:37 — **"Limited" → "Limited discount".** Adds the word "discount" to the launch-special remaining-count label so it\'s unambiguous what\'s limited. Without it, a buyer scanning a tier card with a launch ribbon might read "Limited: 10 remaining" as "only 10 licenses left at this tier" rather than "only 10 uses of the discount code left." Both surfaces (buy page tier card + landing-page dynamic card) now render "Limited discount: N remaining". Cosmetic.',
  '',
  '0.2.0:36 — **Launch-special remaining count drops the total.** The buy-page tier card\'s "Limited: N of M remaining" line now reads just "Limited: N remaining". The total cap (M) is operator-private — there\'s no upside to exposing initial volume to buyers, and it can make a tier look smaller than the operator wants to signal. Symmetric change in the landing-page dynamic tier-card render. Cosmetic; no API or schema change.',
  '',
  '0.2.0:35 — **Free tiers render as "Free" on the buy page tier card.** Previously the server rendered a 0-priced tier as `0 sats` (or `0.00 USD`) in the tier-card price headline, even though the price card BELOW the picker already swapped to `FREE` via the JS path. Inconsistency fixed at the server-render layer: when post-discount price is 0, the tier card renders the headline as `Free` with no unit suffix and no cadence (`Free /yr` would be incoherent). Recurring-meta line ("Renews annually") still surfaces beneath for recurring tiers that happen to be free, so the cadence is still visible — just not stuffed into the headline. Cosmetic; no API or schema change.',
  '',
  '0.2.0:34 — **Buy page: featured discount pre-populates the code field and shows "Launch special applied" on load.** Previously the launch-special discount auto-applied silently at payment time but the discount-code input was empty, leaving buyers unsure whether they needed to type anything. Now: when a tier has an active featured code, the input renders pre-filled with the code (e.g. `LAUNCH`) and the green "Launch special applied" status badge shows on load. The price card has always rendered the slashed-original + discounted-current price; this change just makes the form match. Tier switches clear an auto-populated code so a code that doesn\'t apply to the new tier doesn\'t linger; buyer-typed codes are untouched. UI / JS only; no API or schema change.',
  '',
  '0.2.0:33 — **Drop a long-standing unused-variable warning.** Removed `let invoice_id_safe = html_escape(&invoice_id);` in `src/api/mod.rs` — the value was computed but never referenced anywhere in the thank-you-page template (the HTML uses `invoice_id_json` for the inline JS, and the on-screen invoice id renders from JS via that JSON variable). One-line cleanup; `cargo check` is now warning-free. No behavior change.',
  '',
  '0.2.0:32 — **Per-product policy cap also pre-checked + grandfathered.** Extends the v0.2.0:31 cap-handling pattern to the third tier-enforced surface (Creator caps each product at 5 policies). Same shape, just scoped to a single product instead of the whole instance.',
  '',
  '**Pre-check.** When the operator clicks "+ Add tier" on a product that already has 4 of 5 policies, the draft tier card opens with a gold-bordered "Approaching cap" warning at the top: "You\'re at 4/5 policies on this product. Creating one more will hit your Creator tier cap." Includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator pushes through.',
  '',
  '**Grandfather.** When a single product carries more policies than the current tier\'s per-product cap (e.g. operator on Pro authored 7 policies, then downgraded to Creator), that product\'s card now renders a persistent grandfather banner above its tier grid: "Grandfathered: 7 policies on this product active vs Creator tier cap of 5. Existing policies keep working. Creating new ones is blocked until you upgrade to Pro." The banner appears per-product (not page-wide) since the cap is per-product. Other products on the same instance show their own state independently.',
  '',
  '**Implementation note.** Reuses the v0.2.0:31 helpers (`capPreCheckCard`, `grandfatherBanner`) by synthesizing a `tierStatus` shape with `caps.policies` mapped to the per-product cap — no new component code needed, just an extra parameter threaded through `renderPolicyCardGrid` → `renderDraftTierCard`.',
  '',
  '**Test count: 87** (unchanged — pure UI).',
  '',
  '**Upgrade path.** v0.2.0:31 → v0.2.0:32 is a drop-in. No schema, no SDK breaking change.',
  '',
  '0.2.0:31 — **Four-item punchlist landed: cap-hit pre-check, grandfather banner, webhooks empty state, help-icon overhaul.** Clears the remaining outstanding admin-UI items.',
  '',
  '**Cap-hit pre-check (item #7).** Operators no longer have to submit-and-bounce off a 402 to learn they\'re about to hit a tier cap. The Products page and the Discount Codes page each call `/v1/admin/tier` on render and surface a gold-bordered "Approaching cap" warning inline above the create-form submit button whenever usage is at cap-1 (e.g. 4/5 products on Creator). The warning includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator goes ahead and submits.',
  '',
  '**Grandfather banner (item #6).** When the operator downgrades a tier and ends up with more existing rows than the new tier\'s cap allows (e.g. 8 products under Creator\'s 5-product cap), the relevant page now renders a persistent grandfather banner at the top: "Grandfathered: 8 products active vs Creator tier cap of 5. Existing products keep working. Creating new ones is blocked until you upgrade to Pro." The daemon\'s enforcement was already correct — it only blocks NEW writes, never deletes existing — but the UI was silent about it, leaving operators confused about why creates failed. Banner appears on Products + Discount Codes pages (the two surfaces with global tier caps). Per-product policy caps not yet pre-checked; that\'s a follow-up polish.',
  '',
  '**Webhooks empty state (item #10/15).** Previously the Webhooks tab rendered a bare "No webhooks registered." empty table on a fresh instance — no CTA, no context. Now there\'s a centered card with eyebrow + headline ("Get notified when something happens"), a 2-sentence "what\'s a webhook for?" explainer covering common use cases (license issued, code redeemed, invoice settled, fulfillment automation), and a primary "Add your first webhook" button that opens the create disclosure and focuses the URL input. Mirrors the Machines tab\'s empty state for visual consistency.',
  '',
  '**Help-icon click-to-toggle (item #13).** The "?" tooltips peppered through the admin UI previously used the browser\'s native `title=` attribute — hover-only, browser-styled, no keyboard access, accidentally triggered on grazes. Refactored to a small outlined `<button>` that opens a navy-styled popover anchored next to itself on click. Click outside / press Esc / click the icon again closes it. Focus + Enter / Space opens. Visually less prominent (smaller, outlined cream vs the previous filled grey). One JS function (`helpIcon`) — used everywhere — so the change ripples across every help affordance.',
  '',
  '**New helpers.** Three new JS helpers used by the changes above (and available for future surfaces):',
  '- `loadTierStatus({ forceRefresh })` — fetches `/v1/admin/tier`, returns a cached promise within a single route render.',
  '- `capPreCheckCard(tierStatus, key, label)` — inline warning when usage is at cap-1 or over.',
  '- `grandfatherBanner(tierStatus, key, label)` — persistent banner when usage strictly exceeds cap.',
  '',
  '**Test count: 87** (unchanged — pure UI / CSS).',
  '',
  '**Upgrade path.** v0.2.0:30 → v0.2.0:31 is a drop-in. No schema, no SDK breaking change. The `/v1/admin/tier` endpoint already existed; this release just consumes it on more surfaces.',
  '',
  '0.2.0:30 — **Two small copy fixes.** "Embed your public key" tip now says "your product\'s source code" (not "your app\'s source") — clearer for operators distributing libraries, services, or anything that isn\'t literally an app. And the Licenses search row drops the Nostr npub mention from the placeholder, the description, and the search-field dropdown, since the purchase flow doesn\'t capture buyer npubs yet so the option has nothing to find. The npub search code-path on the backend stays — we\'ll bring the UI option back when buyer npub capture lands in the purchase flow.',
  '',
  '0.2.0:29 — **Tier-card cross-card horizontal alignment via CSS subgrid.** Visually equivalent sections (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don\'t fill — the explicit tradeoff the operator asked for, in service of a cleaner grid.',
  '',
  '**How.** Each `.tier` card is now a CSS subgrid that shares row tracks with the parent `.tiers` grid. Eight named rows: launch-meta → name → original-price → price → meta-block → description → features (1fr) → button. Each section in the card emits with an explicit `grid-row`, so omitted sections (e.g. Creator has no struck-through original-price line) just leave the row empty while still preserving the alignment across siblings. The features row is `1fr` so it absorbs vertical slack, pinning the Select button to the bottom of every card.',
  '',
  '**Side fix: popular pill no longer clipped without the in-card hack.** Replaced `overflow:hidden` on `.tier.has-launch` (which was chopping the "MOST POPULAR" pill at top:-10px) with a `clip-path: polygon(0 -20px, 100% -20px, 100% 100%, 0 100%)`. Same effect for the launch ribbon overhang on the right, but leaves the 20px above the card visible so the popular pill survives. Removed the v0.2.0:26-27 `padding-top:36px` workaround that pushed pill inside the card; with subgrid alignment it\'s no longer needed.',
  '',
  '**Meta lines now wrapped in a single `.tier-meta-block`** (duration + recurring cadence + trial banner + trial flag). Previously each was its own `<div class="tier-meta">` which made them impossible to place as one grid row. Now they\'re a flex-column wrapper that lands in row 5, with tier-cards that have fewer meta lines getting whitespace below their content.',
  '',
  '**Browser requirement.** CSS subgrid lands in Chrome 117+ (Aug 2023), Firefox 71+ (2019), Safari 16+ (Sept 2022). Should cover essentially every browser an operator points at by 2026; if a very old browser falls through, the cards still render — just with the old non-aligned look (subgrid degrades to its parent grid track config, which is benign).',
  '',
  '**Test count: 87** (unchanged — pure render-layer + CSS).',
  '',
  '**Upgrade path.** v0.2.0:28 → v0.2.0:29 is a drop-in. No schema, no SDK breaking change. Public `/v1/products/<slug>/policies` JSON unchanged.',
  '',
  '0.2.0:28 — **Settings cleanup, operator-name save fix, Licenses "Hide revoked" toggle.** Three small admin-UI changes.',
  '',
  '**Settings page intro card removed.** The "Operator-facing configuration. Display name, payment provider connections, …" preamble at the top of the Settings page was redundant with the page title + the section headers below; dropping it tightens the layout and gets the operator straight to the controls.',
  '',
  '**Operator name save no longer 404s.** The admin UI was POSTing to `/v1/admin/operator-name`, but the daemon mounts the endpoint at `/v1/admin/settings/operator-name`. Mismatch fixed in the JS — both GET (page load) and POST (save) now hit the correct path. The route on the daemon side was already correct; this was a UI typo from the original wiring.',
  '',
  '**Licenses page: "Hide revoked" toggle.** A pill toggle sits between the product filter row and the stat cards. Off (default) = table shows every license. On = table filters out `status = "revoked"` rows so operators reviewing active state aren\'t scrolling past revoked entries. The Revoked stat card stays accurate either way — the toggle only affects rendered rows, never the stat counts. Per-product grouping, search, and product-filter pills all still work alongside it.',
  '',
  '**Test count: 87** (unchanged — UI-only release).',
  '',
  '**Upgrade path.** v0.2.0:27 → v0.2.0:28 is a drop-in. No data, schema, or API change. Existing operator-name settings in the DB are untouched.',
  '',
  '0.2.0:27 — **Tier-card feature list merged into a single ul; "MOST POPULAR" + "Limited: …" no longer collide.** Two remaining buy-page nits from :26.',
  '',
  '**Single feature list.** The :26 attempt at zeroing the gap between adjacent `<ul.tier-bullets>` and `<ul.tier-entitlements>` worked on paper but the boundary was still visible to the eye — probably from list-item bottom-padding accumulating around the ul switch. Solved structurally: the daemon now builds ONE `<ul class="tier-features">` server-side, concatenating marketing bullets and entitlements in the operator-controlled order. No list boundary = no gap to fight with CSS. Both groups render with the same ✓ checkmark, indistinguishable to the buyer.',
  '',
  '**"MOST POPULAR" no longer collides with the launch-special meta line.** In :26 the popular pill was moved inside the card (top:8px) for tiers that also had a launch ribbon, so the ribbon\'s overflow-hidden wouldn\'t clip it. But that landed it on top of the "Limited: 100 of 100 remaining" line. Added `padding-top:36px` (35px when also `.selected`, to compensate for the thicker border) to the tier card when both classes are present, so the popular pill has its own gap above the meta line.',
  '',
  '**Test count: 87** (unchanged — CSS + HTML composition only).',
  '',
  '**Upgrade path.** v0.2.0:26 → v0.2.0:27 is a drop-in. No data, schema, or API change. SDK consumers that read `/v1/products/<slug>/policies` still get `entitlements` and `marketing_bullets` as separate arrays — the merging happens only at the buy-page render layer.',
  '',
  '0.2.0:26 — **Buy-page + entitlement-picker polish.** Cluster of small visual fixes informed by side-by-side review of the public buy page and the admin entitlements UI.',
  '',
  '**Tier-card feature list reads as one column.** When marketing bullets and entitlements stacked (either above or below), there was ~8px between the two lists but only 6px between items within a list — the seam was visible. Zeroed the margin-top on the second list so the two render as one continuous feature ladder (the per-li padding now contributes the full inter-item gap on both sides of the boundary).',
  '',
  '**"MOST POPULAR" pill no longer clipped.** When a tier was both "most popular" and carried a launch-special ribbon, the ribbon\'s `overflow:hidden` (which clips the ribbon\'s off-card overhang) also chopped the popular pill that sits 10px above the card. Targeted rule moves the popular pill INSIDE the card top edge in that specific combination so it stays visible without changing the default highlighted-tier appearance.',
  '',
  '**Price card narrowed back to 560px.** The :23 release stretched the price card to the full 1040px container width along with the headline; that turned out to be visually overwhelming below the tier picker. Constrained the price card + form to 560px (centered); headline elements still span the full container with centered text.',
  '',
  '**Entitlement bubble picker: navy theme, no strikethrough on hidden chips.** The gold-filled "selected" pill state introduced earlier read as too marketing-y next to the rest of the admin actions, and the strikethrough on hidden chips looked like the entitlement had been deleted. New look: selected chips are navy-filled with cream text (matching "Selected" tier-select buttons + the Featured-ON toggle); hidden chips stay navy-filled but dim to 50% opacity (no strikethrough — the chip is still granted by the license).',
  '',
  '**Discount-code policy multi-pickers follow.** The Create + Edit forms\' "Restrict to policies" pickers re-aligned from gold to the same navy convention. The brief gold pass in :25 was the wrong call; navy reads consistently with the rest of the admin "selected/on" state.',
  '',
  '**Admin Policies grid: hidden entitlements also drop strikethrough.** Opacity-only treatment now, matching the bubble picker. The `(hidden on buy)` italic hint still appears so operators can spot the difference.',
  '',
  '**Test count: 87** (unchanged — pure CSS / inline style edits).',
  '',
  '**Upgrade path.** v0.2.0:25 → v0.2.0:26 is a drop-in. No data, schema, or API change.',
  '',
  '0.2.0:25 — **Discount-code policy pills match the admin UI palette.** Cosmetic-only fix. The "Restrict to policies" multi-pickers on both the Create and Edit forms were rendering selected pills as dark navy with gold text, which clashed with the gold-filled / cream-outlined pill convention used everywhere else in the admin (entitlements bubble picker, marketing-bullets position, etc.). Aligned both pickers to that shared style. No data or behavior change — purely a CSS swap.',
  '',
  '0.2.0:24 — **Per-entitlement "hide on buy page" toggle.** Decouples "what the license grants" (functional) from "what the buyer sees on the tier card" (marketing). The entitlement is still issued — only its display is filtered.',
  '',
  '**Why.** A common pattern: Pro inherits everything from Creator. On the buy page, instead of duplicating "Self-host on Start9 ✓" on every tier, the operator wants to write "Everything in Creator, plus:" as a marketing bullet and hide the duplicate entitlement from Pro\'s card. Before :24, the only way was to either drop the entitlement from the policy (breaks the license) or accept the duplication.',
  '',
  '**How.** The entitlements bubble picker on the policy create + edit forms now has a small eye toggle per granted chip. Click the chip name to grant/revoke as before. Click the eye to hide that entitlement from the buy page tier card only — the issued license still carries it. New per-policy metadata field `hidden_entitlements: string[]` stores the slugs. De-selecting a chip clears its hidden state automatically so stale slugs don\'t accumulate.',
  '',
  '**Where it shows up.** Buy page tier cards filter out hidden entitlements before rendering. Public `GET /v1/products/<slug>/policies` exposes the `hidden_entitlements` array so SDKs / dynamic pricing pages can do the same filtering. Admin Policies grid shows ALL entitlements with strikethrough + a "(hidden on buy)" italic hint on hidden ones — so operators reviewing what a policy actually grants still see the full picture.',
  '',
  '**Test count: 87** (unchanged — metadata pass-through; no new branches to test).',
  '',
  '**Upgrade path.** v0.2.0:23 → v0.2.0:24 is a drop-in. No schema, no SDK breaking change. Existing policies have no `hidden_entitlements` → buy page renders all entitlements as before.',
  '',
  '0.2.0:23 — **Three buy-page fixes: layout proportions, featured discount on default tier, marketing-bullet gap.** Surfaced by feedback after the wider buy page rolled out in :21.',
  '',
  '**Headline + price card no longer look pinched.** The :21 release widened the outer container to 1040px to give the 3-tier picker room, but kept the headline ("Keysat", description) and the bottom price card constrained at 560px — which made them look dwarfed against the wider tier picker. Now: headline elements + the price card span the full container width with center-aligned text. The Email / Discount code / Pay-with-Bitcoin form stays narrower (560px) since input fields look stretched at 1040px. Net effect: the page reads as one cohesive width top to bottom.',
  '',
  '**Featured discount auto-applies on the headline price.** Previously the tier card showed "100,000 sats slashed → 40,000 sats/yr" via a server-rendered ribbon, but the price card below ("PRICE · PRO") rendered the un-discounted 100,000 sats until the buyer typed the code in by hand — even though the discount is featured (auto-applies). Fixed: the tier JSON now carries each tier\'s featured-discount snapshot, and `selectTier()` renders the strike-through + discounted price headline whenever a tier has an active featured code. Tier switching also re-applies the featured code for the new tier instead of clearing the strike-through.',
  '',
  '**Marketing-bullets gap when positioned below.** When `metadata.marketing_bullets_position = "below"` (added in :19), the entitlements list and the marketing-bullets list rendered as two `<ul>`s with default spacing between them — visible as a ~10px gap on the tier card. Symmetrical CSS rule `.tier-entitlements + .tier-bullets { margin-top:2px }` fixes it; the two lists now read as one coherent feature ladder regardless of order.',
  '',
  '**Test count: 87** (unchanged).',
  '',
  '**Upgrade path.** v0.2.0:22 → v0.2.0:23 is a drop-in. No schema, no SDK change. Public buy-page CSS + JS only.',
  '',
  '0.2.0:22 — **Policy scope is now editable on existing discount codes.** Previously, the Edit form showed scope as a read-only "Applies to:" label and forced operators to disable + recreate any code whose tier scope needed adjusting. That rule existed to "avoid silently invalidating distributed links" — but the same argument applies to `amount`, `max_uses`, and `expires_at`, all of which are already editable. Inconsistent. So: policy scope joins them.',
  '',
  '**Edit form: pill multi-picker for policy scope.** The Edit modal now renders the same gold-on-navy pill picker as Create, pre-selected with the code\'s current allowed-policy set. Toggle pills to refine: 0 picked → "any policy on this product"; 1 picked → singular scope (writes the legacy column); 2+ picked → multi-policy scope (writes the JSON column).',
  '',
  '**Product scope: still read-only.** Moving a discount code from one product to another has weirder semantics for historical redemptions (a redeemed code now points at a product it never originally applied to), so the product field stays locked. To re-product a code: disable + recreate.',
  '',
  '**API.** `PATCH /v1/admin/discount-codes/<id>` now accepts an optional `policy_slugs: string[]` field. Server resolves slugs against the code\'s existing product, then normalizes: empty → both scope columns NULL, single → singular column populated + JSON column cleared, multi → JSON column populated + singular column cleared. Sending no `policy_slugs` field at all leaves scope alone (back-compat).',
  '',
  '**Test count: 87** (unchanged — same data model as v0.2.0:20, just exposed on the update path).',
  '',
  '**Upgrade path.** v0.2.0:21 → v0.2.0:22 is a drop-in. No schema, no SDK breaking change.',
  '',
  '0.2.0:21 — **Wider buy page so 3-tier grids breathe.** The public /buy/<slug> page was capped at 560px, which packed three tier cards into a too-narrow column on desktop browsers. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. The form, price card, and intro text below the tier picker remain centered at the 560px reading-width so the buy form doesn\'t look stretched. Mobile (≤480px) breakpoint unchanged. Topbar inner widened to match. UI-only; no API or schema change.',
  '',
  '0.2.0:20 — **Discount codes can apply to multiple policies, not just one.** Operator picks a subset (e.g. "Patron AND Pro but not Creator") on a single code instead of cloning the code under different names.',
  '',
  '**What changed.** Previously, a discount code\'s tier scope was a single policy (`applies_to_policy_id`) or "any policy on this product" / global. To offer the same discount across two of three tiers required creating two codes with distinct strings — operationally messy and harder for buyers. The form now has a tier multi-select pill picker: click tiers to toggle inclusion. 0 picked = "any policy on this product" (unchanged). 1 picked = single-policy scope (writes to the legacy column for clarity). 2+ picked = the code applies if and only if the chosen tier is in the picked set.',
  '',
  '**Migration 0018.** Additive: adds one nullable `applies_to_policy_ids_json` column to `discount_codes` for the multi-scope JSON array. Pre-existing codes have the column NULL and behave identically — the legacy singular column is still authoritative when the JSON column is empty.',
  '',
  '**Scope enforcement.** Both the public purchase endpoint and the admin "preview discount" endpoint now consult a unified `DiscountCode::allowed_policy_ids()` helper that returns the multi-policy list when non-empty or falls back to the legacy singular column. The featured-discount lookup also handles multi-policy: a featured code listing N policies surfaces correctly on the buy page for any of those tiers.',
  '',
  '**Edit form: scope still read-only.** Multi-policy scope is settable on creation and visible on the edit form (e.g. "Applies to: Keysat → Patron (patron), Pro (pro)") but, like all scope fields, isn\'t editable after the fact — operator disables + recreates to re-scope. Same constraint v0.2.0:17 introduced for the singular field; multi-policy follows the same rule to avoid silently invalidating distributed links.',
  '',
  '**SDK / API.** `POST /v1/admin/discount-codes` accepts an optional `policy_slugs: string[]` alongside the existing `policy_slug`. When both are present, `policy_slugs` wins. The list/get endpoints now include `applies_to_policy_ids: string[]` on every code (empty array when not multi-scoped). All other endpoints are unchanged; old SDKs that don\'t know about the field continue to work.',
  '',
  '**Test count: 87** (unchanged — scope logic is the same shape, just unifies over a Vec instead of a singleton).',
  '',
  '**Upgrade path.** v0.2.0:19 → v0.2.0:20 applies migration 0018 (additive). Existing codes keep their existing scope and behavior. No SDK breaking change.',
  '',
  '0.2.0:19 — **Marketing-bullets position: above or below the entitlements.** Tiny operator-control add: pick where the free-form ✓ checkmark copy renders on each tier card.',
  '',
  '**The change.** Marketing bullets (`metadata.marketing_bullets`) have always rendered ABOVE the entitlement chips. That\'s usually right for "lifestyle" bullets like "Up to 5 products" / "BTCPay integration" — they sell the tier. But for tiers where the entitlements ARE the headline and the marketing bullets are caveats or fine-print, operators want them BELOW. New `metadata.marketing_bullets_position` field (`"above"` default, `"below"` opt-in) controls this per-policy. UI: small dropdown next to the bullets textarea on both create and edit forms. Renders consistently across the admin grid, the buy page, and the public `/v1/products/<slug>/policies` JSON (so SDK consumers stay in sync).',
  '',
  '**Test count: 87** (unchanged — metadata pass-through; no new branches to test).',
  '',
  '**Upgrade path.** v0.2.0:18 → v0.2.0:19 is a drop-in. No schema, no SDK breaking change. Existing policies keep rendering bullets above (the default). The new JSON field appears in policies-list responses but is optional and back-compat: old SDKs ignore it.',
  '',
  '0.2.0:18 — **Discount Codes form polish: less typing, clearer intent.** Three small admin-UI changes that make the create + edit forms less footgun-prone.',
  '',
  '**Max-uses: checkbox + dependent number, not "0 = unlimited".** Previously the form had a single number input with a hint that read `"0 = unlimited"`. That meant the default value was `0`, which displayed as "no cap" but read like "0 uses allowed." Now it\'s a "Limit total uses" checkbox + a number input that only appears when the checkbox is checked (default 100). Unchecked = no cap is sent. Edit form matches.',
  '',
  '**Currency dropdown hides for percent + free_license codes.** A "50% off" code has no currency — neither does a free-license code. Previously the form still showed the SAT/USD selector for those kinds, which made buyers wonder what `50% off · SAT` meant. The kind-change listener now hides the currency field for `percent` and `free_license`, shows it for `fixed_amount`. Submit still defaults sensibly so existing forms keep working.',
  '',
  '**Featured: pill toggle, not buried checkbox.** The launch-special feature flag was the third checkbox on the form and getting missed. Replaced with a prominent gold-bordered pill toggle that flips to filled gold/navy when on. Click anywhere on the pill to toggle. Edit form matches; the toggle starts in the correct state for codes that were already featured.',
  '',
  '**Test count: 87** (unchanged — UI-only release).',
  '',
  '**Upgrade path.** v0.2.0:17 → v0.2.0:18 is a drop-in. No schema, no SDK, no behavior change for buyers. Form fields persist the same way they always did.',
  '',
  '0.2.0:17 — **Discount Codes form usability.** Three concrete improvements to make the Discount Codes tab less type-heavy and more discoverable.',
  '',
  '**Product + policy scope as dropdowns, not free-text.** The create form previously had a "Restrict to product slug (optional)" text input that required operators to remember slugs exactly. Now it\'s a dropdown populated from the product list — pick "Any product" or any specific product. A second dependent dropdown appears for "Restrict to policy" once a product is chosen; it loads that product\'s policies on the fly. Both default to "Any" so the previous "no scope = global" behavior is preserved.',
  '',
  '**Datetime picker on expires_at.** The "Expires at (RFC3339)" text input is now a native `datetime-local` picker on both create and edit forms. Operators get a calendar + time spinner UI instead of having to hand-type `2026-12-31T23:59:59Z`. Submit converts back to RFC3339 (UTC) automatically. Empty stays empty (no expiry).',
  '',
  '**Edit form shows current scope read-only.** Previously the Edit modal didn\'t surface which product or policy a code was scoped to — operators reviewing a code couldn\'t tell at a glance. Now the top of the edit form shows "Applies to: [product] → [policy]" (or "Applies to: all products on this instance" for global codes) as a muted info block. Scope is still immutable (disable + create new to change), but at least it\'s visible.',
  '',
  '**Test count: 87** (unchanged — UI-only release).',
  '',
  '**Upgrade path.** v0.2.0:16 → v0.2.0:17 is a drop-in. No schema, no SDK, no behavior change for buyers. The admin form fields persist the same way they always did (`product_slug`, `policy_slug` strings sent to the existing endpoints).',
  '',
  '0.2.0:16 — **Launch-special discount codes + marketing bullets + discount codes per-product UI.** Operators can now run public promotional discounts that auto-apply on the buy page, plus author marketing-copy bullets on tiers that don\'t map to real entitlements.',
  '',
  '**Launch-special (featured) discount codes (migration 0017).** Flag a discount code as `featured` and three things happen automatically: (1) the buy page renders a diagonal "LAUNCH SPECIAL" gold ribbon on every tier the code applies to; (2) the original price is struck through and replaced with the discounted price; (3) the purchase endpoint auto-applies the discount for buyers who don\'t type any code. Operator-typed codes still win — a buyer who pastes a different code in the form gets that code instead. When a featured code exhausts its `max_uses` cap (e.g. "first 100 buyers"), the ribbon disappears automatically and pricing reverts to standard. Expiry dates work the same way. New repo helper `find_applicable_featured_discount` picks the most specific match (policy > product > global) with operator priority by created_at.',
  '',
  '**Marketing bullets on policies.** Tier cards can now carry operator-controlled marketing copy in addition to the technical entitlements. Authored via a textarea on the policy create + edit forms (one bullet per line), stored as `metadata.marketing_bullets` on the policy, and rendered as ✓ checkmarks ABOVE the entitlement bullets on both the admin grid and the buy page. Use for things like "Up to 5 products" or "BTCPay integration" — features that are real but don\'t gate on a daemon-level entitlement. SDK consumers also receive these via `GET /v1/products/<slug>/policies` so dynamic pricing pages can render them too.',
  '',
  '**"Most popular" checkbox on draft tier cards.** Previously only the Edit modal had this toggle, which meant authoring a new tier required commit-then-edit to get the gold "Most Popular" pill. Now exposed on the draft create card too, alongside the new marketing-bullets textarea. Writes `metadata.highlight = true`.',
  '',
  '**Discount codes admin tab — per-product organization.** Replaces the flat-table view with per-product sections matching the Licenses + Subscriptions tab pattern. Each card shows a breakdown ("3 codes · 2 active · 1 featured"). Global codes (those without `applies_to_product_id`) get their own "All products (global)" section. Single-product instances continue to see a flat table. Each code row now carries a small gold "featured" badge when applicable.',
  '',
  '**Test count: 87** (unchanged — UI-heavy release; the existing CORS + entitlements-catalog regression tests cover the surface).',
  '',
  '**Upgrade path.** v0.2.0:15 → v0.2.0:16 is a drop-in. Migration 0017 is additive (one nullable column on `discount_codes` + a partial index). All new behavior is opt-in — `featured` defaults to false on existing codes, `marketing_bullets` defaults to absent. No SDK changes; the new fields appear in JSON responses but old SDKs ignore unknown fields. Operators who don\'t use launch specials see no behavior change at all.',
  '',
  '0.2.0:15 — **Multi-draft tier authoring + custom durations on draft cards.** Small admin-UI release that fixes two papercuts from authoring fresh policies side-by-side.',
  '',
  '**Multi-draft survival.** Previously, committing one draft tier (clicking Create on a side-by-side draft card) reloaded the whole Policies tab — which wiped any other drafts the operator had open. Now the commit replaces ONLY that draft\'s grid slot with a finalized tier card; sibling drafts keep their in-progress input state untouched. Author Creator, Pro, Patron in parallel and click Create on each as it\'s ready, in any order.',
  '',
  '**Custom duration on draft cards.** The Duration dropdown on the draft tier card now has a "Custom (days)" option at the bottom. Selecting it reveals a number input; on submit, days × 86400 seconds is what gets sent. Matches the same pattern the Edit-policy modal has had (in raw seconds) — bringing it to the create flow so operators don\'t have to "create then immediately edit" for non-standard durations.',
  '',
  '**Test count: 87** (UI-only release).',
  '',
  '**Upgrade path.** v0.2.0:14 → v0.2.0:15 is a drop-in. No schema, no SDK, no behavior change for buyers.',
  '',
  '0.2.0:14 — **Product entitlements catalog bug fix + drag-and-drop tier ordering.** One real bug fix that was silently breaking operator workflows, plus a UX rework of how tier ranks get set.',
  '',
  '**Bug fix: product entitlements catalog reads.** Every SELECT against the `products` table in repo.rs was missing the `entitlements_catalog_json` column. The PATCH handler wrote the catalog correctly, but every read returned it as null — so admin UI edits silently appeared to drop on the floor (operator adds entitlements, clicks Save, re-opens the editor, entitlements are gone). The data was always in the DB; only the API was blind to it. Now all four product SELECTs include the column. Net effect: catalog edits persist correctly; the bubble-picker on policy create / edit forms populates from the parent product\'s catalog.',
  '',
  '**Regression test.** New `product_entitlements_catalog_round_trips_through_list_endpoint` test creates a product, PATCHes a catalog, reads it back through `/v1/products`, and asserts the catalog is present. Would have caught this bug at PR time.',
  '',
  '**Drag-and-drop tier ordering.** The Policies tab\'s tier-card grid now supports drag-and-drop reordering. Operator drags any tier card to a new position; on drop, the daemon receives parallel PATCH /v1/admin/policies/<id> requests setting tier_rank 1..N based on the new visual order. The cursor flips to grab/grabbing on hover/drag, the dragging card visibly lifts + fades. Archived tiers aren\'t draggable (their position in the ladder is moot). The Edit-policy modal keeps a `tier_rank` number field for two edge cases drag-and-drop can\'t express: precise manual override, and blanking the field to remove a policy from the ladder entirely (so it\'s not offered as an upgrade target).',
  '',
  '**Two small copy fixes.** Section headers on the Policies tab now show just the product name (`Keysat`) instead of the redundant `Keysat — keysat`. The entitlements-catalog row editor\'s Description column placeholder shortened from "Description (shown on buy page tooltip)" to "Description (buyer tooltip)" so it fits in the column; full hover-explanation now lives in the input\'s title attribute.',
  '',
  '**Test count: 87** (was 85 — +1 entitlements catalog regression, +1 from CORS additions that were re-counted).',
  '',
  '**Upgrade path.** v0.2.0:13 → v0.2.0:14 is a drop-in. No schema migrations, no SDK changes, no behavior change for buyers. Operators who had entitlements catalogs silently dropped on previous versions will see them populate correctly on next product PATCH. Operators who manually set tier_ranks via the number field will see those ranks reflected as the initial visual order in the policies grid.',
  '',
  '0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:',
  '',
  '- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.',
  '- The agent-friendly pitch on keysat.xyz can now link to `licensing.keysat.xyz/v1/openapi.json` and have agents fetch it in-browser without setup.',
  '- Third-party tooling (SDK demos, integration sandboxes) can call `/v1/validate` from a browser without a server-side proxy.',
  '',
  '**Security posture is unchanged.** `Access-Control-Allow-Credentials` is deliberately OFF. That combination — `ACAO: *` plus no-credentials — means a cross-origin page can read public responses but cannot ride a logged-in admin session cookie to hit `/v1/admin/*`. Admin endpoints still require an explicit Bearer token; that token isn\'t auto-attached by browsers; nothing changes for operators.',
  '',
  '**Test count: 85** (was 83 — +2 new CORS regression tests covering both the ACAO header on public endpoints and OPTIONS preflight handling).',
  '',
  '**Upgrade path.** v0.2.0:12 → v0.2.0:13 is a drop-in. No schema migrations, no SDK changes, no admin-side behavior change. The only operator-visible difference is that browser-side scripts fetching public endpoints from a different origin start working.',
  '',
  '0.2.0:12 — **Settings tab + agent-friendly operator API.** Major release that consolidates operator configuration into the admin UI and ships first-class agent / AI-automation support: OpenAPI 3.1 spec, scoped API keys, agent integration guide. Plus a slate of UX cleanups carried over from operator testing.',
  '',
  '**New Settings tab in the admin UI.** Three subsections in one place:',
  '',
  '- **Operator name** — the display name shown on /buy/<slug> + thank-you pages. Was in StartOS Actions; now lives where the rest of operator configuration lives.',
  '- **Payment providers** — BTCPay + Zaprite connect / disconnect / activate, with provider status and active-provider toggle in one view. Replaces 5 separate StartOS Actions. Zaprite is grayed out + tagged "locked" for operators on Creator tier; clicking through opens the Pro upgrade page (gated on the `zaprite_payments` entitlement).',
  '- **API keys** — generate / list / revoke scoped Bearer tokens for agents and automation.',
  '',
  '**Scoped API keys (migration 0016).** Master `KEYSAT_ADMIN_API_KEY` is full-access and reserved for the operator. Scoped keys are additional credentials with bounded permissions. Pick a role at generate time:',
  '',
  '- `read-only` — list everything, mutate nothing',
  '- `license-issuer` — reads + issue / revoke / suspend / change-tier on licenses; cannot touch products, policies, or codes',
  '- `support` — license-issuer + cancel subscriptions + deactivate machines',
  '- `full-admin` — every scope except operator-only settings (payment providers, operator name, generating other API keys)',
  '',
  'Tokens look like `ks_<43 chars>`, are returned ONCE on create (never again — only sha256 stored), and can be revoked instantly from the same UI. Endpoints that have been wired through `require_scope` accept either the master key or a scoped key with the appropriate role. Endpoints not yet wired stay master-only — secure by default.',
  '',
  '**OpenAPI 3.1 spec.** `GET /v1/openapi.json` returns a curated, stable spec covering the agent-relevant subset of endpoints. No auth required. Drop the URL into a Custom GPT, OpenAI Assistant, LangChain, Claude Code, or any agent framework with OpenAPI support and the agent discovers Keysat\'s API automatically.',
  '',
  '**Agent integration guide.** New `KEYSAT_AGENT_GUIDE.md` doc covering scoped key generation, role-to-scope mapping, error envelope conventions (every error response returns `{ok: false, error: <stable_code>, message: ...}` with stable codes like `tier_cap`, `not_found`, `license_revoked`), webhook event types, and worked recipes for common automation patterns.',
  '',
  '**Landing page — agent-friendly section.** New section at keysat.xyz/#agents calling out OpenAPI discovery, scoped API keys, and HMAC-signed webhooks, with a link to the integration guide. Top-nav gains "Agents" anchor.',
  '',
  '**Enforce mode killed.** The compile-time `KEYSAT_LICENSE_ENFORCE=1` flag is gone. Every Keysat binary now boots permissively — a missing or invalid self-license falls back to the Creator (free) tier instead of refusing to start. Simpler mental model: every Keysat install is Creator-tier out of the box, paid tiers add entitlements. The "Unlicensed" label is gone from the admin UI; the free state surfaces as "Creator" everywhere.',
  '',
  '**Zaprite gated to Pro (`zaprite_payments` entitlement).** Both Connect Zaprite and Activate Zaprite now check the daemon\'s self-tier and return 402 with an upgrade URL when the entitlement is missing. BTCPay (Bitcoin / Lightning) remains free on every tier. Renamed the entitlement from `card_payments` to `zaprite_payments` to reflect that the Zaprite gateway covers cards plus Apple Pay, bank transfers, and more — not just credit cards.',
  '',
  '**StartOS Actions reduced to 4 essentials.** Pruned 8 actions that duplicated admin-UI functionality. What remains: `Set web UI password`, `Activate Keysat license`, `Show license status`, `Show credentials`. Everything else (operator name, payment providers, products, policies, licenses, codes, machines, webhooks, audit log, scoped API keys) lives in the admin UI under Settings or the workspace sidebar.',
  '',
  '**Creator code cap bumped 5 → 10.** Promo campaigns multiply faster than products do; doubling the active-codes cap removes a friction point that wasn\'t actually driving upgrades. Existing operators see the new cap immediately.',
  '',
  '**Machines tab — global default view + drill-down.** The old "paste a license ID to see anything" form is gone. Default view now lists every machine across every license, grouped by product, with the same product / status filter pills used by Licenses and Subscriptions. Quick-stats row shows total machines + active count + top platform. Each row shows buyer email, license id (with status pill), hostname, platform, last IP, last heartbeat as a relative date. The Licenses tab now has a "Machines" button on every license row that drills directly into that license\'s machines without copy-pasting UUIDs. New repo helper `list_machines_admin` joins machines × licenses × products server-side so there\'s no N+1 fetch. `GET /v1/admin/machines` is backwards-compatible — the old `?license_id=X` form still works (it\'s how the drill-down passes through).',
  '',
  '**Buyer-facing copy aligned with the new positioning.** Thank-you page + buy page footers, registry tagline, OpenAPI spec description, every SDK README, and the integration guide all now say "Bitcoin-native self-hosted software licensing." The older "Bitcoin-paid" phrasing is fully retired from production-facing surfaces.',
  '',
  '**UX polish.** Welcome card removed from Overview (was redundant). Analytics opt-in card aligned with other Overview cards and copy tightened. support.html stripped down to Patron / Lightning / OpenSats (the speculative "what funds go toward" section is gone). Thank-you page status-detail copy honest about Lightning vs on-chain settle timing. Reason-modal label has proper vertical spacing. Stray "legacy" references in user-facing copy swept.',
  '',
  '**Cross-product safety doc.** New §9a in KEYSAT_INTEGRATION.md explaining the same-keypair-multiple-products situation: licenses for any product on an operator\'s instance signature-verify against the same key, so applications must assert `payload.product_slug` after offline verify OR pass `product_slug` to online validate. Daemon already enforces it when product_slug is passed; the SDK doesn\'t auto-assert offline. New bullet in §15 Common mistakes points at §9a.',
  '',
  '**Test count: 78 + new tests landed in this release** (full suite passes across the 8 categories: unit / api / migrations / btcpay / crosscheck / etc.). The api.rs test for `payment_provider_preference_round_trip` was updated to pin the test daemon to a licensed Pro tier so Zaprite activation still passes through the new gate.',
  '',
  '**Upgrade path.** v0.2.0:11 → v0.2.0:12 is a drop-in. Migration 0016 is additive (one new table). No SDK changes. **One operator-facing behavior change**: operators currently on a Pro/Patron self-license should rename `card_payments` → `zaprite_payments` in their `keysat` product\'s entitlements catalog + on Pro/Patron policy rows. Until they do, Connect/Activate Zaprite will 402 even though they bought the right tier. (Master operator: run this rename on your master instance, then re-mint and re-issue customer self-licenses.) Pre-launch this affects very few people.',
  '',
  '0.2.0:11 — **Policy archive + saner delete semantics + brand-consistent confirm modals.** Three closely-related cleanups driven by hands-on testing: policies can now be archived (soft-hide) instead of forced through hard-delete, the safe-delete check stopped treating revoked-license tombstones as blockers, and every confirm dialog in the admin UI now uses the in-app overlay card instead of the browser-native one.',
  '',
  '**Archive a policy.** New Archive button on every tier card. Archived policies are hidden from the admin grid (toggle "Show archived" at the top of the Policies tab to reveal them), hidden from `/buy/<slug>`, and the renewal worker refuses to renew subscriptions tied to an archived policy (dispatches a `subscription.renewal_skipped` webhook with `reason: policy_archived`). Existing licenses keep validating because entitlements are signed into the LIC1 payload — the policy row is not consulted at validate time. Reversible: Unarchive button on archived cards puts everything back. Migration 0015 adds `policies.archived_at TEXT NULL`; existing rows default to NULL (live).',
  '',
  '**Safe-delete ignores tombstones.** Previously, any reference at all — including revoked-license rows kept for audit — blocked the safe-delete path and pushed the operator to force-delete. Now the safe-delete check counts only **live licenses** (status != revoked), **settled invoices**, and **active subscriptions** (status in active/past_due). Revoke an outstanding license and the policy is immediately safe-deletable, with the cascade sweeping up the revoked tombstone + any dead invoices. Force-delete still works the same way (wipes everything regardless), and the cascade now also handles `tier_changes` rows + nulls out `discount_codes.applies_to_policy_id` so it doesn\'t fall over on FK violations.',
  '',
  '**Branded confirm dialogs.** Every `window.confirm()` call in the admin UI replaced by a new `confirmModal()` overlay-card helper styled the same as the existing `reasonModal()`. No more "immense-voyage.local:62488" host string at the top of irreversible-action confirmations. Sites swept: policy delete, product delete, analytics-ID wipe, admin tier downgrade, discount-code delete, machine force-deactivate, webhook subscription delete. Modal copy adapts to context — e.g. deleting a policy with zero licenses now just asks "Sure?" without spelling out the cascade implications.',
  '',
  '**Test count: 78** (no test changes; framework PR coming separately).',
  '',
  '**Upgrade path.** v0.2.0:10 → v0.2.0:11 is a drop-in. Migration 0015 is additive (one nullable column + an index). No SDK changes. No behavior change for operators who don\'t use the new Archive button — existing policies stay live until someone explicitly archives them.',
  '',
  '0.2.0:10 — **Licenses + Subscriptions tabs reorganized to match Products + Policies.** Both tabs now group by product (matching the per-product card sections used elsewhere in the admin UI), with product-filter pills + per-product license counts at the top. Single-product instances continue to see a flat table; multi-product instances see one section per product with a status breakdown subtitle ("3 active · 1 revoked · 2 expired"). Search results bypass grouping (search is global across all products).',
  '',
  '**Licenses tab gains a quick-stats row** matching the Overview dashboard: Licenses, Active, Revoked, Expiring within 30 days. Scope follows the active product filter — pick a product, the stats reflect just that product. Hover the "?" icons next to each stat label for definitions.',
  '',
  '**Subscriptions tab gains a Product column + status filter pill counts.** "Active (3) · Past due (0) · Cancelled (1) · Lapsed (0)" so operators see the breakdown at a glance. Status badges hover-explain what each state means ("past_due → renewal invoice exists, license still valid through grace window," etc.).',
  '',
  '**Inline reason modals replace browser prompt() dialogs.** Cancelling a subscription or revoking / suspending a license used to fire a jarring native prompt() box and a separate confirm(); both flows are now the same overlay-card UX as Change Tier — title, contextual message, optional warning banner for irreversible operations, audit-reason textarea, Cancel / Confirm buttons. Operators get clearer copy + a less-noisy interaction.',
  '',
  '**Click-to-copy IDs.** License IDs and subscription license_ids in both tabs render as clickable codes — click to copy the full UUID to clipboard with a brief "✓ copied" indicator. Replaces the older hover-to-see-full-id pattern; one fewer step to grab an id for SDK debugging or audit-log spelunking.',
  '',
  '**Relative dates with absolute hover.** `5/12/2026, 2:31:00 PM` becomes `in 3 days` / `12 hours ago` / `2 months ago` with the absolute timestamp in the hover tooltip. Applied to license issued/expires + subscription next_renewal. Operators care about "is this happening soon?" more than the wall-clock value; full timestamp still one hover away.',
  '',
  '**Manual-issue form on Licenses tab uses help icons.** Verbose hint blocks under each input replaced with `?` hover tooltips — same compact-form treatment as the Products + Policies tabs got in :8 / :9.',
  '',
  '**Test count: 78** (UI-only release, unchanged from :9).',
  '',
  '**Upgrade path.** v0.2.0:9 → v0.2.0:10 is a drop-in. No schema, SDK, or behavior change. Pure admin UI.',
  '',
  '0.2.0:9 — **Side-by-side tier-card policy authoring + form polish.** The Policies tab\'s table view is gone — replaced with a card grid where each existing policy renders as a buy-page-style tier card sitting alongside a dashed "+ Add tier" placeholder. Click the placeholder and it morphs into an editable draft card with form fields inline; submit "Create" on the card and it flips back to a read-only tier preview. **Multiple drafts can coexist** in the same product\'s grid, so operators can author Core / Pro / Patron in parallel and visually compare what each will look like to a buyer before committing any of them. Same visual language as the buy page, so what you see while authoring is what buyers see.',
  '',
  '**Form polish.** New `helpIcon()` helper renders a small "?" hover-tooltip next to field labels — replaces the verbose hint text under inputs that was making forms feel cluttered. Applied first to the product create form (Display name → Slug → Description → Price all use help icons now); spread to other forms incrementally over follow-up releases.',
  '',
  '**Auto-slug from display name.** Type "Bitcoin Ticker Pro" into the new product form\'s Display name field and the Slug field auto-fills with `bitcoin-ticker-pro` as you type. Operators can still override; the auto-fill stops mirroring once they edit the slug manually. Cuts a step out of the most common product-creation path.',
  '',
  '**Legacy create-policy disclosure removed from the UI.** The "Create a new policy" form that used to sit at the top of the Policies tab is gone — the card grid below replaces it for all common authoring. Advanced fields (custom grace period, tip recipient, tier rank) still live on the existing Edit modal of any committed tier card; create-the-basics-then-edit-for-advanced is the new flow.',
  '',
  '**No code surface change for SDKs or buy page.** This release is admin-side UX only. The catalog work shipped in v0.2.0:8 still applies (closed-list bubble pickers, display-name rendering); the new draft cards just package those into a more usable authoring flow.',
  '',
  '**Test count: 78** (unchanged from :8 — UI-only release).',
  '',
  '**Upgrade path.** v0.2.0:8 → v0.2.0:9 is a drop-in. No schema changes, no SDK changes. Operators see the new card-grid layout the next time they open the Policies tab.',
  '',
  '0.2.0:8 — **Entitlements catalog on products.** Operators define each product\'s entitlements once with display names + descriptions; policies pick from that closed list with a click-to-toggle bubble picker; the buy page renders human-readable names ("AI summaries") with descriptions as tooltips, never the raw slug ("ai_summaries"). Existing products are auto-backfilled from the union of their policies\' current entitlements (with name = slug-with-underscores-stripped) — operator can edit afterward to add proper descriptions.',
  '',
  '**Admin UI changes.** Product create + edit forms gain an "Entitlements catalog" editor: repeating rows for slug + display name + description, with an "+ Add entitlement" button. Policy create + edit forms swap the free-text entitlements textarea for a row of clickable pill chips populated from the parent product\'s catalog — click each chip to toggle that entitlement on or off for the policy. Policies list table renders entitlement display names (resolved via catalog) instead of slugs.',
  '',
  '**Buy page rendering.** Tier cards now show display names with the description as a hover tooltip on each entitlement bullet. Falls back to raw-slug rendering for legacy entries that predate the catalog (no buy-page-side breakage on upgrade).',
  '',
  '**Closed-list enforcement.** Once a product has a non-empty catalog, policy create + update endpoints reject any entitlement slug that\'s not in the catalog with a clear error pointing at the right path ("add it to the product\'s entitlements catalog first"). Products without a catalog stay in legacy "free-text" mode where any string is accepted — back-compat preserved.',
  '',
  '**SDK support.** All four SDKs (`@keysat/licensing-client`, `keysat-licensing-client` Rust crate, `keysat-licensing-client` Python package, `keysat-client-go`) bumped to 0.3.0. `Client.listPublicPolicies()` response now includes `product.entitlementsCatalog` (camelCase TS / snake_case Rust + Python + Go) — an array of `{slug, name, description}` so SDK consumers\' in-app tier pickers can render the same human-readable names + tooltips the buy page does. Empty array on legacy products without a catalog.',
  '',
  '**Schema (migration 0014).** Adds `products.entitlements_catalog_json` (nullable). Auto-backfill per product: collect the distinct union of all entitlement slugs across the product\'s policies, build a catalog with `name = slug.replace("_", " ")` and empty description, write it. Products with no policy entitlements anywhere stay NULL (legacy mode preserved).',
  '',
  '**Documentation.** KEYSAT_INTEGRATION.md section 8 ("Picking entitlement names") gets a new subsection explaining the catalog: how the bubble picker works, how the buy page renders, how SDKs surface it, and the catalog-stability rule (renaming a slug breaks existing licenses).',
  '',
  '**Test count: 78** (was 77; +1 for `migration_0014_backfills_entitlements_catalog_from_policies`).',
  '',
  '**Upgrade path.** v0.2.0:7 → v0.2.0:8 is a drop-in. Migration 0014 is additive; auto-backfill runs at boot. No behavior change for operators who don\'t open the product editor — old free-text policy entitlements continue to work. Operators who open the editor see the catalog already populated from their policies\' existing entitlements; they can refine display names + add descriptions.',
  '',
  '**What\'s next (v0.2.0:9):** side-by-side card-grid policy authoring UI. The current "open a disclosure, fill a form, click Create, repeat" flow gets replaced by a tier-card grid where operators can see existing policies as buy-page-style cards alongside editable draft cards, with multiple drafts allowed simultaneously. Lands as its own focused release on top of this.',
  '',
  '0.2.0:7 — Marketing-copy alignment. Package short and long descriptions now read "Bitcoin-native self-hosted licensing service for software creators" — matches keysat.xyz and the new positioning. Long description also calls out Zaprite (Bitcoin + cards), recurring subscriptions, and tier upgrades, all of which shipped in earlier :N revisions but weren\'t reflected in the registry listing. Same change applied to the daemon Cargo.toml description, repo READMEs, and the in-StartOS About panel for consistency. No code changes; pure copy.',
  '',
  '0.2.0:6 — **Recurring subs + trials + self-tier live refresh actually work now.** Major bug-and-UX-fix release driven by hands-on testing of v0.2.0:5. The recurring-sub feature shipped in :4 had a critical gap: buying a recurring policy issued a license but never created the corresponding subscription row, so the renewal worker never picked it up — purchases silently behaved like one-shots. The trial flow shipped with `trial_days` configurable in admin but the field had zero effect on the purchase path. And admin tier changes on the daemon\'s own license never propagated to the running daemon, making it impossible to test Creator-tier gates on the master Keysat. This release fixes all three plus a slate of UX papercuts found during testing.',
  '',
  '**Recurring purchases now create subscriptions.** `issue_license_for_invoice` calls `subscriptions::create_subscription` whenever the resolved policy has `is_recurring=1`. The Subscriptions tab populates correctly; the renewal worker sees the row; cancellation works. Idempotent against webhook re-delivery.',
  '',
  '**Free trials actually work.** When a buyer hits "Pay with Bitcoin" on a recurring policy with `trial_days > 0`, the daemon now: (a) synthesizes a free invoice via the same shortcut used for free-license-code redemptions, (b) issues a license inline with `expires_at = now + trial_days`, (c) creates the subscription with `next_renewal_at = trial_end` so the renewal worker fires the FIRST paid invoice when the trial ends, (d) returns the license key directly with no checkout step. The buy page CTA flips to "Start N-day free trial" so the buyer knows they\'re not being charged today. Discount codes are intentionally ignored on trial purchases (trial = free; layering a discount is a no-op). Trial license carries the TRIAL flag on the signed payload.',
  '',
  '**Self-tier live refresh.** The daemon\'s own tier (`state.self_tier`) was previously loaded from the on-disk LIC1 key at boot and never refreshed — entitlements baked into the signed payload at signing time were the daemon\'s permanent reality. Now there\'s a `license_self::refresh_self_tier_from_db` helper that re-reads the local `licenses` row and rebuilds `state.self_tier` from LIVE entitlements. Wired to fire (a) once at boot right after `check_at_boot`, (b) every hour as a background task, (c) on demand via `POST /v1/admin/self-license/refresh`. Admin tier changes now propagate. This is the same online-entitlement-refresh pattern any operator should implement in their own app — Keysat dogfoods it for itself.',
  '',
  '**Renewal-pending webhook payload enriched.** `subscription.renewal_pending` now includes `buyer_email`, `product_id`, `policy_id`, `cycle_start_at`, `cycle_end_at`, `due_at`, and `is_first_paid_cycle` so operators\' webhook receivers have everything they need to render and send "your free trial is ending" / "your monthly renewal is due" emails to the buyer with the checkout URL. (Without this, renewal invoices were created server-side but no one knew about them — the buyer had no way to learn they needed to pay.)',
  '',
  '**Admin Change Tier modal redesigned.** The "skip_payment" toggle is gone — admin tier changes always apply as comp from the UI now. Paid tier changes are buyer-initiated via the SDK\'s in-app upgrade flow; admin path is for operators who want to give someone a free upgrade or fix a screwup. Reduces the attack surface of "operator generates invoice, dismisses modal, orphan invoice lives on the provider." The modal also now detects downgrades (target rank or price < current), shows a yellow warning banner listing the entitlements the buyer will lose, and confirms via dialog. The dropdown shows the current tier in disabled state with "(current)" suffix — operators see what they\'re starting from but can\'t pick a no-op.',
  '',
  '**Self-tier guard.** `POST /v1/admin/licenses/<id>/change-tier` now refuses when `<id>` is the daemon\'s own self-license, with a clear error pointing at either the master Keysat\'s re-mint flow or the file-rename trick (`mv /data/keysat-license.txt /data/keysat-license.txt.bak; restart`) for testing Creator-tier gates.',
  '',
  '**Zaprite webhook flow improved.** Connect Zaprite now shows the EXACT `https://your-keysat-url/v1/zaprite/webhook` URL to paste (was a placeholder before, which Zaprite\'s form rejected). New "Show Zaprite webhook setup" StartOS Action surfaces the URL persistently for operators who skipped the step on first connect. Connect-while-already-connected returns 409 Conflict with a clear message instead of overwriting silently (BTCPay already had this guard).',
  '',
  '**Single "Switch active payment provider" StartOS action** replaces the two confusing "Activate BTCPay" / "Activate Zaprite" actions. Dropdown-driven, pre-fills with currently-active provider so opening it is informative.',
  '',
  '**UX polish on the admin dashboard:**',
  '- Policy list duration column is human-readable (`1 year` / `1 week` / `perpetual`) instead of raw seconds (`31536000s`).',
  '- "Preview buy page" button on each product\'s policies card opens `/buy/<slug>` in a new tab.',
  '- Buy page tier cards: clicked button reads "Selected" while others stay "Select" — clearer "this is the active choice" cue.',
  '- Licenses tab POLICY column shows display name primary with slug secondary (was slug-only).',
  '- Thank-you page copy: "Lightning settles in seconds; on-chain typically 10–20 minutes" instead of misleading "next block confirms" for Lightning payments.',
  '',
  '**KEYSAT_INTEGRATION.md adds section 0a "How enforcement actually works"** — the offline-vs-online framing every operator hits when they realize they want to revoke / downgrade / lapse a license. Walks through the two patterns (A: true perpetual, offline-only; B: perpetual price, online-enforced) with TS code samples and the design dials operators pick.',
  '',
  '**Test count: 77** (unchanged). The bug fixes are above the renewal-worker tests\' scope (those tests construct subscriptions explicitly via `create_subscription`, bypassing the broken purchase path); test additions deferred to the v0.3 work that\'ll cover the integration paths properly.',
  '',
  '**Upgrade path.** v0.2.0:5 → v0.2.0:6 is a drop-in. No new schema migrations. No behavior change unless you actively use recurring policies, trials, or admin tier changes — all of which were broken before and now work.',
  '',
  '0.2.0:5 — **In-place tier upgrades are functional end-to-end.** Buyers can self-serve "upgrade to Pro" inside the operator\'s app — they pay only the prorated difference for the time remaining in their current cycle, the existing license keeps its key, and the daemon flips entitlements on next online validation. Operators can force-change any license to any policy from the admin UI, with optional comp-mode (skip the invoice).',
  '',
  '**Buyer flow.** New `POST /v1/upgrade-quote` returns the prorated charge in the listed currency: "Standard $25/mo → Pro $75/mo with 15 days remaining = $25.00 today, $75.00 next cycle." `POST /v1/upgrade` creates a payment provider invoice for the prorated charge and returns a checkout URL. When the invoice settles, the webhook handler flips the license\'s policy_id + entitlements + max_machines + expires_at and any tied subscription\'s policy_id + listed_value + period_days. The signed license key stays the same — the buyer\'s app just sees the new entitlements on its next call to `/v1/validate`.',
  '',
  '**Admin flow.** New `POST /v1/admin/licenses/:id/change-tier` for force-changes. Two modes: `skip_payment: true` applies on the spot for comp upgrades / support fix-ups (no invoice, audit-logged); `skip_payment: false` creates an invoice and returns the checkout URL the operator forwards to the buyer through whatever channel (email, chat, etc.). Bypasses ladder rules — admin can move sideways, downgrade perpetuals, or change to/from policies that aren\'t in any ladder.',
  '',
  '**Tier ladder.** Policies gain a `tier_rank` integer column (NULL = excluded from buyer-facing upgrade flows). Operators set this in the policy editor: free=0, standard=1, pro=2, etc. The buyer endpoint enforces that target.tier_rank > current.tier_rank for upgrades; sideways and reverse moves return 400 "admin-only".',
  '',
  '**Recurring downgrades, scheduled at cycle boundary.** When the admin records a downgrade tier_change with `effective_at = next_renewal_at`, the renewal worker checks for pending changes before pricing the next cycle and applies them in place. This means "downgrade me at end of cycle" actually fires correctly — the next invoice bills at the new (lower) tier, not the old one. Audit-logged with `actor=system`, `applied_via=renewal_worker`.',
  '',
  '**New tables + columns.** Migration 0013 adds `policies.tier_rank` and a new `tier_changes` audit table (one row per upgrade or downgrade ever applied; FK\'d to license + invoice + both policies). Schema is purely additive — existing licenses and policies are untouched and inherit `tier_rank = NULL` (not in any ladder).',
  '',
  '**Webhook event.** `license.tier_changed` fires whenever a license\'s policy changes, with `actor=buyer|admin|system` so downstream tooling can distinguish self-service vs operator vs scheduled changes.',
  '',
  '**Test count: 77** (was 57 at v0.2.0:4). +5 covering renewal-worker pending-tier-change hook + admin endpoint variants; +6 buyer-endpoint variants + webhook tier-change branch; +8 unit tests for the quote/apply math; +1 migration regression test for the 0013 schema.',
  '',
  '**Upgrade path.** v0.2.0:4 → v0.2.0:5 is a drop-in. Migration 0013 is additive only. No behavior change for existing operators unless they explicitly set tier_rank on their policies and start using the new endpoints.',
  '',
  '0.2.0:4 — **Recurring subscriptions are functional end-to-end.** Migration 0011 stopped being dormant: operators on Pro/Patron tier can now mark a policy as recurring, the renewal worker creates fresh invoices on cadence, the buy page renders subscription pricing, and both operator and buyer can cancel cleanly.',
  '',
  '**Admin UI.** Policy editor (create + edit) gains a "Recurring subscription (Pro)" section: tick the box, pick a cadence (Monthly / Quarterly / Semi-annual / Annual / Custom days), set grace-period days (default 7) and optional free-trial days. The Policies list table shows a gold "every Nd" badge alongside the existing trial badge so recurring tiers are recognisable at a glance. Free / Creator-tier operators see a 402 with an upgrade link if they try to flip a policy to recurring — same gating pattern as the existing product/policy/code caps.',
  '',
  '**Buy page.** Recurring tier cards render a "Renews monthly / annually / every N days" line plus a "/mo" / "/yr" / "/Nd" suffix on the headline price ("$25 / mo" not just "$25"). First-cycle trial banner shows when trial_days > 0 ("14 day free trial"). Tier-switching JS keeps the cadence suffix in sync as the buyer clicks between tiers.',
  '',
  '**Renewal worker.** Background worker sweeps every 60 seconds for subs whose `next_renewal_at` has passed. SAT-priced subs use identity conversion (no rate fetcher); fiat-priced subs re-quote each cycle so a billing cycle always reflects the BTC/USD rate at the moment of renewal (per MULTI_CURRENCY_DESIGN). Failed renewals back off on a 5min → 30min → 2h → 6h → 12h schedule, capped at 5 consecutive failures before the worker stops touching the row. Past-due subs whose grace window has elapsed transition to `lapsed` automatically.',
  '',
  '**New Subscriptions tab in the admin UI.** Lists all subs with status filter pills (All / Active / Past due / Cancelled / Lapsed). Each row shows the license, cadence, listed price (in original currency), status, next renewal, consecutive failures, and a one-click Cancel button (confirms with an optional reason captured to the audit log). Cancellation is non-destructive — the license stays valid through the end of the current billing cycle, the renewal worker just stops creating new invoices.',
  '',
  '**Buyer self-service cancel.** New `POST /v1/subscriptions/cancel` endpoint takes the buyer\'s signed license key as auth (no admin token, no cookie) and cancels the tied subscription. SDKs can wire a "Cancel subscription" button in the operator\'s app without involving the operator\'s support workflow. Bad/wrong/revoked keys all return 401 (not 404) so a probe can\'t enumerate which licenses have active subs.',
  '',
  '**Webhooks.** New `subscription.cancelled` event fires with `actor=admin|buyer` so operators can distinguish self-service cancels in their downstream tooling. The existing `subscription.lapsed` event fires when the worker transitions a past-due sub past its grace window.',
  '',
  '**Auto-charge via saved payment profiles is NOT in this release.** The renewal worker creates fresh invoices that the buyer must pay manually. v0.2.0:5+ adds the auto-charge path (Zaprite\'s `paymentProfileId` flow). Until then, subscriptions are "we send you a fresh invoice link every month" — closer to GitHub Sponsors than Stripe.',
  '',
  '**Test count: 57** (was 42 at v0.2.0:3). +7 renewal-worker integration tests, +4 admin policy tests covering recurring fields and the Pro-tier gate, +4 cancellation tests covering both admin and buyer paths.',
  '',
  '**Upgrade path.** v0.2.0:3 → v0.2.0:4 is a drop-in. The schema columns for recurring policies were already added in v0.2.0:2 (migration 0011); existing policies have `is_recurring=0` so the renewal worker has nothing to do. No behavior change unless an operator explicitly creates a recurring policy.',
  '',
  '0.2.0:3 — **Durable payment-provider switching.** Fixes a gap from v0.2.0:2 where Connect Zaprite swapped the in-memory provider but BTCPay silently re-took active on the next daemon restart. Both providers\' configurations can now coexist, with a persisted preference flag determining which one is active. New "Activate BTCPay" / "Activate Zaprite" StartOS Actions let operators flip between configured providers in one click without re-running Connect. Disconnect on either provider clears the preference only if it pointed at the disconnected one — symmetric handling preserves operator intent.',
  '',
  'New endpoints: `GET /v1/admin/payment-provider/status` (both configs\' state + active preference in one call), `POST /v1/admin/payment-provider/activate` (flip active without re-authorizing). The boot-time loader now reads the persisted preference, so what an operator activates today is what loads tomorrow regardless of which config rows happen to be in the DB.',
  '',
  'Test count: 42 (added `payment_provider_preference_round_trip` covering the full lifecycle).',
  '',
  '0.2.0:2 — **Zaprite payment provider lands.** Operators can now choose between BTCPay (Bitcoin-only, you run the BTCPay Server yourself) and Zaprite (Bitcoin + fiat cards via Stripe/Square, brokered by Zaprite, settles to your connected wallets). Switching is Disconnect → Connect via new StartOS Actions ("Connect Zaprite" / "Disconnect Zaprite" / "Check Zaprite connection"). Existing BTCPay-connected operators see zero change unless they explicitly switch.',
  '',
  'How it works: paste your Zaprite API key (created at app.zaprite.com → Settings → API) into the Connect Zaprite action. Daemon validates the key, swaps the active provider atomically. Then add a webhook in your Zaprite dashboard pointing at `<your-keysat-url>/v1/zaprite/webhook`.',
  '',
  '**Webhook security.** Zaprite does NOT sign webhook deliveries (verified May 2026 against their public OpenAPI + dashboard). Keysat\'s defense is the externalUniqId round-trip: we attach our local invoice UUID at order creation, and the webhook handler trusts the body only insofar as the order id resolves to a local invoice in an expected state. An attacker spoofing a webhook would need to know a UUID we never put on the wire to reach a real local invoice.',
  '',
  '**Migration 0011 (dormant) lands the recurring-subscriptions schema** — `subscriptions` + `subscription_invoices` tables, plus `is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/`trial_days` (default 0) columns on policies. No daemon code uses these yet; phases 2-6 of `RECURRING_SUBSCRIPTIONS_DESIGN.md` ship in follow-up releases. The schema is purely additive and existing policies inherit the safe defaults.',
  '',
  '**Migration 0012** adds the `zaprite_config` table (singleton row mirroring `btcpay_config` from migration 0002).',
  '',
  '**Limitation called out cleanly:** Zaprite\'s API has no native subscription endpoints — Keysat\'s renewal worker (when it ships) drives the cycle on our side and uses Zaprite\'s `paymentProfileId` + `POST /v1/orders/charge` to charge saved cards each cycle. This is actually a cleaner model than provider-managed subscriptions because Keysat keeps the source of truth on when to bill.',
  '',
  '**Test count: 41** (was 39; +2 covering the Zaprite webhook event-parsing contract and the provider kind self-identification, +1 covering migration 0011\'s populated-data backfill contract).',
  '',
  '**Upgrade path.** v0.2.0:1 → v0.2.0:2 is a straight drop-in. Two new SQLite migrations (0011, 0012); both are additive only. No behavior change for current operators unless they explicitly run Connect Zaprite.',
  '',
  '0.2.0:1 — Buy-page discount-code box no longer shows a "FOUNDERS50" placeholder. Empty placeholder now; buyers paste their actual code without a misleading hint.',
  '',
  RELEASE_NOTES,
].join('\n\n')

export const v0_2_0 = VersionInfo.of({
  version: '0.2.0:62',
  releaseNotes: { en_US: ROUTINE_NOTES },
  // No on-disk transformation needed — v0.2.0:0 is a label change.
  // SQLite-level migrations live separately under
  // licensing-service/migrations/ and run at daemon boot regardless
  // of the ExVer-level version graph.
  migrations: {},
})
