Skip to content

Promote staging → main - #988

Merged
harrymove-ctrl merged 181 commits into
mainfrom
staging
Sep 22, 2026
Merged

harrymove-ctrl merged 181 commits into
mainfrom
staging

Conversation

@harrymove-ctrl

Copy link
Copy Markdown
Collaborator

Takes current staging onto main (post–#922 / post–WALM-617 hop).

Heads at open

Branch SHA
staging 0a183481
main 0ffbacbb

173 commits on staging not in main, 167 files, +21245 / −996. The 10 commits on main not in staging are older promotion merge commits only (merge-base cbbc9c62 = tip of #916).

Gate evidence

CI on 0a183481: 28 success, 1 skipped, 0 failures. The skip is E2E / dev relayer (dev-only).

Staging tip includes #922 (merge of dev, with #921 migration wiring) plus the security / MCP / remember-latency batch that landed on dev after #909.

What this promotes (high level)

Write path / MCP latency (#918, #921, follow-ups)

  • Bounded memwal_remember / bulk / analyze return with job_id; new status tooling; deadline across body (WALM-648); orphan / failure-report correctness.

Security / plugin surface (#958–#963)

  • WALM-639→644: creds dir refusal, npx install isolation, Codex installer hardening, hook symlink / secret filtering, etc. Pin still mcpPackageVersion: 0.0.14-dev.2 in packages/mcp/plugin/plugin.json (published on npm); packages/mcp version field is 0.0.14.

SDK / Python / other

  • TS SDK 0.1.8 (main 0.1.7), MCP package 0.0.14 (main 0.0.13), plus WALM-646/647/649/656–660/663/665/666 and related fixes merged via Merge dev into staging #922.

Package / npm release ordering — read before merging

npm today:

memwal-mcp dist-tags: { latest: 0.0.13, rc: 0.0.14-rc.0, dev: 0.0.14-dev.6 }
memwal     dist-tags: { latest: 0.1.7,  rc: 0.1.8-rc.0,  dev: 0.1.8-dev.4 }

0.0.14 / 0.1.8 are not on latest yet. Merging this runs Release MCP Package / SDK release on main and is expected to publish stables from the in-tree version fields (no pending changesets under .changeset/ besides config).

Watch after merge: confirm npm view @mysten-incubation/memwal-mcp@0.0.14 version and SDK 0.1.8 resolve before announcing plugin/latest. The in-repo plugin launcher pin remains 0.0.14-dev.2 until a follow-up bumps it to 0.0.14 (CI currently requires the pin to exist on npm — 0.0.14-dev.2 does).

Not in this promotion

  • #972 (WALM-668 indexer Validity / Sui pin) — still open on dev, not on staging.
  • #987 (SECURITY.md / vuln-reporting 404) — open on dev, not on staging.

Supersedes

#907 (WALM-626 Redis reconnect hotfix on main) — content already on the line via #906 / staging history. Close #907 after this merges rather than merging both.

Suggested merge gate

  1. Confirm staging Railway services are on (or past) 0a183481 and smoke still green.
  2. Merge this.
  3. Watch Release MCP / SDK workflows; verify npm 0.0.14 / 0.1.8.
  4. Close fix(relayer): reconnect Redis so account setup is not 503 (WALM-626) #907.
  5. Do not claim production validation until post-deploy prod smoke (separate from this PR review).

hien-p and others added 30 commits September 15, 2026 18:51
A remember is a background job (pending -> running -> uploaded -> done) and
the tool waited for `done` with a 90s deadline. Measured against production
that costs 30-75s for a single short fact, while the job is durably accepted
about a second in. Phase timing on one job: accepted 1.2s, pending->running
2.1s, running->uploaded 29.6s (84% of the call), uploaded->done 2.1s. Payload
size does not drive it — a 1.2 KB fact finished faster than a 97-character
one — so the agent spends half a minute waiting on a queue it cannot affect.

memwal_remember now waits MEMWAL_MCP_REMEMBER_WAIT_MS (default 10s) for the
write to land, so the fast path still returns a blob_id, and otherwise hands
back the job_id and says plainly that the fact is not saved yet. Set 0 to
always return at accept, or 90000 to restore the previous behaviour.

Returning early cannot mean losing writes: a job can still fail after it is
accepted — an outage on the SEAL encrypt sidecar lands it in `failed` with
"Memory encryption backend is unavailable" long after the tool returned. So
memwal_remember_status resolves an in-flight job by id, reporting a failed or
missing job as an error envelope rather than a status line, and a genuinely
failed job still reaches the agent as an error from memwal_remember itself.
Only our own wait expiring is treated as a non-failure.
The fast-return path keyed "job is still running" off a
`MemWalRememberJobTimeout` constructor name. No shipped SDK throws that: both
the pinned 0.0.x and the current 0.1.x line reject with a plain Error carrying
`status` — 504 when the wait ran out with the job still going, 500 when the job
itself failed. `wrapTool`'s mapping for those names is dead code for the same
reason.

So every slow write came back as `isError: true` with "Tool error: remember job
timed out after 30000ms", telling the agent the save had broken when it was
simply still running — the exact failure mode the fast return exists to avoid.
Caught by driving the tools against the production relayer; the unit tests
missed it because the stubs threw a conveniently-named class instead of the
shape the SDK actually produces.

Match on `status` instead, and reproduce the real error shape in the stubs.
memwal_remember_status now also names a failed job (500) plainly rather than
letting it fall through to the generic "Tool error" prefix.
The 10s wait this branch shipped was the worst of both options. Against the
measured 30-75s completion spread it lands in the pending branch on nearly
every call, so the agent pays the full 10s and still gets no guarantee. Wait
long enough to mean it (MEMWAL_MCP_REMEMBER_WAIT_MS=90000) or do not wait —
so the default is now 0 and the tool returns once the relayer has durably
accepted the job, ~1.1s.

Returning at accept is safe from a disconnect: the job is a row in
remember_jobs driven by the relayer, not work held in this process. It is not
safe from a job that fails after acceptance, which is why the result never
reads as saved and memwal_remember_status exists to settle it.

Split the shared budget parsing and job-error mapping into remember-wait.ts
rather than duplicating the 504-vs-500 discrimination in both tools.

memwal_remember_status gains waitMs=0 for an immediate read. waitForRememberJob
cannot express that — it sleeps before its first poll, so a 0ms deadline
answers "still running" without ever asking the relayer. That needs
getRememberStatus, added to the SDK in 0.0.4, hence the dependency bump. The
wider 0.1.x upgrade stays with the sidecar SDK work.

Also adds the Streamable HTTP transport behind MEMWAL_MCP_TRANSPORT (sse by
default). It answers a call on the same request, so there is no idle watchdog
and no replay-on-reconnect.

Tests: 269 run, 236 pass, 33 fail — the same 33 sandbox port-binding failures
present on dev. tsc --noEmit clean.
Round-robin `next_index()` hands out the next wallet in sequence whether or
not it is mid-upload. The sidecar allows one upload per wallet
(WALRUS_UPLOAD_PER_WALLET_CONCURRENCY defaults to 1), so landing on a busy
wallet costs the full upload ahead of it while other wallets sit idle.
Observed in production as a job waiting 6.2s for its assigned wallet with the
global semaphore reporting available:2, queued:0 — the contention was never
global, only per-wallet:

    [walrus/upload] limiter_acquired {"keyIndex":6,"waitMs":6221,
      "limits":{"global":{"capacity":8,"available":2,"queued":0},
                "perWalletCapacity":1,"wallet":{"capacity":1,"available":0,"queued":1}}}

`least_loaded_index()` picks the wallet with the fewest in-flight attempts and
falls back to round-robin ordering among equals, so an idle pool still spreads
evenly and a fully busy one does not pile onto one signer.

Marking a wallet busy is a `WalletAttemptGuard` rather than paired
increment/decrement calls: the upload path has many early returns, and every
one of them has to release the slot. A guard cannot forget.

Tests: 11 new unit tests covering selection, guard release on early return,
nested attempts, saturating release, and an out-of-range index. Lib suite goes
466 → 477 passing with the same 21 pre-existing failures (no database in the
sandbox). cargo check clean, rustfmt clean on the touched files.
The sidecar installs @mysten-incubation/memwal from npm (Dockerfile runs
`npm ci` in scripts/), so packages/sdk in this repo is not what production
runs — and the pin had drifted far behind it. 0.0.4 predates every release
that matters here; most importantly it has no idempotency support at all,
so `rememberAsync` cannot send an idempotency_key and the relayer treats
every retry of a write as a brand-new one. A replayed remember therefore
mints a SECOND paid Walrus blob for a write already in flight. The SDK
grew keys in 0.1.2 ("collapse retries onto one paid remember job") and
production never received it.

0.1.7 is the current published version. The MCP layer only calls
analyzeAndWait / health / recall / rememberAndWait / rememberBulkAndWait /
restore, and deliberately excludes the manual-mode methods that carry the
one breaking change in the range (0.1.5 reshaped rememberManual), so the
used surface is unchanged. Verified by `npm ci` against the regenerated
lockfile followed by a clean `tsc --noEmit` over scripts/.

Also picks up the widened zod peer (^3.23.0 || ^4.0.0), which the
sidecar's own zod ^3.25.0 already satisfied.
memwal_remember no longer blocks to terminal, but memwal_remember_bulk and
memwal_analyze still do, and both were left on the SDK's default cadence.
That default backs off as min(10s, 1500ms * 1.5^attempt), so status checks
land roughly 1.5, 3.75, 7.1, 12.2, 19.8 and 29.8s apart. Writes finish in
the 15-35s band, which is exactly where those gaps are widest: a batch that
truly completed at 20.5s is not reported until 29.8s. None of that is work,
it is waiting to be told the work finished.

Reuses REMEMBER_POLL_INTERVAL_MS rather than picking a second number — the
rate-limit reasoning behind 400ms is the same one, and a bulk poll covers
every pending job in one request (/api/remember/bulk/status takes all the
ids), so the request budget matches a single remember's.
The sweeper applied one callTimeoutMs to every tracked request. That number
is DEFAULT_CALL_TIMEOUT_MS, sized for memwal_analyze — the slowest tool
there is — so a memwal_remember whose reply was lost (the relayer answered,
the stream dropped before it arrived) kept the agent blocked for 240s even
though that tool cannot still be working: it gives up on its own job long
before. Users read a four-minute silence as a hang and reload the client,
which is the reload-for-minutes symptom.

Each entry now carries the deadline its own tool enforces plus 30s of
transport headroom, fixed when the request is first tracked so a reconnect
replay keeps the original budget. memwal_remember lands at 120s. The
stalled-handshake shortcut still wins when it is tighter but can no longer
extend a call past its tool's ceiling.

Deliberately generous headroom: the sidecar answers at its deadline with a
result or an error envelope rather than going quiet, so a reply is one hop
behind it. Cutting a merely-late reply off early is the expensive mistake,
because the agent then retries a write that actually landed.

Behaviour is unchanged wherever MEMWAL_MCP_CALL_TIMEOUT_MS is set, which is
every existing expiry test — resolveDeadlineMs returns the override as-is,
and in the stalled path min(stalledHandshakeMs, callTimeoutMs) is already
stalledHandshakeMs. Also drops the duplicate local toolNameOf in favour of
the module-scope one this needs.
Three changes to what a caller waits for, none to what a write means.

The backoff ceiling drops from 10s to 2s and the base default from 1500ms
to 600ms. The ceiling is pure observation cost — a job that finished is not
reported until the next poll lands — and at 10s the checks fell at ~1.5,
3.75, 7.1, 12.2, 19.8 and 29.8s, straddling the 15-35s band where writes
actually complete. Polling is one indexed row read on remember_jobs, so the
extra checks are cheap; callers on a long budget still pass a larger base
because the relayer's per-delegate-key rate limit, not cost, is the real
constraint.

Both wait loops slept BEFORE their first check, so an idempotent replay of
a write the relayer had already finished paid a full interval for a result
that was ready on arrival. They now check first and sleep second.

Generated idempotency keys are derived from the content over a 30-minute
bucket instead of crypto.randomUUID(). pendingRememberKeys only dedupes
retries that reuse one client instance, and the MCP sidecar builds a fresh
MemWal per transport session — so a reconnect replay found an empty map and
a random key read as a brand-new write, minting a second paid Walrus blob
for one already in flight. The bucket bounds the collapse: remember_jobs
rows are never pruned, so an unbucketed key would dedupe against a job from
any point in history and re-saving a since-deleted fact would hand back the
old blob id instead of storing it again.

Callers passing an explicit idempotencyKey are unaffected, and distinct
text or namespaces still derive distinct keys.
memwal_remember stopped blocking on the whole Walrus write; bulk did not,
and bulk is the path an agent actually takes. The server instructions send
it here whenever it learned more than one thing, so leaving this tool on a
fixed 120s block meant the common multi-fact turn still stalled — the fast
return only covered the case that mattered less.

Blocking is also worse per fact here, not better. A batch is N separate
Walrus writes contending for the same upload slots
(WALRUS_UPLOAD_PER_WALLET_CONCURRENCY defaults to 1), so they land one after
another. Against the measured 30-75s single-write spread a five-fact batch
could exhaust the whole budget and come back as nothing but timeouts, having
held the agent for two minutes first.

Same shape as memwal_remember: rememberBulkAsync to accept, then hand back
the job_ids. Safe on the same grounds — remember_bulk commits every
remember_jobs row before it spawns preparation or answers
(services/server/src/routes/remember.rs), so acceptance survives the client
going away. Each job_id is returned paired with its fact, because with a
batch "one of these failed" is only actionable if you can tell which.

A non-zero MEMWAL_MCP_REMEMBER_WAIT_MS still waits, and that path now
reports a mixed batch honestly: waitForRememberJobs does not throw on
expiry, it marks stragglers `timeout`, so those are shown as still uploading
with their job_ids rather than as failures.

memwal_remember_status takes job_ids to settle a whole batch in one call.
It deliberately does not throw on a failed job the way the single-id path
does: a batch comes back mixed, and throwing on the first failure would
discard the blob_ids of the writes that did land.
memwal_remember was observed still running past 120s by an MCP client, on a
tool documented as capping at 90s. The cap is not a bound.

The SDK's signedRequest aborts a request only when the caller hands it a
signal, and of the memory methods only recall() does (15s). rememberAsync,
rememberBulkAsync and every job-status read call it with no signal, so the
underlying fetch has no deadline. waitForRememberJob then tests
`Date.now() < deadline` at the TOP of its poll loop, which bounds when the
next poll starts, not how long one takes — so a single stalled socket runs
as long as it stays open and sails straight past timeoutMs. With nothing
else in the way the stdio bridge's orphan sweeper is the first thing to fire,
minutes later, which is what a user sees as the client hanging.

Returning at accept does not fix this on its own: the accept POST is one of
the unbounded calls, so memwal_remember could hang indefinitely even at
MEMWAL_MCP_REMEMBER_WAIT_MS=0.

Every entry point now runs under a deadline — accepts at 15s (the only
deadline the SDK sets for itself; a healthy accept is ~1.1s), waits at their
own budget plus grace, since the budget cannot bound its own last poll.

The request is NOT cancelled: the SDK exposes no way to pass a signal, so
fetch keeps running until it settles. What this bounds is how long the agent
waits, which is the part a user experiences as a hang. An orphaned request
costs one socket and resolves into a promise nobody reads.

The timeout error is named MemWalRelayerUnresponsive rather than reusing a
job-failure name, because "we do not know whether it was queued" is not
"it failed" — and it says a retry is safe, since the SDK holds the same
idempotency key until an accept succeeds, so a retry attaches to the
existing job instead of queueing a second paid copy.

Also documents MEMWAL_MCP_REMEMBER_WAIT_MS, which this branch introduced
without an entry.
`fetch` has no timeout of its own, and this SDK passed an abort signal on
exactly one method — `recall`, at 15s. The accept POST, every job-status
read, and the `/version` and `/config` handshake calls that run before any
of them could stay pending for as long as the socket stayed open.

That is not merely untidy. A poll loop tests its budget at the TOP of each
iteration, so it bounds when the next request starts, not how long one
takes: a single stalled read ran straight past `timeoutMs`. A
`memwal_remember` documented as capping at 90s was seen by an MCP client
still running after 120s, with the stdio bridge's orphan sweeper the first
thing to fire, minutes later. The MCP layer now wraps its own calls, but
that only covers one consumer — the hole is here.

Default 30s, matching the relayer's own outbound HTTP client: any call that
needs the relayer to reach the sidecar, Walrus or OpenAI has already failed
upstream by the time it fires. Settable via `requestTimeoutMs`; a
non-positive or non-finite value falls back to the default rather than
disabling the bound, since "no deadline" is the bug being fixed.

Two endpoints legitimately outrun it and say so: `restore` (60s — the route
bounds itself at 55s server-side and answers with an error rather than going
quiet, so a tighter client deadline would abandon a reply already on its
way) and `analyze` (60s — it runs the extractor LLM inline before it
accepts). `recall` keeps 15s, now a named constant instead of a hand-rolled
AbortController.

Each poll inside a wait loop is bounded by the client deadline clamped to
the remaining budget. Both directions carry weight: the remaining budget
stops a poll outliving the wait it belongs to, and the client deadline stops
ONE stalled poll swallowing the whole budget, which would leave the loop no
room to retry. Expiry raises `MemWalRequestTimeout` with `status: 504` —
already transient to `isTransientPollingStatus` — so a stalled poll is
retried against what is left instead of failing the wait.

An abort the caller asked for, and any other transport error, propagates
untouched: an operator debugging DNS or TLS needs the original error, not
"timed out".
`remember` commits the job row, then spawns preparation — summarize, embed,
SEAL encrypt, enqueue — in a `tokio::spawn` inside the relayer process. If
the process stops in that window the row is left at `pending` with nothing
to resume it, and the sweeper never looked at `pending` at all. The state
machine in migration 005 does not even name a `pending → failed` edge: the
state had no exit.

Nothing surfaced it either. `memwal_remember_status` read the row and
reported "still uploading" indefinitely, so a write the user was told was
on its way was simply gone. Returning at accept made that worse rather than
causing it: the blocking call at least ended in a timeout the caller saw,
where now nobody is waiting to notice.

`pending` cannot be swept wholesale — a job that IS prepared waits at
`pending` until a wallet worker takes it, and with
WALRUS_UPLOAD_PER_WALLET_CONCURRENCY defaulting to 1 that queue is
legitimately minutes deep, so failing those would abandon paid work about to
run. `preparation_encrypted_b64 IS NULL` separates them: it is written by
the statement immediately before `enqueue_wallet_job`, so its absence means
the job never reached the queue.

Failing is the only available outcome, not a preference. The row holds the
SEAL ciphertext and never the plaintext, so a job that died before
encrypting has nothing to retry from — the error says the fact was never
stored and must be sent again, rather than implying a job merely died.

Clearing `prepare_claim_token` is what makes this safe against a preparation
that was slow rather than dead: that task's own UPDATE is fenced on the
token, so it now matches zero rows, logs the lost claim and returns before
`enqueue_wallet_job`. It cannot mint a paid blob for a job just declared
dead.

Quota needs nothing extra — `main` already runs
`release_reservations_for_terminal_jobs` immediately after this on the same
tick, ordered that way so rows this pass just failed are reconciled without
waiting another minute.

Composes with the existing idempotency recovery: a failed row with no
blob_id is exactly what the remember route resets and re-prepares, so a
client that does retry the same key gets a real second attempt instead of
collapsing onto a corpse.

Migration 005's comment still lists the old transitions. It is deliberately
left alone — sqlx checksums migration files, so editing a shipped one breaks
`migrate` on every deployed database; the transition is documented on the
sweeper instead.
…M-470) (#911)

* fix(server): stop scoring_weights reordering an explicit recall sort (WALM-470)

A recall request carrying both sort and scoring_weights returned neither
order: select_hits_for_sort ordered and truncated the hits, then the
ranker reordered the survivors by composite score, so sort=recent stopped
meaning newest-first.

Per the decision on WALM-460, an explicit sort is the order and weights
apply only when sort is omitted. RecallRequest.sort becomes
Option<RecallSort> so omitted and "relevance" differ, and
resolve_scoring_weights validates the weights, then suppresses them when
sort is set. The SDK docs and both API references state the rule.

* fix(server): narrow the embedding size check to the provider path (WALM-470)

The WALM-423 check ran before the embedder looked for an API key, so
deployments without one (local dev, CI, self-hosted) rejected remember
and recall text over 16 KiB, though the key-less fallback hashes locally
and has no context window. It now runs only when a provider key is set.

The same change mapped every provider 400 to "input exceeds the model
context limit". A 400 now becomes BadRequest only when its body names a
context-length problem; anything else, such as an unknown model id,
stays Internal so it is not blamed on the caller and still alerts.

The embed call moves into embed_text, which takes only the key, base URL
and text, so the tests can drive it against a local stand-in provider.

* docs(sdk,server): add the 0.1.7 changelog entry and trim WALM-470 comments

Review follow-up: record the recall sort precedence change under the
unreleased 0.1.7 in both SDK changelogs (no version bump), and drop
ticket ids, dates and design history from the new code comments.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
…s (WALM-608) (#910)

* fix(relayer): point the upload-queue saturation alert at real counters (WALM-608)

The saturation monitor read queuedWalrusUploads, activeWalrusUploads and
walrusUploadLimits.globalCapacity from the sidecar's /health. Since
cef9728 (v1_new port, 31 Jul) /health is bare liveness and returns none
of them, so every read fell through to unwrap_or(0) and the alert could
never fire.

/ready has the counters but waits on Sui, Walrus and an uncached archival
GraphQL query, each with a 5s timeout, against the monitor's 2s budget.
Backlogs arrive with Sui RPC pressure, so polling it would go blind
exactly when the alert matters.

- sidecar: add GET /metrics/uploads, serving the in-memory counters and
  limits with no auth, in both route modes
- relayer: poll it; move parsing and the consecutive-check state into
  sidecar_saturation.rs; a missing or non-integer field, a non-JSON body
  or a non-2xx status logs an error instead of reading as an empty queue
- docs: list the endpoint and the built-in alert in relayer observability

* docs(relayer): trim WALM-608 comments to what the code guarantees

Review follow-up: drop the incident history from the route, test and
parser comments; the PR body keeps it.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
…pted [WALM-332] (#793)

* feat(server): add GET /api/whoami so a delegate key can resolve its own account

A client that holds a delegate key but lost the surrounding metadata has no
way to rebuild credentials: `account_id` is required locally, and nothing
exposed it. `find_account_by_delegate_key` is internal to the auth middleware,
`account_exists` takes an owner address and returns only `{exists}`, and
`StatsResponse` carries `owner` rather than the account id.

The middleware already resolves exactly what is needed while authenticating,
so this hands back what it computed instead of doing new work: `account_id`
and `owner` from the registry scan, `package_id` from config.

Returning `account_id` is safe here precisely because the route is
authenticated — the caller proved possession of a key registered against that
account, so it only ever learns about itself. The public existence-check route
deliberately withholds it, and that reasoning is unchanged.

The field mapping is factored into `whoami_response` so it can be tested
without a live AppState (this codebase has no axum-handler harness).
`account_id` and `owner` are both 0x-prefixed 32-byte hex, so transposing them
would compile and silently hand back the wrong identity — pinned by a test.

Motivated by WALM-332.

* fix(mcp): stop losing the delegate key when a login is interrupted

The browser registers our delegate public key on-chain — a paid, irreversible
action — and only afterwards POSTs the callback that makes us save the private
half. Until now that private half existed solely in memory (`login.ts` created
it, `saveCreds` persisted it only on success), and the callback listener lived
in the same process.

So any death in that window destroyed the only copy of a key that had already
been paid for and committed on-chain, leaving an orphaned registration nobody
could use. Nothing reported it: the browser's POST hit a closed port, and the
process was gone so it logged nothing. `handleLocalLogin` had already returned
the URL and told the client the call succeeded.

Reproduced against the real binary over stdio: preflight succeeds while alive
(200), the process is killed, and the callback POST gets ECONNREFUSED. Ports
are never reused across restarts, so the stale tab cannot reach a new listener
either — it fails at /preflight, never reaching the state check.

Note this is NOT the state-nonce mismatch WALM-332 originally described. That
path is unreachable: the callback handler gates `preflightVerified` before it
compares state, so a bad-state 403 requires the same live process to have
accepted a preflight carrying its own nonce.

The fix is write-ahead. Persist the keypair before anything can hand the public
key to a browser, and clear it once the key is safely in credentials.json. On
the next start a stranded record is reclaimed via the new authenticated
`GET /api/whoami`, which supplies the account metadata the lost callback would
have carried.

Two deliberate constraints:

  - A rejected key is never deleted. A 401 means "not registered" on mainnet,
    but on testnet the registry scan is disabled outright and a genuinely
    registered key is rejected for want of an x-account-id hint. Deleting would
    destroy a paid key in exactly that environment, so the record waits out its
    24h TTL instead.

  - Recovery never rolls back a newer sign-in. If the user gave up and signed
    in again, adopting the older stranded key would silently downgrade them.

Also fixes the swallowed failure in `startOrReuseLoginFlow`: a login that fails
while the process is still alive (timeout, listener error) was eaten into a
`warn` and never reached the client. It now logs at error, writes to stderr,
and emits an MCP notification so the agent stops waiting on a dead flow.

The canonical signature string is duplicated across Rust and TypeScript because
they cannot share code. A silent drift there would fail only in production, as
an opaque 401, so the exact literal is pinned by a test on each side with a
comment pointing at the other.

WALM-332.

* docs(relayer): drop em dashes from the whoami section

The Sui docs style guide disallows em dashes in prose. Split the first
aside into its own sentence and parenthesized the second; wording is
otherwise unchanged.

* fix(mcp): make the WALM-332 recovery path actually able to reclaim a key

Review follow-ups on WALM-332. The write-ahead record was in place, but nothing
downstream of it worked.

- `whoami` signed `String(Date.now())`. The relayer freshness-checks
  `x-timestamp` against `Utc::now().timestamp()` — seconds — so a millisecond
  value was always outside the drift window and every recovery attempt 401'd
  with ERR_TIMESTAMP_OUT_OF_BOUNDS. Recovery could not have succeeded once.

- Every non-200 was `rejected`, which tells the user to sign in again and revoke
  the key. That is the wrong action for a 503 `AUTH_UPSTREAM_UNAVAILABLE`, a
  429, or a 404 from a relayer too old to serve the route — the key is fine, and
  re-registering costs gas for nothing. `rejected` is now 401/403 without the
  upstream-unavailable marker; everything else is `unavailable` and retried.

- Every `loginFlow` minted a keypair and overwrote `login-pending.json`.
  Recovery only runs at process start and is skipped for `--login`, so a
  timed-out login followed by `memwal_login` in the same process replaced the
  only copy of a key the browser may already have paid to register. A still
  valid record for the same relayer is now reused, `createdAt` included so the
  TTL keeps measuring from the attempt that may have registered it.

- Logout cleared `credentials.json` and left the pending record, so the next
  start recovered from it and signed the user back in. Both logout paths now
  clear it. Deliberately not folded into `clearCreds`, which also runs on 401
  session teardown where a newer stranded key is what recovery still needs.

- `savePendingLogin` swallowed write errors, restoring the original loss with no
  log line. It now logs and throws: the invariant is that the key is durable
  before its public half can reach a browser, and a directory that cannot take
  this file cannot take `credentials.json` either — that login was going to fail
  at the callback anyway, one paid `add_delegate_key` later.

On the reviewer's suggestion to exempt `GET /api/whoami` from the testnet
`x-account-id` requirement: that gate is not policy. The registry scan behind it
runs over Sui JSON-RPC, which testnet no longer serves (auth.rs "Strategy 3"),
so exempting the route would only route it to a retired endpoint. Documented as
mainnet-only in the API reference instead.

Also reattached the orphaned `whoami` JSDoc and skipped the pending-record mode
assertion on Windows, where the bits are not enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsS2mBzMfpzy3QE8EMiKvS

* docs(relayer): drop the em dash from the whoami network note

Style-guide audit: no em dashes in prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsS2mBzMfpzy3QE8EMiKvS

* fix(mcp): stop pointing a failed sign-in at revoking the key it can reclaim

Review follow-ups.

The logout comment justified keeping `clearPendingLogin` out of `clearCreds`
by saying `clearCreds` also runs on 401 session teardown. It does not: this
tree deliberately refuses to wipe credentials on a relayer 401 (creds-wipe DoS,
bridge.ts). The two logout paths are its only callers. Reworded to the reason
that is actually true — discarding a reclaimable key is a decision only an
explicit sign-out gets to make, and `clearCreds` is exported. Renamed the test
that repeated the same false claim.

The login-timeout message told the user to remove unused keys from the
dashboard. Write-ahead exists precisely so that key survives, and revoking it
destroys what the next start would reclaim. It now points at the two paths that
work: run login again and the same key is reused, or restart and it is
reclaimed.

The `rejected` stranded notice had the same defect in the other order, telling
the user to sign in again and then revoke. With keypair reuse that is
self-defeating: the sign-in adopts that very key. Reworded. `superseded` keeps
its revoke advice, which is correct there — that record is cleared, so the key
really is dangling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsS2mBzMfpzy3QE8EMiKvS

* docs(mcp): move the WALM-332 note into the unreleased 0.0.13 section

Review asked for a 0.0.12 -> 0.0.13 bump across package.json, the verify
script and the six plugin/marketplace manifests. origin/dev has since done
exactly that itself, for WALM-480: every manifest, the verify script and both
changelogs are already on 0.0.13, and npm still has `latest` at 0.0.12 with
only a `0.0.13-dev.0` prerelease published. So 0.0.13 is open, not released,
and this change belongs in it rather than in a further bump.

Merged dev and moved the #793 note out of the shipped 0.0.12 section into
0.0.13. docs/mcp/changelog.mdx gets the same entry, plus the release summary
and the `answer` frontmatter the reviewer flagged as missing.

Also capitalizes Testnet in the whoami note, the one style-guide violation
still outstanding from the docs audit.

* docs(mcp): apply the style-guide wording, and fix three stale comments

Review nits.

The audit re-ran on 71981f4 and still flagged the 0.0.13 bullet: `on-chain`
-> `onchain` (docs run 155 to 19 that way), and two passive constructions.
Applied to `packages/mcp/CHANGELOG.md` and `docs/mcp/changelog.mdx` together so
the two stay byte-identical, and to the `answer` frontmatter, which carried the
same hyphenation the audit does not scan.

Three comments still described advice ab1636b replaced:

- `recovery.ts` — the denied/unavailable split justified `rejected` by advice
  ("sign in again, then revoke the key") that the branch no longer gives. The
  reason still holds under the new copy, so it now states that one.
- `recovery.ts` — `formatStrandedLoginNotice`'s JSDoc called revocation *the*
  actionable step. It is now the abandon path only; naming the key serves both.
- `bridge.ts` — the logout comment pointed at "the relayer-401 handling below".
  It is above: the module doc, and the SSE 401 path.

`superseded` keeps its revoke advice, which is correct there. No user-facing
string changed, so the tests pinning the unavailable copy are untouched.

* fix(mcp): send an approved stranded key to a restart, not a revoke or a retry

loginFailureNotice and the troubleshooting page still told the user to
remove the key from the dashboard and sign in again. That revokes the
only copy a restart would reclaim.

"Sign in again, the same key is reused" is not a fix for an approved key
either. ConnectMcp.tsx always sends add_delegate_key, and the contract
aborts on a key that is already registered. So the notice, the timeout
reason, the bridge warning, the rejected-recovery notice and the
troubleshooting page now split on whether the wallet step was approved:
approved means restart to reclaim, not approved means sign in again, and
the dashboard is only for abandoning the key. On Testnet, where reclaim
cannot confirm the key, remove it first and then sign in.

The notice and the rejected-recovery notice are pinned by tests.

* fix(mcp,app): stop the failure surfaces retrying a key only a restart reclaims

Two surfaces still contradicted the notice they sit next to.

auth-required appended the generic LOGIN_INSTRUCTION after a failed
sign-in, so the same blob said "restart the MCP client and it is
reclaimed" and "no terminal command, no client restart", and led with
memwal_login for a key that cannot be registered twice. A failure now
gets a retry instruction scoped to the case it fixes: the wallet step was
never approved. The concatenated blob is pinned so it cannot say both.

The dashboard's callback-failed card told the user to sign in again and
remove the unused key, which throws away the registration a restart would
reclaim. It now points at the restart, and at the dashboard only for
abandoning the key, matching the troubleshooting page it links to.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session instructions are what actually steer an agent, and they still
described only memwal_remember as returning ACCEPTED-but-not-saved. Now that
memwal_remember_bulk behaves the same way, an agent reading this would take
a pending batch for a stored one and tell the user their facts were saved.

Also names the two things that only apply to a batch: its writes are stored
one at a time rather than together, so it takes longer than a single fact,
and memwal_remember_status accepts job_ids to settle the whole batch in one
call rather than one id at a time.
…econd

Apalis attaches no retry/backoff layer, so a retriable upload error is
re-polled almost immediately. Production shows what that costs: one job took
attempts 2, 3, 4 and 5 against Walrus `503 Too Many Requests` inside a single
second, rotated through four wallets, and died as "exhausted retries" about a
second after its first failure. The upstream limit is time-based, so rotating
wallets cannot help — only waiting can, and nothing was waiting.

Reuse the existing `backoff_duration` schedule (2s, 4s, 8s, 16s) between
upload attempts, the way the lock-defer path already does. The wallet slot is
held across the sleep on purpose: a backing-off job is still that wallet's
turn, and releasing it would invite another job onto a wallet about to retry.

`upload_retry_backoff` returns None for an aborting error and for the final
attempt, so nothing sleeps when no retry is coming.

19 of the 24 hours of upload failures sampled were this one rate-limit, and
roughly 23% of jobs reached a second attempt.
`memwal_remember` now returns as soon as the relayer accepts the job, which
leaves a window where the write still dies — a SEAL encrypt outage, an
exhausted upload budget — with nobody listening. `memwal_remember_status`
answers for one job, but nothing obliges an agent to ask, and storing a memory
is typically the last thing it does in a turn. An unasked question is the same
as a silent loss, and for a product whose promise is durable memory, silent
loss is worse than slow.

Recall is the call an agent always makes, so the bad news rides along there.
`/api/recall` now carries `failed_writes` — this owner's writes that reached
`failed` in the last 24h, capped at 5 — and the MCP tool renders them as a
warning naming the job, the namespace and the relayer's own error text, so the
caller can judge whether re-sending will work.

Scoped to `AuthInfo.owner`, never request input. Bounded by window and limit
because recall is the hottest authed route;
`remember_jobs (owner, status, updated_at DESC)` from migration 006 already
covers the predicate and ordering. A lookup failure degrades to "nothing to
report" rather than failing the recall it is attached to.

Reports repeat until they age out. Suppressing after one sighting would put
the burden back on the caller remembering to act, which is the failure mode
this removes. `failed_writes` is skipped when empty, so an older client and an
older relayer both see exactly today's response.
`memwal_remember_status` advertised `waitMs` up to 60000. The MCP SDK times a
request out at `DEFAULT_REQUEST_TIMEOUT_MSEC` — also 60000 — unless the caller
overrides it, so a caller using the documented maximum always lost the race:
the client gave up first and the agent saw `MCP error -32001: Request timed
out` with no way to tell a slow write from a broken tool.

Found by driving the real tools against the production relayer rather than a
mock: `waitMs: 60000` on a three-job batch reproduced it every time.

The ceiling is now 45s, which leaves headroom for the round trip and still
covers the median write, and the tool description interpolates the constants
instead of restating them, so the advertised range cannot drift from the
schema again. A job outliving the wait is not lost — the job_id stays valid
and the caller asks again, which is why this tool is separate from the write.

Also start the recall failure report concurrently instead of awaiting it after
hydration. The published SDK aborts a recall after a hard 15s that no caller
can raise, and a live recall was measured landing on exactly 15.0s, so the
report has to cost the critical path nothing.

Adds a regression test asserting the tool's advertised maximum keeps at least
10s of headroom under the SDK's request deadline, and a live end-to-end script
that walks remember → status and bulk → job_ids → blob_ids against a real
relayer, since the mocked tests cannot catch a client/tool deadline collision.
…anded

The live check demanded a blob_id for every job in a 45s window. Measured
write latency is p50 ~34s and p90 ~65s, so that window legitimately expires
with writes still in flight — the check was asserting the relayer be fast
rather than the tool be correct.

What must hold is that every job comes back with a definite state, that the
count of blob_ids matches the count reported saved, and that a report with
nothing saved says so rather than reading as success.
…cepts

The bridge carries its own copy of the tool list to answer tools/list before
the relayer session is up. Two entries had drifted from the sidecar and both
break a real flow during that window.

memwal_remember_status advertised only `job_id`, marked it required, and set
additionalProperties:false — while the sidecar takes `job_id` OR `job_ids`,
and the pending body memwal_remember_bulk returns tells the agent to come
back with `job_ids=[...]`. A schema-validating client refuses that call, so
the instruction the tool itself gives is unfollowable and a batch cannot be
settled at all until tools/list_changed lands.

memwal_remember_bulk still carried its pre-fast-return description, with no
hint that a result can come back ACCEPTED-but-not-saved. An agent reading it
reports a queued batch to the user as stored.

Neither id field is marked required: the sidecar rejects both-at-once and
neither-at-all, which JSON Schema cannot express here without a `oneOf` some
clients mishandle, so that stays a handler check.

tool-definitions.test.mjs only ever compared the bridge against literals,
never against the sidecar — which is why the drift was invisible. It now
pins the parts an agent acts on: that a batch can be settled, and that both
write tools admit a result may not be saved yet. Verified against the old
definitions, where both new tests fail.
Observed against the live relayer while timing the flow: settling a batch
printed

  4. [still uploading] job_id=2868f48f… error=polling timed out after 45000ms

`waitForRememberJobs` stamps that message on every row that had not landed
when the budget ran out. It is our clock expiring, not the job failing — the
write is still on its way — but rendering it as `error=` next to "still
uploading" tells the agent the opposite, and the agent tells the user. Only a
terminal row (failed / not_found) explains itself now.

Also re-syncs the bridge's advertised waitMs ceiling, which drifted again in
the other direction when the sidecar lowered MAX_STATUS_WAIT_MS to 45s. The
bridge still advertised 60000, and a caller taking it at its word got

  MCP error -32602: Number must be less than or equal to 45000

That is the same failure shape as the job_ids drift, so it gets the same
treatment: a test pinning the bound rather than a one-off correction.
…lyze writes

The sweep I added keyed only on `preparation_encrypted_b64 IS NULL`, on the
assumption that column marks "preparation never finished". It does not. It is
written in exactly one place — spawn_prepare_remember_job, the SINGLE remember
path. `/api/remember/bulk` and `/api/analyze` insert their rows directly and
never write it, so for those two the column is ALWAYS NULL, healthy or not.

The predicate therefore matched every bulk and analyze job that sat in
`pending` past the 10 minute TTL — which is ordinary, not pathological, since
WALRUS_UPLOAD_PER_WALLET_CONCURRENCY defaults to 1 and the queue is minutes
deep under load. It would have marked live paid writes `failed` while their
upload was still queued, and the recall failure report would then have told
the user to send them again. Worse than the gap it was meant to close.

`prepare_claimed_at IS NOT NULL` is the missing half: only
claim_remember_preparation sets it, and only the single path calls it (analyze
passes prepare_claim_token: None). Together the two columns mean "this row
claimed a preparation slot and never redeemed it", which is the state that
actually has nothing left to resume it.

Orphaned bulk and analyze preparations stay unswept. That is the pre-existing
behaviour, left alone deliberately rather than guessed at: neither path
persists anything that separates stranded from queued, so sweeping them needs
a durable marker they do not have yet.

Two tests seed exactly the row shape those endpoints write — pending, no
claim, no preparation, well past the TTL — and assert it survives.

Also repairs the build: RecallResponse gained `failed_writes` but two test
initializers in routes/recall.rs were not updated, so `cargo test` did not
compile on this branch at all (3 errors, present before this commit).
…y is free

Two defects with the same shape: a message written for the single-remember
path was reused where its guarantees do not hold.

SECURITY — the accepted-then-failed report attached to every recall read
`remember_jobs.error_msg` straight out of the table. Every other client-facing
view of that column (`GET /api/remember/:job_id`,
`POST /api/remember/bulk/status`) runs it through
`sanitize_job_error_for_client` first, which exists to do two things: replace
an infrastructure-funding failure with INFRA_JOB_ERROR_MESSAGE, and redact
long hex runs. Skipping it meant a WAL shortfall published the relayer's own
hot wallet and its exact balance — "Insufficient balance of 0x356a26…::wal::WAL
for owner 0x8d3c1f0a…c0d. Required: 64367730, Available: 10708877" — to every
tenant whose write landed on that wallet, on every recall, for 24 hours. It
also reads to an agent as "top that address up", which is the precise scam
confusion INFRA_JOB_ERROR_MESSAGE was written to prevent. The repo already
asserts this cannot happen (infra_wal_balance_failure_hides_relayer_wallet_address);
that assertion was simply never extended to this path. Sanitized at the recall
boundary rather than in storage, since `routes` is not reachable from the lib.

CORRECTNESS — `withAcceptDeadline` emitted one message for every caller,
ending "the SDK reuses the same idempotency key, so a retry attaches to the
existing job instead of queueing a second paid copy". True for
`POST /api/remember`, which carries a content-derived key. False for
`POST /api/remember/bulk`, which carries none: the handler mints a fresh uuid
per item and inserts with no conflict clause, so a retry is N more paid Walrus
blobs for the same N facts. The deadline makes that near-certain rather than
merely possible — `withDeadline` deliberately does not cancel the request, so
when it fires the relayer has usually accepted already. The advice is now
chosen per path, and bulk is told to check `memwal_recall` before re-sending
anything.

Tests pin both directions, because collapsing the two messages into one is how
this happened: bulk must never claim idempotency it does not have, and the
single path must keep saying a retry is safe.
`deadlineSignal` unref'd its timer, which reads as tidy and is exactly
wrong for this timer: the deadline is the one thing a caller IS waiting
on. Unref'd, it stops the clock the moment nothing else holds the event
loop open, and the stalled request it exists to bound then hangs forever
— the bug the deadline was added to prevent.

CI caught it as six cancelled tests in request-timeout.test.mjs
("Promise resolution is still pending but the event loop has already
resolved"): the stub fetch is a bare promise with no socket behind it,
so the loop drained and the deadline never fired. A real socket normally
keeps the loop alive, which is why this survived manual testing — but
"normally" is not a bound, and any caller whose transport does not ref
the loop inherits the unbounded hang.

Both call sites already run `dispose()` in a `finally`, so a ref'd timer
cannot outlive its request either.
`a_queued_bulk_job_is_never_swept` and `a_queued_analyze_job_is_never_swept`
failed against a sweep that is correct. The helper was the problem: it
stamped `prepare_claimed_at` on every seeded row, claim token or not, so
a row meant to stand in for a queued bulk write carried the one column
the orphan pass keys on and was duly failed.

`/api/remember/bulk` and `/api/analyze` insert neither the token nor the
timestamp; only the single-write path claims, and it writes both at once.
Stamping the timestamp alone is a shape the database never holds, so the
two tests were asserting against a fiction while the sweep they guard
went unexercised.

Seed the timestamp only alongside a token. Every row that must be swept
already passes one, so the orphan-preparation tests are unaffected.

Verified by `cargo check --tests`; the DB tests themselves need a
pgvector Postgres, which CI has and this machine does not.
The cold-start tool list gained `memwal_remember_status` so a client that
keeps the first `tools/list` can resolve the job ids `memwal_remember`
and `memwal_remember_bulk` now return. The login-handoff test pins that
list exactly, and was never updated, so it failed on the tool being
present rather than on anything being wrong.

Title and annotations are copied from the server definition
(read-only, non-destructive), so the assertion keeps pinning the
metadata clients receive rather than just the name.
Observed live against production while benchmarking: once the per-delegate-key
budget (60 weighted requests/minute) was spent, memwal_remember failed four
times in a row with

  Tool error: Walrus Memory server error (429): {"error":"Rate limit exceeded",
  "layer":"delegate_key","limit":"60 weighted-requests/min","retry_after_seconds":60}

and the facts were never written. Nothing retried, nothing honoured the
advertised cooldown, and nothing told the user a memory had been dropped. That
is the quietest failure this system has.

Fast-return makes it likelier rather than rarer: settling a batch adds requests
on top of the write, so an agent saving several facts in one turn spends the
budget faster than one that blocked.

A short cooldown is now absorbed (503 AUTH_UPSTREAM_UNAVAILABLE advises ~5s),
and a long one is reported. Sleeping out a 60s cooldown inside a tool call
would just be the hang this branch exists to remove, and the MCP client would
time out first — so the message names the wait, states plainly that the fact
was NOT saved, and points at the cheaper shape: one memwal_remember_bulk
instead of N memwal_remember calls, one memwal_remember_status(job_ids) instead
of N status calls.

Only rejections that provably never reached the handler are retried — 429 from
the limiter, AUTH_UPSTREAM_UNAVAILABLE from the delegate-key lookup. A 500 is
left alone: it could have been thrown after a write started, and
/api/remember/bulk has no idempotency key, so retrying it would store every
fact twice.

The absorb budget lives inside the accept deadline (8s of 15s) because
withAcceptDeadline wraps this; a test pins that ordering, since growing the
budget past the deadline would turn every absorbed retry into a spurious
"did not accept".
… claim TTL

`memwal_remember` tells an agent to send a failed fact again. The derived
idempotency key collapses that retry onto the failed row, and
`claim_remember_preparation` refused the claim for 60s — while the handler
answered 202 ACCEPTED regardless. The caller was told the write was durably
queued while nothing at all was running, which is the one thing this branch's
whole pending-vs-saved contract exists to prevent.

The TTL protects a preparation that is still RUNNING. A job that reached
`failed` has none: its preparation either errored on its own or the stale
sweeper failed it and cleared its token. So `status = 'failed'` now bypasses
the age check.

That is safe because fencing is done by the token, not the clock. A claim
rotates `prepare_claim_token`, and a straggler's own write is
`WHERE ... prepare_claim_token = <old>`, so it matches zero rows and returns
before `enqueue_wallet_job` — it cannot mint a paid blob for a job someone
else has re-prepared. The test drives exactly that: re-claim a just-failed job,
then watch the previous token's UPDATE affect nothing.

Second half: stop answering "pending" when no claim was taken. Losing the race
now means a concurrent retry won it, so the handler re-reads and reports what
that winner actually left behind instead of asserting a state it never reached.

A companion test pins that the TTL still does its real job — a `pending` row
claimed a moment ago keeps its claim.
analyze was the last tool still blocking to terminal, which left it the
slowest in the set by a wide margin: 37.0s measured against dev in the same
session where memwal_remember had dropped to 0.2s there. Its wait has the same
shape as bulk's — N Walrus writes, one upload per wallet — so there was no
reason for the answer to be shaped differently.

Extraction is still waited for. `analyze()` resolves once the LLM has run, so
the facts it found lead the reply, which is the half an agent can act on
straight away. Only the upload of those facts is handed back as job_ids, each
paired with its fact so a later partial failure is actionable.

Text that yields nothing says "Extracted 0 facts — nothing was saved" rather
than handing back an empty batch and a status tool to call about it.

6 new tests. Suite: 302 run, 269 pass, 33 fail — the same 33 sandbox
port-binding failures present on dev. tsc --noEmit clean.
harrymove-ctrl and others added 16 commits September 21, 2026 19:16
…-lookup-to-chat

fix(chatbot): scope the vote existence check to the chat (WALM-657)
…(WALM-648)

Holding the deadline across the body read put the non-2xx block inside the
`try`, and `timedOut()` is a sticky flag — so once the timer had fired the
catch replaced whatever was thrown, including a fully classified 429 carrying
`status`, `serverCode`, `retryAfterSeconds` and `cause`, with a generic 504.

`retryAfterDelayMs` reads exactly `retryAfterSeconds` and `cause`, so the
polling loop then fell back to its own curve and re-tripped the relayer's rate
limiter — the starvation #921 fixed. Measured against a 429 whose body
lands after the poll deadline: 426ms between polls instead of the stated 1s.

Convert to a timeout only when the error carries no status of its own. A
genuine abort mid-read has none, so a stalled body still surfaces as the
bounded 504 this branch added.
…cross-body

fix(sdk): keep the request deadline armed across the body read (WALM-648)
…-with-a-version into harryphan/walm-643-codex-installer-deletes-unrelated-hooks-with-matching

Both branches rewrite install_codex_hooks.mjs. WALM-640 replaces the Codex MCP
registration (npx -> node + absolute launch_mcp.mjs, migrating an existing npx
block); WALM-643 replaces the filename-substring ownership heuristic with the
_memwal marker plus exact command identity and filters hooks individually. The
two touch disjoint parts of the file, so the only textual conflict was the
header docblock, resolved to state both.
…ain-pending-jobs

fix(python-sdk): keep unreturned jobs pending across bulk status polls (WALM-660)
…n-a-projects-fake-mcp-package-even-with-a-version

fix(mcp): launch the MCP server from a trusted absolute path (WALM-640)
…aller-deletes-unrelated-hooks-with-matching

fix(mcp): remove only MemWal's own codex hook entries (WALM-643)
Keep sanitized safeFacts echo from #961 and only show blob_id when status is done (from #959/#962 era dev).
…memory-lacks-consistent-secret-filtering-and-user

fix(mcp,server): one automatic-memory policy with write-side secret redaction (WALM-642)
* feat(sdk): send recall's deadline so the relayer can say where it stalled (WALM-396)

recall() now puts deadline_ms (15000, the same number it aborts at) in the
signed body. A relayer that reads it answers a recall about to miss that
deadline with a 504 RECALL_TIMEOUT naming the stuck step. Older relayers
ignore the unknown field, so deploy order does not matter; a header would
have needed the CORS allow-list first.

* feat(server): answer a recall about to miss its caller's deadline with the stuck stage (WALM-396)

When a recall carries deadline_ms, the handler runs its pipeline under a
budget one second shorter (2s floor, input capped at 600s) and, if that
runs out, answers 504 RECALL_TIMEOUT naming the stage it was in: embed,
vector_search, walrus_download or seal_decrypt. The stage lives in a
task-local marker, so fetch_batch keeps its signature and analyze, which
shares it, is unaffected.

Callers that send no deadline (Python, older SDKs) run to completion as
before. For every caller, a recall dropped before it finishes logs the
stage it was in, which is where a caller hanging up used to leave nothing.

* fix(mcp): name the cause of a timed-out tool call and check relayer health (WALM-396)

A recall the SDK gave up on reached the agent as 'Tool error: This
operation was aborted'. wrapTool now sorts failures: a relayer
RECALL_TIMEOUT names the stuck step with advice for it; an SDK timeout or a
failed connect probes the relayer's /health (2s) and reports the result.
Each message carries Cause / Relayer health / Next step. Writes are never
told a retry is safe. Every other error keeps its old wording.

* fix(mcp): check relayer health before answering a call whose reply was lost (WALM-396)

A sent call past its deadline used to be answered 'did not answer this
call ... safe to retry', which cannot tell a dead relayer from a wrong URL
from one stuck call. The sweeper now asks the relayer's /health first (3s,
MEMWAL_MCP_HEALTH_PROBE_MS) and answers with Cause / Relayer health / Next
step; writes keep their no-blind-retry text with the health line added.

Bookkeeping stays synchronous: the answer is written only if the entry is
still the one in flight when the probe settles, so a late reply, a logout
or a shutdown that answers it meanwhile wins. memwal_recall gets its own
90s ceiling, so a lost recall reply is answered at 2 minutes, not 4.

* fix(mcp,server): harden the health probe and stop blaming a healthy relayer (WALM-396)

Review follow-ups:

- A non-integer or huge MEMWAL_MCP_HEALTH_PROBE_MS made AbortSignal.timeout
  throw outside the probe's try, and the sweeper's chain had no catch: the
  first lost reply crashed the bridge. The signal is now built inside the
  try (bridge and sidecar), the value is floored and capped at 60s, and the
  sweeper answers even if the probe rejects.
- On SDK 0.1.7 the SEAL session is built on the Sui fullnode before the
  recall request, under the same 15s clock. A failure or stall there was
  reported as the relayer's. The relayer is now named only when its own
  /health also fails; otherwise the message says the relayer answered and
  names the host that failed when Node reports it.
- The recall deadline is measured from request arrival, so auth and rate
  limiting come out of the caller's budget rather than the 1s margin.
- The bridge probes once per sweep, not once per expired call, and writes
  nothing after stdin has closed. A panic no longer logs as a hang-up.
- /health's write_ready=false and writes=paused show in the health line.
- 'This call only reads' becomes 'cannot store a duplicate', which is also
  true of memwal_restore.

* fix(server,mcp): answer a spent deadline at once and keep the failed-write report off it (WALM-396)

Review follow-ups from #931:

- The 2s floor applied after subtracting time already spent, so a recall
  whose deadline went on auth still ran 2s and answered a caller that had
  given up. budget_for now returns Unbounded | Run | Exhausted: the floor
  applies to the caller's deadline only, and a spent deadline is answered
  at once with stage 'auth'.
- The failed-write report was awaited inside the deadline, so a stalled
  courtesy lookup could discard a finished recall and blame the last stage.
  recall joins it after the pipeline, bounded by the same deadline.
- The bridge's answering callback had no catch; a throw left the call
  marked probing and unanswered. It now clears probing so the next sweep
  answers.
- 'safe to retry once that is resolved' read as 'retry once'.
- Code comments keep a one-line why; ticket ids and history stay in the
  changelog.

* fix(mcp,sdk): keep a call being answered off the replay, and give the 504 room (WALM-396)

The sweeper marks a sent call whose reply is lost, then answers it once
`/health` comes back — but left the entry in `inFlight` for the whole probe.
A reconnect landing in that window replayed it, so a write with no
idempotency key ran a second time while the agent was told it never ran.
The entry is now flagged `orphaned` when the sweeper takes it, and
`reconnect()` skips those; the flag is never cleared, so the path where the
answer itself throws cannot let a replay back in either.

`recall()` sends `deadline_ms: 14000` against its own 15s abort, derived from
it so the two cannot drift. The relayer's 1s margin runs from request
arrival, so it pays for the reply's trip back but not for the connect the
SDK's timer has already been counting — on a cold TLS handshake the 504
landed after the abort it exists to beat.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Align SECURITY.md with MystenLabs/walrus (email security@mystenlabs.com).
Retarget issue templates from /security/advisories/new to /security/policy.
docs: add security policy and fix vulnerability-reporting 404
@harrymove-ctrl harrymove-ctrl mentioned this pull request Sep 22, 2026
3 tasks
@harrymove-ctrl
harrymove-ctrl deployed to benchmark-staging September 22, 2026 12:22 — with GitHub Actions Active
@harrymove-ctrl
harrymove-ctrl deployed to benchmark-staging September 22, 2026 12:22 — with GitHub Actions Active
@railway-app
railway-app Bot temporarily deployed to Walrus Memory / staging September 22, 2026 12:22 Inactive
@harrymove-ctrl
harrymove-ctrl merged commit 86adb30 into main Sep 22, 2026
48 of 53 checks passed

This branch was successfully deployed

1 active and 1 inactive deployments
Walrus Memory / staging — 8fa42bc7 Deployed Sep 22, 2026 by railway-app[bot]
benchmark-staging — 8fa42bc7 Deployed Sep 22, 2026 by harrymove-ctrl via SDK / E2E (live relayer) #54
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.

5 participants