- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII) - CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19) - portal/index.html + ops-portal/: customer portal + ops portal single-file builds - .gitignore: demo-creds*.txt excluded
30 KiB
VerdictTank — Subscription Model, Contact Form, Receipt Fixes: Technical Contract
Status: DRAFT for review. Read-only analysis of the live codebase on Core (152.53.192.33) as of 2026-08-19. No code was modified, no service restarted, no schema migration applied.
Surfaces inspected:
- Backend API:
/opt/verdicttank/api.py(989 lines, FastAPI, systemdverdicttank-api.service, port 8201 behind Caddy) - Database:
/opt/verdicttank/users.db(SQLite) - Ops admin portal:
/var/www/verdicttank-ops/index.html(1060 lines, static SPA, served at ops.verdicttank.com, calls/api/verdicttank/admin/*) - Marketing site:
/var/www/verdicttank/index.html(1602 lines, served at verdicttank.com) - Client portal source:
/root/projects/verdicttank-mockups/portal-index.html(1068 lines) — source of the LIVE file on app3 at/home/myverdicttank/htdocs/my.verdicttank.com/index.html, served at my.verdicttank.com
Canonical tier quotas used throughout this contract (authoritative, not invented):
| Tier | Included / term | Term | Overage price/each |
|---|---|---|---|
| Free | 1 | lifetime | none (hard stop) |
| One-Shot | 1 | one-time | none (hard stop) |
| Pro | 8 | monthly | $18 |
| Enterprise | 50 | monthly | $16 |
| White-Label | 100 | monthly | $28 |
1. CURRENT API INVENTORY
All routes are under PREFIX = "/api/verdicttank" unless noted. FastAPI app verdicttank-api,
version="0.2.0", CORS allows https://verdicttank.com, https://proposals.iamgmb.com,
https://core.itpropartner.com, https://my.verdicttank.com; methods GET/POST/OPTIONS only.
Public / health
GET /api/verdicttank/health→{status:"ok", service:"verdicttank-api", version:"0.2.0"}
Customer auth (embedded JWT, no social, no Stack Auth)
POST /api/verdicttank/auth/register— body{email, password, name?}→{token, user:{id,email,name}}. 400 invalid email, 400 password<8 chars, 409 duplicate email.POST /api/verdicttank/auth/login— body{email, password}→{token, user:{id,email,name}}. 401 bad creds, 429 after 5 failures/15min (in-memory, resets on restart).GET /api/verdicttank/auth/me— Bearer →{user:{id,email,name,created_at}}. 401 if none.POST /api/verdicttank/auth/forgot-password— body{email}→{message}(always 200, no account enumeration). Sends reset email viasmtplibif account exists.POST /api/verdicttank/auth/reset-password— body{token, new_password}→{message}. 400 invalid/expired token, 400 password<8.POST /api/verdicttank/auth/change-password— Bearer, body{current_password, new_password}→{message}. 401 not authed, 400 wrong current password / short new password.
Submission / results
POST /api/verdicttank/submit— multipart form:name(str,""),email(str,""),proposal_name(required),proposal_text(str,""),proposal_file(UploadFile, optional),tier(str,"free"), optional Bearer. Invalid tier silently coerces to"free"— no rejection, no quota check today. 422 if name/email missing or neither text nor file. Response (SubmissionResponse):{review_id, status:"queued", message, submitted_at}.GET /api/verdicttank/status/{review_id}→{review_id, status, verdict, proposal_strength, investor_readiness, composite, progress, report_url, submitted_at, completed_at}. 404 unknown id.GET /api/verdicttank/results/{review_id}→ full structured result:review_id, status, proposal_name, name, tier, tier_label, submitted_at, completed_at, verdict, verdict_rationale, executive_summary, proposal_strength, investor_readiness, composite, divergence, dimensions[], fatal_flaws[], action_plan[], kill_criteria[], consensus[], report_url, error.GET /api/verdicttank/reviews— Bearer →{reviews:[{review_id, proposal_name, tier, tier_label, status, verdict, proposal_strength, investor_readiness, composite, submitted_at, completed_at}]}(only rows whereuser_idmatches). 401 if no Bearer.GET /api/verdicttank/report/{review_id}→ PDF file or 404 untilstatus=="complete".GET /api/receipt/{review_id}(note: no/verdicttankprefix, pre-existing quirk) → receipt PDF, no auth required, 404 if not generated.
Staff/admin (separate auth track, see section 3)
POST /api/verdicttank/admin/login— body{email, password}→{token, user:{email,name}}. Delegates to auth2/Hexclave password sign-in, then a staff-team or owner-email check; 401 bad creds, 403 not staff.GET /api/verdicttank/admin/submissions?status=&q=&limit=&offset=— Bearer staff →{submissions:[...], total}(all users, filterable,limitclamped 1-500).GET /api/verdicttank/admin/submissions/{review_id}— Bearer staff → full detail incl.user_id,panel_audit,receipt_url.GET /api/verdicttank/admin/stats— Bearer staff →{total, completed, failed, queued, processing, by_status:{}, by_tier:{}}.
Misc
POST /api/verdicttank/assist— body{message, section_id, section_title, current_content, full_draft}→{reply}. Calls internal LLM (admin-ai.itpropartner.com, DeepSeek Flash) for proposal-builder brainstorming. No auth. Unrelated to subscription work but shares the router.
No Stripe or any payment-provider code exists anywhere in api.py. tier on /submit is a
free-text form field with zero enforcement — a client can submit enterprise with no account and
no charge. There is no per-user submission counter, no quota table, no renewal/expiration logic.
2. CURRENT DATA MODEL
/opt/verdicttank/users.db, SQLite, confirmed via sqlite3 ... ".schema":
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE sqlite_sequence(name, seq); -- auto-managed by SQLite for AUTOINCREMENT
That is the entire schema. No tier, plan_status, term, quota, renewal, expiration,
cancelled_at, stripe_customer_id, or any subscription-related column exists on users, and
there is no second table for subscriptions, invites, quota grants, or notifications.
Submissions themselves are not in SQLite — each submission is a flat JSON file at
/opt/verdicttank/data/{review_id}.json (fields: review_id, name, email, user_id, proposal_name,
proposal_text, proposal_file, tier, status, submitted_at, completed_at, verdict, report_path,
receipt_path, plus fields the worker fills in later: proposal_strength, investor_readiness,
composite, divergence, dimensions, fatal_flaws, action_plan, kill_criteria, consensus,
panel_audit, executive_summary, verdict_rationale, error, progress). There is no SQL table
indexing submissions by user_id/tier/date — the API scans DATA_DIR.glob("*.json") for
/reviews, /admin/submissions, and /admin/stats. Any subscription/quota logic that needs to
count "submissions this term" must either scan this JSON directory (works today, gets slower as
volume grows) or maintain a running counter in SQLite (recommended — see Section 6).
3. AUTH MODEL (as actually implemented — confirmed, not assumed)
There is no Stack Auth / Hexclave auth2 for customers. Two entirely separate, non-interoperable auth tracks exist in the same file:
3a. Customer auth — self-contained embedded JWT
- Identity source: local SQLite
userstable only. - Password hashing: stdlib
hashlib.pbkdf2_hmac("sha256", ..., 200_000 iterations), stored aspbkdf2_sha256$<iters>$<salt_hex>$<hash_hex>. bcrypt is NOT installed/used. - Token: PyJWT (
import jwt as pyjwt), algorithm HS256, 30-day TTL (TOKEN_TTL_SECONDS = 60*60*24*30). Payload:{sub: str(user_id), email, exp}. - Transport:
Authorization: Bearer <token>header only. No cookies, no session store. - Secret:
_load_jwt_secret()reads envVERDICTTANK_JWT_SECRETfirst; else reads/creates/opt/verdicttank/.jwt_secret(0600,secrets.token_urlsafe(48)). Confirmed on disk:.jwt_secretEXISTS (64 bytes, 0600, root-owned, last modified 2026-08-18 21:38), alongside.admin_jwt_secret(64 bytes, 2026-08-18 23:15)./etc/verdicttank.envdoes NOT defineVERDICTTANK_JWT_SECRET, so the on-disk file is the live secret source and persists across restarts. No secret-persistence concern. - Password reset: separate short-lived JWT (
purpose:"password_reset", 1h TTL), same secret, emailed via directsmtplibtomail.itpropartner.com:2525(creds in/etc/verdicttank.env).
3b. Staff/admin auth — auth2 (Hexclave) delegated, JWT re-mint
POST admin/loginreceives{email,password}, calls auth2 password sign-in (https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in) server-side usingHEXCLAVE_PUBLISHABLE_KEY/HEXCLAVE_PROJECT_IDfrom env, thenGET users/me.- Staff gate: hardcoded
OWNER_EMAILS = {g@germainebrown.com, info@itpropartner.com, shonuff@germainebrown.com}bypasses the team check; otherwise queries auth2 teamverdicttank-it-staffviaGET /api/latest/teams?user_id=me&query=verdicttank-it-staff. - On success, api.py mints ITS OWN JWT (not an auth2 token) with
{sub: email, role:"staff", name, iat, exp}, HS256, 12h TTL, signed with a separate secret: envVERDICTTANK_ADMIN_JWT_SECRETelse/opt/verdicttank/.admin_jwt_secret(0600). Confirmed on disk: this file exists, 64 bytes, root-owned,-rw-------, last modified 2026-08-18. require_staff()dependency decodes with the admin secret and requiresrole=="staff". Because the two JWT secrets are different, a customer token can never be replayed against an admin route.- No customer identity is used anywhere in the admin path — admin/staff users are NOT rows in
users.db. This means admin actions in a1/a2 below (invite, cancel) must look up the target customer by email inusers.db, not by staff identity.
Implication for the subscription build
Any new admin endpoint (invite, cancel-subscription, quota grant) is protected by require_staff
exactly like the existing four admin endpoints — no new auth mechanism is needed. Any new
client-facing endpoint (subscription-state, contact, quota display) is protected by the existing
current_user(authorization) helper exactly like /reviews. No Stack Auth exists for customers
and none should be introduced — do not add Hexclave dependencies to the customer path.
4. PROPOSED SCHEMA MIGRATION
Two changes: (1) extend users with subscription/quota columns, (2) add three new tables for
invites, extra-submission grants, and ops notifications. All statements are additive
(ALTER TABLE ... ADD COLUMN / CREATE TABLE IF NOT EXISTS) so they are safe to run against the
live users.db without data loss; SQLite ALTER TABLE ADD COLUMN never rewrites existing rows and
existing users get the column default.
-- 4.1: extend users with tier/subscription state
ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'free';
-- one of: free | oneshot | pro | enterprise | whitelabel
ALTER TABLE users ADD COLUMN subscription_status TEXT NOT NULL DEFAULT 'active';
-- one of: active | pending_cancel | expired | demo
ALTER TABLE users ADD COLUMN term_start TEXT;
-- ISO8601 UTC; start of current billing/quota term. NULL for free/oneshot (no recurring term).
ALTER TABLE users ADD COLUMN term_end TEXT;
-- ISO8601 UTC; renewal date for monthly tiers (pro/enterprise/whitelabel).
-- For demo accounts, this doubles as the auto-expiration timestamp (see a1).
ALTER TABLE users ADD COLUMN cancel_at_term_end INTEGER NOT NULL DEFAULT 0;
-- 0/1 boolean. Set to 1 by a2 (cancel). Subscription stays 'active' with full quota until
-- term_end, then a scheduled job (see 6.4) flips tier to 'free' and status to 'expired'.
ALTER TABLE users ADD COLUMN submissions_used_this_term INTEGER NOT NULL DEFAULT 0;
-- Running counter of submissions consumed against the included quota in the CURRENT term.
-- Reset to 0 whenever term_start advances (monthly rollover) — see 6.3.
ALTER TABLE users ADD COLUMN extra_submissions_granted INTEGER NOT NULL DEFAULT 0;
-- Cumulative count of manually-admin-granted overage submissions available beyond the
-- included quota for the CURRENT term. Since there is no payment provider, this is set only
-- by an admin action (b3 "paid additional submissions" are recorded manually).
ALTER TABLE users ADD COLUMN extra_submissions_used INTEGER NOT NULL DEFAULT 0;
-- Of extra_submissions_granted, how many have been consumed this term.
ALTER TABLE users ADD COLUMN is_demo INTEGER NOT NULL DEFAULT 0;
-- 1 if this account was created via the ops "create demo account" flow (a1).
ALTER TABLE users ADD COLUMN demo_expires_at TEXT;
-- ISO8601 UTC; set only for demo accounts. Same auto-expire mechanism as term_end but kept
-- as a distinct column so a real subscriber's term_end is never confused with a demo cutoff.
-- 4.2: invite-to-register tokens (a1)
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'free',
is_demo INTEGER NOT NULL DEFAULT 0,
expires_in_days INTEGER NOT NULL DEFAULT 14, -- the X in "X-day expiration"
created_by TEXT NOT NULL, -- staff email from admin JWT
created_at TEXT NOT NULL,
redeemed_at TEXT, -- NULL until the invite is used
redeemed_user_id INTEGER,
invite_expires_at TEXT NOT NULL -- the invite LINK's own expiry (separate from
-- the resulting demo account's expiry)
);
-- 4.3: extra-submission grant ledger (b3 — audit trail for manually-added overage)
CREATE TABLE IF NOT EXISTS submission_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
quantity INTEGER NOT NULL, -- positive = granted, negative = consumed (optional; or
-- keep consumption as a simple counter on users and use
-- this table purely as a grant-only audit log)
reason TEXT, -- free text, e.g. "manual overage payment received 8/19"
granted_by TEXT NOT NULL, -- staff email
granted_at TEXT NOT NULL
);
-- 4.4: ops notifications (b1 contact form)
CREATE TABLE IF NOT EXISTS ops_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'contact', -- extensible: contact | cancellation | demo_expiring
payload TEXT NOT NULL, -- JSON blob: {name, email, subject, message, user_id}
created_at TEXT NOT NULL,
read_at TEXT -- NULL until a staff member marks it read in ops portal
);
Notes:
submissions_used_this_term+extra_submissions_usedtogether define "remaining" (Section 6).- Free and One-Shot are lifetime/one-time, so
term_start/term_endstay NULL for them; quota enforcement instead checkssubmissions_used_this_term >= 1for the life of the account (Free) or the single submission ever made (One-Shot) — see 6.1/6.2. - No
stripe_customer_idor any payment column is added, matching the "no Stripe exists" constraint.submission_grantsis the entire "billing" record for overage — a manual ledger.
5. PROPOSED NEW ENDPOINTS
All new endpoints live under the existing PREFIX = "/api/verdicttank" router in api.py. Admin
endpoints reuse require_staff(); client endpoints reuse current_user().
a1. Ops — invite-to-register + create demo account
POST /api/verdicttank/admin/invite (staff Bearer)
// Request
{
"email": "prospect@example.com",
"tier": "pro",
"is_demo": true,
"expires_in_days": 14
}
// Response 200
{
"invite_token": "3f9c1a...e2",
"invite_url": "https://my.verdicttank.com/?invite=3f9c1a...e2",
"email": "prospect@example.com",
"tier": "pro",
"is_demo": true,
"expires_in_days": 14,
"invite_expires_at": "2026-09-02T00:00:00+00:00"
}
Behavior: inserts a row into invites (invite_expires_at = now + expires_in_days for the LINK
itself, default 7 days for the link unless staff overrides — the "X-day expiration" in the task
refers to the resulting demo account's lifetime, not necessarily the invite link's; if the
same X should govern both, set link expiry = expires_in_days too). Does NOT create the user yet.
409 if an unredeemed invite already exists for that email+tier combo (optional; else always allow
multiple). Sends an email to the invitee with invite_url (reuses the existing send_email() /
SMTP helper).
GET /api/verdicttank/invite/{token} (public, no auth)
// Response 200
{"email": "prospect@example.com", "tier": "pro", "is_demo": true, "valid": true}
// Response 410 (expired or already redeemed)
{"detail": "This invite link is no longer valid."}
Used by the client portal to pre-fill/lock the registration form when ?invite= is present.
POST /api/verdicttank/auth/register (existing endpoint, extended)
// Request adds optional field
{"email": "...", "password": "...", "name": "...", "invite_token": "3f9c1a...e2"}
If invite_token is present and valid: on success, set the new user's tier, is_demo=1,
demo_expires_at = now + expires_in_days, term_start/term_end per tier rules, mark the invite
row redeemed_at/redeemed_user_id. If invalid/expired, 410 (do not silently fall back to free —
surface the error so the client can prompt for a fresh invite).
Auto-expiration mechanism: no code path runs on a timer inside api.py today (no scheduler). Two implementation options, pick one explicitly before building:
- Lazy expiry (recommended, zero new infra): every place that reads
tier/quota (current_user,/submit,/subscription-state) checksis_demo=1 AND demo_expires_at < nowand, if true, downgrades the row totier='free', subscription_status='expired', is_demo=0in that same request before proceeding. Self-healing, no new process, but a demo that no one touches after expiry stays "expired-but-not-yet-flagged" in the DB until next access — cosmetic only, does not affect enforcement since /submit checks it too. - Cron sweep: a new script (
/opt/verdicttank/expire_demos.py) run via systemd timer/cron that scansWHERE is_demo=1 AND demo_expires_at < nowand downgrades in bulk. More visible inadmin/stats/ops list at all times, but is new infrastructure this contract does not currently assume exists — would need its own service unit, outside api.py's process model.
a2. Ops — cancel subscription, effective at end of term
POST /api/verdicttank/admin/cancel-subscription (staff Bearer)
// Request
{"user_id": 42}
// Response 200
{
"user_id": 42,
"tier": "pro",
"subscription_status": "pending_cancel",
"cancel_at_term_end": true,
"term_end": "2026-09-19T00:00:00+00:00",
"message": "Subscription will remain active with full quota through 2026-09-19, then move to Free."
}
Behavior: sets cancel_at_term_end=1. Does not change tier, subscription_status
(stays active until term boundary, per the "not immediate lock" constraint — this endpoint sets
subscription_status='pending_cancel' purely as a display flag, tier/quota enforcement is
unaffected until term_end passes), does not touch submissions_used_this_term. The customer keeps
full remaining quota for the rest of the paid term. At term_end, the same lazy-expiry or
cron-sweep mechanism from a1 flips tier='free', subscription_status='expired',
cancel_at_term_end=0.
POST /api/verdicttank/admin/reactivate-subscription (staff Bearer, companion undo)
// Request
{"user_id": 42}
// Response 200
{"user_id": 42, "subscription_status": "active", "cancel_at_term_end": false}
Lets staff undo a pending cancellation before term_end (e.g. customer called back). Not explicitly requested but a near-zero-cost companion to a2 that avoids a support dead-end; flag as optional.
b1. Client — Contact VerdictTank form
POST /api/verdicttank/contact (public; Bearer optional to attach user_id/pre-fill)
// Request
{
"name": "Jane Prospect",
"email": "jane@example.com",
"subject": "Question about Enterprise seats",
"message": "..."
}
// Response 200
{"message": "Thanks — we've received your message and will reply within one business day."}
// Response 422 (missing/invalid fields)
{"detail": "A valid email address is required."}
Behavior: (1) sends an email via the existing send_email()/smtplib helper — sender
noreply@verdicttank.com, recipient support@verdicttank.com, subject prefixed
[VerdictTank Contact] {subject}, body includes name/email/message/user_id-if-known; (2) inserts
a row into ops_notifications (type='contact', payload = the request JSON + resolved
user_id) so it also surfaces in the ops portal without depending on email deliverability. Note:
current SMTP config (VERDICTTANK_SMTP_FROM) defaults to noreply@itpropartner.com, not
noreply@verdicttank.com — confirm the verdicttank.com domain has SPF/DKIM/relay authorization
for that From address before wiring, or the send will fail/land in spam (see Constraints note in
Section 6).
GET /api/verdicttank/admin/notifications (staff Bearer, ops portal display for b1)
// Response 200
{"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1}
POST /api/verdicttank/admin/notifications/{id}/read (staff Bearer)
// Response 200
{"id": 1, "read_at": "2026-08-19T14:02:00+00:00"}
b2 / b3. Client — subscription/quota state (remaining submissions, renewal date, paid extras)
GET /api/verdicttank/subscription (Bearer required)
// Response 200 — Pro example
{
"tier": "pro",
"tier_label": "Pro Review",
"subscription_status": "active",
"cancel_at_term_end": false,
"term_start": "2026-08-01T00:00:00+00:00",
"term_end": "2026-09-01T00:00:00+00:00",
"included_per_term": 8,
"submissions_used_this_term": 5,
"included_remaining": 3,
"extra_submissions_granted": 2,
"extra_submissions_used": 1,
"extra_remaining": 1,
"total_remaining": 4,
"overage_price_each": 18,
"is_demo": false,
"demo_expires_at": null
}
// Response 200 — Free example
{
"tier": "free",
"tier_label": "Free Assessment",
"subscription_status": "active",
"cancel_at_term_end": false,
"term_start": null,
"term_end": null,
"included_per_term": 1,
"submissions_used_this_term": 1,
"included_remaining": 0,
"extra_submissions_granted": 0,
"extra_submissions_used": 0,
"extra_remaining": 0,
"total_remaining": 0,
"overage_price_each": null,
"is_demo": false,
"demo_expires_at": null
}
This single endpoint backs both b2 (remaining + renewal date = term_end) and b3 (paid additional
submissions = extra_submissions_granted/extra_submissions_used/extra_remaining). The client
portal's dashboard view calls this alongside the existing /reviews call and renders a quota card.
Quota-blocking on submit (implicit, not a new endpoint — extends existing /submit)
POST /api/verdicttank/submit gains a pre-check: if the caller is authenticated and
total_remaining <= 0, return 403 with:
{"detail": "You've used all included and extra submissions for this term. Contact us to add more."}
Anonymous (no-Bearer) submissions are unaffected by quota (today's behavior — tier is self-reported and unenforced for guests; enforcing quota requires an account, so this contract does not change the current guest-checkout path, only the authenticated path).
6. QUOTA ENFORCEMENT RULES
6.1 Included quota by tier (authoritative table, restated)
| Tier | Included/term | Term length | Overage/each |
|---|---|---|---|
| free | 1 | lifetime (no reset, ever) | not allowed (hard stop) |
| oneshot | 1 | one-time (no reset, ever) | not allowed (hard stop) |
| pro | 8 | 1 month | $18 |
| enterprise | 50 | 1 month | $16 |
| whitelabel | 100 | 1 month | $28 |
6.2 Remaining-submissions formula
included_remaining = max(0, included_per_term(tier) - submissions_used_this_term)
extra_remaining = max(0, extra_submissions_granted - extra_submissions_used)
total_remaining = included_remaining + extra_remaining
For free/oneshot, included_per_term is a fixed lifetime cap of 1 and term_end is always
NULL — there is no rollover, so included_remaining only ever goes from 1 to 0 and never resets.
6.3 Consumption order and counter updates (on a successful /submit by an authenticated user)
- Compute
total_remainingas above. If<= 0, reject with 403 (see Section 5). - If
included_remaining > 0: incrementsubmissions_used_this_termby 1 (consume included quota first). - Else (included exhausted, extra available): increment
extra_submissions_usedby 1 (consume paid/manual extras second). - Anonymous/guest submissions (no Bearer) are NOT counted against any user's quota — they have no
user_idto attribute to, matching current behavior wheretieris self-reported and unenforced for guests.
6.4 Term rollover (monthly tiers: pro/enterprise/whitelabel)
On any authenticated request that reads or mutates quota (submit, /subscription), first check:
if now >= term_end: then roll the term forward:
new_term_start = term_end
new_term_end = term_end + 1 month
submissions_used_this_term = 0
extra_submissions_granted = 0 -- extras do NOT carry over; each term's manual grants are scoped
extra_submissions_used = 0 -- to that term, consistent with "no billing exists to track a running balance"
Then, if cancel_at_term_end == 1 at the moment of rollover: instead of rolling forward, downgrade
— tier='free', subscription_status='expired', cancel_at_term_end=0, term_start=NULL,
term_end=NULL. This is the actual mechanical trigger for a2's "effective at end of term": the
cancellation flag is inert until the term boundary is crossed, at which point the same rollover
check that would normally renew the term instead terminates it. This makes rollover/expiry a
pure function of "did a request happen after term_end", not a background job — matching the
lazy-expiry approach recommended in Section 5/a1 and requiring no new systemd timer.
6.5 Demo accounts (a1)
Independent of tier quota — a demo account still has a real tier (e.g. "pro") and consumes quota
normally per 6.2/6.3, but additionally has demo_expires_at. On any request, if
is_demo==1 AND now >= demo_expires_at: downgrade identically to the cancellation end-of-term path
(tier='free', subscription_status='expired', is_demo=0, demo_expires_at=NULL) — a demo's
expiration is immediate/hard (not "end of term" like a real cancellation), since a demo was never a
paid commitment.
6.6 Marketing site consistency (c1)
The #pricing section text must not contradict the enforced numbers above — in particular, the
current live copy calls Pro "Unlimited submissions and revisions for one active proposal at a
time," which directly conflicts with the 8/month quota. Section c1's job is to replace that claim
with the actual per-tier count (Free: 1 review; One-Shot: 1 review; Pro: 8/month; Enterprise:
50/month; White-Label: 100/month) so the enforced backend and the marketing promise match.
7. KNOWN RECEIPT DEFECTS (confirmed exact lines, /opt/verdicttank/api.py, generate_receipt())
- Line 892 —
<div class="value">{submission["submitted_at"][:19].replace("T", " ")}</div>— prints raw UTC with no timezone label or conversion (e.g. "2026-08-19 14:02:00" with no "UTC" suffix), so an Eastern-time submitter sees what looks like their own local time but is 4-5 hours off. Fix: append explicit" UTC"suffix (safest, no timezone-conversion risk) or convert to a configured business timezone with a labeled abbreviation. - Lines 897-905 — the "What Happens Next" card hardcodes the full panel description
("Multi-Seat Panel — Independent reasoning seats from separate providers review your proposal
in a single pass, scoring ten dimensions") on every receipt regardless of tier. Per
references/subscription-and-receipt.mdandapi-and-auth.md, Free tier actually runs a reduced 4-seat panel (research + primary + legal + financial), not the 9-seat/10-dimension panel. Fix: branch this block onsubmission["tier"] == "free"and use tier-accurate seat/ dimension counts in both branches. - Line 891 —
<div class="value">{label} — {price}</div>— em dash between tier label and price. Line 908 —VerdictTank — a product of <strong>IT Pro Partner</strong>— em dash in footer. Both violate the house style rule (no em/en dashes on VerdictTank surfaces). Fix: replace—with a plain separator (e.g.,or|) in both lines; grep the wholegenerate_receipt()function body for any other—/–before considering the fix complete (a byte-level codepoint scan is safer than eyeballing, per the platform skill's pitfall notes).
No other defects in generate_receipt() were found on this pass; the QUEUED badge and 4-step
copy layout are otherwise accurate to the current submit-flow behavior.
Open items requiring a decision before implementation (not assumptions made in this contract)
- Whether the invite LINK's own expiry should equal the demo account's expiry X, or be a shorter fixed window (e.g. 7 days to redeem) independent of the demo lifetime once redeemed (Section 5/a1).
- Whether
noreply@verdicttank.comhas outbound send authorization (SPF/DKIM/relay credentials) distinct from the currently configurednoreply@itpropartner.com— b1 assumes it does per the task's stated sender, but the liveVERDICTTANK_SMTP_FROMenv default is the itpropartner.com address; this needs its own SMTP credential/domain verification, not a code change. - Whether extra-submission grants (b3) should be a simple counter (as modeled here) or a full
ledger table (
submission_grants, also proposed in Section 4) for audit purposes — the schema proposes both so either can be adopted without a second migration.