// Draft of the v0.2.0 milestone version entry. // // NOT YET WIRED INTO `versions/index.ts` — this file sits ready to // use when we cut v0.2.0:0 from the alpha-iteration line. To // activate: // 1. In `versions/index.ts`: // import { v0_2_0 } from './v0.2.0' // export const versions = VersionGraph.of({ // current: v0_2_0, // other: [v0_1_0], // ← so installs on 0.1.0:N can upgrade // }) // 2. Build the .s9pk (`make x86`). // 3. Publish via `~/.keysat/publish.sh` (the version-changed gate // will fire because `0.2.0:0` differs from the recorded // `0.1.0:N`). // // Why this draft exists separately: // - The cut is an irreversible release decision for already-installed // operators (downgrade paths exist in StartOS but they're sticky). // - Wiring it in changes how StartOS computes the upgrade dialog // shown to operators on registry refresh — best to QA the // release-notes content in this file before flipping the switch. // - Lets us write the v0.2.0 release notes carefully and then ship // them all at once, rather than amending mid-build. // // Version-string format reminder: ExVer is `:`. // The `` 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: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()` 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 `