Files
verdicttank/CONTRACT.md
T
root 6ac8a185ef Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML
- 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
2026-08-26 02:26:50 -04:00

538 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, systemd `verdicttank-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 via `smtplib` if 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 where `user_id` matches). 401 if no Bearer.
- `GET /api/verdicttank/report/{review_id}` → PDF file or 404 until `status=="complete"`.
- `GET /api/receipt/{review_id}` (note: **no `/verdicttank` prefix**, 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, `limit` clamped 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"`:
```sql
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 `users` table only.
- Password hashing: stdlib `hashlib.pbkdf2_hmac("sha256", ..., 200_000 iterations)`, stored as
`pbkdf2_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 env `VERDICTTANK_JWT_SECRET` first; else reads/creates
`/opt/verdicttank/.jwt_secret` (0600, `secrets.token_urlsafe(48)`). **Confirmed on disk**:
`.jwt_secret` EXISTS (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.env` does NOT define
`VERDICTTANK_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 direct `smtplib` to `mail.itpropartner.com:2525` (creds in `/etc/verdicttank.env`).
### 3b. Staff/admin auth — auth2 (Hexclave) delegated, JWT re-mint
- `POST admin/login` receives `{email,password}`, calls auth2 password sign-in
(`https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in`) server-side using
`HEXCLAVE_PUBLISHABLE_KEY`/`HEXCLAVE_PROJECT_ID` from env, then `GET users/me`.
- Staff gate: hardcoded `OWNER_EMAILS = {g@germainebrown.com, info@itpropartner.com,
shonuff@germainebrown.com}` bypasses the team check; otherwise queries auth2 team
`verdicttank-it-staff` via `GET /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: env `VERDICTTANK_ADMIN_JWT_SECRET`
else `/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 requires `role=="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 in `users.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.
```sql
-- 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_used` together define "remaining" (Section 6).
- Free and One-Shot are lifetime/one-time, so `term_start`/`term_end` stay NULL for them; quota
enforcement instead checks `submissions_used_this_term >= 1` for the life of the account
(Free) or the single submission ever made (One-Shot) — see 6.1/6.2.
- No `stripe_customer_id` or any payment column is added, matching the "no Stripe exists"
constraint. `submission_grants` is 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)
```json
// 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)
```json
// 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)
```json
// 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:
1. **Lazy expiry** (recommended, zero new infra): every place that reads `tier`/quota
(`current_user`, `/submit`, `/subscription-state`) checks `is_demo=1 AND demo_expires_at < now`
and, if true, downgrades the row to `tier='free', subscription_status='expired', is_demo=0` in
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.
2. **Cron sweep**: a new script (`/opt/verdicttank/expire_demos.py`) run via systemd timer/cron
that scans `WHERE is_demo=1 AND demo_expires_at < now` and downgrades in bulk. More visible in
`admin/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)
```json
// 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)
```json
// 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)
```json
// 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)
```json
// Response 200
{"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1}
```
**`POST /api/verdicttank/admin/notifications/{id}/read`** (staff Bearer)
```json
// 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)
```json
// 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:
```json
{"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)
1. Compute `total_remaining` as above. If `<= 0`, reject with 403 (see Section 5).
2. If `included_remaining > 0`: increment `submissions_used_this_term` by 1 (consume included
quota first).
3. Else (included exhausted, extra available): increment `extra_submissions_used` by 1 (consume
paid/manual extras second).
4. Anonymous/guest submissions (no Bearer) are NOT counted against any user's quota — they have no
`user_id` to attribute to, matching current behavior where `tier` is 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()`)
1. **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.
2. **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.md` and `api-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 on `submission["tier"] == "free"` and use tier-accurate seat/
dimension counts in both branches.
3. **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 whole
`generate_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.com` has outbound send authorization (SPF/DKIM/relay credentials)
distinct from the currently configured `noreply@itpropartner.com` — b1 assumes it does per the
task's stated sender, but the live `VERDICTTANK_SMTP_FROM` env 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.