You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Move agentnote's Railway-hosted Postgres + agentnote-collab off the personal chasehuh Railway workspace onto the Sume Railway workspace by recreating the stack and restoring data with pg_dump/pg_restore. Do not use Railway project transfer. Keep the Vercel app on team cwhuh / project memo (https://www.agentnote.dev) for this issue — only Railway-side ownership + env cutover.
Why This Matters
chasehuh Railway hit a trial expiry outage (2026-08-09): Postgres Offline, collab Failed, Vercel saw ECONNRESET / Connection terminated unexpectedly (Next.js digest 2206690639). Data survived on volume (~120 MB) after Hobby reactivation, but personal-trial billing is an unacceptable SPOF for a daily notes app.
Sume workspace already hosts other paid infra; putting agentnote DB + collab there aligns billing, backups, and ops with the rest of Chase's stack.
Dataset is small (~12 MB DB, tens of notes) — recreate+restore is cheaper and safer than workspace transfer edge cases (volumes, TCP proxies, env refs).
Conversation Context
Decisions locked in chat (2026-08-09, agent transcript f550bb91-8b0e-4029-944c-abdeb28e8fcf):
Chosen approach: recreate under Sume Railway (new Postgres + collab), pg_dump → pg_restore, flip Vercel envs, then decommission chasehuh. Rejected: Railway project transfer.
Scope preference: Railway-first. GitHub repo transfer and Vercel team move are optional later, not required for this issue.
Immediate prod recovery already done (do not redo as "migration"):
Hobby plan restored chasehuh Postgres Online; data intact.
Sticky serverless failure after outage: warm Vercel isolates kept a rejected ensureSchema promise → every hit resurfaced digest 2206690639 even though DB was healthy. Fixed in PR #93 (lib/db.ts: clear sticky promise, recreate pool, retry once; serverless-friendly pool settings). Production redeploy flushed warm isolates.
Operator keeps multiple agentnote.dev tabs open by default — cutover must not leave split-brain CRDT (two DBs / two collab URLs).
chasehuh (personal), project chasehuh, id 075539a8-061e-4223-9c7b-e0eb9cebe200, env production / 911f18c1-8dc5-40b5-8f1c-563eb080fad5
Postgres
Online, image postgres-ssl:18, region sfo, volume postgres-volume ~120 MB / 500 MB, volume id cd5bb48b-661d-4f99-9233-8e69b669a408, service id 1e6b049b-87bb-4d04-8795-35481d5593d7
Public DB proxy
tokaido.proxy.rlwy.net:21292 → app DB railway
Private DB
postgres.railway.internal:5432 (collab should use this)
Collab
service agentnote-collab, id 3c62fd5e-d13a-42fd-b9c9-f5bebb3d1555, https://agentnote-collab-production.up.railway.app, /health → {"ok":true,"service":"agentnote-collab"}
Clerk
existing production Clerk app (keys on Vercel + collab) — unchanged by this migration
Vercel production env keys (values secret — do not paste into issues)
DATABASE_URL → public TCP proxy host tokaido.proxy.rlwy.net:21292 / db railway
No agentnote project there yet. Existing Sume projects are unrelated (sume-com, sume_so, sume-com-gha-runners, train_chasehuh_com, agent-sandbox, sume-hermes-cloud-poc, abgcmo_com).
Naming: "Sume" vs "sumelabs" (read this before touching a console)
Three different vendors use three different names for the same org. Confusing the Railway workspace with the Vercel team is the single easiest way to provision into the wrong account.
Thing
Exact name to look for
Id
Not called
Railway workspace — target
Sume
286434e5-8392-454e-b4d5-3458e919dbae
not sumelabs, not Sume Labs
Railway workspace — source
chasehuh (personal)
c8e45e5e-9fc4-4d92-a062-77004d068145
—
Vercel team owning this app
cwhuh → project memo → www.agentnote.dev
—
not sumelabs; out of scope to move
Vercel team for other Sume work
sumelabs
—
unrelated to this issue
GitHub repo
chasehuh/agentnote
—
out of scope to transfer
Needs verification (raised by read-only audit, 2026-08-09): the Railway API reports both workspaces as type: personal, team_id: none. So Sume is a personal Railway workspace, not a Railway Team/org. Moving there separates billing account and blast radius from the trial-expired chasehuh account, which is the stated goal — but it does not by itself remove the "one personal account is a SPOF" class of risk (no seat-based recovery, no org billing contact). Decide before cutover:
(a) proceed as-is onto Sume personal workspace (accepted, documented risk), or
(b) first upgrade/convert Sume to a Railway Team so billing and access survive one person.
This does not change the runbook below — only which workspace step 1 provisions into.
Needs verification: which card/plan pays for Sume (must be a non-trial paid plan before cutover), and preferred region (source is sfo — match unless Sume standard is elsewhere).
New Railway project under Sume workspace, e.g. agentnote (name TBD), region preferably sfo, with:
Postgres 18 (or current Railway Postgres template) + volume
Daily volume backups + PITR enabled on day one
TCP proxy for Vercel DATABASE_URL (public)
agentnote-collab service deployed from services/collab with privateDATABASE_URL (*.railway.internal), same Clerk secret as prod, AGENTNOTE_ALLOWED_ORIGINS pinned to production origins
Logical dump of chasehuh railway DB restored into the new DB with row-count + digest equality checks.
Controlled cutover behind a hard write freeze on the source database — no dual-write, no "close your tabs and hope".
Post-cutover: /health ok, signed-in notes list loads, open a CRDT note, multi-tab sync works, revision count matches.
chasehuh Railway project left frozen read-only and scheduled for delete after a 7-day soak, not deleted on cutover day.
README + ops notes updated with new hosts (no secrets).
Restore with pg_dump --format=custom --no-owner --no-acl into an empty database so pg_restore recreates schema, data, indexes, FKs and setvals the BIGSERIAL sequences. ensureSchema then runs as an idempotent no-op on first app hit.
Do not restore --data-only into an app-created schema. note_revisions.id and note_doc_updates.seq are BIGSERIAL; a data-only load into fresh tables can leave the sequences at 1 and the next CRDT append will collide on the PK.
Validation Rules
Cutover is atomic from the app's POV: never leave prod Vercel pointing at the new DB while any collab writes the old DB (or vice versa).
Do not rotate Clerk keys as part of this issue.
Do not change note ids / public_ids.
Do not paste production passwords, Clerk secrets, full DATABASE_URLs, or note bodies into GitHub. Digests and row counts only.
Implementation Notes
Cutover runbook
Ordering is load-bearing. Steps 0–4 are reversible and can be done days ahead; the maintenance window is only steps 5–8 (target < 20 minutes, realistically ~5 for a 12 MB dump).
Schedule constraint: avoid 09:00–09:35 UTC — all three Vercel crons fire in that window against DATABASE_URL and would run mid-flip.
Preconditions: psql/pg_dump/pg_restore≥ 18 on the operator machine (pg_dump --version; brew install libpq on macOS — a v16 client cannot dump an 18 server), Railway CLI logged in, Vercel CLI logged in to team cwhuh.
0. Inventory freeze (source, read-only)
Capture the source baseline. Keep the output locally; only counts/digests go in the issue.
railway link # select workspace chasehuh → project chasehuh → env production
railway connect Postgres --tunnel-only # leave running; prints local host/port/password
-- baseline.sql — run against SOURCESELECT current_setting('server_version') AS pg_version,
pg_size_pretty(pg_database_size(current_database())) AS db_size;
SELECT'notes'AS t, count(*) FROM notes
UNION ALLSELECT'note_aliases', count(*) FROM note_aliases
UNION ALLSELECT'note_revisions', count(*) FROM note_revisions
UNION ALLSELECT'note_doc_snapshots', count(*) FROM note_doc_snapshots
UNION ALLSELECT'note_doc_updates', count(*) FROM note_doc_updates
ORDER BY1;
SELECT sequencename, last_value FROM pg_sequences
WHERE schemaname ='public'ORDER BY1;
Also record: the collab service's build settings (see caveat below), the TCP proxy host/port, and the AGENTNOTE_ALLOWED_ORIGINS value.
1. Provision the Sume project (no data yet)
Create project agentnote in Railway workspace Sume (286434e5-…) — re-read the naming table above before clicking.
Add Postgres (match source major version 18), region sfo unless Sume standard differs.
Backups tab → Enable PITR. Do this now: the restore window starts at the first post-enable base backup, so enabling it after cutover leaves the migration day itself uncovered.
Postgres service → Settings → Networking → TCP Proxy on port 5432. Copy DATABASE_PUBLIC_URL from the service variables — do not assume the old tokaido.… hostname or port carry over; recreate always mints a new host, port and password.
Confirm the Sume plan is a paid, non-trial plan before continuing.
2. Deploy agentnote-collab to Sume (still pointed at an empty DB)
Connect the service to GitHub chasehuh/agentnote (the Sume workspace needs its own GitHub authorization) and set:
Setting
Value
Root directory
repo root (build runs pnpm from the workspace root)
Caveat found in the read-only audit (2026-08-09): the live chasehuh service reports builder RAILPACK, while services/collab/railway.json declares NIXPACKS. That file is not being applied (it sits at services/collab/, not the service root). The current build config lives in dashboard settings, so recreating from the repo file alone will not reproduce it — enter the settings above by hand, or set the service's config-as-code path to services/collab/railway.json deliberately and accept the builder change. Pick one and record which.
Variables on the new collab service:
DATABASE_URL → reference variable${{Postgres.DATABASE_URL}} (private *.railway.internal), never the public proxy URL. A reference survives password rotation; a pasted URL does not.
CLERK_SECRET_KEY → same production Clerk secret as today (not rotated by this issue).
⚠️/health proves almost nothing.onRequest answers /health before any DB call, and readCollabEnv() only checks that DATABASE_URL is set. A collab service with a wrong or unreachable DATABASE_URL will still report Online and pass the Railway healthcheck. Real DB verification only happens when a client opens a room (step 8).
✅ That same property is what makes this step safe to do before the restore: nothing touches Postgres until a room opens, so the target DB stays empty. Do not point any browser at the new collab URL before step 6 — one authenticated room open runs ensureSchema, creates the tables, and the pg_restore in step 7 will then fail on an already-populated schema.
3. Rehearsal restore (proves the dump, touches nothing live)
Record how long the restore took — that number sizes the maintenance window. Then confirm railway is still pristine:
SELECTcount(*) AS public_tables FROM pg_tables WHERE schemaname ='public'; -- must be 0
4. Stage the Vercel env change (do not apply yet)
Have the new values ready to paste, and note the current production deployment id for Instant Rollback:
vercel --scope cwhuh ls memo --prod # record the current prod deployment URL/id
New values to stage: DATABASE_URL = Sume DATABASE_PUBLIC_URL, NEXT_PUBLIC_AGENTNOTE_COLLAB_URL = wss://<new-collab-host>.
Optional but recommended, and cheapest to do now: put a stable custom domain in front of collab (collab.agentnote.dev CNAME → the Railway service domain) so NEXT_PUBLIC_AGENTNOTE_COLLAB_URL never has to change again — every future change to it costs a full production rebuild. Needs verification: where agentnote.dev DNS is managed.
🔒 Maintenance window starts here
5. Freeze the source (this is the anti-split-brain primitive)
Closing tabs is not a freeze. Make the source database physically unable to accept writes:
-- against SOURCEALTERDATABASE railway SET default_transaction_read_only =on;
-- kick existing sessions so they reconnect under the new defaultSELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname ='railway'AND pid <> pg_backend_pid();
Then scale the source collab to 0 replicas (Railway → agentnote-collab → Settings → Replicas), so no Hocuspocus process can hold a room open.
Expect, and accept, that the source app now errors rather than degrading to read-only: ensureSchema issues CREATE TABLE IF NOT EXISTS / ALTER TABLE on cold isolates, which fail under default_transaction_read_only. Warm isolates may keep serving reads. Treat www.agentnote.dev as down for the length of the window; that is the point — nothing can write to the old DB behind your back.
Confirm the freeze:
SELECTcount(*) AS writers FROM pg_stat_activity
WHERE datname ='railway'AND state <>'idle'AND pid <> pg_backend_pid();
pg_dump has no incremental mode — this is a fresh full dump, and at ~12 MB it takes seconds. (The earlier draft's "final incremental dump" is not a thing; ignore it.) Store the file somewhere private: it contains every note body. Not in git, not in a chat message.
7. Restore into the empty Sume railway database
Guard first, then restore:
psql "$SUME_DATABASE_PUBLIC_URL" -c \
"SELECT count(*) AS public_tables FROM pg_tables WHERE schemaname='public';"# must be 0
pg_restore --dbname="$SUME_DATABASE_PUBLIC_URL" \
--no-owner --no-acl --exit-on-error \
agentnote-final-*.dump
Now run the full verification SQL against both source and target. Because the source is frozen, the digests must be exactly equal. If any row differs, stop and roll back (§ Rollback) — do not flip.
8. Flip Vercel and rebuild
vercel --scope cwhuh env rm DATABASE_URL production
vercel --scope cwhuh env add DATABASE_URL production # paste Sume DATABASE_PUBLIC_URL
vercel --scope cwhuh env rm NEXT_PUBLIC_AGENTNOTE_COLLAB_URL production
vercel --scope cwhuh env add NEXT_PUBLIC_AGENTNOTE_COLLAB_URL production # wss://<new-collab-host>
vercel --scope cwhuh --prod --force # NEW BUILD, not a promote
NEXT_PUBLIC_* is inlined at build time. A new build is mandatory. Promoting or rolling back to an existing deployment reuses the old bundle and would ship the old collab URL against the new database — the exact split-brain this window exists to prevent. (Instant Rollback is the right tool for rollback, and the wrong tool for cutover.)
DATABASE_URL is read at runtime, but the old deployment's warm isolates hold pooled sockets to the old DB until they drain. #93 hardened the pool against this; the source freeze is what actually makes it safe. Vercel keeps serving the old deployment until the new build is ready — a few minutes of frozen-source errors is the expected user-visible cost.
Leave the source frozen. Do not un-freeze on success — a frozen source is the guarantee that no write can land somewhere that will be thrown away.
🔓 Maintenance window ends here
9. Smoke (immediately after the build goes live)
curl -sS https://<new-collab-host>/health → ok.
Load https://www.agentnote.dev signed in → notes list renders with the expected count.
Open a recent note; confirm the body matches (this is the first real proof that collab's private DATABASE_URL works).
Open the same note in a second tab; type in one, confirm it appears in the other in well under a second (WebSocket path, not the 1.5 s poll).
Hard-refresh both tabs; confirm no duplicated text (would indicate two document histories).
Confirm note_revisions gained a row for the edited note.
Vercel runtime logs: no Connection terminated unexpectedly, no digest 2206690639 storms.
Railway → new Postgres → confirm the Daily backup schedule and PITR are still enabled and healthy after the write load.
10. Soak (7 days), then decommission
Keep the chasehuh project Online but frozen (default_transaction_read_only = on, collab at 0 replicas) for 7 days. Frozen-not-deleted is what makes rollback possible.
Watch the first cron cycle (09:00–09:35 UTC next day) succeed against the new DB.
Before deleting anything, archive one final pg_dump of the source offsite. Railway docs: wiping a volume deletes all of its backups — volume backups do not survive project deletion, only logical dumps do.
Record the decommission date in an issue comment, then delete the chasehuh project.
Verification SQL
Run identically against source and target. Output is counts, lengths, and digests — no note content, so results are safe to paste into the issue.
-- 1. row countsSELECT'notes'AS t, count(*) FROM notes
UNION ALLSELECT'note_aliases', count(*) FROM note_aliases
UNION ALLSELECT'note_revisions', count(*) FROM note_revisions
UNION ALLSELECT'note_doc_snapshots', count(*) FROM note_doc_snapshots
UNION ALLSELECT'note_doc_updates', count(*) FROM note_doc_updates
ORDER BY1;
-- 2. full-content digests (must match exactly while the source is frozen)SELECT'notes'AS t,
md5(string_agg(n::text, '|'ORDER BYn.id)) AS digest FROM notes n
UNION ALLSELECT'note_aliases',
md5(string_agg(a::text, '|'ORDER BYa.alias)) FROM note_aliases a
UNION ALLSELECT'note_revisions',
md5(string_agg(r::text, '|'ORDER BYr.id)) FROM note_revisions r
UNION ALLSELECT'note_doc_snapshots',
md5(string_agg(s::text, '|'ORDER BYs.note_id)) FROM note_doc_snapshots s
UNION ALLSELECT'note_doc_updates',
md5(string_agg(u::text, '|'ORDER BYu.seq)) FROM note_doc_updates u
ORDER BY1;
-- 3. CRDT payload integrity (byte lengths, not bytes)SELECT note_id, through_seq,
octet_length(state_bin) AS state_bytes,
octet_length(state_vector) AS sv_bytes
FROM note_doc_snapshots ORDER BY note_id;
-- 4. sequences — the data-only-restore footgunSELECT sequencename, last_value FROM pg_sequences
WHERE schemaname ='public'ORDER BY1;
-- 5. referential integrity after restore (all must be 0)SELECT
(SELECTcount(*) FROM note_aliases a LEFT JOIN notes n ONn.id=a.note_idWHEREn.id IS NULL) AS orphan_aliases,
(SELECTcount(*) FROM note_revisions r LEFT JOIN notes n ONn.id=r.note_idWHEREn.id IS NULL) AS orphan_revisions,
(SELECTcount(*) FROM note_doc_snapshots s LEFT JOIN notes n ONn.id=s.note_idWHEREn.id IS NULL) AS orphan_snapshots,
(SELECTcount(*) FROM note_doc_updates u LEFT JOIN notes n ONn.id=u.note_idWHEREn.id IS NULL) AS orphan_updates,
(SELECTcount(*) FROM notes WHERE user_id IS NULL) AS null_user_ids;
-- 6. structure parity (target only, after first app hit; ensureSchema must be a no-op)SELECT table_name, column_name, data_type, is_nullable
FROMinformation_schema.columnsWHERE table_schema ='public'ORDER BY table_name, ordinal_position;
Rollback
Pick the row matching how far you got. The decision hinges on whether any write has landed on the Sume DB.
Failure point
Action
Data loss
Steps 0–4 (provisioning, rehearsal)
Nothing to undo. Leave Sume resources or delete them. Source was never frozen.
none
Step 5–7 — freeze applied, restore failed or digests mismatch
ALTER DATABASE railway RESET default_transaction_read_only; on source, terminate sessions again so it takes effect, scale source collab back to 1 replica. Drop/recreate the Sume railway DB before retrying. Vercel untouched.
none
Step 8 — env flipped, build failed or smoke fails, no writes on Sume yet
Vercel Instant Rollback to the pre-cutover deployment id from step 4 (its bundle still has the old collab URL inlined — correct here). Then revert both env vars so the next build is consistent. Unfreeze the source as above.
none
After smoke passed and users have written to Sume
Do not roll back envs naively — the old bundle writes to the old DB and you get two divergent histories. Fix forward if at all possible. If reverting is unavoidable: freeze the Sume DB the same way, pg_dump it, restore into a fresh empty database on the source server, repoint DATABASE_URL at that new DB, unfreeze, rebuild.
none if done as a reverse migration; unbounded if envs are flipped without one
Always verify after any rollback: source unfrozen (SHOW default_transaction_read_only; → off), source collab back to 1 replica, and the two-tab CRDT smoke test passing.
Likely files to modify (docs only)
README.md — production collab URL (line ~203), Railway workspace name in the ops/backups section (~234–240), and a pointer to PITR alongside the existing daily-backup steps
Optionally docs/ops-railway-sume.md if README grows too long — Needs verification whether that split is wanted
Tests
No unit test required for infra.
The verification SQL above is the test; paste counts + digests into the issue as the evidence trail.
Split-brain CRDT — two writers, one document. Two collab processes (old + new) or two databases holding the same note doc produce divergent note_doc_updates sequences. This does not heal: snapshots are compacted per-database with their own through_seq, so a naive union of the two histories surfaces as duplicated body text, not a merge. Prevention is the step-5 freeze plus scaling the source collab to 0 — not tab hygiene. README already warns that two room keys for one note means two in-memory documents reconciling only through Postgres; two Postgres instances is the same bug, worse.
Client IndexedDB survives the cutover. Tabs mirror each note into agentnote.note.{userId}.{noteId} (y-indexeddb) and push anything the server has never seen on reconnect. After the flip, stale tabs re-push local edits into the new server — self-healing for the CRDT body, but not for HTTP-path writes (title, parent_id, publish, archive, note_revisions), which simply go to whichever DB that tab's deployment points at. A note archived during the window can also be resurrected by a stale tab's replay. The source freeze is what bounds this.
/health is a liveness check, not a DB check (verified in services/collab/src/index.ts): it returns before touching Postgres, and readCollabEnv() only asserts DATABASE_URL is non-empty. A misconfigured collab shows Online and green healthchecks. Only step 9.3 proves the database wiring.
Booting the app against the target too early poisons the restore.ensureSchema runs on the first room open / first request and creates the full schema. pg_restore --exit-on-error into that non-empty schema aborts; without --exit-on-error it half-loads and leaves sequences wrong. Hence the public_tables = 0 guard in step 7.
Sticky serverless pools: hardened in fix: recover DB pool after Railway disconnects #93 (rejected ensureSchema promise cleared, pool recreated, one retry, max: 1 / allowExitOnIdle), but still require a new production build — and the build is mandatory anyway for NEXT_PUBLIC_AGENTNOTE_COLLAB_URL.
Promote ≠ deploy. Vercel Instant Rollback/promote reuses a built bundle with NEXT_PUBLIC_* already inlined. Correct for rollback, catastrophic as a cutover shortcut.
Proxy host/port/password churn: recreate always mints a new TCP proxy hostname, port and Postgres password. Copy DATABASE_PUBLIC_URL fresh; never assume tokaido.proxy.rlwy.net:21292 carries over.
Use a reference variable for collab's DATABASE_URL (${{Postgres.DATABASE_URL}}). A hardcoded private URL breaks silently on password rotation; the public proxy adds latency, egress billing, and idle disconnects.
Private networking is IPv6-only between Railway services. pg handles it, but if the new collab is ever moved outside the project the private host stops resolving.
Volume backups do not protect against deletion. Railway: wiping a volume deletes all of its backups, and backups restore only into the same project + environment. The offsite pg_dump is the only artifact that survives deleting chasehuh.
PITR window starts at enable time. Enabling it after cutover leaves migration day uncovered — hence step 1.4.
Backup retention is shorter than intuition: Daily = 6 days, Weekly = 1 month, Monthly = 3 months. A 7-day soak outlives the daily retention by a day.
Manual backups are capped at 50% of volume size — fine at 120 MB / 500 MB, worth knowing before the volume grows.
Cron collision: the three vercel.json crons fire 09:00/09:15/09:30 UTC against DATABASE_URL. Cutting over inside that window means a purge or compaction job running against a half-flipped stack.
pg_dump client version must be ≥ server (18). An older client refuses the dump outright.
Dump files contain every note body — treat as production-sensitive; never attach to the issue.
Sume is a personal-type Railway workspace (see naming section) — resolves the trial-expiry SPOF, not the single-account SPOF.
Region mismatch if Sume defaults away from sfo — added latency Vercel ↔ DB on every request.
Trial/billing on Sume: confirm the paid plan before cutover so a fresh volume is not stranded mid-migration.
Non-Goals
Transferring the Railway project between workspaces
Moving GitHub repo org or the Vercel project to sumelabs
Changing Clerk application / OAuth setup
Schema redesign, CRDT protocol changes, or multi-region HA
Deleting chasehuh on cutover day
Migrating local/dev databases
Acceptance Criteria
Sume Railway project has Online Postgres + Online agentnote-collab, and collab's DB wiring is proven by opening a real note (not by /health)
Daily volume backups and PITR enabled on Sume Postgres before the cutover window
Rehearsal restore completed into a scratch DB and its duration recorded
Source database frozen (default_transaction_read_only = on) and source collab scaled to 0 for the whole window
Row counts and md5 digests match exactly, source vs target, for all five tables
pg_sequences.last_value for note_revisions and note_doc_updates matches source
Referential-integrity query returns all zeros on the target
Vercel Production DATABASE_URL and NEXT_PUBLIC_AGENTNOTE_COLLAB_URL point at Sume endpoints
A new production build (not a promote) completed after the env change
Signed-in smoke passes: list + open note + two-tab CRDT edit + refresh with no duplicated text + note_revisions row added
First post-cutover cron cycle (09:00–09:35 UTC) succeeded against the new DB
Pre-cutover deployment id recorded for Instant Rollback; rollback path rehearsed on paper
README updated with new collab URL / workspace name / PITR pointer (no secrets)
Offsite final dump archived before decommission; chasehuh decommission date recorded in an issue comment and the source left frozen until then
QA Plan
Pre-flight:pg_dump --version ≥ 18; Sume plan is paid; PITR + Daily backups green; target railway has public_tables = 0.
Rehearsal: restore into restore_drill, run all six verification queries, drop it, re-confirm public_tables = 0.
Frozen parity: with the source frozen, run the count + digest queries on both sides; require exact equality before flipping.
Post-flip functional:/health; load https://www.agentnote.dev; open a recent note; two-tab live edit; hard refresh both tabs and confirm no duplicated text; confirm a note_revisions row appeared.
Log check: Vercel runtime logs clean of Connection terminated / digest 2206690639; Railway collab logs clean of collab auth rejected bursts and collab store skipped.
Cron: confirm the next 09:00–09:35 UTC cycle returns 200s (CRON_SECRET unchanged).
Backup drill on the new host: take one manual backup and confirm it lands.
Soak: 7 days with the source frozen, then archive the final dump and decommission.
Suggested PR Scope
S — docs-only PR after infra cutover (README.md, optional docs/ops-railway-sume.md)
Infra work is Railway dashboard + CLI + Vercel env — likely zero app code
Do not bundle unrelated editor features
Runbook provenance
Expanded 2026-08-09 by an Opus pass over the live infrastructure using read-only Railway APIs and the repo at fix/db-schema-ready-reset. No resources were created, no env changed, no data dumped or restored. Findings folded in above: Railway Sume reports type: personal; the live collab service builds with RAILPACK while services/collab/railway.json declares NIXPACKS (the file is not in effect); /health never touches Postgres; backup retention is Daily 6d / Weekly 1mo / Monthly 3mo and PITR must be enabled ahead of time; pg_dump has no incremental mode, so the earlier "final incremental dump" step was replaced with a single frozen-source full dump.
Open Needs verification items for a human operator: (1) Sume personal workspace vs. converting to a Railway Team; (2) who pays for Sume and on which plan; (3) preferred region if not sfo; (4) where agentnote.dev DNS is managed, if the collab.agentnote.dev stable-CNAME option is taken; (5) whether to keep collab build config in the dashboard or start honouring services/collab/railway.json; (6) whether ops docs live in README or a new docs/ file.
Summary
Move agentnote's Railway-hosted Postgres +
agentnote-collaboff the personal chasehuh Railway workspace onto the Sume Railway workspace by recreating the stack and restoring data withpg_dump/pg_restore. Do not use Railway project transfer. Keep the Vercel app on teamcwhuh/ projectmemo(https://www.agentnote.dev) for this issue — only Railway-side ownership + env cutover.Why This Matters
ECONNRESET/Connection terminated unexpectedly(Next.js digest2206690639). Data survived on volume (~120 MB) after Hobby reactivation, but personal-trial billing is an unacceptable SPOF for a daily notes app.Conversation Context
Decisions locked in chat (2026-08-09, agent transcript
f550bb91-8b0e-4029-944c-abdeb28e8fcf):pg_dump→pg_restore, flip Vercel envs, then decommission chasehuh. Rejected: Railway project transfer.ensureSchemapromise → every hit resurfaced digest2206690639even though DB was healthy. Fixed in PR #93 (lib/db.ts: clear sticky promise, recreate pool, retry once; serverless-friendly pool settings). Production redeploy flushed warm isolates.note_revisionsand CRDT tables.Current Behavior
Ownership map (source = chasehuh)
chasehuh/agentnotecwhuh, projectmemo, prodhttps://www.agentnote.dev(alsoagentnote.dev, legacymemo.chasehuh.comredirects)075539a8-061e-4223-9c7b-e0eb9cebe200, envproduction/911f18c1-8dc5-40b5-8f1c-563eb080fad5postgres-ssl:18, region sfo, volumepostgres-volume~120 MB / 500 MB, volume idcd5bb48b-661d-4f99-9233-8e69b669a408, service id1e6b049b-87bb-4d04-8795-35481d5593d7tokaido.proxy.rlwy.net:21292→ app DBrailwaypostgres.railway.internal:5432(collab should use this)agentnote-collab, id3c62fd5e-d13a-42fd-b9c9-f5bebb3d1555,https://agentnote-collab-production.up.railway.app,/health→{"ok":true,"service":"agentnote-collab"}Vercel production env keys (values secret — do not paste into issues)
DATABASE_URL→ public TCP proxy hosttokaido.proxy.rlwy.net:21292/ dbrailwayNEXT_PUBLIC_AGENTNOTE_CRDT=1NEXT_PUBLIC_AGENTNOTE_COLLAB_URL=wss://agentnote-collab-production.up.railway.appCLERK_SECRET_KEY,NEXT_PUBLIC_CLERK_*,CRON_SECRETLive data snapshot (2026-08-09, chasehuh)
notesnote_revisionsnote_aliasesnote_doc_snapshotsnote_doc_updatesTarget Railway workspace
286434e5-8392-454e-b4d5-3458e919dbae).agentnoteproject there yet. Existing Sume projects are unrelated (sume-com,sume_so,sume-com-gha-runners,train_chasehuh_com,agent-sandbox,sume-hermes-cloud-poc,abgcmo_com).Naming: "Sume" vs "sumelabs" (read this before touching a console)
Three different vendors use three different names for the same org. Confusing the Railway workspace with the Vercel team is the single easiest way to provision into the wrong account.
Sume286434e5-8392-454e-b4d5-3458e919dbaesumelabs, notSume Labschasehuh(personal)c8e45e5e-9fc4-4d92-a062-77004d068145cwhuh→ projectmemo→www.agentnote.devsumelabs; out of scope to movesumelabschasehuh/agentnoteNeeds verification (raised by read-only audit, 2026-08-09): the Railway API reports both workspaces as
type: personal, team_id: none. SoSumeis a personal Railway workspace, not a Railway Team/org. Moving there separates billing account and blast radius from the trial-expired chasehuh account, which is the stated goal — but it does not by itself remove the "one personal account is a SPOF" class of risk (no seat-based recovery, no org billing contact). Decide before cutover:Sumepersonal workspace (accepted, documented risk), orSumeto a Railway Team so billing and access survive one person.This does not change the runbook below — only which workspace step 1 provisions into.
Needs verification: which card/plan pays for
Sume(must be a non-trial paid plan before cutover), and preferred region (source is sfo — match unless Sume standard is elsewhere).App code that depends on this infra
lib/db.ts— Pool +ensureSchemamigrations (post-fix: recover DB pool after Railway disconnects #93 recovery)lib/notes.ts,lib/crdt/*— note CRUD + CRDT persistenceservices/collab/— Hocuspocus server; env:DATABASE_URL,CLERK_SECRET_KEY, optionalAGENTNOTE_ALLOWED_ORIGINS,PORTcomponents/agentnote-app.tsx— readsNEXT_PUBLIC_AGENTNOTE_CRDT/NEXT_PUBLIC_AGENTNOTE_COLLAB_URLREADME.md— CRDT + Railway ops docs (update URLs after cutover)vercel.jsoncrons —/api/cron/purge-archived09:00 UTC,/api/cron/purge-note-revisions09:15 UTC,/api/cron/compact-note-docs09:30 UTC, allCRON_SECRET-gated and all hittingDATABASE_URLRelated issues
ensureSchemarecovery after disconnectsDesired Behavior
agentnote(name TBD), region preferably sfo, with:DATABASE_URL(public)agentnote-collabservice deployed fromservices/collabwith privateDATABASE_URL(*.railway.internal), same Clerk secret as prod,AGENTNOTE_ALLOWED_ORIGINSpinned to production originsrailwayDB restored into the new DB with row-count + digest equality checks./healthok, signed-in notes list loads, open a CRDT note, multi-tab sync works, revision count matches.Source Of Truth
Internal
README.md— CRDT, collab env table, Railway backup steps, production collab URLservices/collab/railway.json— build/start/healthcheck (see caveat in Implementation Notes — this file is not what the live service runs)services/collab/src/env.ts— required envlib/db.ts— schema bootstrap + pool behavior (fix: recover DB pool after Railway disconnects #93).env.example— env namesExternal
services/collab/src/room-auth.tsProposed API / Schema
No application schema change. Migration is infra + data only.
Preserve public tables, indexes, FKs and sequence positions:
notes(idPK TEXT,user_id,title,body,created_at,updated_at,is_public,public_id,published_at,author_handle,deleted_at,parent_id)note_aliases(aliasPK,note_idFK)note_revisions(idBIGSERIAL,note_idFK,user_id,title,body,created_at)note_doc_updates(seqBIGSERIAL,note_idFK,user_id,update_binBYTEA,created_at)note_doc_snapshots(note_idPK/FK,user_id,state_binBYTEA,state_vectorBYTEA,through_seqBIGINT,updated_at)Restore with
pg_dump --format=custom --no-owner --no-aclinto an empty database sopg_restorerecreates schema, data, indexes, FKs andsetvals the BIGSERIAL sequences.ensureSchemathen runs as an idempotent no-op on first app hit.Validation Rules
public_ids.DATABASE_URLs, or note bodies into GitHub. Digests and row counts only.Implementation Notes
Cutover runbook
Ordering is load-bearing. Steps 0–4 are reversible and can be done days ahead; the maintenance window is only steps 5–8 (target < 20 minutes, realistically ~5 for a 12 MB dump).
Schedule constraint: avoid 09:00–09:35 UTC — all three Vercel crons fire in that window against
DATABASE_URLand would run mid-flip.Preconditions:
psql/pg_dump/pg_restore≥ 18 on the operator machine (pg_dump --version;brew install libpqon macOS — a v16 client cannot dump an 18 server), Railway CLI logged in, Vercel CLI logged in to teamcwhuh.0. Inventory freeze (source, read-only)
Capture the source baseline. Keep the output locally; only counts/digests go in the issue.
Also record: the collab service's build settings (see caveat below), the TCP proxy host/port, and the
AGENTNOTE_ALLOWED_ORIGINSvalue.1. Provision the Sume project (no data yet)
agentnotein Railway workspaceSume(286434e5-…) — re-read the naming table above before clicking.Daily(retained 6 days) and, recommended,Weekly(1 month).5432. CopyDATABASE_PUBLIC_URLfrom the service variables — do not assume the oldtokaido.…hostname or port carry over; recreate always mints a new host, port and password.2. Deploy
agentnote-collabto Sume (still pointed at an empty DB)Connect the service to GitHub
chasehuh/agentnote(the Sume workspace needs its own GitHub authorization) and set:pnpm install --frozen-lockfile && pnpm --filter @agentnote/collab buildnode services/collab/dist/index.mjs/healthVariables on the new collab service:
DATABASE_URL→ reference variable${{Postgres.DATABASE_URL}}(private*.railway.internal), never the public proxy URL. A reference survives password rotation; a pasted URL does not.CLERK_SECRET_KEY→ same production Clerk secret as today (not rotated by this issue).AGENTNOTE_ALLOWED_ORIGINS→https://www.agentnote.dev,https://agentnote.dev,https://memo.chasehuh.comPORT→ injected by Railway; do not set.Verify:
curl -sS https://<new-collab-host>/health→{"ok":true,"service":"agentnote-collab"}.3. Rehearsal restore (proves the dump, touches nothing live)
Restore into a scratch database on the Sume server, leaving
railwayempty:Run the verification SQL (§ Verification SQL) against
restore_drill, then:Record how long the restore took — that number sizes the maintenance window. Then confirm
railwayis still pristine:4. Stage the Vercel env change (do not apply yet)
Have the new values ready to paste, and note the current production deployment id for Instant Rollback:
vercel --scope cwhuh ls memo --prod # record the current prod deployment URL/idNew values to stage:
DATABASE_URL= SumeDATABASE_PUBLIC_URL,NEXT_PUBLIC_AGENTNOTE_COLLAB_URL=wss://<new-collab-host>.Optional but recommended, and cheapest to do now: put a stable custom domain in front of collab (
collab.agentnote.devCNAME → the Railway service domain) soNEXT_PUBLIC_AGENTNOTE_COLLAB_URLnever has to change again — every future change to it costs a full production rebuild. Needs verification: whereagentnote.devDNS is managed.🔒 Maintenance window starts here
5. Freeze the source (this is the anti-split-brain primitive)
Closing tabs is not a freeze. Make the source database physically unable to accept writes:
Then scale the source collab to 0 replicas (Railway →
agentnote-collab→ Settings → Replicas), so no Hocuspocus process can hold a room open.Expect, and accept, that the source app now errors rather than degrading to read-only:
ensureSchemaissuesCREATE TABLE IF NOT EXISTS/ALTER TABLEon cold isolates, which fail underdefault_transaction_read_only. Warm isolates may keep serving reads. Treatwww.agentnote.devas down for the length of the window; that is the point — nothing can write to the old DB behind your back.Confirm the freeze:
6. Final dump
pg_dumphas no incremental mode — this is a fresh full dump, and at ~12 MB it takes seconds. (The earlier draft's "final incremental dump" is not a thing; ignore it.) Store the file somewhere private: it contains every note body. Not in git, not in a chat message.7. Restore into the empty Sume
railwaydatabaseGuard first, then restore:
Now run the full verification SQL against both source and target. Because the source is frozen, the digests must be exactly equal. If any row differs, stop and roll back (§ Rollback) — do not flip.
8. Flip Vercel and rebuild
Leave the source frozen. Do not un-freeze on success — a frozen source is the guarantee that no write can land somewhere that will be thrown away.
🔓 Maintenance window ends here
9. Smoke (immediately after the build goes live)
curl -sS https://<new-collab-host>/health→ ok.https://www.agentnote.devsigned in → notes list renders with the expected count.DATABASE_URLworks).note_revisionsgained a row for the edited note.Connection terminated unexpectedly, no digest2206690639storms.10. Soak (7 days), then decommission
default_transaction_read_only = on, collab at 0 replicas) for 7 days. Frozen-not-deleted is what makes rollback possible.pg_dumpof the source offsite. Railway docs: wiping a volume deletes all of its backups — volume backups do not survive project deletion, only logical dumps do.Verification SQL
Run identically against source and target. Output is counts, lengths, and digests — no note content, so results are safe to paste into the issue.
Rollback
Pick the row matching how far you got. The decision hinges on whether any write has landed on the Sume DB.
ALTER DATABASE railway RESET default_transaction_read_only;on source, terminate sessions again so it takes effect, scale source collab back to 1 replica. Drop/recreate the SumerailwayDB before retrying. Vercel untouched.pg_dumpit, restore into a fresh empty database on the source server, repointDATABASE_URLat that new DB, unfreeze, rebuild.Always verify after any rollback: source unfrozen (
SHOW default_transaction_read_only;→off), source collab back to 1 replica, and the two-tab CRDT smoke test passing.Likely files to modify (docs only)
README.md— production collab URL (line ~203), Railway workspace name in the ops/backups section (~234–240), and a pointer to PITR alongside the existing daily-backup stepsdocs/ops-railway-sume.mdif README grows too long — Needs verification whether that split is wantedTests
scripts/verify-note-db-counts.ts— Needs verification / non-goal unless cheap.Edge Cases And Risks
note_doc_updatessequences. This does not heal: snapshots are compacted per-database with their ownthrough_seq, so a naive union of the two histories surfaces as duplicated body text, not a merge. Prevention is the step-5 freeze plus scaling the source collab to 0 — not tab hygiene. README already warns that two room keys for one note means two in-memory documents reconciling only through Postgres; two Postgres instances is the same bug, worse.agentnote.note.{userId}.{noteId}(y-indexeddb) and push anything the server has never seen on reconnect. After the flip, stale tabs re-push local edits into the new server — self-healing for the CRDT body, but not for HTTP-path writes (title,parent_id, publish, archive,note_revisions), which simply go to whichever DB that tab's deployment points at. A note archived during the window can also be resurrected by a stale tab's replay. The source freeze is what bounds this./healthis a liveness check, not a DB check (verified inservices/collab/src/index.ts): it returns before touching Postgres, andreadCollabEnv()only assertsDATABASE_URLis non-empty. A misconfigured collab shows Online and green healthchecks. Only step 9.3 proves the database wiring.ensureSchemaruns on the first room open / first request and creates the full schema.pg_restore --exit-on-errorinto that non-empty schema aborts; without--exit-on-errorit half-loads and leaves sequences wrong. Hence thepublic_tables = 0guard in step 7.ensureSchemapromise cleared, pool recreated, one retry,max: 1/allowExitOnIdle), but still require a new production build — and the build is mandatory anyway forNEXT_PUBLIC_AGENTNOTE_COLLAB_URL.NEXT_PUBLIC_*already inlined. Correct for rollback, catastrophic as a cutover shortcut.DATABASE_PUBLIC_URLfresh; never assumetokaido.proxy.rlwy.net:21292carries over.DATABASE_URL(${{Postgres.DATABASE_URL}}). A hardcoded private URL breaks silently on password rotation; the public proxy adds latency, egress billing, and idle disconnects.pghandles it, but if the new collab is ever moved outside the project the private host stops resolving.pg_dumpis the only artifact that survives deleting chasehuh.vercel.jsoncrons fire 09:00/09:15/09:30 UTC againstDATABASE_URL. Cutting over inside that window means a purge or compaction job running against a half-flipped stack.pg_dumpclient version must be ≥ server (18). An older client refuses the dump outright.Non-Goals
sumelabsAcceptance Criteria
agentnote-collab, and collab's DB wiring is proven by opening a real note (not by/health)default_transaction_read_only = on) and source collab scaled to 0 for the whole windowpg_sequences.last_valuefornote_revisionsandnote_doc_updatesmatches sourceDATABASE_URLandNEXT_PUBLIC_AGENTNOTE_COLLAB_URLpoint at Sume endpointsnote_revisionsrow addedQA Plan
pg_dump --version≥ 18; Sume plan is paid; PITR + Daily backups green; targetrailwayhaspublic_tables = 0.restore_drill, run all six verification queries, drop it, re-confirmpublic_tables = 0./health; loadhttps://www.agentnote.dev; open a recent note; two-tab live edit; hard refresh both tabs and confirm no duplicated text; confirm anote_revisionsrow appeared.Connection terminated/ digest2206690639; Railway collab logs clean ofcollab auth rejectedbursts andcollab store skipped.CRON_SECRETunchanged).Suggested PR Scope
README.md, optionaldocs/ops-railway-sume.md)Runbook provenance
Expanded 2026-08-09 by an Opus pass over the live infrastructure using read-only Railway APIs and the repo at
fix/db-schema-ready-reset. No resources were created, no env changed, no data dumped or restored. Findings folded in above: RailwaySumereportstype: personal; the live collab service builds with RAILPACK whileservices/collab/railway.jsondeclaresNIXPACKS(the file is not in effect);/healthnever touches Postgres; backup retention is Daily 6d / Weekly 1mo / Monthly 3mo and PITR must be enabled ahead of time;pg_dumphas no incremental mode, so the earlier "final incremental dump" step was replaced with a single frozen-source full dump.Open Needs verification items for a human operator: (1)
Sumepersonal workspace vs. converting to a Railway Team; (2) who pays forSumeand on which plan; (3) preferred region if not sfo; (4) whereagentnote.devDNS is managed, if thecollab.agentnote.devstable-CNAME option is taken; (5) whether to keep collab build config in the dashboard or start honouringservices/collab/railway.json; (6) whether ops docs live in README or a newdocs/file.