Skip to content

fix(mcp): never write to the production DataEdge after a failed billing lookup - #732

Merged
p-diogo merged 2 commits into
mainfrom
fix/689-mcp-dataedge-lazy-resolve
Nov 3, 2026
Merged

p-diogo merged 2 commits into
mainfrom
fix/689-mcp-dataedge-lazy-resolve

Conversation

@p-diogo

@p-diogo p-diogo commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Addresses p-diogo/totalreclaw-internal#689. L2, MCP-only (mcp/src, mcp/tests, mcp/CHANGELOG.md).

Problem

At startup the server makes one billing lookup (/v1/billing/status) to learn which DataEdge contract belongs to its relay. In phrase (mnemonic) mode, if that single 5-second call timed out, the server fell back to the production default DataEdge (0xC445…) for the rest of its life, even when TOTALRECLAW_SERVER_URL was staging. In the 3.5.1-rc.1 staging smoke, a cold staging relay sent a throwaway account's writes to production (tx 0xd9297d84…), and recall on staging then found nothing.

Change

  • Retry at startup: the lookup is retried with backoff (STARTUP_BILLING_RETRY: 2 attempts, 8 s each, 1 s apart). Phrase-mode registration now runs before the lookup, so a newly created account can resolve at first start.
  • Lazy resolution: if every startup attempt fails, the state starts with chainResolved: false and no DataEdge, and recall still works. All 5 on-chain write sites now get their config from writeConfigFor(state). That function re-runs the lookup (WRITE_BILLING_RETRY: 3 attempts, 10 s each; concurrent writes share one lookup). On success it fills in chain and DataEdge for the rest of the process. On failure it throws DataEdgeUnresolvedError: the write is not sent, and the error says nothing was stored and how to recover.
  • Defence in depth: buildSubgraphOverrides throws if it is handed an unresolved state, so a future write site that skips writeConfigFor still can't reach the production default.
  • Unchanged:
    • A successful lookup behaves exactly as before.
    • TOTALRECLAW_DATA_EDGE_ADDRESS still wins and counts as resolved.
    • Bundle mode still refuses to start when the lookup fails (its reviewed design), but now only after the retries.
  • The reason a write failed is now returned: totalreclaw_remember and totalreclaw_debrief used to log a failed batch only to stderr, and their result said success:false with no reason. They now include error on the response and on each failed result, so the agent can see the "not sent" message.
  • CHANGELOG: Unreleased → Fixed.

Tests

mcp/tests/dataedge-lazy-resolve.test.ts (13 tests):

  • Startup retry: a cold relay (first attempt times out, the retry succeeds) resolves to staging; when every attempt fails, the server stays unresolved with one failure report per attempt; a first-try success makes a single call.
  • Write path: after a startup timeout, a write whose lookup also fails is refused and nothing is submitted, so no write reaches the fallback address. If the write-time lookup succeeds, the write goes to the staging DataEdge, and later writes don't look up again.
  • Recovery and edge cases: a failed write-time lookup is retried on the next write; concurrent writes share one lookup; the env override skips the lookup; the guard in buildSubgraphOverrides throws on its own.
  • Static scan: every write site in index.ts goes through writeConfigFor, and the old "proceeding without a confirmed DataEdge" fallback is gone.

End to end against real staging, local build of this branch: a new harness script (qa-cold-relay-check.cjs, internal) runs the server through a local proxy to staging. The proxy holds /v1/billing/status past the client timeout.

  • Every billing call held: startup stays unresolved, remember is refused with the "was NOT sent" error, and no batch is submitted.
  • Only the 2 startup calls held: the write-time lookup resolves dataEdge=0xE7a4…, and remember returns tx 0x21c41430…. The receipt check says GO on the staging DataEdge, with no production log; the staging subgraph has 1/1.

Local results: tsc clean; full MCP suite 49 suites / 844 passed; URL guard OK; phrase-safety dist scan clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bd3G1BkVMFSXkcdAQ7nFax

p-diogo and others added 2 commits November 3, 2026 22:11
…ng lookup

A single 5 s startup billing call that timed out made a phrase-configured
server fall back to the PRODUCTION default DataEdge for its whole lifetime,
even with TOTALRECLAW_SERVER_URL on staging (3.5.1-rc.1 smoke: writes on
prod, empty staging recall).

- Startup retries the lookup with backoff (2 x 8 s). Registration now runs
  first so a new account's lookup can succeed.
- Still unresolved: the state stays unresolved; every write goes through
  writeConfigFor, which re-runs the lookup (3 x 10 s, one in-flight per
  state) and throws DataEdgeUnresolvedError (write not sent) if it fails.
- buildSubgraphOverrides refuses an unresolved state as defence in depth.
- Successful lookups and TOTALRECLAW_DATA_EDGE_ADDRESS behave as before;
  bundle mode keeps failing closed at startup, now after retries.
- tests/dataedge-lazy-resolve.test.ts: timeout -> no write to the fallback
  address; later success -> staging address; retry/backoff; concurrency;
  env override; static guard that all write sites use writeConfigFor.

Addresses p-diogo/totalreclaw-internal#689.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd3G1BkVMFSXkcdAQ7nFax
A refused write (#689 DataEdgeUnresolvedError) was only logged to stderr;
the tool result said success:false with no reason. Add error to the
response and to each failed result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd3G1BkVMFSXkcdAQ7nFax
@p-diogo
p-diogo merged commit 149384a into main Nov 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant