Appearance
Product primitives
The things the Reform Assistant product is made of, the rule each one follows, and the file that enforces it. All paths are relative to cloud/reform-assistant/ unless they start with deploy/ or skills/. If a rule here and the code disagree, the code is right and this page is a bug.
How the pieces fit: a user has one inmate and at most one subscription to a plan. A paid subscription creates a connection that Hermes turns into an allowance in Securus. Each message Hermes answers is reported back as a usage event and counted against the monthly window. See the feature map for the user-facing features built on these, and the data model for the tables.
Account
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| User | A family account: id (UUID), email, name, password hash and salt. | Email is trimmed, lowercased, must match ^[^\s@]+@[^\s@]+\.[^\s@]+$, and is unique (409 email_taken, also when two signups race). name must be non-empty after trimming. Password is at least 10 characters (UTF-16 length). Passwords are stored only as PBKDF2-SHA-256 (100,000 iterations, 16-byte salt). | src/index.ts (signup, login), src/auth.ts, migrations/0001_init.sql (users.email UNIQUE) |
| Session | A server-side login, identified by a random token held only in the ra_session cookie. | Token is 32 random bytes; only its SHA-256 is stored. Lifetime is 30 days. Every login or signup adds a session (older ones stay valid). A cookie whose session is missing or expired is treated as logged out and its row is deleted. Logout deletes the session and clears the cookie (204 even without a cookie). Expired sessions are purged on every session creation: all of that user's, plus up to 100 of anyone's. | src/auth.ts (createSession, getSessionUser, clearSession), src/http.ts (sessionCookie) |
| Session cookie | ra_session=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000, plus Secure on HTTPS. | Secure follows the scheme of the request URL, never APP_URL. Clearing sets Max-Age=0 and a 1970 Expires. | src/http.ts (sessionCookie) |
| Session-protected route | Any family API route that needs a logged-in user. | Missing or invalid session gives 401 unauthorized. The method check runs first, so a wrong method answers 405 even without a cookie. | src/auth.ts (requireUser), src/index.ts (routeApi) |
Inmate
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Inmate | The incarcerated person the account pays for: inmate_number, first_name, last_name, state, facility, plus the connection status and detail. | One per account (inmates.user_id UNIQUE); PUT /api/inmate creates or updates it. All five fields are required, non-empty after trimming and at most 80 characters (400 invalid_field naming the field). state is uppercased and must be two letters A-Z. inmate_number must match ^[A-Za-z0-9-]{3,20}$ (Securus ids are short letter/digit ids; Arizona uses 6 digits). | src/inmate.ts (saveInmate), migrations/0001_init.sql |
| Inmate number not unique across accounts | The same inmate_number may exist on two accounts. | The column is only indexed. Two guards keep it safe: checkout refuses a second live subscriber (409 inmate_taken), and Hermes usage is attributed through the live subscription (see Usage event). | src/billing.ts (createCheckout), src/stripe_webhook.ts (hasOtherLiveSubscriber), src/usage.ts (recordUsage) |
| Inmate lock | While the account's subscription is live (active, trialing or past_due), the identity cannot change. | Changing inmate_number gives 409 inmate_locked; so does DELETE /api/inmate. Other fields (name, state, facility) stay editable. The lock does not cover canceled or incomplete. | src/inmate.ts (saveInmate, removeInmate) |
| Number change resets connection | Changing the number while unlocked. | connection_status goes back to not_connected and connection_detail is cleared. | src/inmate.ts (saveInmate) |
| Inmate removal | DELETE /api/inmate. | Allowed only without a live (active/trialing/past_due) subscription. Deletes the row; usage events cascade; the subscription's inmate_id becomes NULL. | src/inmate.ts (removeInmate), migrations/0001_init.sql (foreign keys) |
Plan
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Plan catalog | Three plans, served by GET /api/plans (public) and mirrored statically on the landing page. | Fixed in code: monthly, quarterly, annual. The Worker's list is the single source of truth; the landing page numbers must match. | src/plans.ts (PLANS, findPlan), public/index.html |
| Months | How many calendar months one billing period covers. | monthly 1, quarterly 3, annual 12. Decides how many monthly windows a billing period has. | src/plans.ts, src/period.ts |
| Messages per month | The allowance, 40 on every plan. | Per month, not per billing period: a quarterly or annual subscriber gets 40 in each monthly window. Compared against the sum of reported stamps. | src/plans.ts, src/usage.ts, src/hermes.ts |
| Prices | price_cents and save_pct. | monthly 800 ($8.00, per month, no saving); quarterly 2160 ($21.60, billed once, 10%); annual 8160 ($81.60, billed once, 15%). The charged amount comes from the Stripe price configured in the STRIPE_PRICE_* secrets, not from price_cents. | src/plans.ts, src/billing.ts (priceForPlan) |
| Plan from price | Which plan a Stripe subscription is on. | The first subscription item's price id is matched against STRIPE_PRICE_MONTHLY/QUARTERLY/ANNUAL; only if none match, metadata.plan_id, then the stored plan, then monthly. | src/stripe_webhook.ts (subscriptionPlan) |
Subscription
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Subscription | The account's Stripe subscription as mirrored in D1: plan, status, billing period, cancel_at_period_end, Stripe ids. | One row per account (subscriptions.user_id UNIQUE); a second Stripe subscription for the same account is not stored (see Duplicate subscription). Written only by the Stripe webhook. | src/stripe_webhook.ts (upsertSubscriptionRow), migrations/0001_init.sql |
| Status | active, trialing, past_due, canceled, incomplete. | Stripe unpaid and paused are stored as past_due; any unknown value as incomplete. A customer.subscription.deleted event stores canceled directly only when no STRIPE_SECRET_KEY is set; otherwise the status is re-fetched from Stripe. | src/stripe_webhook.ts (stripeStatus, upsertSubscriptionRow) |
| Live | A subscription that counts as paying: active, trialing or past_due. | Live blocks a new checkout (409 already_subscribed), blocks another account from the same inmate (409 inmate_taken), and lets /api/usage and POST /api/hermes/usage work. The Hermes endpoints for connections and allowances use only active and trialing, so a past_due account is treated by the bridge as no longer linked. | src/billing.ts, src/usage.ts, src/hermes.ts (listConnections, listAllowances) |
| Billing period | current_period_start and current_period_end from Stripe. | Taken from the subscription object, or from its first item when Stripe puts periods there. Exposed as billing_period_end. | src/stripe_webhook.ts (subscriptionPeriod), src/usage.ts |
| Cancel at period end | The portal flag cancel_at_period_end. | Only displayed. Access continues until Stripe sends customer.subscription.deleted. | src/stripe_webhook.ts, public/console/app.js |
| Duplicate subscription | A second Stripe subscription for the same account, or for an inmate another account already has live. | Not written to D1. Recorded in R2 at stripe/duplicates/<subscription_id>.json and, when STRIPE_SECRET_KEY is set, cancelled in Stripe. | src/stripe_webhook.ts (recordDuplicateSubscription) |
| Open checkout session | users.open_checkout_session_id, the account's latest Stripe Checkout Session. | A new checkout first expires the previous one if it is still open. Cleared on checkout.session.completed and checkout.session.expired. | src/billing.ts (createCheckout), src/stripe_webhook.ts |
Monthly window
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Monthly window | The one-month slice of the current billing period that contains now. period_start and period_end in GET /api/usage and GET /api/hermes/allowances. | Anchored on current_period_start and stepping by calendar months in UTC (time of day kept, day clamped to the month's last day, each boundary computed from the anchor). A billing period has exactly months windows; the last one always ends at current_period_end. Outside the billing period, or with a missing or invalid date, there is no window and usage is zero. Usage used counts only events with window_start <= occurred_at < window_end. | src/period.ts (monthlyWindow), src/usage.ts, src/hermes.ts |
Examples (UTC, now inside the period):
| Plan | Billing period | Window |
|---|---|---|
monthly | 2026-10-01 to 2026-11-01 | the whole period |
monthly | 2025-02-28 to 2025-03-31 | the whole period (no extra short window) |
quarterly | 2026-09-01 to 2026-12-01, now 2026-10-17 | 2026-10-01 to 2026-11-01 |
quarterly | 2025-01-31T12:30Z to 2025-04-30T12:30Z, now 2025-03-01 | 2025-02-28T12:30Z to 2025-03-31T12:30Z (31st clamps to February, then returns to the 31st) |
quarterly | 2025-04-30 to 2025-07-31, now 2025-07-30 | 2025-06-30 to 2025-07-31 (windows: Apr 30, May 30, Jun 30) |
annual | 2024-02-29T08:00Z to 2025-02-28T08:00Z, now 2025-02-01 | 2025-01-29T08:00Z to 2025-02-28T08:00Z (twelfth, last window) |
The full algorithm and more examples are in the HTTP API reference.
Connection
A connection is the hand-off of a paid inmate to Hermes. The inmate carries the visible connection_status; the connections row is what Hermes lists and acknowledges.
| Status | Meaning |
|---|---|
not_connected | No live Hermes link: no subscription yet, subscription not active/trialing, or the inmate number just changed. |
pending | Paid and waiting: Hermes has not acknowledged yet. Listed by GET /api/hermes/connections?status=pending. |
connected | Hermes added the contact and set the allowance. |
failed | Hermes could not complete setup; connection_detail carries the reason. |
| From | Event | To | Where |
|---|---|---|---|
not_connected | Webhook: subscription active or trialing, inmate present | pending | src/stripe_webhook.ts (upsertSubscriptionRow, updateSubscriptionConnection) |
pending | Hermes POST .../ack with status: "connected" | connected | src/hermes.ts (acknowledgeConnection) |
pending | Hermes ack status: "failed" | failed | src/hermes.ts |
failed | Next webhook event for an active/trialing subscription | pending (retry) | src/stripe_webhook.ts |
connected | Renewal or plan change (webhook, subscription still active/trialing for the same inmate) | connected (no re-pending; detail kept) | src/stripe_webhook.ts (priorConnection) |
connected or pending | Webhook: status past_due, incomplete or canceled (including the deletion that follows a cancel) | not_connected | src/stripe_webhook.ts |
not_connected | Webhook: subscription active again | pending | src/stripe_webhook.ts |
| any | Inmate number changed while unlocked | not_connected | src/inmate.ts (saveInmate) |
Cancel: with cancel_at_period_end the status stays connected until Stripe sends the deletion at period end; the bridge then sees the inmate missing from the allowances list and revokes the remaining stamps. Acknowledgements do not check the connection's current status, and an unknown connection id is 404 not_found.
Usage event
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Usage event | One row in usage_events: a message Hermes sent for an inmate: kind (reply, snap, videogram), stamps (non-negative number), occurred_at. | Fields validated (400 invalid_field); occurred_at is stored as normalized ISO. Reported only by Hermes; the family API reads, never writes. | src/usage.ts (recordUsage), migrations/0001_init.sql |
| Idempotency key | message_id, an opaque string, UNIQUE. | A repeat answers {ok: true, duplicate: true} and writes nothing, including when two requests race on the insert. The bridge sends <ledger_entry_id>:<message_id>, so a reply id reused by a later ledger entry is not mistaken for a duplicate. | src/usage.ts, hermes_bridge/portal_sync.py (_usage_records) |
| Attribution | Which inmate row receives the event. | The inmate joined to a live subscription (inmates.inmate_number = ?, newest subscription first); never just the first row with that number. No match gives 404 unknown_inmate. | src/usage.ts (recordUsage) |
| Usage view | GET /api/usage: allowance, used, remaining, events of the current monthly window. | Needs a live subscription, else all zeros and no events. remaining = max(0, allowance - used). | src/usage.ts (getUsage) |
Allowance in Securus
The allowance is Hermes-side: a stamp balance per member in the Securus skill's store. The portal tells Hermes how much a subscriber is entitled to; the bridge applies it.
| Primitive | Definition | Rule / invariant | Where enforced |
|---|---|---|---|
| Stamps per month | The plan's messages_per_month (40). | One stamp is the unit of both the allowance and the usage events. | src/plans.ts, src/hermes.ts |
| Initial grant | When a pending connection is processed. | Target is messages_per_month minus what the current monthly window already used (never below 0). The skill's portal mode sets the exact balance (grant or revoke the difference) before the welcome message, sends no admin notice and uses no admin reply mark. | hermes_bridge/portal_sync.py (_process_connections), skills/communication/securus-emessage/scripts/securus_poc_contacts.py (_set_portal_allowance) |
| Refill | Once per new monthly window. | The bridge remembers the last window start per inmate; a newer period_start tops the balance up to messages_per_month. A refill never revokes, so unused stamps do not accumulate above 40 and a balance already higher is left alone. | hermes_bridge/portal_sync.py (_process_allowances, _set_allowance with refill_only) |
| Revoke | When an inmate with a recorded allowance is no longer in GET /api/hermes/allowances (subscription not active/trialing). | The remaining balance is revoked to 0, once (a marker is kept). Skipped if the allowances response itself failed. If the inmate reappears, the next pass refills them. | hermes_bridge/portal_sync.py (_process_allowances) |
| Reconnect | A paid inmate connecting again. | Receives only the unused part of the current window, not a fresh 40. | hermes_bridge/portal_sync.py (_process_connections) |
| Usage report | Stamps spent per ledger entry, posted for every linked inmate. | Each ledger entry becomes one event per message id with the stamps split evenly; a per-inmate cursor and a posted-ids list keep it at-most-once across runs; the bridge sends kind: "reply". | hermes_bridge/portal_sync.py (_sync_usage) |
| Default allowance (outside the portal) | A contact added by the admin or outside Hermes. | 20 stamps, not the plan's 40. A deliberately revoked allowance is not refilled by that default. | skills/communication/securus-emessage/scripts/securus_poc_contacts.py (DEFAULT_ALLOWANCE), deploy/securus-agent/PRODUCT.md |
Messaging service
What the plan buys: Hermes's Prison Reform Securus eMessage service. The versioned product spec is deploy/securus-agent/PRODUCT.md (current version 1.10.1, 2026-10-27); when this summary and that file disagree, the file wins.
| Primitive | Rule (from PRODUCT.md 1.10.1) |
|---|---|
| Identity | Called Prison Reform; never names the account owner, the admin or anyone who runs it; never calls itself an AI, model or bot. |
| Automation | The inbox is checked every minute. Nothing needs switching on. |
| One reply per message | Every inbound message gets its own reply, oldest first, one message per agent run; the sender's previous 3 messages are context only. A message is answered once seen for 3 minutes. |
| Detail | At least 2,000 characters for any question or request, up to about 19,000 (one Securus message). Greetings may be short. |
| Ending | Every text reply ends with (N stamps left) (after this reply; (1 stamp left) for one), then I hope this was helpful. |
| Pictures | Sent alone as Snap n' Send: no text, one stamp (the facility's snap price). Picture requests get 2 pictures at most. Real pictures are searched; new ones are made with the ChatGPT image model. |
| Video | Sent as a VideoGram (up to 30 s) only when asked, at the facility's price (4 stamps at Yuma). Otherwise titles and links. A new video cannot be made; the reply says so and lists real related clips. |
| Safety | No realistic depictions of real people seriously hurt, explicit sexual content, sexualized fabrication of real people, or help committing crimes. Legal information, not legal advice. Non-explicit public beach or swimwear photos of adults are allowed. |
| Research cap | At most 10 searches or page reads per message, then the best answer found; the research process is never mentioned. |
| Costs (Yuma) | Message 1 stamp; picture attached to a message +1; snap 1; VideoGram 4; one extra stamp per extra part of a long reply. A member's reply is priced and reserved before sending, settled after, released on failure. |
| Member allowance | A member whose allowance is used up waits; the admin bypasses it. |
| Health alerts | An hourly read-only script check. Since 1.10.0, alerts go only by email to the two configured operator emails (via ntfy), never over Securus: Reform Assistant Balance low for facility balances of 5 stamps or fewer, Reform Assistant Service error for errors; unchanged categories are deduplicated per recipient and re-armed after recovery. |
Read next: Feature map, Hermes bridge, Security.