Back to Worldmonitor

Railway Seed Consolidation Runbook

docs/railway-seed-consolidation-runbook.md

2.10.077.0 KB
Original Source

Railway Seed Consolidation Runbook

Date: 2026-04-10 PR: #2891 Current services: 100 (at Railway limit) Target services: 65 (~35 slots freed)

Single source of truth for Railway-deployed scripts: scripts/railway-services.json. When adding a new Railway service (nixpacks or Dockerfile), add an entry to the registry before merging. Both tests/scripts-railway-nixpacks-no-escape-import.test.mts and tests/dockerfile-digest-notifications-imports.test.mjs derive their entry lists from the registry, and tests/railway-services-registry-coverage.test.mts fails if a Dockerfile.* CMD, runbook "Start command:" entry, or standalone service row references a script the registry doesn't know about. The scripts-root guard also conservatively scans unregistered legacy seeders.


Prerequisites

  1. Merge PR #2891 to main
  2. Verify the bundle scripts are in the deployed branch
  3. Have Railway dashboard access and gh CLI authenticated

Deployment safety guardrails

Watch paths are a live contract, and an accurate one

Railway stores watch paths in each service's environment configuration, not in the repository. The repo-side contract is scripts/railway-services.json: every registry-managed production seeder pins its cron and the exact repository-relative files in its runtime dependency closure. tests/railway-watch-path-audit.test.mjs walks each entry point's imports and fails when that closure grows without a matching registry update. This keeps the declared closure complete without making unrelated changes under scripts/** or shared/** rebuild every seeder.

The filter matches accurately — read meta.skippedReason before concluding otherwise. Railway records a refused push as a SKIPPED deployment carrying meta.commitHash and a reason, and the two reasons it uses mean opposite things. Re-measured 2026-08-04 across all 77 repository-backed services and 600 commits of main, of 7,391 No changes to watched files skips, 7,331 were plainly correct, 57 matched a pattern pointing outside the service's build context (a rootDirectory: scripts container cannot see repository-root shared/, so the shared/** several of them carry is unreachable by construction), and 3 were a registry closure not yet applied to Railway. None were unexplained.

The lag comes from the other reason. CI check suite failed — 1,504 skips — is Railway refusing to build a commit whose whole GitHub check suite is failing, including scheduled workflows that re-report onto main's head SHA long after the merge gates went green. Over closure-relevant merges that is p90 4.7h against p90 0.01h when Railway simply builds, and it is self-reinforcing: the freshness monitor goes red exactly when the fleet is behind.

So the closures stay, and .github/workflows/railway-deploy-trigger.yml deploys the services whose closure actually changed, gated on main's own gate status rather than on Railway's reading of the entire suite. Clearing the filters fleet-wide remains rejected on cost: roughly 75 build-minutes per push across 77 services at ~30 merges a day, and three always-on services (ais-relay, notification-relay, scenario-worker) restarting on every merge, dropping the AIS websocket connections among them — and it would not fix the dominant cause anyway. The full measurement is in Railway defers deploys on a red check suite, not on watch paths.

The always-on bootstrap publisher is the deliberate exception: its empty watch path list means Railway watches the whole repository. That broader trigger covers its Dockerfile and future bootstrap inputs without rebuilding the cron seeder fleet.

scripts/audit-railway-watch-paths.mjs compares the registry with live production configuration. It reports exact watch-path and cron drift, missing registered services, and missing required source-routing variables. Apply mode refuses a partial mutation while a service or required variable is absent.

After linking the CLI to the world-monitor production environment, audit the live settings with:

bash
node scripts/audit-railway-watch-paths.mjs

To reconcile only drifted seeders and verify the read-back:

bash
node scripts/audit-railway-watch-paths.mjs --apply

The apply mode changes only drifted build.watchPatterns, build.dockerfilePath and deploy.cronSchedule fields, uses one environment config commit, and waits for Railway's eventually consistent config read-back before reporting success. It does not assign a cron to explicitly always-on services such as the bootstrap publisher, while still auditing their watch paths and required environment. Run the audit after adding or replacing a standalone seeder, changing a bundle dependency, or changing a production cron.

The audit only proves the trigger config matches the registry. Proving a merge actually reached production is the separate deploy-drift check below.

The scheduled operational-acceptance workflow performs the same audit in read-only mode, then the deploy-drift check, before checking compact health. Create the dedicated GitHub Actions environment ingestion-acceptance-production, restrict its deployment branch policy to main, and configure:

  • environment secret RAILWAY_PRODUCTION_TOKEN: a Railway project token scoped to the production environment;
  • environment variable RAILWAY_PROJECT_ID: the world-monitor project ID.

Do not define the Railway token as a repository or organization secret: workflow_dispatch can target another ref, while the environment's server-side branch policy keeps the production credential unavailable there. The workflow references the environment with deployment tracking disabled, maps the project token to the CLI's standard RAILWAY_TOKEN variable only for the link and audit steps, links only inside the ephemeral runner, and never passes --apply. Do not use the broader account-scoped RAILWAY_API_TOKEN. Missing or inaccessible context intentionally fails the acceptance run rather than silently skipping the live audit.

Reconciliation control-plane provisioning

The reconciliation controller is a dedicated Cloudflare Worker backed by one SQLite Durable Object. Its foundation can be deployed while the existing Railway integration remains unchanged. Keep both RAILWAY_RECONCILE_CUTOVER_ACTIVE and RAILWAY_RECONCILE_AUTO_RECOVERY_ENABLED set to false until the lease-aware target workflow, protected environments, role credentials, and authenticated status probe are ready. Activate the flags separately with the staged cutover below.

Provision these GitHub Actions environments with a main-only deployment branch policy:

Set the non-secret repository variable RAILWAY_RECONCILE_CONTROL_URL to the dedicated HTTPS origin compiled into the shipped control client; the client rejects every other host even if a workflow variable is misconfigured.

EnvironmentCapability
railway-reconcile-control-productionCloudflare deploy token, account ID, fixed control scope, and all four pairwise-distinct HMAC values
ingestion-acceptance-production-watchdogWatchdog HMAC only; no Railway credential
ingestion-acceptance-productionMutation HMAC, RAILWAY_RECONCILE_DEPLOY_TOKEN_V2, and project ID
ingestion-acceptance-production-verificationVerifier HMAC, RAILWAY_RECONCILE_VIEWER_TOKEN, project ID, and GitHub read evidence
ingestion-acceptance-production-breakglassOperator HMAC plus the same RAILWAY_RECONCILE_VIEWER_TOKEN; main-only secret boundary with no required reviewer

Both Railway tokens are distinct project tokens with identical capability; the names record intended use, not an enforced boundary. See the scope note below.

The ordinary lease-aware mutation and verifier jobs receive only their own HMAC roles in their separately protected environments.

The Viewer token is read-only by convention, not by credential scope. Railway issues exactly two token types and neither accepts a role or scope: apiTokenCreate takes only {name, workspaceId} and inherits the creating user's permissions, and projectTokenCreate takes only {projectId, environmentId, name}. The VIEWER value on ProjectRole and TeamRole applies to project members — user accounts — not to tokens. A genuinely read-only token would require a separate Railway user account joined to the project as a VIEWER member, which this project does not maintain.

So mint the Viewer and deploy tokens as two distinct project tokens on the one owner account: distinct tokens still give independent revocation, a separate audit trail, and blast-radius containment if one leaks. What they do not give is an inability to deploy. That property is enforced instead by the --workflow-authorized fence in scripts/trigger-railway-deploys.mjs and the workflow contract tests, so treat any change to those guards as a change to a security boundary. Note also that projectTokenCreate rejects a CLI session with Not Authorized; both tokens must be created from the Railway dashboard.

Breakglass authorization is one-person by deliberate choice. The environment keeps its main-only branch policy and isolated secrets, but has no required reviewer. Requiring that same operator to review their own dispatch adds no independent authorization and sends one approval email for every recovery attempt. The approver workflow input records the delegated operator identity in the immutable controller audit and can equal the dispatch actor; it is not a verified second party. Real two-person control requires a second named operator and prevent_self_review.

The protected resolver deliberately repeats the GitHub, convergence, and provider-inactivity reads after environment approval; the earlier proof is not fresh enough to authorize a state transition by itself.

GET /version proves only that the expected Worker revision propagated. Authenticated /v1/watchdog/status is the readiness check because it also opens the canonical Durable Object and exercises the deployed watchdog HMAC.

HMAC rotation is a coordinated maintenance operation. First disable automatic recovery, confirm authenticated status has no lease, barrier, or dispatch hold, and confirm no target mutation or verifier job is active. Then update the Worker and the one matching consumer environment together and repeat both the version and authenticated status probes. Never rotate across an active lease, hold, or barrier: the old run would lose its only authorization path and the result would require protected operator recovery.

Staged cutover

Do not enable both activation flags in one operation.

  1. Confirm the current main head has an exact green gate, no target mutation or verifier run is active, and authenticated controller status has no lease, barrier, attempt, or dispatch hold. Confirm every protected environment has only the role credentials its jobs read.
  2. Set RAILWAY_RECONCILE_CUTOVER_ACTIVE=true while keeping RAILWAY_RECONCILE_AUTO_RECOVERY_ENABLED=false. This enables the ordinary lease-fenced mutation and verifier cycle plus protected manual retry, but it cannot authorize a watchdog dispatch. Run one exact-green-head cycle and require terminal acceptance to populate lastAccepted. Unset the cutover flag to roll back admission of new mutation jobs.
  3. Enable RAILWAY_RECONCILE_AUTO_RECOVERY_ENABLED only after lastAccepted has been observed fresh, watchdog read failures fail the job after their bounded streak, and a baseline exists for green deferrals caused by a live paginated GitHub inventory. Run the watchdog in dispatch mode and require HEALTHY with no dispatch. Unset this flag first to roll back automatic recovery authority.

The native Railway source integration is not the lease-aware mutation path. It can continue to accept ordinary builds: the reconcile trigger reads the live deployment inventory and treats an accepted or in-progress build for the exact commit as a no-op. The repository has no separate legacy GitHub workflow that deploys with RAILWAY_PRODUCTION_TOKEN; that token remains only in read paths. If either fact changes, disable and drain the new competing mutation path before the staged cutover.

To print the authenticated raw controller state without dispatching anything, run the watchdog's status mode from main:

bash
gh workflow run railway-deploy-trigger-watchdog.yml --ref main -f mode=status

The resulting Read or classify reconciliation control state log contains the closed STATUS_REPORTED envelope. An operator with the separately provisioned watchdog environment variables can run the same reader locally with node scripts/dispatch-stale-railway-reconcile.mjs --phase status; never copy the watchdog HMAC into an issue, command history, or shared shell profile.

Manual recovery evidence contract

evidence_json is a closed JSON object: unknown or omitted fields fail the request. Every decision uses these common fields:

json
{
  "version": 1,
  "evidenceId": "incident-evidence-0001",
  "runEvidenceId": "github-run-evidence-0001",
  "environmentEvidenceId": "breakglass-review-0001",
  "priorKind": "dispatch_hold",
  "priorCreatedAt": "2026-08-08T08:00:00.000Z",
  "targetRunId": null,
  "targetRunAttempt": null,
  "decisionEvidence": {
    "kind": "pre_mutation_hold",
    "mutationBoundaryCrossed": false
  }
}

The decision-specific decisionEvidence schemas are:

DecisionpriorKindTarget runExact decision evidence
resolve_pre_mutation_holdattempt or dispatch_holdNull for an unbound hold; otherwise exact run ID and attempt{"kind":"pre_mutation_hold","mutationBoundaryCrossed":false}
accept_observed_convergenceattemptRequired{"kind":"observed_convergence","resultManifest":{...}}, where resultManifest is the unmodified artifact from that exact target run
authorize_current_main_retryattemptRequired unless the outage-wait form is used{"kind":"current_main_retry","providerCallsActive":false,"retryEvidence":...}

The target must be an exact run of the Railway deploy-trigger workflow on the main branch. All decisions separately require an exact current green main authorization. The target can be an older main run: observed convergence is verified against that run's immutable manifest and exact incident head, while the protected resolution also records the separately revalidated current head. This separation keeps the convergence-acceptance route reachable after main moves, including when it must reset a full 64-run mutation lineage.

The protected retry hold carries an immutable capability that is the only path that can replace a same-head Railway deployment in FAILED. Ordinary runs and watchdog-created recovery holds continue to report that failure without retrying it. A recovery still adopts any active same-head deployment, or a running same-head replacement newer than the failure, so the one-use controller hold cannot duplicate work that Railway already accepted.

retryEvidence has exactly one of these forms. Its evidenceId must differ from the outer evidence ID:

json
{"kind":"terminal_inactive","evidenceId":"retry-audit-0001","auditedAt":"2026-08-08T08:45:00.000Z"}
json
{
  "kind": "outage_wait",
  "evidenceId": "retry-audit-0002",
  "automaticEntrantsDisabledAt": "2026-08-08T08:00:00.000Z",
  "allJobsTerminatedAt": "2026-08-08T08:02:00.000Z",
  "lastPossibleLeaseAcquiredAt": "2026-08-08T08:01:00.000Z",
  "auditedAt": "2026-08-08T08:44:00.000Z"
}

The protected resolver rebuilds all GitHub and Railway proof after environment approval, then rechecks it immediately before recording the immutable resolution. The operator HMAC is removed from the process environment before either read-only Railway subprocess can start.

Watchdog outcome decisions

OutcomeAutomatic decisionRequired evidence or next action
HEALTHYNo dispatchFresh accepted attempt and no active lease, hold, or barrier
WAITING_FOR_ACTIVE_RUNDeferExact active-run inventory, including runs older than 24 hours
RECOVERY_ELIGIBLE_OBSERVE_ONLYDeferRecovery predicates passed, but activation flags do not authorize a hold
RECOVERY_AUTHORIZEDDispatch onceDurable hold bound to target head plus the source workflow's separate frozen head
RECOVERY_DISPATCH_ACCEPTEDBind exact runGitHub returned and the helper re-read the exact run ID, attempt, workflow, branch, and head
RECOVERY_DISPATCHEDWait for target acceptanceController confirmed RUN_BOUND
PRE_DISPATCH_NOT_STARTEDClose hold, fail workflowPositive evidence that no POST began
DISPATCH_CONFIRMED_REJECTEDClose hold, fail workflowOne definitive non-retryable GitHub 4xx response
DEFERRED_NON_GREEN_MAINDeferMain moved or its newest exact gate is not green
DEFERRED_AMBIGUOUSPreserve hold and deferAny incomplete history, budget exhaustion, timeout, 5xx, 408/409/429, or post-send ambiguity
MANUAL_REQUIRED_AFTER_MUTATIONBlock automationDurable mutation marker or barrier; use the protected manual recovery evidence above

Bootstrap R2 publisher contract

The public bootstrap tiers use the dedicated private bucket worldmonitor-bootstrap. Managed r2.dev access stays disabled and the bucket has no custom domain; clients continue to enter through /api/bootstrap so the WAF, origin policy, rate limits, telemetry, and future access controls remain in the request path.

This service is an always-on publisher, not a Railway cron. Configure it with Dockerfile.publish-bootstrap-tiers (the root application Dockerfile does not contain the publisher) and start command node scripts/publish-bootstrap-tiers.mjs --loop, no cron schedule, and an empty watch-path list (whole-repository watching). It publishes both tiers on startup, then fast every two minutes and slow every ten minutes. Keep Redis authoritative: until the publisher and later rollout gates pass, /api/bootstrap continues to serve its existing Redis assembly.

The environment contract is deliberately split by consumer:

ScopeVariablesInstall inCapability
Shared routing and tier shapeR2_ACCOUNT_ID, optional R2_ENDPOINT, R2_BOOTSTRAP_BUCKET=worldmonitor-bootstrap, IRAN_EVENTS_ENABLEDRailway production and Vercel productionNames plus the feature flag that controls iranEvents tier membership; values must match
PublisherR2_BOOTSTRAP_ACCESS_KEY_ID, R2_BOOTSTRAP_SECRET_ACCESS_KEYRailway production publisher onlyPublisher can PUT and GET only in worldmonitor-bootstrap
Edge readerR2_BOOTSTRAP_READ_KEY_ID, R2_BOOTSTRAP_READ_SECRETVercel production onlyEdge can GET; it cannot PUT or DELETE, and cannot read worldmonitor-data

Preview and development do not receive either credential; missing credentials must use the Redis path. The publisher must not fall back to any CLOUDFLARE_R2_* account, bucket, key, secret, or API token. Never copy the publisher credential into Vercel or the edge credential into Railway, and never add a VITE_ alias for any bootstrap R2 credential. Set IRAN_EVENTS_ENABLED explicitly to the same value in both production services; otherwise the publisher and edge handler resolve different tier contents.

Provision and release in this order:

  1. Create the repo-root Railway service, install only the shared and publisher variables above, and confirm the live watch paths and lack of a cron schedule.
  2. Deploy the publisher before enabling shadow measurement or serving from R2.
  3. Parse both fast.json and slow.json, then verify generatedAt advances in two successive publishes for each tier.
  4. Install only the shared and read-only variables in Vercel production. Keep them absent from preview and development.
  5. Run the negative permission probes: publisher cannot access worldmonitor-data; edge cannot write/delete in worldmonitor-bootstrap and cannot read worldmonitor-data.

Rotate one consumer at a time: create a replacement token, update that consumer, verify its publish or read with the replacement, then revoke the old token. On suspected compromise, revoke first; Redis fallback preserves availability while a replacement is issued. Never log, commit, or copy credential values into an incident note.

Merged does not mean deployed

.github/workflows/seed-freshness-monitor.yml runs every 15 minutes on the default branch. Scheduled runs first require the latest main commit's gate status to be green; a missing, pending, or failed gate makes the workflow fail closed instead of producing a green skipped run. Manual runs execute directly. The workflow runs two independent jobs. Read the one that answers your question.

  • monitor is the gated job. After the repository gate, it checks live Railway watch paths, cron schedules, required routing variables, and service presence against scripts/railway-services.json, then checks public compact health.
  • Railway deploy drift is a separate job. It has no needs: and no gate condition, so it runs in parallel with monitor and publishes its own conclusion. A failed gate turns monitor red but leaves this job's verdict intact (#6523).

During a gate outage, read the Railway deploy drift job, not monitor. A red monitor alone says nothing about the fleet. Note that the run's overall conclusion and its badge stay red whenever monitor fails, so you must open the run and read the job conclusions to tell a clean fleet from a stranded one.

monitor fails on every actionable problem, including SEED_ERROR, STALE_SEED, STALE_CONTENT, and degraded composed coverage. Statuses that explicitly end in _ON_DEMAND remain informational. It deliberately does not run on an ingestion push because Railway may not have deployed or executed that revision yet. This is the operational acceptance gate for the "merged and green, but production data is still unhealthy or running under stale deployment controls" gap.

Deploy-drift check

bash
node scripts/check-railway-deploy-drift.mjs        # add --json for the machine-readable form

The watch-path filter is one way a merge fails to reach production; a GitHub integration that stopped delivering (#6064) and a build that failed after the merge landed are others. This check is deliberately agnostic about which. For every service whose Railway source is this repository it takes the newest deployment that actually reached a running state, reads meta.commitHash off it, and asks whether that source contains everything that can reach the service. Four verdicts are healthy — CURRENT, CURRENT_FOR_CLOSURE, AHEAD, PENDING_BUILD — and the problem set is derived from them by negation, so the reported verdicts are REJECTED_PUSH, BEHIND, CLOSURE_UNKNOWN, BUILD_FAILED, UNKNOWN_SOURCE, UNKNOWN_STATUS, NO_DEPLOYMENTS, NO_BUILD_IN_WINDOW and QUERY_FAILED. The file's header comment and exported constants are the exact semantics.

CURRENT_FOR_CLOSURE is what makes this compatible with watch-path filtering: the service is not on head, and that is correct, because nothing it can see has changed since. REJECTED_PUSH now means Railway refused a push that did reach the service, and the reason Railway gave is printed with it — a CI check suite failed refusal and a path refusal have different owners.

Two questions need local history: ancestry (git merge-base --is-ancestor) and the diff between the commit each service is running and head. A service legitimately weeks behind sits outside any fixed depth, so both workflows check out full history with filter: blob:none — the diff walks trees and never needs blobs. Never re-fetch with --depth afterwards: that re-shallows the clone and strands exactly those commits. An unanswerable question reports the service (CLOSURE_UNKNOWN) rather than excusing it.

Accepted degradations go in scripts/railway-deploy-drift-baseline.json, each with an owner issue and the whole file with an expiry, split by the same applyAcceptanceBaseline that scripts/check-seed-freshness.mjs applies to compact health — so expiry, prune-on-recovery, and "a service failing with a different verdict than the one baselined still blocks" cannot acquire two meanings. It held 62 REJECTED_PUSH entries — every filtered service — until #6142 made the check closure-aware; those were not degradations, they were the check demanding that a filtered service run head. The last remaining entry — umami at BEHIND against #6064 — was pruned on 2026-08-06 once the service was running head again, so the file is now empty. Acknowledged entries are printed on every run and do not fail it, so a green monitor here means "nothing new went stale", not "every service is on head". A service that recovers is printed as recovered — prune it. The expiry does not fire on an empty list: it exists to stop a suppression outliving its cause, and with nothing suppressed there is nothing to re-review.

Recovering a stale service

Use the Railway Deploy Trigger workflow. Its manual dry-run reports the closure plan without mutation; a normal manual dispatch runs the same green-main authorization as the automatic path. Do not run the trigger script directly for production recovery:

bash
# Local inspection only; this cannot authorize a production mutation.
node scripts/trigger-railway-deploys.mjs --only <service-name> --dry-run

The workflow builds from an exact green main commit, so the resulting deployment carries a SHA the drift check can compare. It is a no-op for a service Railway already took. After the lease-aware cutover, non-dry-run direct invocation fails closed: only the protected workflow can acquire the 30-minute non-renewing owner lease and bind the attempt manifest. The script's remaining local guarantees are still useful for previews:

  • The deployed commit defaults to origin/main, never your local HEAD, so standing on a feature branch cannot ship it to production.
  • A --head that is not reachable from origin/main is refused outright.
  • --only throws on a name the fleet does not have rather than silently selecting nothing — a typo that reported "no service needs a build" would read exactly like a healthy fleet.

Run git fetch origin before a local preview so origin/main is current.

The reconciliation control plane deliberately separates two failures:

  • A runner-less or pre-mutation attempt owns no unbounded GitHub lock. Once any bounded LEASED/PREPARED lease expires and no dispatch hold is ambiguous, the independent watchdog can dispatch a replacement. It never cancels the old run.
  • Once MUTATION_STARTED is durable, a project/environment-wide barrier blocks every automatic head until the exact result passes terminal deployment convergence plus strict zero drift, or the protected Railway Reconcile Manual Recovery workflow records an audited resolution. Lease expiry alone never clears this barrier.

The watchdog cannot create a hold or dispatch a new recovery unless both RAILWAY_RECONCILE_CUTOVER_ACTIVE and RAILWAY_RECONCILE_AUTO_RECOVERY_ENABLED are exactly true. Classification is deliberately read-and-repair: if an earlier authorized watchdog or manual recovery dispatch left a DISPATCH_HELD record, an ordinary scheduled run may bind its exact accepted workflow run or close it after definitive pre-dispatch rejection. That repair is independent of both flags because it completes an authorization that already exists; it never creates a hold or sends a dispatch. The first flag enables the lease-aware mutation contract and protected manual retry, while the second authorizes new automatic recovery. RECOVERY_AUTHORIZED means the controller persisted a one-use dispatch hold but has not yet dispatched it; RECOVERY_DISPATCHED means GitHub accepted the request and the controller durably bound its exact workflow run and attempt. A green watchdog run means only that observation did not poison main; authoritative success is the replacement's final strict-acceptance step, including a verified no-op. The run summary prints the current prior attempt or hold ID for protected manual recovery.

The observer combines run summaries from the preceding 24 hours with separate queries for every active target status, including runs that started before that window. It repeats the active sweep around the history read and defers if the inventory changes. With a durable mutation barrier, it reads attempt jobs only for active and durably referenced runs because the barrier already forbids recovery. After strict terminal acceptance, lastAccepted.acceptedAt retires older failures while a failure that finishes after the watermark is still read. Before the first trusted acceptance, it keeps the full fail-closed non-success scan. It follows at most 10 pages per query, makes at most 250 GitHub API requests, and bounds each request to 10 seconds. Durable barriers and dispatch holds remain authoritative beyond that window; exhausting any read budget defers recovery rather than dispatching on partial history.

Do not use railway redeploy: Railway documents it as rebuilding the most recent deployment with the same code, so it cannot pick up a newer fixed commit.

railway up is not an ordinary recovery path. If the control plane itself is unavailable, first disable every automated mutation entrant, inventory all mutation jobs, wait for them to terminate, and preserve the durable state. A separately audited manual action may be considered only after the 30-minute lease plus the documented 5-minute termination grace, 1-minute clock/network margin, and 6-minute safety margin have elapsed. Time does not override a restored MUTATION_STARTED barrier. If an approved break-glass recovery still requires railway up, use a clean detached worktree at origin/main:

bash
git fetch origin
git worktree add --detach /tmp/railway-deploy origin/main
cd /tmp/railway-deploy
git rev-parse HEAD                       # must equal origin/main
railway up --service <service-name> --environment production --detach

An upload carries no commit SHA, so check-railway-deploy-drift.mjs reports that service as UNKNOWN_SOURCE until the next git-triggered build replaces it. That is expected after a recovery upload, not a second failure — and the deploy trigger treats it as a reason to deploy, so it self-heals on the next run.

After any recovery path, verify the deployment commit SHA and the relevant compact-health problem have both advanced. See Railway's official redeploy CLI reference and deployment actions reference.

railway run is also not production-network evidence: Railway documents it as executing locally after injecting service variables. For an immediate long-cron backfill, use a controlled temporary Railway cron execution, verify its terminal run plus seed metadata and compact health, then restore the captured command and schedule and rerun the operational-config audit. The full rollback-safe sequence is documented in A merged seeder fix is not live until its cron fires.


How It Works

Each "bundle" is a single Railway cron service that replaces N individual services. The bundle script spawns each member seed sequentially via child_process.execFile, checking Redis seed-meta: timestamps to skip seeds that ran recently. Original seed scripts are unchanged.

The derived-signals bundle also owns the final China composition (seed-china-decision-signals.mjs). It runs after the cross-Strait source lane, calls the public six-domain RPC, publishes intelligence:china-decision-signals:v1, and records seed-meta:intelligence:china-decision-signals. It does not add providers or recompute any source-domain method. Before rollout, run node scripts/audit-china-decision-parity.mjs; after staging is deployed, pass --require-live --url <public-staging-api-base>. Against production that live probe is already enforced every six hours by .github/workflows/china-decision-parity-live.yml, so the manual run is for pre-production environments that workflow does not reach. The probe output is intentionally sanitized to reachability, latency, generation time, and group states.

Graceful fetch failures: runSeed now treats transient upstream fetch failures as non-zero graceful failures after extending the last-good Redis TTL. This applies to bundled members and standalone runSeed cron seeders: Railway may mark that cron run failed, but /api/health and seed-contract probes still read the preserved seed-meta: freshness. Alerting should either tolerate these transient cron failures or key sustained data-health pages off those freshness checks. Bundle member logs use status=GRACEFUL_FAIL; external log consumers that match only status=FAILED should include GRACEFUL_FAIL. The bundle summary still reports these under failed:N, so use per-section status when distinguishing graceful upstream outages from hard failures.

Standalone follow-up: scripts/seed-military-flights.mjs and scripts/seed-service-statuses.mjs still have manual graceful failure paths that exit 0. Track those separately if the standalone graceful-failure contract needs to be made fully uniform beyond shared runSeed users.

Per-bundle migration:

  1. Delete ONE old member first (to free a slot under the 100 limit)
  2. Create the bundle service on Railway
  3. Wait 2-3 cron cycles, verify /api/health shows OK for all member seeds
  4. Delete remaining old member services
  5. Monitor 24h before proceeding to next bundle

Rollback: Delete the bundle service, re-create individual services. Scripts are unchanged in the repo.


Services to DELETE (46 total)

Standalone service retired before bundle restoration

#Service NameService IDReason
1seed-defense-patents (DISABLED)6f8bfd1b-7ccc-4db5-b03c-a2075b173e91Standalone remains deleted; producer restored in seed-bundle-static-ref using USPTO ODP

Replaced by seed-bundle-ecb-eu

#Service NameService IDOriginal Cron
2seed-ecb-fx-rates9cc81d27-745f-4925-a956-d9e0acacc8a2daily
3seed-ecb-short-ratesb695dd14-12fd-4493-a41b-30d50a9519d5daily
4seed-yield-curve-eub372da1c-e67d-44c0-ae23-4e391e75709bdaily
5seed-fsi-eu9c67552d-0a0a-409a-bf4f-571ac3f741c3weekly

Replaced by seed-bundle-portwatch

#Service NameService IDOriginal Cron
6seed-portwatch72b553c9-bf63-4905-ab47-706b0cc674e8every 6h
7seed-portwatch-disruptionscb0aea5d-806b-49f9-85f3-b0a0e1372a26hourly
8seed-portwatch-chokepoints-ref7907937c-5730-4768-a3cc-f4a3f555a9c5weekly
9seed-portwatch-port-activity334303bb-41a2-4e66-9add-b1762fda9a1aevery 12h

Replaced by seed-bundle-static-ref

#Service NameService IDOriginal Cron
10seed-submarine-cablesfde66e2c-e542-47e0-8ff5-49026b229949weekly
11seed-chokepoint-baselinesde51db71-3492-4521-873c-90b9c08dd8b4infrequent (400d TTL)
12seed-military-bases54b44749-c318-4392-aebe-aaf8308db1e9infrequent (one-time)

Replaced by seed-bundle-resilience

#Service NameService IDOriginal Cron
13seed-resilience-scorese87c212a-eab6-4a85-9e43-b855ca207823every 6h
14seed-resilience-statice0709305-0270-4f53-b133-7d74e8260400annual window

Replaced by seed-bundle-derived-signals

#Service NameService IDOriginal Cron
15seed-correlation6cb62419-f354-419a-835c-67f494347680every 5min
16seed-cross-source-signals57708db4-37a9-490e-98ee-dcdc783ce0f9every 15min

Replaced by seed-bundle-climate

#Service NameService IDOriginal Cron
17seed-climate-zone-normals01d57359-bccd-46f7-8b78-351040058f5fmonthly
18seed-climate-anomalies90095ed3-c9a8-4e42-b955-3b66fe288edbevery 3h
19seed-climate-disasters7a8e2384-925a-42c3-9767-c4cf14822985every 6h
20seed-climate-ocean-ice05c54150-226f-471d-9938-90fde67a8f11daily
21seed-co2-monitoring2a1cd437-fed3-4f74-b327-f2336ffcbb3fevery 3 days

Replaced by seed-bundle-energy-sources

#Service NameService IDOriginal Cron
22seed-gie-gas-storage70a43803-f91e-4306-973c-b99ce29fb055daily
23seed-gas-storage-countriesa8dd33d5-ed2a-4462-97ef-3e9654920e19daily
24seed-jodi-gas7b7c7198-60e0-48b4-8f9c-33036d530586monthly
25seed-jodi-oilc0d829a5-42ce-4644-bd7d-94f93bf92e26monthly
26seed-owid-energy-mix31303e69-ec86-4fa0-b956-0c5524f038a1monthly
27seed-iea-oil-stocks8a05aaa6-8802-4221-ab3b-59001a4df5d3monthly

Replaced by seed-bundle-macro

#Service NameService IDOriginal Cron
28seed-bis-data8a2896ea-207e-4bef-8cd0-c6871df09a1devery 12h
29seed-bls-seriescf6f0bd4-3b09-4e77-b720-f2d08cb2c04fdaily
30seed-eurostat-country-data9314f05a-c9d6-4d5a-8af6-575da09174b0daily
31seed-imf-macro5634de02-83ff-4ab1-8b88-aef73c4055e7monthly
32seed-national-debt7ca57c8b-5d26-4a47-ba76-ae8f465eb0f3monthly
33seed-fao-food-price-indexc923b38f-3a52-4933-96d1-89443c8deda1daily

Replaced by seed-bundle-health

#Service NameService IDOriginal Cron
34seed-health-air-quality7be8c278-1c00-4761-adb5-85336ee4661bhourly
35seed-disease-outbreaks12c8681b-6e82-464d-b6e5-6b397123643ddaily
36seed-vpd-trackerbd286f94-39f2-4341-895d-4ea6ea4d1905daily
37seed-displacement-summaryfed916c2-97bc-434b-ad2d-636121bcd70ddaily

Replaced by seed-bundle-market-backup

#Service NameService IDOriginal CronAlso in ais-relay?
38seed-crypto-quotes3bf34a40-e4dc-4fac-9fa6-8438118d0f53every 5minYes (Market loop)
39seed-stablecoin-markets0410d0eb-81ee-46e0-a50f-8fd9de334ef8every 10minYes (Market loop)
40seed-etf-flows6d907720-b274-4b4c-a2e5-a37e9161f349every 15minYes (Market loop)
41seed-gulf-quotesba1ad92b-1813-412d-b6e5-6c37f3f741c2every 10minYes (Market loop)
42seed-token-panelsa975dc1a-6ac3-4db0-89bf-bdcdecb92fdeevery 30minYes (Market loop)

Replaced by seed-bundle-relay-backup

#Service NameService IDOriginal CronAlso in ais-relay?
43seed-climate-newsc4875401-90b5-4738-ba64-6f27496d41a0every 30minYes (child spawn)
44seed-usa-spendingf420ca72-c41d-46aa-a151-0315ce45df2dhourlyYes (Spending loop)
45seed-ucdp-events6bce510f-d3a9-4252-b896-45aef3521cacevery 6hYes (UCDP loop)
46seed-wb-indicatorsad9df8af-f27c-41db-a89d-f68f2fab2cf6dailyYes (WB loop)

Services to CREATE (11 total)

All new services share these settings:

  • Root directory: . (repo root, so npm ci installs all deps)
  • Build command: (default nixpacks, uses scripts/nixpacks.toml)
  • Source branch: main
  • Resources: 1 vCPU / 1 GB RAM
  • NODE_OPTIONS: --dns-result-order=ipv4first

Watch paths: Use scripts/**, shared/** for all bundles. scripts/** covers all seed scripts and their helpers. shared/** is needed because loadSharedConfig() in _seed-utils.mjs resolves ../shared/ (repo root) before ./shared/ (scripts dir), so config JSON files like country-names.json, iso3-to-iso2.json, and others live at the repo root shared/ directory. Without shared/**, config-only edits won't trigger redeploys.

Bundle 1: seed-bundle-ecb-eu

SettingValue
Service nameseed-bundle-ecb-eu
Start commandnode scripts/seed-bundle-ecb-eu.mjs
Cron schedule0 13 * * * (daily 13:00 UTC)
Watch pathsscripts/**, shared/**
Replaces4 services (ecb-fx-rates, ecb-short-rates, yield-curve-eu, fsi-eu)
Net savings3 slots
MembersECB FX Rates (daily), ECB Short Rates (daily), Yield Curve EU (daily), FSI EU (daily)

Why 13:00 UTC (not 06:00): the daily ECB SDMX series (€STR, yield curve, CISS) are rebuilt during ECB's early-morning refresh window. A 0 6 * * * run (08:00 CEST — exactly €STR's publication moment) intermittently hit that window and got empty/incomplete datasets, so those three sections failed gracefully (TTL extended, no data loss) while the bundle exited non-zero and showed red on Railway. 13:00 UTC (15:00 CEST) clears €STR (08:00 CET), the yield curve (~12:00 CET) and CISS morning publication. Changed 2026-07-01.

Bundle 2: seed-bundle-portwatch

SettingValue
Service nameseed-bundle-portwatch
Start commandnode scripts/seed-bundle-portwatch.mjs
Cron schedule0 */1 * * * (hourly)
Watch pathsSee scripts/railway-services.json (exact runtime closure; run node scripts/audit-railway-watch-paths.mjs)
Replaces4 services
Net savings3 slots
MembersDisruptions (hourly), Main (6h), Port Activity (12h), Chokepoints Ref (weekly)

Bundle 3: seed-bundle-static-ref

SettingValue
Service nameseed-bundle-static-ref
Start commandnode scripts/seed-bundle-static-ref.mjs
Cron schedule0 3 * * * (daily at 03:00 UTC)
Watch pathsscripts/**, shared/**
Replaces4 services (including the retired defense-patents producer)
Net savings3 slots
MembersArms Suppliers (10d), Defense Industrial Base (10d), Submarine Cables (weekly), Defense Patents (weekly), Chokepoint Baselines (400d, runs rarely), Military Bases (30d, runs rarely), Mineral Production (60d, runs rarely)
Wall-time budgetmaxBundleMs: 570_000 in scripts/seed-bundle-static-ref.mjs. The daily tick exists so this budget can defer lower-priority members rather than starve them; a member's own timeoutMs is a per-member cap inside that total, not an independent budget.
Required variableUSPTO_API_KEY=${{shared.USPTO_API_KEY}}

Defense Patents is an intentional data-series migration, not a continuation of the former grant/issue series. USPTO ODP Patent File Wrapper records represent applications, so date is the application filing date and abstract remains empty for wire compatibility. The producer marks the discontinuity with sourceVersion: uspto-odp-v1 and schemaVersion: 2; operational comparisons must not treat pre-migration grant dates and post-migration filing dates as one continuous metric.

Defense Industrial Base writes military:industrial-base:v1 from World Bank MS.MIL.* series and military:arms-suppliers:v1 from SIPRI-derived five-year supplier shares. Both values have a 30-day TTL. Each source is eligible every 10 days, and the daily service evaluates that interval before day 10, which keeps the canonical TTL above the three-refresh safety floor. The sources run as separate bounded processes under a 570-second bundle budget, so one failure cannot block the other source's publication. A SIPRI portal failure preserves last-good supplier rows with their original timestamps. A separate SIPRI-completion marker stays old after a partial pass, so the next daily tick retries the portal instead of treating the partial pass as complete. Strict health-probe registration is a staged follow-up after the first Railway run publishes both seed-meta keys; the follow-up must cite real Railway pre-seed evidence under the health-probe cutover contract.

Bundle 4: seed-bundle-resilience

SettingValue
Service nameseed-bundle-resilience
Start commandnode scripts/seed-bundle-resilience.mjs
Cron schedule0 */6 * * * (every 6h)
Watch pathsscripts/**, shared/**
Replaces2 services
Net savings1 slot
MembersResilience Scores (6h), Resilience Static (annual window Oct 1-3, skips most runs), Food Stocks (monthly USDA PSD + FAOSTAT fill; needs USDA_FAS_PSD_API_KEY)
Wall budget570 seconds, below Railway's 10-minute container kill. Section timeouts are 240s / 420s / 480s, so each fits the budget once the runner's 10s kill grace is added. Resilience Scores stays first in the array: it is the member that keeps resilience:ranking:v27 and resilience:intervals:v10:* alive between cron fires, so it must be offered the budget before the heavier annual and monthly members.
Do notraise any section timeout above maxBundleMs - 10_000. A section whose timeout plus kill grace exceeds the budget is deferred on every tick while the bundle still exits 0 — #6556 ran this service dead for six hours behind a green badge. tests/bundle-budget-admission.test.mjs fails the PR, and runBundle refuses to start, but the arithmetic is worth knowing before you edit.

Bundle 5: seed-bundle-derived-signals

SettingValue
Service nameseed-bundle-derived-signals
Start commandnode scripts/seed-bundle-derived-signals.mjs
Cron schedule*/5 * * * * (every 5 min)
Watch pathsSee scripts/railway-services.json (exact runtime closure; run node scripts/audit-railway-watch-paths.mjs)
Replaces2 services
Net savings1 slot
MembersCorrelation (5min), Cross-Source Signals (15min), Cross-Strait Activity (3h), China Decision Signals (15min), Regional Snapshots (6h)
Required envJAPAN_MOD_PROXY_URL or PROXY_URL (Cross-Strait Activity's Japan MOD exit; the section declares an any-of group, so either satisfies it and only an environment with neither fails as CONFIG_ERROR)
NoteCross-Strait Activity is the only direct external-source member; it uses bounded MND/Japan MOD requests and a 3h freshness gate. China Decision Signals validates and republishes the bounded public composition after reading its domain lanes. Other members are Redis-derived. The bundle enforces a 570s wall-time admission budget so a non-fitting due section defers before Railway's 10-minute container limit.

Staged correlation runtime modes

Correlation uses one Redis control key, correlation:runtime-mode:v1, whose value is a JSON object with one strict field, for example {"mode":"legacy"}, {"mode":"exact"}, or {"mode":"fuzzy"}. The browser reads the public GET /api/correlation-runtime-mode contract with cache: "no-store" at startup and before every correlation refresh. The correlation seeder reads the Redis key again on every compute cycle; it does not reuse a previous cycle's decision.

Every missing key, malformed JSON or shape, unknown mode, missing Redis credentials, failed Redis request, non-OK browser response, or failed browser payload parse resolves to legacy. legacy remains the current keyword clustering behavior. Exact entity clustering and fuzzy resolution are staged follow-ups owned by #5984 and #5989; this control slice does not activate either mode or change live configuration.

Changing the key is an operational activation or rollback and requires separate operator approval. Keep that approval, the observed validation evidence, and the rollback decision outside the code deployment; the code path is only the fail-closed read and hand-off contract.

Japan MOD discovery surface and recovery gate

The official discovery URL is the Japanese Joint Staff homepage, https://www.mod.go.jp/js/. The runtime makes one direct request and, after a transport failure or an empty allowlisted index, one request through JAPAN_MOD_PROXY_URL (falling back to PROXY_URL). It never downloads linked PDFs during a scheduled run.

Japan MOD's Cloudflare rule is path-level, not egress-level. Measured 2026-08-01, from both direct egress and the configured Decodo path:

PathResult
https://www.mod.go.jp/js/200, 33,419 bytes, 9 /js/pdf/2026/ links
https://www.mod.go.jp/js/pdf/2026/*.pdf200, application/pdf
https://www.mod.go.jp/js/press/index-en.html403 Just a moment...
https://www.mod.go.jp/js/index-en.html403
https://www.mod.go.jp/js/index.html403
https://www.mod.go.jp/js/press/403
https://www.mod.go.jp/js/en/403

Note that /js/ succeeds while /js/index.html does not. Cloudflare fronts the succeeding path too — the 200 response carries a window.__CF$cv$params beacon — so this is a rule that exempts /js/, not a zone the CDN does not cover. Changing the discovery URL to any other path on this host is a regression, not a refinement. This is the general lesson for other Japanese government sources: probe the bare directory before concluding the host is blocked.

Do not derive an English companion PDF by inserting e before .pdf. The English series carries its own counter, so the mapping resolves to unrelated releases. Measured 2026-08-01:

DocumentContent
p20260730_01.pdf (JA)中国海軍艦艇の動向について — Chinese Navy, Renhai/Jiangkai II
p20260730_01e.pdf"Russian aircraft activity around Japan" (July 27)
p20260730_03e.pdf"Chinese Military Activities" — the real counterpart

That day Japanese published _01/_02 while English published _01e_05e. Every check that mapping would be validated against — HTTP 200, application/pdf, %PDF magic, Joint Staff publisher marker — passes on the wrong document, so it cannot be validated into correctness. The correct counterpart is only resolvable from the English index, which is the surface Cloudflare blocks. parseJapanModIndex therefore accepts only /js/pdf/<year>/p<YYYYMMDD>_<NN>.pdf, which structurally excludes the English series, and the source reports companionResolution: english_index_blocked_no_derivable_companion.

Discovery records candidates for manual review; it never admits an observation. admittedDocumentCount counts hand-reviewed rows and unreviewedCandidateCount counts the discovered backlog. A 200 carrying no allowlisted release is JMOD_INDEX_EMPTY, not success.

The blocked English index stays wired as shadowIndexUrl: a direct probe that runs at most once per 24 hours, only after the homepage request already succeeded. It is diagnostic only — it never contributes to requestCount, errorCodes, transportStatus, or lastSuccessAt. Watch shadowIndexProbe.status flip from blocked to reachable; that is the signal that English provenance can be restored and the +e constraint above revisited. candidates and shadowIndexProbe are operator-only and stripped from the anonymous bootstrap projection.

If a future provider change is needed instead, the concrete external dependency for the current provider is an active Decodo Site Unblocker subscription with source-specific credentials and a successful target test. The ordinary residential gateway credential is not a substitute for that product. If the provider requires disabling TLS verification, do not weaken the adapter; provision a trusted provider CA or use an approved authenticated HTTPS integration instead.

Recovery is accepted only when:

  1. crossStraitActivityJapanMod reports OK for two consecutive scheduled three-hour runs from distinct scheduled executions;
  2. lastSuccessAt is non-null, later than the deploy, and advances between those two successful runs;
  3. the published _seed.sourceVersion reads taiwan-mnd-html+japan-joint-staff-homepage-v3, proving the resilient homepage-discovery adapter is the code that ran rather than a merged-but-not-deployed PR;
  4. the source reports transportMode: japanese_homepage_candidate_discovery and at least one newly discovered candidate tied to each successful fetch — retained rows do not count;
  5. the combined cross-Strait publication remains available and explicitly source-degraded when a later Japan MOD request fails.

Bundle 6: seed-bundle-climate

SettingValue
Service nameseed-bundle-climate
Start commandnode scripts/seed-bundle-climate.mjs
Cron schedule0 */3 * * * (every 3h)
Watch pathsscripts/**, shared/**
Replaces6 services
Net savings5 slots
MembersNatural Events (3h, EONET/GDACS/NHC/HKO), Zone Normals (monthly, skips ~359/360), Anomalies (3h, depends on zone-normals), Disasters (6h), Ocean Ice (daily), CO2 Monitoring (3 days)
NoteZone-normals runs before anomalies (dependency ordering)

Bundle 7: seed-bundle-energy-sources

SettingValue
Service nameseed-bundle-energy-sources
Start commandnode scripts/seed-bundle-energy-sources.mjs
Cron schedule30 7 * * * (daily 07:30 UTC)
Watch pathsscripts/**, shared/**
Replaces6 services
Net savings5 slots
MembersGIE Gas Storage (daily), Gas Storage Countries (daily), JODI Gas (monthly), JODI Oil (monthly), OWID Energy Mix (monthly), IEA Oil Stocks (monthly)

Bundle 8: seed-bundle-macro

SettingValue
Service nameseed-bundle-macro
Start commandnode scripts/seed-bundle-macro.mjs
Cron schedule0 8 * * * (daily 08:00 UTC)
Watch pathsscripts/**, shared/**
Replaces6 services
Net savings5 slots
MembersBIS Data (12h), CBR Rates (daily), China Macro (36h), China Release Calendar (36h), China Policy Events (6h), BIS Extended (12h), BLS Series (daily), Eurostat (daily), Eurostat House Prices (7d), Eurostat Government Debt (2d), Eurostat Industrial Production (daily), IMF Macro (30d), National Debt (30d), FAO FFPI (daily), World Bank External Debt (30d), BIS LBS (7d), FATF Listing (30d), Education Attainment (7d)
Wall budget570 seconds. The runner defers a section when its timeout plus 10-second kill grace cannot fit before Railway's 10-minute limit. Education stays last on six UTC days so a persistent failure in the new flag-dark producer cannot starve established production members; it gets first priority each Sunday UTC so sustained production load cannot defer its first envelope forever.

Bundle 9: seed-bundle-health

SettingValue
Service nameseed-bundle-health
Start commandnode scripts/seed-bundle-health.mjs
Cron schedule0 */1 * * * (hourly)
Watch pathsscripts/**, shared/**
Replaces4 services plus the China control-plane evaluator
Net savings3 slots
MembersChina Coverage (hourly), Air Quality (hourly), Disease Outbreaks (daily), VPD Tracker (daily), Displacement (daily)

Bundle 10: seed-bundle-market-backup

SettingValue
Service nameseed-bundle-market-backup
Start commandnode scripts/seed-bundle-market-backup.mjs
Cron schedule*/5 * * * * (every 5 min)
Watch pathsSee scripts/railway-services.json (exact runtime closure; run node scripts/audit-railway-watch-paths.mjs)
Replaces5 services
Net savings4 slots
MembersCrypto Quotes (5min), Hyperliquid Flow (5min), Stablecoin Markets (10min), ETF Flows (15min), Market Correlation Series (15min), China Corporate Disclosures (30min), China Stock Connect (60min), Gulf Quotes (10min), Token Panels (30min), Gold ETF Flows (2h), Gold CB Reserves (daily), SEC CIK Map (daily), SEC 8-K Stream (30min)
Required envPROXY_URL (required independently by Gulf Quotes / ETF Flows and selected for an exchange only when its source-specific setting is absent). Proxy configuration precedence is SSE_PROXY_URLSZSE_PROXY_URLPROXY_URL for SSE and SZSE_PROXY_URLPROXY_URL for SZSE; the process selects the first non-empty setting rather than attempting each URL sequentially. This is the deployment contract; production provisioning and live fallback acceptance require separate verification.
NoteCrypto Quotes, Stablecoin Markets, ETF Flows, Gulf Quotes, and Token Panels back up ais-relay inline loops. Hyperliquid Flow, Market Correlation Series, China Corporate Disclosures, China Stock Connect, Gold ETF Flows, Gold CB Reserves, SEC CIK Map, and SEC 8-K Stream are primary in this bundle. China Corporate Disclosures reads official metadata only: SSE uses direct then the selected proxy, while SZSE uses direct then distinct port attempts within the selected proxy. China Stock Connect reads aggregate exchange statistics over direct then the selected proxy only — it stops short of the edge hop, because a seeder fetches upstream data and the web tier serves it from Redis, and borrowing an edge function's egress for acquisition inverts that. It additionally caps every www.szse.cn request in a run under one shared 100s wall-clock budget, because its SZSE endpoints are date-keyed and the number of probes depends on how many sessions the exchange has published. Gulf Quotes uses Alpha Vantage (richer than relay's Yahoo-only).

Bundle 11: seed-bundle-relay-backup

SettingValue
Service nameseed-bundle-relay-backup
Start commandnode scripts/seed-bundle-relay-backup.mjs
Cron schedule*/30 * * * * (every 30 min)
Watch pathsscripts/**, shared/**
Replaces4 services
Net savings3 slots
MembersClimate News (30min), USA Spending (hourly), Global Tenders (hourly), UCDP Events (6h), WB Indicators (daily)
NoteExisting members are backups for ais-relay inline loops/child spawns; Global Tenders is hosted directly in this bundle. Each seed's freshness gate skips when the canonical data is already fresh.

Registry-covered live resilience services

These live Country Resilience services are not slot-saving consolidation migrations and should not be counted in the 35-slot savings plan above. They are listed here so their Railway start commands are first-class registry-covered entries.

seed-bundle-resilience-recovery

SettingValue
Service nameseed-bundle-resilience-recovery
Start commandnode scripts/seed-bundle-resilience-recovery.mjs
Cron scheduleMonthly recovery cadence; use the active Railway schedule for the existing service
Watch pathsscripts/**, shared/**
PurposeDedicated Country Resilience recovery inputs bundle
MembersFiscal Space, Reserve Adequacy, External Debt, Import HHI, Fuel Stocks, Re-export Share, Sovereign Wealth
NoteThis is the service referenced by the Import-HHI controls below. It is registry-covered so nixpacks packaging and start-command drift are tested.

seed-bundle-resilience-energy-v2

SettingValue
Service nameseed-bundle-resilience-energy-v2
Start commandnode scripts/seed-bundle-resilience-energy-v2.mjs
Cron schedule0 6 * * * (daily 06:00 UTC; per-slot interval gates real seeds to 7 days)
Watch pathsscripts/**, shared/**
PurposeDedicated Country Resilience energy-v2 input bundle
MembersLow Carbon Generation, Fossil Electricity Share, Power Losses
NoteDaily cron avoids the weekly dead window described in scripts/seed-bundle-resilience-energy-v2.mjs; the bundle's 7-day section intervals prevent unnecessary World Bank polling.

Services that STAY unchanged (54 total)

Infrastructure (4)

ServiceIDType
Postgres8a5871b9-5ca9-4551-8343-aef7fa67b8a4Database
Postgres-azIG3ea8ae20-44f4-49bd-a363-76b0adec8dcdDatabase
Valkey651a4b62-e224-47c2-9f7c-64e35908c44aCache
umamid7620480-e05a-4c09-b210-05166c3c0e59Analytics

Long-running services (4)

ServiceIDType
worldmonitor (ais-relay)a5f66d97-217f-44a0-a42d-5f3b67752223AIS relay + inline seeds
notification-relayaa37bd8e-c28d-4e9b-9d1e-0961f1b63d97Notification dispatch
simulation-worker67264e35-0b51-457b-984f-4ef20e36a117Forecast simulations
deep-forecast-worker750bc68f-9840-49a3-95eb-7c8bcc060485Deep forecast tasks

Consumer prices pipeline (3)

ServiceIDType
seed-consumer-prices2a369c41-cc5c-486a-a8d7-f0ca552e27a8Scraper
seed-consumer-prices-publish4492a338-cb37-40da-9e98-95a8d67e49c9Redis publisher
seed-consumer-aggregate4fdd1078-7884-48f8-92fc-06b390d0fdc4Index calculator

Standalone seed crons (43, not bundled)

#ServiceIDWhy not bundled
1digest-notifications01d644b8-057f-4040-a50e-500bd684daa8Notification dispatch, not a data seed
2seed-airport-delays444e9cc0-4eb2-4820-b430-3228e6ce9568Unique aviation domain
3seed-aviationa8e49386-64c1-4e1e-9f82-4eb69a55fce3Different keys from relay's aviation loop
4seed-bigmace8269317-c717-498b-adcf-be693a2bb8d3Weekly, web scraping via Exa
5seed-chokepoint-exposure12e8e87d-1214-4ba3-a813-709f279a5ba9Derived from Comtrade flows
6seed-conflict-intele4188e09-ae3b-4398-bb24-04f4b4b48b52Fast cadence (15min), notifications
7seed-cot23b2597f-1989-4904-9018-b3722a9e1bc2Weekly CFTC data
8seed-cyber-threatsfd27928b-0b9b-45d6-b056-92fa2f5d60a6Relay disabled its loop, cron is sole source
9seed-earnings-calendarcd07f48e-6433-4847-9f7b-1f05d062e619Finnhub, different domain
10seed-earthquakes5a953848-0678-4946-8ea0-b2269914ea12Independent seismology
11seed-economic-calendar555fc987-a043-4f64-bfa3-c827157ec706FRED + Eurostat + Fed/ECB scrape
12seed-economy565a66c1-662d-4a3a-b8e2-83b79d75dbe4Already multi-section (11+ keys)
13seed-electricity-prices1aee77cd-3af9-4640-a78d-e957c322adc0ENTSO-E + EIA, large dataset
14seed-ember-electricity67e01a64-d3cb-4b53-bf7d-cd5d223323b3Large CSV download
15seed-energy-intelligence9c2135c6-d638-4137-955a-8819c4d969f6RSS parsing
16seed-energy-spinea6c1d05f-a639-4470-829d-9337ffbdcbbeComposite from other seeds
17seed-fear-greedfcff514b-7b32-46c2-9413-0a48bcf4968eComposite index, unique sources
18seed-fire-detections1ebe342b-074b-4fb5-b012-c1dbfdef1971Feeds thermal-escalation
19seed-forecasts9bcbf89e-2785-452b-b59f-144b4863bd95LLM-heavy, long runtime
20seed-fuel-prices8d966e58-e01c-42cf-8d28-b85fd5d45460EU XLSX download
21seed-fx-rates5221253d-a22e-4560-a3db-ea4634c2049aShared dependency for other seeds
22seed-gdelt-intel3472577e-dff4-49f9-bc17-f32c2f366f7515-minute bulk GKG/export materializer
23seed-gpsjam16949dc7-b908-4740-bfbe-74a213db7c0bGPS interference monitoring
24seed-grocery-basketc8438692-843d-46ae-bee7-8c19e6847fa4Web scraping via Exa
25seed-hormuze6156007-e917-4139-90bd-71b6333a6d0ePower BI scraping
26seed-infrac615c211-1237-47cc-8d90-e23657437838Warm-ping to Vercel
27seed-insightsd1e092bb-6a5b-4225-8043-8ed93ccff268LLM-dependent
28seed-internet-outages5a07e099-14d8-42aa-ad6e-e66631fdd19fCloudflare Radar
29seed-iran-events5d294bd6-7943-4454-aa9c-eb90bd9d9124Iran-focused aggregation
30seed-military-flights7953a066-0627-4550-b72c-d2aceb33fbd3Real-time tracking, live/stale keys
31seed-military-maritime88768189-f80b-4615-87d1-dbc7803a6a28USNI warm-ping
32seed-natural-events7119c932-05f5-4727-a54f-e4e2de2a907fNASA EONET + GDACS + NHC
33seed-prediction-markets96fabace-d56d-4854-8096-3f5bcfe0d88aPolymarket anti-bot measures
34seed-radiation-watch3b76bb85-637c-43b7-ab90-5dee288f8bcaEPA + Safecast
35seed-regulatory-actions249ae8df-5746-4cdb-9978-ec61dce9121fFinancial regulator RSS
36seed-researchab850199-4d48-4af8-9681-aafbe2f31b8earXiv + HN + GitHub
37seed-sanctions-pressuree1686cdf-980f-426d-b5f2-a7757729fe9b120MB+ XML streaming
38seed-security-advisories8fb9c6b7-0ae9-441b-ae02-0f31baa3aed624 advisory feeds
39seed-supply-chain-traded7cc29f0-691b-40fd-84f2-ce8e8f12b567Already multi-section
40seed-thermal-escalation71d124d5-a4fb-42c3-9c5b-2fb0e5645e5bDerived from fire detections
41seed-trade-flowsdd3097f7-df65-4b0e-89ca-86a5fac7d558UN Comtrade, 6 reporters
42seed-unrest-events33c8c2a1-ad66-45ec-ac7e-609d69a59455ACLED + materialized GDELT bulk events
43seed-webcams2bf93afa-1922-4f9c-936d-f5054051b8a5Paginated across 8 regions

Inventory check: 4 infra + 4 long-running + 3 consumer + 46 delete + 43 standalone = 100


Standalone seed crons added after this snapshot

These data seeds were added after the 2026-04-10 inventory above. The rows marked planned are registry/documentation entries for services that are not provisioned in production; they remain excluded from the live audit and --apply until an explicit lifecycle activation. The four planned rows below are repository-root nixpacks-root-repo cron candidates (root directory ., start command node scripts/<file>), so their eventual packaging can include valid imports outside scripts/. Active rows must instead follow the deploy mode and exact watchPatterns recorded in scripts/railway-services.json. These rows are intentionally not part of the 100-service inventory count above and are registered in scripts/railway-services.json with deploy mode nixpacks-root-repo.

Cadence below is inferred from each seed's cache TTL as a documentation aid; confirm the live cron schedule and Service ID against the Railway dashboard before relying on it. Rows showing a bold cron expression with a verified date were read from the Railway API rather than inferred.

To verify one yourself (reads cronSchedule for every service in the project; the CLI stores the token at ~/.railway/config.json):

bash
railway whoami   # confirm you are logged in, then query the API:
node -e "const c=require(require('os').homedir()+'/.railway/config.json');
fetch('https://backboard.railway.com/graphql/v2',{method:'POST',
 headers:{'Content-Type':'application/json',Authorization:'Bearer '+(c.user.token||c.user.accessToken)},
 body:JSON.stringify({query:'query(\$id:String!){project(id:\$id){services{edges{node{name serviceInstances{edges{node{cronSchedule}}}}}}}}',
 variables:{id:'29419572-0b0d-437f-8e71-4fa68daf514f'}})})
 .then(r=>r.json()).then(d=>d.data.project.services.edges.forEach(e=>{
   const cs=e.node.serviceInstances.edges.map(x=>x.node.cronSchedule).filter(Boolean);
   if(cs.length)console.log(e.node.name.padEnd(40),cs.join(','));}))"
ServiceStart commandInferred cadenceDomain
seed-aaii-sentimentnode scripts/seed-aaii-sentiment.mjsweekly (7d TTL)AAII bull/bear investor sentiment survey
seed-market-quotesnode scripts/seed-market-quotes.mjsplanned — not provisionedEquity index / stock bootstrap quotes (Yahoo + Finnhub + Alpha Vantage)
seed-commodity-quotesnode scripts/seed-commodity-quotes.mjs~30 min (30m TTL)Commodity + extended-gold bootstrap quotes
seed-crypto-sectorsnode scripts/seed-crypto-sectors.mjsplanned — not provisionedCoinGecko crypto sector performance
seed-market-breadthnode scripts/seed-market-breadth.mjsdaily (30d history window)S&P 500 breadth (% above 20/50/200-day, Barchart)
seed-weather-alertsnode scripts/seed-weather-alerts.mjsplanned — not provisionedNWS active weather alerts
seed-fx-yoynode scripts/seed-fx-yoy.mjsdaily (25h TTL)Wide-coverage FX YoY + 24m drawdown (resilience FX-stress inputs)
seed-comtrade-bilateral-hs4node scripts/seed-comtrade-bilateral-hs4.mjs0 6 1 * * (monthly, verified 2026-07-27)UN Comtrade bilateral HS4 trade flows — only scheduled consumer of the keyed 500/mo Comtrade quota
seed-hs2-chokepoint-exposurenode scripts/seed-hs2-chokepoint-exposure.mjsperiodic (TTL-extended)HS2 chokepoint trade-exposure (derived)
seed-service-statusesnode scripts/seed-service-statuses.mjsplanned — not provisionedService-status warm-ping; primary seeder is the AIS relay loop

The bilateral HS4 cron uses COMTRADE_API_KEYS and a 480-request hard budget under the provider's 500-call monthly quota. The authenticated route requests one four-year window (Y-2 through Y-5) in each of two HS4 batches and keeps the newest row per product/partner. The public-preview fallback cannot accept that period list and tries Y-2, then Y-3. A 24-day freshness gate prevents accidental repeat runs; health reports COVERAGE_PARTIAL below 110 country shards and stale after 35 days. Country payloads live for 40 days so a missed monthly tick becomes visible before last-good data expires.

Not standalone services (documented here to avoid confusion):

  • scripts/seed-chokepoint-flows.mjs — spawned in-process by the AIS relay (ais-relay.cjs), not deployed as its own cron.
  • scripts/seed-military-maritime-news.mjs — this is the script behind the existing seed-military-maritime standalone cron (USNI/NGA warm-ping) listed in the inventory above.

Start with lowest-risk, highest-savings bundles.

OrderBundleSlots FreedRiskCron Frequency
1seed-bundle-ecb-eu3Low (daily, same API)Daily
2seed-bundle-static-ref3Low (daily tick, static data)Daily
3seed-bundle-resilience1Low (6h, annual window)6h
4seed-bundle-portwatch3Medium (hourly, 4 members)Hourly
5seed-bundle-climate4Medium (3h, 5 members)3h
6seed-bundle-energy-sources5Medium (daily, 6 members)Daily
7seed-bundle-macro5Medium (daily, 18 members)Daily
8seed-bundle-health3Medium (hourly, 5 members)Hourly
9seed-bundle-derived-signals1Medium (5min bundle; one bounded 3h external member)5min
10seed-bundle-market-backup4Low (backup for relay)5min
11seed-bundle-relay-backup3Low (backup for relay)30min

Running total: 3 + 3 + 1 + 3 + 4 + 5 + 5 + 3 + 1 + 4 + 3 = 35 slots freed


Verification Checklist (per bundle)

After deploying each bundle and before deleting old services:

  • Bundle service shows "Active" in Railway dashboard
  • First cron fire produced logs (check Railway logs)
  • Logs show expected [Bundle:X] Starting (N sections) and Finished lines
  • Each member seed shows Done or Skipped (not all failed)
  • /api/health shows OK for all member seed-meta keys (not STALE_SEED)
  • Wait at least 2 full cron cycles before deleting old services
  • After deleting old services, verify health still shows OK on next cycle

Env Vars

Each bundle service inherits the same env vars as the individual seeds it replaces. Copy these from any existing seed service in Railway:

  • UPSTASH_REDIS_REST_URL
  • UPSTASH_REDIS_REST_TOKEN
  • NODE_OPTIONS=--dns-result-order=ipv4first
  • Plus any API keys used by member seeds (GIE_API_KEY, ICAO_API_KEY, etc.)
  • SAM_GOV_API_KEY for the Global Tenders SAM.gov adapter. The other initial procurement adapters do not require credentials.

The simplest approach: use Railway's "shared variables" or copy all env vars from the worldmonitor (ais-relay) service, which has a superset of all API keys.


Import-HHI Comtrade 429 Runbook

Issue #3979 covers the residual operational failure mode for the Country Resilience Index importConcentration dimension: AE/RU/NO/CH can still remain absent from resilience:recovery:import-hhi:v1 when UN Comtrade rejects the monthly recovery bundle for key budget, pacing, or reporter metadata reasons.

Decision: treat this as Comtrade quota/pacing while the seed logs show HTTP 429 or quota-exhausted HTTP 403 responses. Do not change importConcentration scoring until the rate-limit path has been addressed and a force-refresh proves that Comtrade is returning non-quota responses for the watched reporters.

Controls

Set these on the Railway service that runs node scripts/seed-bundle-resilience-recovery.mjs:

VariableDefaultUse when
COMTRADE_API_KEYSrequiredAdd keys first when multiple reporters are missing with 429s or quota-exhausted 403s.
IMPORT_HHI_PER_KEY_DELAY_MS1500Increase to 10000-15000 if logs still show import-HHI 429s. PER_KEY_DELAY_MS is accepted as a legacy alias.
IMPORT_HHI_MAX_CONCURRENCYkey countSet to 1 if quota failures look IP-level or global, not per-key.
IMPORT_HHI_VERBOSEunsetSet to 1 only for a diagnostic force-refresh; logs per-reporter status.

Reporter cohort splitting is the last resort. Prefer more COMTRADE_API_KEYS, then wider per-key delay, then lower concurrency. The import-HHI seeder fetches the watched #3979 reporters first when they are missing, so a replenished force-refresh should recover AE/RU/NO/CH before unrelated registry backfill can consume the hourly provider budget. Aggressive incident pacing such as IMPORT_HHI_PER_KEY_DELAY_MS=15000 with IMPORT_HHI_MAX_CONCURRENCY=1 can exceed the 30-minute bundle window; that mode intentionally relies on checkpoint/resume across ticks, not one-pass completion. Cohort splitting should only be used if a single full pass still exhausts the provider budget after the first three controls.

The import-HHI publish gate requires AE/RU/NO/CH as well as the normal country-count floor. If one of those watched reporters is still absent, the seed run fails validation with emptyDataIsFailure: true, does not refresh seed-meta, and leaves the bundle eligible to retry instead of stranding a fresh-but-incomplete canonical payload for the full monthly interval.

If a watched reporter is still missing and the seed log says status=200 rows=0, stop treating that reporter as a key-budget problem. Inspect Comtrade reporter metadata, data availability, and query-shape filters (customsCode, motCode, cmdCode) before considering any scoring change. The known non-M49 reporter-code exceptions are pinned in scripts/shared/comtrade-reporter-overrides.json; as of the #3979 follow-up this includes Norway (NO=579) and Switzerland (CH=757). Russia (RU=643) currently needs the seed-only stale period fallback (Y-5..Y-8) because Comtrade returns zero annual import rows for the standard Y-1..Y-4 window but still exposes 2018 rows.

Force-Refresh

After deploying a pacing/key-budget change, bypass the 30-day freshness gate:

bash
IMPORT_HHI_VERBOSE=1 FORCE_RESEED=true node scripts/seed-recovery-import-hhi.mjs

Then warm live scores so importConcentration reads the refreshed canonical key:

bash
API_BASE_URL=https://api.worldmonitor.app \
WORLDMONITOR_SEED_REFRESH_KEY=<seed-refresh-key> \
WORLDMONITOR_API_KEY=<read-key> \
node scripts/seed-resilience-scores.mjs

WORLDMONITOR_SEED_REFRESH_KEY is required: the resilience score seeder uses it for the seed-only get-resilience-ranking?refresh=1 recompute path. Keep WORLDMONITOR_API_KEY or WORLDMONITOR_VALID_KEYS available too so laggard per-country score warms can fall back to the normal premium read endpoint. In Railway, the service environment should already provide the Upstash Redis credentials; for a local force-run, export UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN as well.

If the run is fixing missing interval data, the success signal is the seed_complete log for domain="resilience:scores" with intervalsWritten > 0 and no status="ERROR". A failed interval recovery sets status="ERROR" plus intervalFailureReason and includes the diagnostic counts intervalMissingScorePayloadCount, intervalStaleScorePayloadCount, intervalInvalidScorePayloadCount, intervalMalformedScorePayloadCount, intervalFormulaSkipCount, and intervalPayloadSkipCount.

Verify the public audit surfaces after the run:

bash
curl -fsS https://api.worldmonitor.app/api/resilience/v1/get-runtime-manifest \
  | jq '{formulaTag, rankingCache, constructVersions, intervals}'
curl -fsS https://api.worldmonitor.app/api/health \
  | jq '.checks.resilienceIntervals'

Pass condition for interval recovery: runtime manifest reports intervals.available=true, and /api/health reports resilienceIntervals.status="OK" with records > 0.

Verification

Verify both Redis and the live score API:

bash
WORLDMONITOR_API_KEY=<key> node scripts/verify-import-hhi-coverage.mjs

Pass condition for AE/RU/NO/CH:

  • resilience:recovery:import-hhi:v1.countries.<ISO2> is present.
  • seed-meta:resilience:recovery:import-hhi is fresh.
  • Live GetResilienceScore has importConcentration.coverage > 0.
  • Live importConcentration.imputationClass is empty.

If the live API key is not available during Redis-only triage, use:

bash
IMPORT_HHI_VERIFY_REDIS_ONLY=1 node scripts/verify-import-hhi-coverage.mjs

Redis-only verification is not sufficient to close #3979; it only confirms that the seeder recovered the canonical payload before score warmup.