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-managedauth.usersandauth.identities. Schema insupabase/migrations/0001_init.sql.Views:
token_balances(SUM(delta) per user),entitlement_view(joins subscriptions + token_balances).Triggers:
handle_new_usergrants 7-day trial + 10 tokens onauth.usersinsert.
Edge functions¶
Function |
Auth |
Purpose |
|---|---|---|
|
self-verified (JWT or device-token) |
Returns |
|
self-verified |
Idempotent. Body: |
|
Stripe signature |
Handles |
|
JWT-only |
Creates Stripe Checkout session; reuses existing |
|
JWT-only |
Returns Stripe Billing Portal URL for self-serve cancellation, payment-method updates, invoice history. |
|
JWT-only |
Mints (POST), lists (GET), revokes (DELETE) device tokens bound to |
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) andcv-tokens(one-time bundles 10 + 100).Webhook endpoint configured to
idn.simplestruct.com/stripe-webhookwith 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 byidentifyCaller.500 { "error": "<postgrest-msg>" }— internal Postgres failure readingentitlement_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, oridempotency_keyabsent.400 { "error": "artifact_not_chargeable" }— artifact not in["pdf","dxf","csv","print"].400 { "error": "app_mismatch" }—body.appdiffers fromcaller.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 |
|
cv-web |
free |
Inter-app transport — receiver pays when they print/export |
|
cv-web |
free |
Inter-app transport |
|
cv-web |
free |
Inter-app transport |
|
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 |
Show |
401 from |
Silent retry via session refresh; if still 401, render |
|
Top-bar |
|
AI buttons disabled with tooltip “Activate membership to use AI features”. |
Stripe Checkout cancel |
Return to |
Stripe Checkout success |
Return to app with |
cv-cad¶
Error |
UI behavior |
|---|---|
|
Show CV dialog: “You’re out of tokens. Buy more at simplestruct.com.” Abort the print/export. |
|
Show CV dialog: “Can’t reach licensing server. Reconnect or sign in again.” Abort. |
|
Optimistic spend: allow the export, queue the |
Device token revoked (401 from |
Show CV dialog: “Your device was deauthorized. Sign in again to continue.” Open browser to |
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
.cvpanelin 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 insrc/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/andsrc/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-tokensafter 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 viacrypto.getRandomValues. Source:device-tokens/index.ts:26-33.Storage: Supabase stores only
sha256(token)indevice_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/:idfrom the Authorized Devices UI in/billing. Revocation is arevoked_attimestamp on the row — the next/entitlementcall 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. TheidentifyCaller(req)helper in_shared/auth.tstries 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 holdsentitlement.json(cached entitlement) andspend_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_tokenfile and relaunches the same URL.User cancels OAuth →
/cv-cad-loginshows “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.
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 helpercvidn.ps1is 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/spendreturns 402 mid-plot, the handler must un-setfiledia 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.au3is 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 |
“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 |
“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.lsp—c:csventry;progcontdispatcher (lines 800-940);*error*handler pattern. Token-spend wrappers live around the four chargeable branches.src/x32/TB11-01x32/csvmenu.lsp— menu macros that setprogcontand 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 forPOST /entitlement.backend/supabase/functions/spend/index.ts— server-side contract forPOST /spend, including the idempotency path and 402 case.backend/supabase/functions/device-tokens/index.ts— server-side contract forPOST/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 ( |
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. Noexp.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.