Skip to content

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 ​

PrimitiveDefinitionRule / invariantWhere enforced
UserA 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)
SessionA 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 cookiera_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 routeAny 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 ​

PrimitiveDefinitionRule / invariantWhere enforced
InmateThe 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 accountsThe 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 lockWhile 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 connectionChanging the number while unlocked.connection_status goes back to not_connected and connection_detail is cleared.src/inmate.ts (saveInmate)
Inmate removalDELETE /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 ​

PrimitiveDefinitionRule / invariantWhere enforced
Plan catalogThree 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
MonthsHow 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 monthThe 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
Pricesprice_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 priceWhich 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 ​

PrimitiveDefinitionRule / invariantWhere enforced
SubscriptionThe 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
Statusactive, 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)
LiveA 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 periodcurrent_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 endThe 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 subscriptionA 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 sessionusers.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 ​

PrimitiveDefinitionRule / invariantWhere enforced
Monthly windowThe 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):

PlanBilling periodWindow
monthly2026-10-01 to 2026-11-01the whole period
monthly2025-02-28 to 2025-03-31the whole period (no extra short window)
quarterly2026-09-01 to 2026-12-01, now 2026-10-172026-10-01 to 2026-11-01
quarterly2025-01-31T12:30Z to 2025-04-30T12:30Z, now 2025-03-012025-02-28T12:30Z to 2025-03-31T12:30Z (31st clamps to February, then returns to the 31st)
quarterly2025-04-30 to 2025-07-31, now 2025-07-302025-06-30 to 2025-07-31 (windows: Apr 30, May 30, Jun 30)
annual2024-02-29T08:00Z to 2025-02-28T08:00Z, now 2025-02-012025-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.

StatusMeaning
not_connectedNo live Hermes link: no subscription yet, subscription not active/trialing, or the inmate number just changed.
pendingPaid and waiting: Hermes has not acknowledged yet. Listed by GET /api/hermes/connections?status=pending.
connectedHermes added the contact and set the allowance.
failedHermes could not complete setup; connection_detail carries the reason.
FromEventToWhere
not_connectedWebhook: subscription active or trialing, inmate presentpendingsrc/stripe_webhook.ts (upsertSubscriptionRow, updateSubscriptionConnection)
pendingHermes POST .../ack with status: "connected"connectedsrc/hermes.ts (acknowledgeConnection)
pendingHermes ack status: "failed"failedsrc/hermes.ts
failedNext webhook event for an active/trialing subscriptionpending (retry)src/stripe_webhook.ts
connectedRenewal 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 pendingWebhook: status past_due, incomplete or canceled (including the deletion that follows a cancel)not_connectedsrc/stripe_webhook.ts
not_connectedWebhook: subscription active againpendingsrc/stripe_webhook.ts
anyInmate number changed while unlockednot_connectedsrc/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 ​

PrimitiveDefinitionRule / invariantWhere enforced
Usage eventOne 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 keymessage_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)
AttributionWhich 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 viewGET /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.

PrimitiveDefinitionRule / invariantWhere enforced
Stamps per monthThe 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 grantWhen 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)
RefillOnce 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)
RevokeWhen 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)
ReconnectA 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 reportStamps 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.

PrimitiveRule (from PRODUCT.md 1.10.1)
IdentityCalled Prison Reform; never names the account owner, the admin or anyone who runs it; never calls itself an AI, model or bot.
AutomationThe inbox is checked every minute. Nothing needs switching on.
One reply per messageEvery 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.
DetailAt least 2,000 characters for any question or request, up to about 19,000 (one Securus message). Greetings may be short.
EndingEvery text reply ends with (N stamps left) (after this reply; (1 stamp left) for one), then I hope this was helpful.
PicturesSent 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.
VideoSent 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.
SafetyNo 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 capAt 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 allowanceA member whose allowance is used up waits; the admin bypasses it.
Health alertsAn 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.