retrograde/docs/STATE.md at main · Sutherlandon/retrograde · GitHub
Skip to content

Latest commit

 

History

History
101 lines (74 loc) · 18.5 KB

STATE.md

File metadata and controls

101 lines (74 loc) · 18.5 KB

Retrograde — Project State

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.


Quick Pulse

Stack React 19, React Router 7 (SSR), Tailwind 4, PostgreSQL via raw pg
Hosting Vercel (web) + Neon (Postgres); Auth0, Stripe, Porkbun — see DEPLOYMENT.md
Auth OAuth 2.0 — Keycloak in Docker for local, external IDP in prod
Tests 4,106 passing across 113 files (Vitest + RTL, jsdom, mocked pg) — includes a 2,808-cell permission matrix and a registry-linkage check
Real-time Polling, no WebSockets
Schema Idempotent DDL in app/server/db_init.ts, no migration tool — 33 numbered blocks

Branch state

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:

  1. 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/antares tokens in app.css, always dark regardless of theme; SiteLayout gives 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 from app/components/landing/. Its crew price is hardcoded to match the Stripe Price, as crews.tsx already is. Its plan cards name the tiers Guest, Pilot and Crew; Pilot is tier 2 (Registered), a homepage label only. The link-preview image public/og-image.png is a capture of /og-card (SITE-007) and must be recaptured when the site's look changes.
  2. Run the facilitator grant flow end to end with two real registered accounts.
  3. Confirm the Crew Access modal copy is explicit that open_facilitation hands locks, column deletion, and facilitator-granting to anonymous participants.
  4. Decide whether facilitators managing other facilitators matches intent, or should tighten to owner-only.

Access model

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).

Known rough edges

  1. 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_SECRET are required on the hosted service and refused when SELF_HOSTED=true. npm run build does not execute db_config.ts, so a CI build catches none of this — only a server boot does. Vercel production needs SITE_ADMIN_IDS set before this branch deploys.
  2. 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_tax must never be set — Managed Payments forbids it, and a test enforces that.
  3. The permission matrix has no registered-but-unsubscribed actor. Its fixture answers the entitlement query with active for every registered human, so it proves "registered + active → allowed" and "everyone else → denied" for CREW-002; the not-subscribed case is covered by entitlements.test.ts and crews.test.ts, not the matrix.
  4. Entitlement is active only. A past_due renewal (still inside Stripe's retry window) closes the gate immediately. One-line change in entitlements.ts if a grace period is wanted. The same rule means discounts must be coupons, never Stripe free trials: a trial subscription is trialing, which grants nothing and, as a non-active status, revokes the crew's API keys. A 100%-off promotion code keeps the subscription active; Checkout accepts codes, and a session with nothing to charge (no_payment_required) activates like a paid one.
  5. The one-time reset in db_init.ts block 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.
  6. Logging is stdout only (issue #82), with one exception. Key actions print [METRIC] <action> - key=value … through app/server/logger.ts, and unexpected loader/action errors print [ERROR] <path>: from handleError in app/entry.server.tsx. Nothing stores them: they live as long as Vercel keeps runtime logs, so they cannot power history. The exception is the events table (ADR-0026): event_model.recordEvent writes a row, tagged with the action's registry ID, as well as a [METRIC] Event line. So far only the board calls to action record events; wiring feature usage is issue #115.
  7. Test coverage gaps: board.poll.ts has no direct route test beyond the permission matrix; Board, ClaimModal, AttachmentModal, ThemeToggle have no component tests, and AppLayout has loader tests but none of its rendering. Every registry row is Verified.
  8. 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.
  9. ~30 stale local branches from merged PRs.
  10. 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 start against a local Postgres without TLS does not connect; develop with npm 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 dropped and discarded; the next query opens a new one.

Behavioral notes that are easy to get wrong

  • 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. The dev-test seed 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 a COALESCE — never write it to board_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_facilitation alone 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, never team_id/teamName, when deciding whether a board is anonymous.
  • boards.created_by is 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 on created_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 from archiveDateFor in auto_archive.ts, which mirrors archiveStaleBoards' 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.

Deferred by design

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.

Operational reality

  • 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 project retrograde-staging, production branch staging, with real Auth0, a Stripe sandbox and its own Neon database), and production. docs/DEPLOYMENT.md holds the matrix; ADR-0023 and ADR-0025 say why.
  • initializeDatabase() runs on every startup — idempotent, but startup always touches the DB. db_config.ts starts it once and every pool.query/pool.connect waits for it to commit (ADR-0024); db_init.ts itself does nothing on import. The build uses its own schemaPool. 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 of db_init.ts and 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. events records invite_click (BRD-022, homepage loader), invite_board_created (SITE-003; board_id is the new board, properties.from_board_id the one the invite was on), keep_click (BRD-021) and claim_reminder_click (BRD-020) from the board's Log in links, and board_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:
    SELECT 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;
    A keep-notice conversion is a board_claimed on the same board_id after its keep_click. Feature use, once wired, is count(*) 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.