Skip to main content

Arya architecture (as built)

Status: living document, revised 2026-11-04. Section 0 is the one-page map with the diagram. Sections 1 to 13 are the design record. SPEC.md states the product rules. This document states how the built system is put together. Where the two disagree, SPEC.md wins for behavior and this document wins for structure.

0. The whole app on one page​

Detailed view (wide, scrolls sideways on a phone):

Arya architecture diagram

Phone-friendly view of the same map
Who uses itThree ways in
Phone appReact + Vite PWA. Today, Spending, Quest, Ask, Moreoffline shell, push, live group sync
Muse agentMCP connector at /api/mcpbearer token, stored hashed
Lists API/api/v1bearer token
OwnerOps screen in the appVM scripts: reminders, backup, restore
↓
Front doorOracle VM, Docker Compose
CaddyTLS, CSP, HSTS
FastAPI, one processserves the app at / and the API
Guardssign-in, demo sandbox, rate limitsbudget guard before every paid call; Ask guard, $0
SettingsOps value, then env, then default
↓
Routesone router per area, api/routes/
Lists and itemsadd, check, expiry
Receipts inphoto, PDF, text, voice
Today and Askhome, chat
Spendingprices, insights
Questhunts, shelf scan, store maps, Eyes
Groupsshared home, live sync
Accountsign-in, settings, export
MCP and Lists APItools for agents
Opsowner only
Demo Townsimulated neighbours
↓
What answerscode first, models only where needed

Code · $0

Receipt mathtotals must add up
Ledgerreturns netted, who it was for
Patternshow often, which store
The listrunning low, with reasons
Pricesown receipts only
Expiry, dictionarydates, names
Live syncSSE, Postgres NOTIFY
Remindersone push a day at most

Meta models · via meta_client.py

Muse Spark 1.3reads receipts first; Ask, judge, Curator, list photos
Muse Wonder 1.0re-reads only flagged receipts
SAM 3.1shelf scan; pixelates people
Voice, Imagevoice to text; avatar and celebration characters
Spark Openon Baseten; staple judge, item names only
↓
Where it's keptone document per area
JSON fileson the Docker volume
Supabase Postgresarya.user_docs, in dual or pg mode
Receipt originalsphoto or PDF, per account
Demo sandboxin memory only
Outside servicesoptional, behind flags
Supabase Authemail codes; Google fallback
Google sign-inArya’s own, flagged
Resendaccount emails, waitlist, alerts
Google Placesstore search
Krogeraisle hint, live only, nothing stored
Web Pushreminders
clientsservercode, $0Meta modelsstorageoutside, optional

The diagram is assistant/docs/architecture.svg (plain SVG, light and dark), redrawn and checked against main on 2026-11-04. The sections below say what each box does. Their facts are checked against main on 2026-11-04, except the live-site values in §0.11 and §10, which come from the Ops screen.

In one paragraph. A phone-first PWA (React, built with Vite) talks to one FastAPI process, which also serves the built app at /. Caddy fronts it with TLS. Code does the money, timing and list logic at $0. Meta models read photos, judge staples and answer questions, and every call to them goes through one module (api/meta_client.py). State is one JSON document per area, kept on a Docker volume, in Supabase Postgres, or both.

0.1 Front end (assistant/web/)​

  • React 19 and TypeScript, built by Vite into web/dist. The API serves the build at / and the static folder assistant/pwa/ (images, store models, three.js, scene.js, the manifest, icons) beside it; the build wins on a name clash. index.html and sw.js are sent with Cache-Control: no-cache. The old address /next/... answers 302 to the same path at /.
  • One screen per file in web/src/screens/, listed in screens/registry.tsx. Navigation is a stack (app/nav.tsx); the session (user, demo, currency, language) is app/session.tsx. Shared parts: web/src/ui/ (sheet, toast, info sheet, the store view) and web/src/lib/ (API calls, formats, stores, language).
  • The hunt, Express, Pack, haul, album, share, mapper, group live sync (app/groupLive.ts), push reminders, powers and the install prompt are ported one to one from the old plain-JS files. The store view is scene.js (three.js), loaded as a classic script.
  • The navigation tab is Today, Spending, Quest, Ask and More (the default; it can be customized). More holds Lists, Receipts, Stores, Help and Settings. Settings opens the Profile page (Group, Preferences with Reminders, Account, About Arya, Sign out last). About Arya is the about view: Arya's powers, Use Arya in Muse, Add to Home Screen, Keeping Arya running. (Updated 2026-11-02, #416; this replaced the earlier six-tab layout.)
  • Service worker web/src/sw.js, built to /sw.js (vite-plugin-pwa, injectManifest). It precaches the built shell and the runtime files of the static folder. It is network first. It sends /api/, /auth/, /decode, /import and /eyes straight to the network, never touches a write (non-GET) or the live stream /api/group/live, and shows push reminders. It takes control at once and deletes every other cache, the old arya-v* caches too.
  • Security: no inline script (the CSP has script-src 'self'; tests/test_csp.py checks web/index.html); no inline handlers.
  • Tests: string pins in assistant/tests/ (they read web/src through tests/frontend.py), vitest tests beside the code (web/src/**/*.test.ts*), and browser runs in web/e2e/.
  • Style: light theme, greyscale structure and one orange accent; see DESIGN.md.

0.2 API (assistant/api/)​

api/main.py is a short file: the app, the require_login middleware, a handler that turns a model failure (RuntimeError) into a 502 with a plain sentence, /health, one ordered router list, the Lists API mounted at /api/v1, and the static mount for the PWA. Routes live in api/routes/, one router per area:

RouterWhat it serves
auth, accountsign-in, registration (closed unless ARYA_REGISTRATION_OPEN=1; the Ops flag overrides the env, and sign-up is closed on the live site), email code (Supabase) and Google (Arya's own sign-in, flagged), connector and Lists API token, settings, export, feedback, push opt-in
intake, review, receiptjobsreceipt photo, PDF, text, order text, structured JSON, Costco JSON, voice transcribe, Curator, review queue; receipt batches that keep reading while the app is closed
lists, items, expirynamed lists, add by text or photo, check, move, judge runs, item pages and flags, expiry dates
home, chatthe Today payload, Ask Arya and its apply step
pricesown-receipt prices, Analysis insights, Arya's powers
groupsshared home: create, invite, join, leave, live stream
quest, questmap, receipts, shelf, storemap, stores, eyes, avatar, friendshunts, filing items into a store's zones, receipts with originals, shelf scan, Map my store, store directory, pantry and shelf photos kept as sightings, avatar, friends
mcp_connector/api/mcp, the tool surface for a Muse agent
lists_v1 (mounted at /api/v1)the Open Lists API: read and write your own lists from another app, bearer token only, its own error format
opsthe owner's Ops screen: models, limits, accounts, freezes, Ask locks (owner only)
townDemo Town routes; the demo account only, and only when ARYA_DEMO_TOWN=1

Older siblings stay beside the package: album_routes, hunt_routes, haul_routes, pack. Shared plumbing is in api/deps.py (cost ledger, budget guard, new_client(), rate-limit buckets, JSON helpers), api/demo_guard.py (login middleware, demo sandbox, door caps, the list of routes a token cannot reach), api/ask_guard.py (the Ask input and output guard and its three-strike lock, §6) and api/settings.py with api/site_flags.py (a value set in Ops, then the env var, then the default).

0.3 Engines in code ($0)​

decode/service.py (PDF text layer, scanned PDF pages rendered with pypdfium2 and sent to the photo reader, arithmetic reconcile), ledger/ (returns netted FIFO, allocations), patterns/ (cadence and price by store), list/ (the derived list and its reasons), state.running_low (one rule for Today and Ask), prices.py (see §0.7), expiry.py, dictionary/, optimizer.py (merges duplicate adds and fixes typos before a hunt) and cart/ (a sandbox cart proposal; approval is a state change only, and the app no longer shows it).

0.4 Models​

All calls go through api/meta_client.py; the personal-data rule is enforced there. open_client.py is the only other model client.

JobModelNotes
Reasoning, decode fallback, judge, Curator, Eyes, Ask Aryamuse-spark-1.3 (Standard; ARYA_SPARK_TIER=contributor is an operator option, off)cached prefixes, low effort
Receipt photo reader (default, auto)Spark first; muse-wonder-1.0 (90 s bound) re-reads only a receipt the arithmetic checks flagdecoded_by records which read was kept; ARYA_PHOTO_READER=spark or wonder, or the Ops "Photo reader" switch, changes it with no code change
Celebration characters, generated oncemuse-image-1.0
Voice inmuse-voice-transcribe-1.0
Shelf and people segmentationsam-3.1people SAM detects are pixelated first, and the frame is discarded if that fails (detection is not perfect and has not been scored)
Second judge seat, dictionary enrichmentSpark Open, hosted on Basetenplatform data only; the client refuses personal data

0.5 Storage​

api/state.py keeps one JSON document per former file (receipts, dictionary, flags, lists, quest, push...). ARYA_STORE picks the backend: json (default; files under data/users/<account>/), dual (files are the truth, each save mirrors to Postgres) or pg (Supabase table arya.user_docs, keyed by owner and document name, role arya_app, via DATABASE_URL). The demo account always uses files, and its visitors write only to an in-memory sandbox overlay. Caches and big blobs (shelf_matches.json, storemap_img.json) stay on disk in every mode. A receipt's original photo or PDF is kept per account.

0.6 Auth​

Password accounts (PBKDF2 hashes, throttled sign-in, cookie sessions whose tokens are stored as SHA-256 hashes). Account names allow only letters, digits and . _ % + - @. Email-code sign-in goes through Supabase Auth and Google sign-in is Arya's own (api/google_oauth.py, used when GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET are set; otherwise Google goes through Supabase). Either way the provider only proves an email address; Arya keeps its own account and cookie (api/supabase_auth.py, flag ARYA_SUPABASE_AUTH). The Muse connector and the Lists API use a bearer token whose hash is stored.

0.7 Price intelligence​

api/prices.py is deterministic and uses only the household's own receipts: per-store unit prices, a cheaper-store hint when the gap is real, a trend only with enough buys over enough days, the household's own basket inflation, a split-the-list suggestion, food waste in dollars. Nothing is fetched from a store, a flyer, an API or the web. Ask Arya answers price questions from these precomputed facts. Community prices, Deals and Arya Shoppers are opt-in, recorded only, and not live.

0.8 Groups and live sync​

With ARYA_GROUPS=1 (on in compose), an account in a group reads and writes the shared stores (lists, flags, product knowledge) in the group's plane; receipts stay with the account that filed them and every member sees them. Invites are a 6-letter code valid 48 hours. Leaving copies the lists. Live sync: every shared save calls live.changed(group_id); each open app holds one Server-Sent Events stream (/api/group/live) and reloads when its group changes. With DATABASE_URL set the change is a Postgres NOTIFY arya_group and one listener thread per process wakes its streams; without it, or with ARYA_LIVE_PG=0, the wake stays in-process.

0.9 Push reminders​

Flag ARYA_PUSH_REMINDERS=1 plus VAPID keys. A phone opts in from Settings; the subscription is saved in push.json. A daily cron line runs python -m api.reminders, which sends at most one push per account per day for staples that run out inside the notice window. No model calls. Steps: PUSH-REMINDERS.md.

0.10 Deploy​

One Oracle VM, Docker Compose with two services: api (uvicorn, deploy/Dockerfile.api) and caddy (TLS, security headers, CSP, plain HTTP redirects to HTTPS). Data lives in the arya-data volume. Settings come from deploy/.env (VM only); compose lists every variable the app reads, and tests/test_compose_env.py fails if one is missing. Deploy is git pull then docker compose up -d --build in assistant/deploy (deploy.sh); never rsync --delete. The api image builds the app (web/) in a node stage and copies web/dist in. backup.sh and restore.sh handle the nightly backup. CI is manual only (.github/workflows/tests.yml).

0.11 Feature flags and settings​

Most limits and switches can also be set in the owner's Ops screen (Settings, then Ops and health). A value set in Ops wins over the env var, which wins over the code default. The table gives the default in compose. The live site (checked with Ops on Nov 2) differs in these places: ARYA_STORE=dual; push reminders on (daily cron at 13:00 UTC); Supabase Auth and email sign-in on; Demo Town on; the Kroger aisle lookup on; own-key sign-up off; photo reader auto; Spark tier standard; new sign-up closed (the Ops flag wins over the env). Budgets: $10 a day for the app, $100 a day for the demo (code default $2), $2 per demo visit (code default $0.40). Per-account limits are $3 heads-up, $20 monthly pause and $10 hourly freeze.

VariableDefault in composeEffect
ARYA_STOREjsonjson, dual or pg storage
DATABASE_URLemptySupabase Postgres connection (role arya_app)
ARYA_GROUPS1shared households (code default is off)
ARYA_LIVE_PG1live sync through Postgres NOTIFY when DATABASE_URL is set
ARYA_PUSH_REMINDERS0push reminders; needs ARYA_VAPID_* keys
ARYA_SUPABASE_AUTH0email code and Google sign-in; needs SUPABASE_URL, SUPABASE_ANON_KEY
ARYA_SUPABASE_SIGNUP10 limits Supabase sign-in to existing accounts
ARYA_REGISTRATION_OPEN0open password sign-up
ARYA_PHOTO_READERautoreceipt photo reader: auto (Spark first, Wonder on a flagged read), spark or wonder
ARYA_DAILY_BUDGET_USD10app model-spend ceiling per day
ARYA_USER_HEAVY_MONTHLY_USD3one account's month of AI spend that shows a heads-up on its usage screen
ARYA_USER_MONTHLY_CEILING_USD5one account's month of AI spend that pauses its paid reads until the 1st
ARYA_USER_HOURLY_FREEZE_USD1one account's hour of AI spend that switches its paid reads off for ARYA_USER_FREEZE_HOURS (24); I can lift it with POST /api/usage/unfreeze
ARYA_COST_PUBLIC_MIN50accounts with spend before the whole-Arya totals show on the usage screen
ARYA_COST_PAID_BYHackathon creditswho the usage screen says pays for the AI reading
ARYA_DEMO_DAILY_BUDGET_USD100 (code default 2)demo household's day bucket
ARYA_DEMO_SESSION_BUDGET_USD2.00 (code default 0.40)one demo visit's share
ARYA_DEMO_DOOR_RATE, ARYA_DEMO_AUTH_RATE240, 120per-IP demo door and auth rate limits
ARYA_FEEDBACK_DAILY_CAP5feedback messages per account per day
ARYA_TOKEN_GUARDSonan API token cannot call the blocked routes (bulk model spend, groups, push, feedback, account export, Kroger aisle hints); off lifts the list
ARYA_DEMO_TOWN0demo household shops at simulated stores in a simulated neighborhood (DEMO-TOWN.md)
ARYA_OWNER_USERNAMEemptythe owner account: it runs Ops and health (models, limits, accounts, freezes, Ask locks, registration). The owner can hand the role to another account in Ops; the old owner keeps owner rights for 24 hours so a wrong pick can be undone
ARYA_OPS_EMAIL, ARYA_ALERT_EMAIL, RESEND_API_KEYemptyemail through Resend: delete-account codes, waitlist and feedback notes, and one alert per Ask lock (at most 20 a day); off until the key is set
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRETemptyArya's own Google sign-in; without both, Google goes through Supabase
ARYA_SCAN_DAILY_FRAMES120Map my store frames per account per day
ARYA_KROGER_AISLES, KROGER_CLIENT_ID, KROGER_CLIENT_SECRET, ARYA_KROGER_DAILY_CALLS0, empty, empty, 800live Kroger aisle hint (api/kroger.py); never stored
MODEL_API_KEY, BASETEN_API_KEY, ARYA_OPEN_MODEL, GOOGLE_PLACES_API_KEYemptymodel keys and the store picker

0.12 Cost guards​

Every model call writes a row to the cost ledger. The app, the demo household and each demo visit spend from separate buckets (§10). Live doors, batch judge runs and hunt creation pass the guard first; the demo's live doors also have per-IP caps. Deterministic code never calls a model. An Ask message the guard refuses costs $0 (no model call), and max_tokens bounds each Ask reply (§6).

1. The shape: two layers​

Arya has two layers. Each layer does the work it is good at.

  • The edge is the user's own Muse. It sees the media: receipt photos, PDFs, pasted orders. It reads them and sends Arya structured data only; on this path no receipt image reaches the server. A receipt photo filed in the app itself is kept per account and is viewable in the app ("View receipt", Discussion 42). This is an accepted trade-off, recorded in SPEC.md.
  • The core is the Arya server. It verifies, remembers, reasons, and answers. It spends no tokens on reading media.

The rule between the layers: the client proposes, Arya disposes. Client data is untrusted. Totals must reconcile in code before a receipt is accepted. Low-confidence lines go to the review queue.

2. Intake paths​

All paths end in one contract: a decoded receipt with lines, subtotal, tax, and total.

  1. Structured intake (primary). POST /api/intake/structured. The user's Muse posts the decoded receipt as JSON. The server recomputes every line status. A subtotal that does not match the lines fails reconciliation and is marked, never silently accepted.
  2. Order text. POST /api/intake/order-text. A pasted online order (Instacart, Amazon) is extracted server-side, then follows the same path. Delivery fees and tips are excluded by rule.
  3. Server decode (fallback). PDF text and photo decode endpoints remain for clients that cannot decode at the edge. They use the Model API under the model policy (MODEL-POLICY.md).
  4. Costco JSON export. Bootstrap seed only. It is not a product intake path.

3. Verification in code (no model, no cost)​

  • Arithmetic: line totals must sum to the subtotal within 5 cents.
  • Line status: auto at confidence 0.8 or above, else unverified and into the review queue.
  • Discount lines are structure, not identity. A discount line (Costco prints it as "/") attaches to its parent item (named on the receipt, else the previous item line) and is always auto-verified. It never enters the review queue, on any path.
  • Fee lines are classified once and never enter patterns or the list.

4. Identity: what things are​

Three mechanisms, in order of strength.

  1. The dictionary maps each store SKU to a canonical name. A name the user confirms teaches the dictionary. An item whose name cannot be established is unresolved: off the list, out of patterns, waiting in the review queue. Arya never guesses a name.
  2. Groups. Products are grouped by an explicit group key when one is set, else by their normalized name. Costco re-SKUs the same product under one name; the group is the product everywhere it matters: patterns, ledger, list. Merges and splits set the explicit key.
  3. The Curator (server-side reasoning) proposes merges and splits inside candidate clusters built in code at no cost. The model judges a cluster under a strict doctrine: same product only; never across organic vs conventional, form, flavor, fat content, or a size that makes a different product; when in doubt, keep separate. A proposal changes nothing until the user applies it. No silent edits, ever. Proposals the dictionary has already overtaken (members already one group, a duplicate, a split already in effect) retire automatically as superseded and are never shown.

5. The Resolver: research at the edge​

Arya's server models have no web access. Identification from the outside world is therefore an edge job, run by the user's Muse:

  • Trigger: an unresolved dictionary item, or an unverified receipt line whose name is abbreviated or unreadable.
  • Method: the retailer's own catalog first. A store item number matched on the retailer's site (for Costco: costco.com and its same-day storefront) is the strongest evidence. Then the wider web.
  • Output: candidates with source URLs, posted to Arya (POST /api/review/suggest) and shown in the review queue next to the item or line. Research is evidence, not a verdict: the user settles with one tap, and settling teaches the dictionary.
  • A brand initial alone is not evidence. Items with no catalog match stay open.

6. The reasoning agents in the core​

Each agent has a narrow job and a named limit.

  • Staple judge. Input: pattern facts only (buys, cadence, quantities, all computed in code). Output: STAPLE, SEASONAL, OCCASIONAL, ONE_OFF, or NOT_CONSUMABLE per product. The verdict gates admission to the list. Explicit user intent always beats the judge. Unjudged and unresolved items stay off the list.
  • Curator. See §4. Proposes identity changes; never applies them.
  • Ask Arya. Chat over a household snapshot. Actions (add to list, remove from list, ran out, mark expired, never suggest, snooze) are validated server-side. A list is found by a near name ("Costco" finds "Costcozy"), and only a row already on a list can be removed. The snapshot carries receipt facts (count, last, biggest, trips per store), spend per store per month, pack dates as words, and the cheapest store per item. Numbers come from the data, never from the model. At most 10 actions run in a turn. More than 3 removes in one turn wait for a yes (Yes, do these or Not now) on every screen. On the Ask tab the other actions apply at once; from a list or a hunt, adds apply with Undo and the riskier actions (out, expired, never, snooze) wait for a yes (/api/chat/apply). Demo sessions can chat; it is a live door under the demo's per-IP and per-visit caps (§10).
  • Ask guard (api/ask_guard.py). The person's latest message is checked against high-confidence phrases only (an override with a target, a prompt leak, secrets, other accounts). A hit gets a fixed refusal with no model call, and counts a strike. Three strikes in 24 hours pause Ask: a demo visit for 24 hours, a real account until the owner unlocks it in Ops (the message says to email support). The owner is never locked. A reply that quotes the system prompt, holds something shaped like a key or links off Arya is swapped for the refusal, with no strike. Stored text (item and list names, the screen, a photo) never strikes. Each Ask reply is capped at 1,500 tokens.

7. Money rules (summary; SPEC.md owns the detail)​

  • Returns are first-class: FIFO netting against buys, refunds reconcile into spend, patterns count kept items only, a returned item is never re-suggested.
  • Every line carries an allocation: HOUSEHOLD (default) or FOR-OTHERS. For-others spend is in the ledger, never in patterns, inventory, or the list.
  • The list is a derived view of the inventory model, never the source of truth.

8. Accounts, tokens, and the connector​

  • Per-user state is a set of JSON documents, one per former file (receipts, dictionary, flags, lists, quest...). ARYA_STORE picks where they live: json (files under data/users//, the default), dual (files are the truth, every save is mirrored to Postgres), or pg (table arya.user_docs, see api/pgstore.py). The demo account always stays on files.
  • An account name is also a folder name, so it holds only letters, digits and . _ % + - @ (auth.valid_name). Passwords are PBKDF2 hashes; sign-in is throttled; sessions are cookies, and session tokens are stored as SHA-256 hashes.
  • Email code sign-in (Supabase Auth) is built behind ARYA_SUPABASE_AUTH=1, off by default and on in the live site. Google sign-in is Arya's own (api/google_oauth.py) when its client id and secret are set, with the Supabase Google provider as the fallback. The provider only proves the email; Arya keeps its own account (named by the email, no password) and its own cookie (api/supabase_auth.py).
  • A connector token is arya_ plus a random secret. Only its SHA-256 hash is stored. It is shown once at mint. Minting a new one replaces the old, and it can be revoked at any time. Bearer tokens are accepted alongside session cookies, and the same token works for the Lists API (/api/v1). A token cannot reach paid bulk reads, Ops, groups, push, export or the Kroger lookup (demo_guard._TOKEN_BLOCKED). Demo accounts cannot mint.
  • The Muse connector (custom.arya) carries its own instruction doc: operations, when to file, the verification rules, the Resolver procedure, filing hygiene. The doc is the standing prompt. The user writes no prompt.
  • Routing is contextual. A receipt, an order, or a question about the user's own list, spend, or inventory goes to Arya. A general question does not. "Arya, …" always routes. Ambiguous input: ask, or state the assumption.

9. Onboarding​

  1. Create an account. A new account opens on Today; there is no Connect an AI agent step on first run.
  2. Connecting Muse is optional, from Settings > About Arya > Use Arya in Muse: make the code there (shown once).
  3. Tell the Muse agent to connect to Arya; it asks for the code and opens a secure credentials vault. Paste the code there, never into a chat message.
  4. Talk to Muse. The screen carries these steps and a copyable fallback setup prompt for assistant apps that do not load the connector doc automatically. The Muse Code settings-file route is the backup (AGENT-CONNECTOR.md).

10. Cost control​

  • MODEL-POLICY.md is normative: deterministic work in code at $0; Spark 1.3 Standard, low effort, cached prefixes for volume reasoning (if the Contributor operator option is on, a client-side 140 requests/min gate applies; its limit is 150); Wonder is banned from volume paths.
  • Every model call is written to a cost ledger. The app's daily ceiling (ARYA_DAILY_BUDGET_USD, $10) stops app model spend; the demo household spends from a separate bucket (ARYA_DEMO_DAILY_BUDGET_USD, $100/day in compose) and each demo visit from its own share (ARYA_DEMO_SESSION_BUDGET_USD, $2 in compose), so demo use cannot drain the app. Every live door, batch judge run and hunt creation passes the guard first.
  • A model answer that cannot be read is a 502 with a plain sentence, never a 500 (meta_client.parse_json). Each account has its own limits: a monthly heads-up, a monthly pause on paid reads, and an hourly freeze (defaults $3, $5 and $1, see the flag table; the live site uses $3, $20 and $10, and the owner account is exempt), on top of the daily ceiling for the whole app.
  • api/meta_client.py is the only module that calls the Model API. The personal-data rule is enforced there by construction.

11. Deployment​

  • One Oracle VM (Always Free; no new cloud resources). Docker Compose: the API and Caddy with Let's Encrypt. No database container: data is JSON files, mirrored to Supabase on the live site (ARYA_STORE=dual); after judging Postgres can become the master copy (pg). Public name: arya.teamnightshift.org (Cloudflare A record, DNS only). The old arya-pilot.duckdns.org name is a permanent Caddy redirect to it.
  • Known limit: one uvicorn process on one VM. Demo sandboxes live in that process's memory and the JSON files have one writer, so it cannot run as several processes yet. Dual-mode saves mirror to Supabase inside the request, so a slow Supabase can slow a save. Roughly a few dozen people at once is the ceiling (README, Known limits). After judging: Postgres as master copy, session state out of memory, several processes, a cloud provider (see docs/ROADMAP.md; the phased design is docs/SCALABILITY-DESIGN.md, a plan, not built).
  • Deploy is git pull on the VM, then a compose rebuild. Never rsync --delete: it once deleted the VM-only deploy environment.
  • Settings come from deploy/.env (VM only). Compose passes only the variables it lists, so every setting the app reads is listed in docker-compose.yml; tests/test_compose_env.py fails if one is missing. .env.example names them.
  • The full test suite runs before a PR merges. A GitHub Actions workflow (.github/workflows/tests.yml) runs it on demand only, not on every PR (my choice). Nothing is committed to main directly; a session log goes to loops/ and STATUS.md is updated.

11.1 Logs and backups (VM runbook)​

  • Log caps. Compose caps each container's log at 5 files of 10 MB (x-logging in docker-compose.yml), so logs cannot fill the disk. The cap applies when a container is recreated: docker compose up -d.
  • Nightly backup. deploy/backup.sh archives the data volume (and the arya Postgres schema when ARYA_BACKUP_DATABASE_URL is set in deploy/.env) into ~/arya-backups, keeping the newest 7 of each. Install once on the VM with crontab -e: 17 3 * * * /path/to/arya/assistant/deploy/backup.sh >> /path/to/arya-backup.log 2>&1 Check it with ls -l ~/arya-backups the next morning.
  • Off-VM copy. A backup on the same disk dies with the VM. Install rclone (sudo apt install rclone), run rclone config once to add a remote you own (Oracle Object Storage, Google Drive, any S3), then add ARYA_BACKUP_REMOTE=<remote>:arya-backups to deploy/.env. The script then copies each night's files there and exits non-zero (so the log shows it) if the copy fails.
  • Restore. deploy/restore.sh <arya-data-*.tar.gz> stops the api, replaces the data volume and starts it again. The Postgres dump is restored by hand with psql.

12. Quest storage​

Per account, in quest.json: sessions, Shop-Offs, friends, Spotter reports, and sightings (item, store, shelf label, time; no image). shelf_frames.json holds one fresh shelf photo per store and item, already scrubbed of people by SAM (shelfscan.scrub_people; no scrub, no frame). GET /api/quest/plan counts purchases per store from the ledger for Plan a hunt ($0). The demo sandbox overlays all of it in memory. The store directory (stores.json) is platform-level.

13. Not built / held​

  • A fixed per-account spend allowance that resets by itself. Not built; per-account limits do exist (§10: defaults $3 heads-up, $5 pause, $1 hourly freeze; live $3, $20, $10).
  • Public registration is closed by default (ARYA_REGISTRATION_OPEN; the Ops flag overrides the env and is closed on the live site). The owner can open it in Settings. Every door runs on Meta models: Muse in chat, Spark on the Meta API in the app.
  • Forgot-password: not built. Once switched on, email code sign-in (§8) lets a person in without a password.