CV-IDN — Identity, Entitlement, Storefront, and Metering

Status: P1 backend + P2 cv-web auth + P3 storefront + P4-web cv-cad handshake landed 2026-05-21. Audience: engineering, ADN reviewers, operations. Supersedes: doc 07 §“CV-IDN” (which marked this work Deferred).

CV-IDN is the identity, licensing, and metering substrate shared by cv-web (browser PWA) and cv-cad (AutoCAD plugin shipping as an Autodesk App Store .bundle). It is the runtime answer to two ADN-listing-blocking questions: who is the user, and are they allowed to do this action right now? This document is the canonical architectural reference.

The deferred-implementation sketch in doc 07 lines 205–277 is now superseded by what’s described here.


Two-axis pricing model

CV-IDN expresses two orthogonal customer-value axes. Both can be purchased independently.

                  ┌────────────────────────────────────┐
                  │  Membership (recurring)            │
                  │  - Unlocks cv-web editor (writes)  │
                  │  - Unlocks AI features (pick sim,  │
                  │    auto-layout)                    │
                  │  - Drips 20 tokens/mo while active │
                  └────────────────────────────────────┘
                                  │
                                  │ orthogonal axes
                                  │
                  ┌────────────────────────────────────┐
                  │  Tokens (one-time bundle)          │
                  │  - Never expire                    │
                  │  - Spent only on deliverable       │
                  │    artifacts (PDF, DXF, CSV, print)│
                  │  - Work in cv-web AND cv-cad       │
                  └────────────────────────────────────┘

SKU

Price

Token grant

Membership?

Monthly subscription

$500/mo

20 tokens/mo drip

yes

Annual subscription

$4,800/yr (20% off)

20 tokens/mo drip

yes

Bundle 10

$500 one-time

+10 tokens

no

Bundle 100

$2,500 one-time

+100 tokens

no

Trial (new account)

$0

+10 tokens

yes, 7 days

Token-spend rule. Every print or export of a deliverable artifact (PDF panel book, DXF, CSV materials list, printed page) costs 1 token, regardless of which app produced it. Export of a native .cvpanel / .cvsite / .cvp envelope is free — it’s the inter-app transport format, and the recipient pays their own token when they print or export from it. No double-charging: encoded native formats aren’t deliverables and aren’t transferable, so they’re charged exactly once at the moment a deliverable is created.

Membership-gate rule. Without active membership, cv-web is a read-only viewer: open a project, browse panels, view the 3D scene, but every dispatch into useStore that would mutate state — and every AI feature — is blocked with an upsell modal. Token-based print/export still works for viewers who own tokens.

AI gating. Pick simulations and auto-layout panels require subscriptions.status IN ('trial', 'active') AND subscriptions.period_end > now(). Lapsed membership immediately blocks AI at the next call.

Trial. New user with verified email or successful OAuth gets 7 days of active membership + 10 free tokens. After 7 days, membership lapses unless converted; tokens remain forever.


Domain model

                            ┌────────────────────────────────────────┐
                            │  User (one row in Supabase auth.users) │
                            │  Linked OAuth identities: google,      │
                            │  apple, linkedin_oidc, dropbox,        │
                            │  autodesk (APS)                        │
                            └────────────────────┬───────────────────┘
                                                 │
                ┌────────────────────────────────┼───────────────────────────────┐
                │                                │                               │
   ┌────────────▼─────────────┐  ┌───────────────▼────────────┐  ┌───────────────▼──────────────┐
   │ MEMBERSHIP (subscriptions)│  │ TOKEN LEDGER (token_ledger)│  │ AUDIT (export_events)        │
   │  - status: trial / active │  │  - delta (+grant, -spend)  │  │  - file_hash                 │
   │    / past_due / canceled  │  │  - source: monthly_drip,   │  │  - artifact: pdf/dxf/csv/    │
   │  - period_end (Stripe)    │  │    bulk_10, bulk_100,      │  │    cvpanel/cvsite/cvp        │
   │  - plan: monthly / annual │  │    trial_grant, refund     │  │  - app: cv-web / cv-cad      │
   │  - stripe_customer_id     │  │  - balance = SUM(delta)    │  │  - ledger_id (FK)            │
   └───────────────────────────┘  └────────────────────────────┘  └──────────────────────────────┘

                                  ┌───────────────────────────────┐
                                  │ DEVICE TOKENS (device_tokens) │
                                  │  - sha256(token) only         │
                                  │  - machine_id (cv-cad bound)  │
                                  │  - revoked_at                 │
                                  └───────────────────────────────┘

Append-only ledger. Balance is SUM(delta) WHERE user_id = $1 AND revoked = false, computed by the token_balances view. There is no “balance” column — the ledger is the truth, the view is the projection. Refunds become negative-delta rows; never edit existing rows.

Idempotency. Spend rows are uniquely keyed by (user_id, idempotency_key). A retry with the same key returns the original row instead of double-spending.

RLS policies. Users select only their own rows in every table. All mutations route through edge functions that use the service role (which bypasses RLS).


System architecture

Backend service — Supabase project cv-idn

  • Host: Supabase Cloud, custom domain idn.simplestruct.com.

  • Stack: Postgres 15 + Supabase Auth + Edge Functions (Deno/TypeScript).

  • Tables: subscriptions, token_ledger, export_events, device_tokens, plus Supabase-managed auth.users and auth.identities. Schema in supabase/migrations/0001_init.sql.

  • Views: token_balances (SUM(delta) per user), entitlement_view (joins subscriptions + token_balances).

  • Triggers: handle_new_user grants 7-day trial + 10 tokens on auth.users insert.

Edge functions

Function

Auth

Purpose

POST /entitlement

self-verified (JWT or device-token)

Returns { membership: { status, period_end, plan }, balance: int, ai_unlocked: bool }. Called by cv-web on app boot + window focus, and by cv-cad on every print/export attempt.

POST /spend

self-verified

Idempotent. Body: { artifact, file_hash, app, idempotency_key }. Inserts -1 row to token_ledger + audit row to export_events. Returns 402 if balance == 0. body.app must match caller’s app.

POST /stripe-webhook

Stripe signature

Handles checkout.session.completed (bundle → +10/+100; subscription → upsert + +20 drip), invoice.paid (renewal drip), customer.subscription.updated/deleted (status mapping). Idempotent via stripe_event_id.

POST /checkout

JWT-only

Creates Stripe Checkout session; reuses existing stripe_customer_id if present.

POST /customer-portal

JWT-only

Returns Stripe Billing Portal URL for self-serve cancellation, payment-method updates, invoice history.

POST/GET/DELETE /device-tokens

JWT-only

Mints (POST), lists (GET), revokes (DELETE) device tokens bound to (user_id, machine_id). Device tokens cannot mint device tokens.

Why verify_jwt = false on entitlement/spend/stripe-webhook. These accept either a Supabase user JWT or a cv-cad device token. We verify ourselves in _shared/auth.ts via identifyCaller(req), which tries the device-token hash first and falls back to JWT.

Stripe

  • Live mode account under simplestruct.com.

  • Two products: cv-membership (monthly + annual prices) and cv-tokens (one-time bundles 10 + 100).

  • Webhook endpoint configured to idn.simplestruct.com/stripe-webhook with signing secret in Supabase env.

  • Customer Portal enabled for self-serve cancellation.


Sequence diagrams

Sign-in (cv-web)

User              cv-web (browser)          Supabase Auth           OAuth provider
  │                    │                        │                        │
  │  click Google      │                        │                        │
  ├───────────────────►│  signInWithOAuth       │                        │
  │                    ├───────────────────────►│  redirect to provider  │
  │                    │                        ├───────────────────────►│
  │                    │                        │                        │
  │  authorize         │                        │                        │
  ├────────────────────┴────────────────────────┴───────────────────────►│
  │                                                                       │
  │  302 to /auth/callback?code=...                                       │
  │◄──────────────────────────────────────────────────────────────────────┤
  │                                                                       │
  │                    │  AuthCallback.tsx                                │
  │                    │  exchangeCodeForSession                          │
  │                    ├───────────────────────►│                        │
  │                    │  JWT in storage        │                        │
  │                    │                        │                        │
  │                    │  AuthProvider fetches  │                        │
  │                    │  POST /entitlement     │                        │
  │                    ├───────────────────────►│  (entitlement edge fn) │
  │                    │  {membership, balance, ai_unlocked}             │
  │                    │◄───────────────────────┤                        │
  │                    │                        │                        │
  │  read-only banner gone (canEdit=true), full editor renders           │
  │◄───────────────────┤                                                  │

Spend (cv-web export)

User              cv-web                  /spend edge fn           Postgres
  │                  │                          │                       │
  │  click "Export PDF"                         │                       │
  ├────────────────►│  gatedExport({ artifact: 'pdf', produce })       │
  │                  │  generate idempotency_key (UUID)                  │
  │                  │  POST /spend { idempotency_key, artifact, app }  │
  │                  ├─────────────────────────►│                       │
  │                  │                          │  identifyCaller       │
  │                  │                          │  SELECT balance       │
  │                  │                          ├──────────────────────►│
  │                  │                          │  INSERT -1 row        │
  │                  │                          │  INSERT export_events │
  │                  │                          ├──────────────────────►│
  │                  │  {ok, new_balance}       │                       │
  │                  │◄─────────────────────────┤                       │
  │                  │                                                  │
  │                  │  produce(): jsPDF → file bytes                   │
  │                  │  file_hash = sha256(bytes)                       │
  │                  │  POST /spend (replay) with file_hash             │
  │                  ├─────────────────────────►│                       │
  │                  │                          │  same idempotency_key │
  │                  │                          │  UPDATE export_events │
  │                  │                          │    SET file_hash      │
  │                  │  {ok, new_balance}       ├──────────────────────►│
  │                  │◄─────────────────────────┤                       │
  │                                                                     │
  │  save PDF to disk                                                   │
  │◄─────────────────┤                                                  │

Spend — insufficient tokens (402)

cv-web                  /spend                  cv-web (App.tsx)
  │  POST /spend         │                          │
  ├────────────────────►│                          │
  │  402 {error: 'insufficient_tokens'}              │
  │◄────────────────────┤                          │
  │                                                 │
  │  exportGateway invokes registered upsell handler│
  ├────────────────────────────────────────────────►│
  │                                                 │  setUpsell('tokens')
  │                                                 │  → UpsellModal renders
  │                                                 │  → "Buy tokens" → /pricing → Stripe Checkout

Subscription purchase

User           cv-web        /checkout       Stripe         /stripe-webhook    Postgres
  │              │              │              │                  │              │
  │ Subscribe    │              │              │                  │              │
  ├────────────►│ POST /checkout │              │                  │              │
  │              ├─────────────►│              │                  │              │
  │              │              │ create Session│                  │              │
  │              │              ├─────────────►│                  │              │
  │              │              │ {url}        │                  │              │
  │              │              │◄─────────────┤                  │              │
  │              │ {url}        │              │                  │              │
  │              │◄─────────────┤              │                  │              │
  │ redirect     │              │              │                  │              │
  │◄─────────────┤              │              │                  │              │
  │ complete payment on stripe.com             │                  │              │
  ├──────────────────────────────────────────►│                  │              │
  │                                            │ checkout.session.completed     │
  │                                            ├─────────────────►│              │
  │                                            │                  │ upsert subs  │
  │                                            │                  ├─────────────►│
  │                                            │                  │ insert +20   │
  │                                            │                  │   drip row   │
  │                                            │                  ├─────────────►│
  │                                            │                  │ 200          │
  │                                            │◄─────────────────┤              │
  │ redirect to ?checkout=success              │                  │              │
  │◄──────────────────────────────────────────┤                  │              │
  │ AuthProvider window-focus refresh picks up new balance        │              │

cv-cad first-run sign-in

User in AutoCAD       cv-cad plugin             default browser           cv-web /cv-cad-login
  │  type CSV          │                              │                          │
  ├──────────────────►│  cvidn_check_entitlement      │                          │
  │                    │  read %APPDATA%\...\device_token │                       │
  │                    │  → missing                   │                          │
  │                    │  generate machine_id GUID    │                          │
  │                    │  startapp browser to         │                          │
  │                    │  idn.simplestruct.com/       │                          │
  │                    │  cv-cad-login?machine_id=GUID│                          │
  │                    ├─────────────────────────────►│                          │
  │                    │                              │  CvCadLogin.tsx          │
  │                    │                              ├─────────────────────────►│
  │                    │                              │  if !session → SignInPanel
  │  complete OAuth in browser                        │  POST /device-tokens     │
  ├──────────────────────────────────────────────────►│  {machine_id}            │
  │                    │                              │  ← {token: '...'}        │
  │                    │                              │                          │
  │                    │                              │  attempt cv-cad://token  │
  │                    │                              │  (deep-link)             │
  │                    │                              │  + show token for copy   │
  │                    │  receive deep-link via Windows handler                  │
  │                    │  OR user pastes into AutoCAD dialog                     │
  │                    │  write %APPDATA%\...\device_token                       │
  │                    │  cvidn_check_entitlement                                │
  │                    │  POST /entitlement {Authorization: Bearer <token>}     │
  │                    ├────────────────────────────────────────────────────────►│
  │                    │  ← {membership, balance, ai_unlocked}                   │
  │                    │  cache to entitlement.json                              │
  │  CSV command runs normally now                                               │

cv-cad print → token spend

User           cv-cad             /spend           cv-web BillingDialog
  │             │                    │                       │
  │ print panel │                    │                       │
  ├───────────►│ cvidn_spend_token   │                       │
  │             │  generate uuid     │                       │
  │             │  shell cvidn.ps1 spend                     │
  │             │  Invoke-RestMethod │                       │
  │             ├───────────────────►│                       │
  │             │  device-token auth │                       │
  │             │  insert -1 ledger  │                       │
  │             │  ←{ok, balance}    │                       │
  │             │◄───────────────────┤                       │
  │  print runs                                              │
  │             │  next time user opens cv-web              │
  │             │  AuthProvider /entitlement returns new balance
  │             │  History tab shows spend with file_hash    │
  │             │                                            │
  │  on offline failure: cached entitlement (<24h) allows    │
  │  optimistic spend, queued for reconciliation             │

API contracts

POST /entitlement

Source of truth: backend/supabase/functions/entitlement/index.ts.

Request: Empty body. Authorization: Bearer <jwt-or-device-token> required. cv-web sends a Supabase user JWT; cv-cad sends a long-lived device token. identifyCaller(req) in _shared/auth.ts tries the device-token hash first and falls back to JWT verification.

Response (200):

{
  "membership": {
    "status": "trial" | "active" | "past_due" | "canceled" | "expired" | "none",
    "plan":   "monthly" | "annual" | "trial" | null,
    "period_end": "2026-06-21T00:00:00Z" | null
  },
  "balance": 0,
  "ai_unlocked": false
}

For brand-new users whose entitlement_view row hasn’t been materialized yet (signup-trigger race), the endpoint returns { "membership": null, "balance": 0, "ai_unlocked": false } rather than 404. The next refresh repairs.

Errors:

  • 401 { "error": "unauthorized" } — Bearer token missing, expired, revoked, or unmatched by identifyCaller.

  • 500 { "error": "<postgrest-msg>" } — internal Postgres failure reading entitlement_view.

POST /spend

Source of truth: backend/supabase/functions/spend/index.ts.

Request:

{
  "artifact": "pdf" | "dxf" | "csv" | "print",
  "file_hash": "<sha256-hex>" | null,
  "app": "cv-web" | "cv-cad",
  "idempotency_key": "<uuid>"
}

artifact is strictly one of the four chargeable types. Native CV-format envelopes (.cvpanel, .cvsite, .cvp, .cvt) never reach this endpoint — clients bypass gatedExport entirely for them. Sending a native format here returns 400 artifact_not_chargeable and is a client bug.

app must equal the caller’s app as resolved by identifyCaller (device tokens resolve to cv-cad, JWTs to cv-web). Mismatch returns 400 app_mismatch — it’s a smoke check on client wiring, not a security boundary (the server-side caller.app is authoritative for the ledger note).

file_hash is optional on the initial call; cv-web does a two-phase pattern (charge, then build, then call again with file_hash set — the replay path updates export_events.file_hash on the original row). cv-cad does a one-phase pattern (compute the hash before calling).

Response (200):

{ "ok": true, "new_balance": 9 }

Replay of the same idempotency_key for the same user returns the original outcome with "replayed": true and no double-charge.

Errors:

  • 400 { "error": "invalid_json" } — body wasn’t JSON.

  • 400 { "error": "missing_fields" }artifact, app, or idempotency_key absent.

  • 400 { "error": "artifact_not_chargeable" } — artifact not in ["pdf","dxf","csv","print"].

  • 400 { "error": "app_mismatch" }body.app differs from caller.app.

  • 401 { "error": "unauthorized" } — Bearer token missing/invalid/revoked.

  • 402 { "error": "insufficient_tokens", "balance": 0 } — balance ≤ 0. cv-web opens upsell modal; cv-cad shows “Out of Tokens” dialog and aborts the export.

  • 405 { "error": "method_not_allowed" } — non-POST.

  • 409 { "error": "idempotency_key_conflict" } — the key was already used by a different user. Genuine client bugs only; never seen in normal operation.

  • 500 { "error": "<postgrest-msg>" } — ledger insert failed after the race-tolerant replay check.

There is no rate-limit response (no 429) and no authenticated-but-forbidden response (no 403) at the time of writing. If we add either later, document it here before deploying.

POST /device-tokens

Request:

{ "machine_id": "<guid>", "label": "Tai's laptop" }

JWT-only — device tokens cannot mint device tokens.

Response (201):

{ "token": "<long-opaque-token>", "expires_at": "2027-05-21T00:00:00Z" }

The token is returned once and never again — Supabase only stores sha256(token). Existing active tokens for the same (user_id, machine_id) are revoked first.

GET /device-tokens, DELETE /device-tokens/{id}

For the Authorized Devices UI in cv-web account settings.


File-format rules (which exports cost tokens)

Format

Produced by

Token cost

Rationale

PDF panel book

cv-web, cv-cad

1 token

Deliverable; finalized output

DXF (panel or site)

cv-web, cv-cad

1 token

Deliverable; goes to fabricator

CSV (materials list)

cv-web, cv-cad

1 token

Deliverable; goes to procurement

Print job

cv-cad

1 token

Physical-page deliverable

.cvpanel (panel envelope)

cv-web

free

Inter-app transport — receiver pays when they print/export

.cvsite (site envelope)

cv-web

free

Inter-app transport

.cvp (project bundle)

cv-web

free

Inter-app transport

.cvt (template)

cv-web

free

Asset, not deliverable

Save/autosave to disk

cv-web

free

Storage, not deliverable

Snapshot (versioned save)

cv-web

free

Storage


Error-state UX requirements

cv-web

Error

UI behavior

402 from /spend

Show UpsellModal with reason='tokens', primary CTA → /pricing → Stripe Checkout for bundle.

401 from /entitlement (token expired)

Silent retry via session refresh; if still 401, render SignInPanel.

canEdit === false

Top-bar ReadOnlyBanner: “Sign in to edit” (anonymous) or “Membership expired — manage” (lapsed). Every mutating reducer dispatches a no-op + opens UpsellModal with reason='edit'.

ai_unlocked === false

AI buttons disabled with tooltip “Activate membership to use AI features”.

Stripe Checkout cancel

Return to /pricing with ?checkout=cancel; no error toast.

Stripe Checkout success

Return to app with ?checkout=success; query param stripped on App load; window-focus triggers entitlement refetch.

cv-cad

Error

UI behavior

/spend returns 402

Show CV dialog: “You’re out of tokens. Buy more at simplestruct.com.” Abort the print/export.

/spend unreachable AND cached entitlement >24h old

Show CV dialog: “Can’t reach licensing server. Reconnect or sign in again.” Abort.

/spend unreachable AND cached entitlement <24h old AND cached balance > 0

Optimistic spend: allow the export, queue the /spend POST for reconciliation. On next online session, drain queue.

Device token revoked (401 from /entitlement)

Show CV dialog: “Your device was deauthorized. Sign in again to continue.” Open browser to /cv-cad-login?machine_id=....


Open design questions (resolve during rollout)

  • Device-token transport from web → cv-cad. Deep-link (cv-cad://) is cleanest but requires URL-handler registration in the cv-cad installer; polling a known temp file is uglier but works on any Windows. Decide during CV-561 (cv-cad first-run sign-in flow).

  • Monthly drip timing. Renew tokens on subscription-start anniversary or on Stripe invoice.paid? Stripe-driven is more proration-correct.

  • Token consumption ordering for users with both monthly drip and bulk-bought tokens. Default: single pool, no FIFO bookkeeping — tokens never expire, so user-visible behavior is identical.

  • Read-only viewer for shared links. Should anonymous users be able to open a member’s exported .cvpanel in cv-web’s read-only viewer? Default: members-only; anonymous = sign-in wall. Revisit if marketing wants link-sharing.

  • Refund / chargeback behavior. Stripe refund → negative-delta ledger row to claw back tokens, or leave granted? Customer-support policy decision. Default: negative-delta row, documented in the ops runbook.


ADN listing implications

The ADN submission package (NLT 2026-07-09, doc 47 §critical-path) requires “license-mechanism source” (doc 47:36). CV-IDN satisfies this with:

  • cvidn.lsp + cvidn.ps1 (the in-plugin license client, files in src/x32/TB11-01x32/)

  • This document, plus the one-page license-flow PDF derived from the sequence diagrams above

  • Autodesk APS as one of the OAuth providers — ADN reviewers see APS support; the listing is not rejected for also accepting Google/Apple/LinkedIn/Dropbox/email.


cv-cad integration

The cv-cad AutoCAD plugin reaches the same edge functions as cv-web, but the auth model, the spend trigger points, and the offline-grace requirement are unique to it. This section is the complete contract the cv-cad machine implements against — paired with the read-only file references in src/x32/TB11-01x32/, an engineer should be able to deliver cvidn.lsp + cvidn.ps1 + the menu-wiring changes without any further design questions.

Machine scope. Code under src/x32/TB11-01x32/ and src/x64/TB11-01x64/ is implemented on the cv-cad workstation; this document is the shared design contract. Sequence diagrams, API contracts, and the deliverable-vs-native table here are the authoritative source for that work.

Device token format

cv-cad authenticates with a long-lived opaque token, not a JWT. Concretely:

  • Issued by POST /device-tokens after the user completes a browser-side OAuth flow on /cv-cad-login.

  • Format: 32 random bytes → 43-character base64url string (URL-safe alphabet — no +, /, or = padding). Generated server-side via crypto.getRandomValues. Source: device-tokens/index.ts:26-33.

  • Storage: Supabase stores only sha256(token) in device_tokens.token_hash. The raw token is returned to the browser exactly once and never recoverable from the database. Reissuing for the same (user_id, machine_id) automatically revokes the prior active row.

  • Lifetime: No expiry. Revocable any time via DELETE /device-tokens/:id from the Authorized Devices UI in /billing. Revocation is a revoked_at timestamp on the row — the next /entitlement call returns 401 and cv-cad must re-run the first-run flow.

  • Transport on the wire: Authorization: Bearer <token> header, identical to the cv-web JWT case. The identifyCaller(req) helper in _shared/auth.ts tries the device-token hash first and falls back to JWT verification.

  • Local persistence on the cv-cad workstation: %APPDATA%\ConstructiVision\device_token — a one-line text file readable only by the current Windows user (ACL set on first write). The same directory holds entitlement.json (cached entitlement) and spend_queue.json (offline-grace queue).

Flow 1 — First-run sign-in

        sequenceDiagram
    autonumber
    actor U as User in AutoCAD
    participant CAD as cv-cad plugin
    participant BR as default browser
    participant WEB as cv-web /cv-cad-login
    participant API as Supabase edge fns

    U->>CAD: type CSV
    CAD->>CAD: read %APPDATA%\...\device_token
    CAD->>CAD: missing → generate machine_id GUID
    CAD->>BR: startapp idn.simplestruct.com/cv-cad-login?machine_id=GUID
    BR->>WEB: GET /cv-cad-login?machine_id=GUID
    WEB->>U: SignInPanel (if no session)
    U->>WEB: complete OAuth (Google / Apple / LinkedIn / Dropbox / APS)
    WEB->>API: POST /device-tokens {machine_id}
    API-->>WEB: 201 {token, id}
    WEB->>BR: attempt window.location = "cv-cad://<token>"
    WEB->>U: also show "Copy token" fallback
    BR->>CAD: deep-link handler delivers <token> (or user pastes)
    CAD->>CAD: write %APPDATA%\...\device_token (0600 ACL)
    CAD->>API: POST /entitlement (Bearer <token>)
    API-->>CAD: 200 {membership, balance, ai_unlocked}
    CAD->>CAD: cache to entitlement.json with timestamp
    CAD-->>U: CSV command proceeds
    

Edge cases:

  • Deep-link handler not registered (installer step skipped) → user pastes the token into a fall-back AutoCAD dialog.

  • Browser closed before token retrieval → user re-runs CSV; cv-cad detects missing device_token file and relaunches the same URL.

  • User cancels OAuth → /cv-cad-login shows “Sign-in cancelled — close this tab and try again in AutoCAD.”

Flow 2 — Online token spend

        sequenceDiagram
    autonumber
    actor U as User in AutoCAD
    participant CAD as cv-cad plugin
    participant PS as cvidn.ps1
    participant API as POST /spend
    participant DB as token_ledger / export_events

    U->>CAD: trigger deliverable menu item (e.g. Print)
    CAD->>CAD: cvidn_spend_token: generate idempotency_key (UUID)
    CAD->>PS: shell cvidn.ps1 spend --artifact pdf --key <uuid>
    PS->>API: POST /spend Bearer <token>
    API->>DB: SELECT balance
    alt balance == 0
        API-->>PS: 402 insufficient_tokens
        PS-->>CAD: exit code 2 + JSON
        CAD-->>U: dialog "Out of tokens — buy at simplestruct.com"
        CAD->>CAD: ABORT print/export
    else balance > 0
        API->>DB: INSERT delta=-1 ledger row
        API->>DB: INSERT export_events audit row
        API-->>PS: 200 {ok, new_balance}
        PS-->>CAD: exit code 0 + JSON
        CAD->>CAD: update entitlement.json balance
        CAD-->>U: print/export proceeds normally
    end
    

Idempotency. If the network drops between the API committing the row and cv-cad reading the response, cv-cad retries with the same idempotency_key. The /spend endpoint detects the prior row by unique-key lookup and returns {ok, replayed: true, new_balance} without double-charging (spend/index.ts:63-81).

Flow 3 — Offline grace

        sequenceDiagram
    autonumber
    actor U as User in AutoCAD
    participant CAD as cv-cad plugin
    participant CACHE as entitlement.json / spend_queue.json
    participant API as POST /spend

    U->>CAD: trigger deliverable menu item
    CAD->>API: POST /spend (Invoke-RestMethod)
    API--xCAD: network unreachable
    CAD->>CACHE: read entitlement.json
    alt cached >24h old
        CAD-->>U: dialog "Connection required — last sync N hours ago"
        CAD->>CAD: ABORT
    else cached <24h AND cached_balance > 0
        CAD->>CACHE: decrement cached_balance
        CAD->>CACHE: append {idempotency_key, artifact, ts} to spend_queue.json
        CAD-->>U: print/export proceeds (optimistic)
    else cached <24h AND cached_balance == 0
        CAD-->>U: dialog "Out of tokens — buy at simplestruct.com"
        CAD->>CAD: ABORT
    end

    Note over CAD,API: ...later, network restored...
    CAD->>API: POST /entitlement (online check)
    API-->>CAD: 200 {balance: server_truth}
    loop drain queue
        CAD->>API: POST /spend (queued row, same idempotency_key)
        API-->>CAD: 200 {ok, new_balance}  (or 200 replayed)
        CAD->>CACHE: remove queued row
    end
    CAD->>CACHE: refresh entitlement.json from server
    

Drift handling. If the server-truth balance after drain is lower than cv-cad’s cached optimistic balance, cv-cad trusts the server and updates the local cache. The user does not see a phantom higher balance after coming back online.

Grace abuse cap. The cached entitlement TTL is 24 hours from last successful /entitlement round-trip, not from issue. A user cannot purchase a token bundle, go offline for a month, and still spend optimistically — the 24h freshness window forces a re-sync. Document this in the upsell modal copy and the ADN listing notes.

Spend trigger map — menu items vs token cost

The token cost is determined by the artifact emitted, not the menu route. csv.lsp’s progcont dispatcher in src/x32/TB11-01x32/csv.lsp:800-940 routes the entry per the table below. Spend is invoked after the user confirms the action (e.g. picks a printer in the AutoCAD plot dialog) but before the file/print job is committed.

progcont route

Menu label

Artifact

Token cost

Spend call site

*pc-print-setup* (262401)

Print

Plotted page(s)

1 token per session

Wrap command "_.PLOT" in cvidn_spend_token "print"

*pc-print-all* (262465)

Print All Layers

Plotted page(s)

1 token per session

Same wrapper; layer setup is pre-plot

*pc-print-select* (262657)

Print Select Layouts

Plotted page(s)

1 token per session

Same wrapper

*pc-materials-list* (263169)

Materials List

CSV file

1 token

Spend before (write-line ...) to the .csv path

*pc-edit-drawing* (262145)

Open Drawing

DWG already on disk

free

View/edit, no deliverable produced

*pc-new-project* (262153)

New Project

DWG scaffolding

free

Project scaffold is native, not deliverable

*pc-new-drawing* (262161)

New Drawing

DWG

free

New blank panel/site drawing

*pc-batch-utils* (262177)

Batch Utilities

varies — see note

per-artifact

Each enclosed sub-action spends if it produces a deliverable

*pc-view-all* (262209)

View All Layers

none

free

Sysvar/layer toggle only

*pc-view-select* (262273)

View Select Layers

none

free

Layer toggle dialog only

*pc-rev-history* (264193)

Revision History

none

free

Read-only metadata view

*pc-edit-project* (524289)

Edit Project Details

none

free

Metadata edit only

*pc-slope-calc* (8193)

Slope Calculator

none

free

Utility — no artifact persisted

Note on Batch Utilities. The batch dialog wraps multiple sub-actions, some of which produce deliverables (e.g. batch plot, batch DXF export). The token spend wraps each sub-action individually, not the batch container. A batch that produces N plotted sheets costs N tokens; a batch that only toggles layers costs 0.

Acceptance criteria (per cv-cad ticket)

The five cv-cad tickets filed under Epic CV-572 share these gate criteria:

  • Source self-sufficiency. Every fix must work in the compiled .vlx. PowerShell helper cvidn.ps1 is acceptable because it ships in the bundle, but the LISP code must be the primary mechanism — no manual registry edits, no out-of-band scripts on the customer machine.

  • *error* handler restores plot state. If /spend returns 402 mid-plot, the handler must un-set filedia 0 / cmdecho 0 / etc. — partial plot state is a CV-540-class observability hazard for cv-cad.

  • OCR validation. Every error dialog (out-of-tokens, offline-grace-exhausted, deauthorized) must be screenshot-captured during the VM 108 validation run and pass OCR ≥95% character match against the golden text strings in the ticket body.

  • DFMEA linkage. Each ticket maps to a row in doc 31 §9 (token-spend retry storm, offline grace abuse, mid-cycle payment failure, etc.). New failure modes discovered during implementation get a new DFMEA row with S/O/D ratings.

  • No modification of validation fixtures. scripts/acad2026/cv-menu-validation.au3 is the test — if it fails, the build is broken. The au3 will need a new pass that exercises the spend dialogs; that pass is filed as a separate ticket on the QA track, not rolled into the implementation tickets.

Error-state dialogs — golden text

These strings are the OCR baseline for the cv-cad validation pass. Match ≥95% character accuracy.

Trigger

Dialog title

Body text

402 from /spend

“Out of Tokens”

“You’re out of tokens. Buy more at simplestruct.com to continue printing and exporting.”

Offline + cache stale (>24h)

“Licensing Unavailable”

“Can’t reach the licensing server. Last sync was %d hours ago. Reconnect to the internet, then try again.”

401 from /entitlement (token revoked)

“Device Deauthorized”

“Your device was deauthorized. Sign in again at simplestruct.com/cv-cad-login to continue.”

Membership lapsed but tokens present

“Membership Required”

“AI features (panel auto-layout, pick simulation) require an active membership. Visit simplestruct.com to activate.”

First-run, browser launch failed

“Sign-in Browser Failed”

“Couldn’t open your browser. Visit simplestruct.com/cv-cad-login manually, then paste the token here.”

Files (read-only on this side)

For ticket-writers and the cv-cad implementer, the touch-points are:

  • src/x32/TB11-01x32/csv.lspc:csv entry; progcont dispatcher (lines 800-940); *error* handler pattern. Token-spend wrappers live around the four chargeable branches.

  • src/x32/TB11-01x32/csvmenu.lsp — menu macros that set progcont and call (c:csv).

  • src/x32/TB11-01x32/csvconst.lsp*pc-* named constants for the progcont values.

  • src/x32/TB11-01x32/csvcompat.lsp — version predicates if behavior diverges by AutoCAD year.

  • backend/supabase/functions/entitlement/index.ts — server-side contract for POST /entitlement.

  • backend/supabase/functions/spend/index.ts — server-side contract for POST /spend, including the idempotency path and 402 case.

  • backend/supabase/functions/device-tokens/index.ts — server-side contract for POST/GET/DELETE /device-tokens.


Addendum (2026-05-29) — Perpetual drawing-license model

The “time-windowed offline grace” framing earlier in this doc and in older planning material is superseded by the perpetual drawing-license model. Take this addendum as authoritative; treat the legacy framing as historical context.

Token economics, updated

Action

Token cost

License envelope

License a new drawing in cv-cad (first plot/print/export)

1

ES256 JWT embedded in drawing NOD XRecord

Re-print / re-plot / re-export of an already-licensed drawing

0 (offline, perpetual)

Existing JWT verifies against bundled public key

Spend an artifact from cv-web (pdf/dxf/csv)

1

No JWT — cv-web is online-only

Native cv-format export (.cvpanel/.cvsite/.cvp/.cvt)

0

Inter-app transport — not chargeable

The customer story: “Buy 10 tokens, license 10 drawings, print them forever — even on a field laptop with no internet.”

License JWT envelope

  • Algorithm: ES256 (ECDSA P-256, SHA-256 / RFC 7518).

  • Signature shape: raw r||s, 64 bytes (IEEE P1363) — NOT DER.

  • Claims: iss=simplestruct, aud=cv-cad, sub=<org_uuid>, jti=<drawing_uuid>, iat, license_version=1. No exp.

  • Storage: NOD XRECORD under the dictionary SIMPLESTRUCT_LICENSE. The drawing carries its own license; copy the file, you copy the license.

  • Public key: SPKI PEM, bundled into the cv-cad assembly at SimpleStruct.Cad.Licensing.Resources.license-public.pem. The verifier is pure (no network).

  • Key rotation: annual or on breach. Procedure in the sensitive ops runbook → “Drawing license — operational procedures”.

Tenant model

The license sub claim is an organization UUID, not a user UUID. Tokens, licenses, and project files are shared across all members of the org. Org membership is auto-derived from the verified email domain at signup; public email domains (gmail/yahoo/outlook/etc.) are blocked. The sensitive ops doc carries the full blocklist and the org-tier schema reference.

Contract document

The frozen wire-format contract between cv-cad and cv-web lives in the private repo at docs-developer/cv-cad-integration/auth-handshake.md (not published via Sphinx). cv-cad implementers build against that doc; backend changes that alter the wire format require a contract version bump in the sibling changelog.md.