Updated: 2026-10-17 · Version: 2.0.0 · Branch: staging, live on staging.retrograde.sh, in review to merge to main
Where the project is right now. For what the product does, action by action, see docs/spec/0001-action-registry.md. For why the load-bearing decisions were made, see docs/adr/. For where it runs — providers, environments, and which secret lives where — see docs/DEPLOYMENT.md.
staging (fast-forwarded from release/2.0.0-rc.1, which was cut from agent-substrate) carries the whole agent-substrate arc and has not shipped to production: the agent JSON API, mandatory agent attribution, teams as the billing unit, API keys, free-tier ephemerality, multi-member crews, the facilitator role, action items, the crew-centric dashboard, members-only crew boards, server-side enforcement of the whole tier model with a permission-matrix proof suite, and a live Stripe paywall with Stripe as merchant of record. ADRs 0001–0025 cover the decisions. It runs on staging.retrograde.sh, where sign-in through Auth0 and a sandbox subscription through Checkout and the webhook have been exercised end to end.
It is 2.0.0, not 1.7.0, because the boot contract changed. Every environment variable is validated at startup, so a deployment that upgrades with missing or malformed configuration exits instead of degrading — SITE_ADMIN_IDS included — and the database connection requires TLS outside local development (ADR-0018). The hosted service also needs the three Stripe variables and CRON_SECRET; a self-hosted install sets SELF_HOSTED=true and must not set them (ADR-0016, ADR-0020). Nothing is configured by editing code: app/config/siteConfig.ts is gone, so a deployment that used dashboardHome to hide logout now sets HIDE_LOGOUT=true, and its logo comes from the SITE_LOGO_* URLs. The logout redirect is read from OAUTH_LOGOUT_REDIRECT_URL (ADR-0017). README.md is the self-hosting guide.
Still open before merge:
- Eyeball in light + dark, desktop + mobile: Mission Objectives panel, Crew Access modal, crew pages, dashboard columns.
The marketing site (homepage, About, Contact, Terms, Privacy) is drawn on a night-sky palette defined as
night/airglow/stardust/nebula/starlight/antarestokens inapp.css, always dark regardless of theme;SiteLayoutgives the header and page background the night treatment. The homepage is a sales funnel (hero board form → how it works → board snapshot → idea boards → crew features → pricing → FAQ → closing CTA), built fromapp/components/landing/. Its crew price is hardcoded to match the Stripe Price, ascrews.tsxalready is. Its plan cards name the tiers Guest, Pilot and Crew; Pilot is tier 2 (Registered), a homepage label only. The link-preview imagepublic/og-image.pngis a capture of/og-card(SITE-007) and must be recaptured when the site's look changes. - Run the facilitator grant flow end to end with two real registered accounts.
- Confirm the Crew Access modal copy is explicit that
open_facilitationhands locks, column deletion, and facilitator-granting to anonymous participants. - Decide whether facilitators managing other facilitators matches intent, or should tighten to owner-only.
Three tiers, named for what the account is entitled to: 1 Anonymous (no account, crewless boards, 30-day TTL), 2 Registered (free, personal crew, facilitator role, API keys), 3 Paid (named crews, human members, members-only board access). The boundary in code is teams.is_personal.
Board access is a separate axis from the tier: a board is members-only only when its crew has restrict_board_access = true, which only a named crew can set. A tier-3 crew with it off is as open as a tier-1 board. The registry's header states the model; its guard column says which mechanism enforces each action.
Facilitator is the primitive. Every board control is designed for the facilitator role; the owner is a facilitator who also holds lifecycle rights (delete, archive, duplicate, move) and cannot be demoted. The owner of a board's crew has full authority over every board on it, whoever owns it: facilitation and every lifecycle right, by crew role with no board_members row (ADR-0027). Only the owner row itself stays unremovable. Both predicates live once in board_permissions_sql.ts. On the dashboard, the lifecycle controls and bulk selection gate on the row's can_manage. On a board page, reach for canFacilitate.
People and agents are held to one rule set (ADR-0027 §3). Every board route resolves its caller with getApiUser (key, agent_token or session) for access, facilitation and authorship alike; every write checks board access before facilitation; every note and column write is scoped in SQL to the board in the URL; length limits come from app/config/board_limits.ts on both surfaces and as input maxLength; and the browser poll and API read share getBoardSnapshot.
Agents have the board's core writes over the JSON API (ADR-0027, API-008 – API-017): renaming the board, editing, moving and deleting notes, column add/rename/prompt/delete, and facilitator list/grant/revoke, each under its browser counterpart's rule via api_board_guard.ts. Still UI-only: likes and votes, note reordering, settings and locks, the timer, attachments, action item edit/complete/delete, the open-facilitation toggle, and board lifecycle. A human can't grant facilitation to an agent, because agents aren't registered users.
Lapse behavior (ADR-0015). Entitlement covers the whole crew surface, not just creation, and is checked against the crew owner rather than the acting user — members are often free accounts. A lapse returns 402, freezes all crew management and new work, and revokes the owner's named-crew API keys via the webhook. It deliberately does not relax restrict_board_access: a billing lapse must never widen access. Known sharp edge, accepted deliberately: a lapsed owner cannot remove a member or delete their own crew. Revisit first if it generates support load. The crew page (crews.$id.tsx) mirrors this in the UI: a lapsed named crew shows a read-only banner with a "Subscribe" CTA and disables every frozen control (rename, roster, AI crew, boards, settings, danger zone, and add/edit/delete on crew action items) — only checking an item off stays interactive, per CREW-015.
Self-hosted instances (ADR-0016). SELF_HOSTED=true makes every account tier 3: the entitlement seam returns true without reading billing state, so every account can create named crews and lapse never applies. Billing and scheduled cleanup do not exist there — the Stripe variables and CRON_SECRET are refused at startup, the three billing routes and the cleanup endpoint return 404, the sidebar hides Billing, and crewless boards are never archived (ADR-0020). The personal crew's one-key cap still applies, as it does to a paying hosted account. The dashboard is also home: every marketing page redirects to it, the sitemap returns 404, robots.txt disallows everything, and the header drops About and Contact. Vercel Analytics does not load there (ADR-0017). There are no guests either: every page and API call needs a signed-in account or an API key, enforced in the identity helpers rather than per route, with a coverage test over routes.ts (ADR-0021).
The model is enforced. Facilitator-only controls, locks on every write route (UI and JSON API), board access on every API write route, ownership on duplicate, and the crewless-board invariant are all checked on the server (commits 21ab531, 01cd18a; ADR-0011). The registry's Gaps table is empty; GAP-009 (cross-board writes by id, #120) closed with ADR-0027. On the hosted service the paid tier is real: accountCanCreateNamedCrew reads users.subscription_status, which only the signature-verified Stripe webhook writes (ADR-0013).
- Every environment variable is validated at startup (ADR-0013, ADR-0016, ADR-0018, CLAUDE.md rule 5). Required ones exit when missing and optional ones exit when malformed; the Stripe variables and
CRON_SECRETare required on the hosted service and refused whenSELF_HOSTED=true.npm run builddoes not executedb_config.ts, so a CI build catches none of this — only a server boot does. Vercel production needsSITE_ADMIN_IDSset before this branch deploys. - Stripe is the merchant of record (ADR-0014). Managed Payments remits sales tax/VAT/GST in 80+ countries for 3.5% per transaction (~$1.40 on $39.99). Consequences to know: Stripe emails customers directly from Link (receipts, invoices, renewal notices), handles payment support, and may refund unilaterally if it asks you for product input and gets no reply within 48 hours.
automatic_taxmust never be set — Managed Payments forbids it, and a test enforces that. - The permission matrix has no registered-but-unsubscribed actor. Its fixture answers the entitlement query with
activefor every registered human, so it proves "registered + active → allowed" and "everyone else → denied" for CREW-002; the not-subscribed case is covered byentitlements.test.tsandcrews.test.ts, not the matrix. - Entitlement is
activeonly. Apast_duerenewal (still inside Stripe's retry window) closes the gate immediately. One-line change inentitlements.tsif a grace period is wanted. The same rule means discounts must be coupons, never Stripe free trials: a trial subscription istrialing, which grants nothing and, as a non-activestatus, revokes the crew's API keys. A 100%-off promotion code keeps the subscriptionactive; Checkout accepts codes, and a session with nothing to charge (no_payment_required) activates like a paid one. - The one-time reset in
db_init.tsblock 32 has not run against production. It strips anonymous/agent owner rows from crewless boards and opens facilitation on the ones left with no owner, gated so it runs once. Boards with a registered owner keep their owner and their facilitation setting (ADR-0022). Nothing observable changes for any board, but it is a data mutation — read it before the first production deploy of this branch. - Logging is stdout only (issue #82), with one exception. Key actions print
[METRIC] <action> - key=value …throughapp/server/logger.ts, and unexpected loader/action errors print[ERROR] <path>:fromhandleErrorinapp/entry.server.tsx. Nothing stores them: they live as long as Vercel keeps runtime logs, so they cannot power history. The exception is theeventstable (ADR-0026):event_model.recordEventwrites a row, tagged with the action's registry ID, as well as a[METRIC] Eventline. So far only the board calls to action record events; wiring feature usage is issue #115. - Test coverage gaps:
board.poll.tshas no direct route test beyond the permission matrix;Board,ClaimModal,AttachmentModal,ThemeTogglehave no component tests, andAppLayouthas loader tests but none of its rendering. Every registry row is Verified. - The README covers self-hosting only (issue #10). Development setup lives in
CLAUDE.md. The Docker instructions in it have not been run against a Docker build of this branch. - ~30 stale local branches from merged PRs.
- Database TLS has been exercised against a local TLS-only Postgres, not against Neon. Outside development the pool connects with
ssl: { rejectUnauthorized: true }. Neon's certificates chain to public CAs, so the hosted connection should verify, but the first deploy of this branch is the first time it runs against Neon.npm startagainst a local Postgres without TLS does not connect; develop withnpm run dev. The schema build's first connection retries a connection dropped in transit (ECONNRESET,ETIMEDOUT,EPIPE,EAI_AGAIN, pg's "Connection terminated") three times over about 4 seconds before exiting; staging logged such resets on cold instances while Neon woke a suspended compute. Bad credentials, an unknown host and certificate failures are not retried and still exit at once (ADR-0018). After startup, a connection that dies while idle in the pool (Neon closing connections when its compute suspends) is logged as[db] idle connection droppedand discarded; the next query opens a new one.
- The admin dashboard's growth charts (
getMetricsTrends, 12 weekly buckets) are counted from surviving rows' timestamps, so deleting a board or note removes it from past weeks too. Thedev-testseed board is excluded from every count. The charts are plain SVG (TrendChart.tsx), no charting library. - Dashboard
role='team'is a presentation-only pseudo-role from aCOALESCE— never write it toboard_members. - Any session user can un-check another user's completed action item. Per-item assignees are the refinement (ADR-0006).
- Crew action items have no attribution display.
- Facilitator grants are not copied when a board is duplicated.
- Claiming a board assigns it to the claimer's personal crew, which makes it permanent (the 30-day TTL only touches crewless boards), but deliberately leaves
open_facilitationalone so a claim never takes the Command Deck away from a live retro (ADR-0012). Moving a board never changes it either (ADR-0022). - "Anonymous" means no owner row, not "no crew" (ADR-0022). Every pre-2.0 board is crewless and most have a registered owner; those get full Crew Access. Check
hasOwner, neverteam_id/teamName, when deciding whether a board is anonymous. boards.created_byis attribution, never ownership: ownership is the owner row. A homepage board records its creator (guest or signed in) and stays ownerless. 2.0 dropped that write by accident when it stopped making guests owners, so guest boards created between 2026-09-23 and the fix have no creator; they are missing from the admin dashboard's "active boards" count, which keys oncreated_by, and a returning guest creator cannot be traced on them.- Each viewer gets at most one plain line under a board (
GuestNotices.tsx). Whoever can keep a crewless board the archive job will take (the guest who started it, a signed-in user while it is unclaimed, or its owner) is told "kept until {date}" and how to keep it; the guest who started a board that never expires is reminded to claim it. A guest who joined someone else's board, or a signed-out visitor on an example board, is only invited to start their own: no account or archive talk, which would contradict "no account needed" and nudge them to claim a board that isn't theirs. The date comes fromarchiveDateForinauto_archive.ts, which mirrorsarchiveStaleBoards' rule. Change one, change both. - Agent-authored notes always carry attribution, regardless of the board's attribution setting (ADR-0002).
- Personal crews cannot be renamed, deleted, restricted, or given human members — but they can mint exactly one API key; the second is a named-crew (tier 3) action.
Recorded so they aren't rediscovered as gaps: annual billing (a second Price on the same Product), MCP server, webhooks, per-key API scopes, account-level (non-crew) API keys, agent-name verification, and pagination on board reads. Agent activity metering and the rate limiting that covers the unauthenticated trial path are tracked, not deferred — issue #105.
- Solo developer with AI-assisted commits.
- Vercel + Neon + Auth0 + Stripe, domains at Porkbun, source on GitHub. Four environments: local, throwaway previews,
staging.retrograde.sh(the Vercel projectretrograde-staging, production branchstaging, with real Auth0, a Stripe sandbox and its own Neon database), and production.docs/DEPLOYMENT.mdholds the matrix; ADR-0023 and ADR-0025 say why. initializeDatabase()runs on every startup — idempotent, but startup always touches the DB.db_config.tsstarts it once and everypool.query/pool.connectwaits for it to commit (ADR-0024);db_init.tsitself does nothing on import. The build uses its ownschemaPool. Before this, Vercel froze the build mid-transaction and a new database was never created.- A dev seed board (
dev-test) is created at the bottom ofdb_init.tsand runs in production too. Harmless, noisy. - The auto-archive cron (API-006) is invoked by Vercel with a GET; it previously implemented POST only, so it answered 405 and never ran. Boards created before 2026-10-01 are exempt (ADR-0019), so the first run archives nothing that exists at release; the earliest archive is 2026-10-31.
- Following up on the board calls to action.
eventsrecordsinvite_click(BRD-022, homepage loader),invite_board_created(SITE-003;board_idis the new board,properties.from_board_idthe one the invite was on),keep_click(BRD-021) andclaim_reminder_click(BRD-020) from the board's Log in links, andboard_claimed(BRD-020; every claim, including DASH-016's). Showing a line is not recorded, so the invite's audience comes from the data: guests who wrote on a board they did not create. Weekly counts:A keep-notice conversion is aSELECT date_trunc('week', created_at)::date AS week, count(*) FILTER (WHERE name = 'invite_click') AS invite_clicks, count(*) FILTER (WHERE name = 'invite_board_created') AS invite_boards, count(*) FILTER (WHERE name = 'keep_click') AS keep_clicks, count(*) FILTER (WHERE name = 'claim_reminder_click') AS reminder_clicks, count(*) FILTER (WHERE name = 'board_claimed') AS claims FROM events GROUP BY 1 ORDER BY 1;
board_claimedon the sameboard_idafter itskeep_click. Feature use, once wired, iscount(*) GROUP BY action_id. Run these on a Neon branch of production, not on production itself. - Honeypot on free-board creation is the only bot defense. Turnstile was removed deliberately (issue #87) — an agent must be able to create a board without solving a captcha. If bots become a problem, the answer is rate limiting, not a captcha.
