Skip to content

Promote staging to main - #614

Merged
harrymove-ctrl merged 117 commits into
mainfrom
staging
Aug 14, 2026
Merged

Promote staging to main#614
harrymove-ctrl merged 117 commits into
mainfrom
staging

Conversation

@ducnmm

@ducnmm ducnmm commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Prepare the current validated staging release for production promotion.

This promotion includes the work already reviewed and merged through dev → staging, including:

  • Claude custom-connector OAuth and MCP authorization hardening
  • Researcher, Noter, SEAL, and migration security remediations
  • bounded restore/provenance handling and restore truncation reporting
  • SDK manual recall/base64 fixes, deterministic offline mocks, and idempotent remember retries
  • chatbot authenticated-user memory isolation
  • manually prepared release versions and concise changelogs:
    • @mysten-incubation/memwal@0.1.2
    • Python memwal@0.1.7
    • @mysten-incubation/memwal-mcp@0.0.7

Staging evidence

  • dev → staging PR Promote dev to staging #612 merged as a8f8262afcf7b1aa667eed06ba2302751d47c2e7
  • staging QA, benchmark smoke/live, and SDK release workflows completed successfully
  • release candidates published:
    • TypeScript SDK 0.1.2-rc.0
    • Python SDK 0.1.7rc0
    • MCP 0.0.7-rc.0

Production gates

This PR is opened for preparation only. Do not merge until all gates are recorded on this PR:

  • Harry completes the staging completion checklist and posts evidence
  • staging OAuth discovery and RFC 9728 unauthenticated challenge are live
  • staging Claude connector login/consent, tools/list, representative remember/recall, session reuse, refresh rotation, and revoked-token rejection pass
  • required CI is green on the exact current head
  • an independent reviewer approves the exact current head
  • production deployment and rollback owners are confirmed

After merge, verify production OAuth discovery/challenge/tool calls and confirm stable package publication (latest / PyPI stable) before unblocking marketplace plugin PR CommandOSSLabs/walrus-memory-mcp-plugin#4.

ducnmm and others added 30 commits July 1, 2026 21:42
…as coins"

useSponsoredTransaction no longer falls back to direct self-pay when the Enoki sponsor step fails. On a gasless zkLogin wallet (0 SUI) that fallback always aborted in the Sui SDK gas resolver with "No valid gas coins found for the transaction.", masking the real cause (network blip, relayer 429/5xx, sponsor dry-run rejection).

Now every user — zkLogin and regular wallet — is gaslessly sponsored: retry the sponsor flow on transient failures (429/500/503/504/408 + network) with backoff, and surface the real reason otherwise. 502 (Enoki dry-run rejection, e.g. duplicate delegate key) fails fast. Public API unchanged.
…N-RPC sunset 2026-07-31)

For dev/testnet validation only. Config-gated and OFF by default (SUI_GRPC_URL
empty -> unchanged JSON-RPC behaviour), so this is a no-op until the env is set.

- config.ts: add SUI_GRPC_URL.
- clients.ts: shared write-path suiClient (Walrus/SEAL/Enoki build+certify) uses
  SuiGrpcClient when SUI_GRPC_URL is set, else JSON-RPC. Keep a dedicated
  suiJsonRpcClient for the query/restore path (getOwnedObjects /
  getDynamicFieldObject / suix_queryTransactionBlocks are JSON-RPC-only).
  getSuiBalanceMist now reads both {totalBalance} (JSON-RPC) and
  {balance:{balance}} (gRPC) shapes — found via live smoke test.
- walrus-query.ts: route index-method reads through suiJsonRpcClient.

NOT validated e2e: full Walrus register/certify/get_blob + Enoki tx.build + SEAL
decrypt over gRPC need a live relayer — that is what this dev deploy tests.
Query path + Rust side JSON-RPC are stage 2.
Under the gRPC client the Walrus certify upload failed at certify_sponsor with
'Transaction was not signed by the correct sender ... given owner/signer 0x0':
the SDK's certify tx is built without a sender (register sets its own via
setSenderIfNotSet, certify does not), and the gRPC client validates owned-object
inputs against the tx sender during build/resolution. Set the sender in the Enoki
sponsor path before tx.build; Enoki still sponsors with its own sender and
onlyTransactionKind excludes the sender from the bytes, so this is resolution-only
and a no-op on JSON-RPC. Surfaced by the dev testnet gRPC relayer test.
…371)

Require the requested key id to be namespaced to the account owner on both
the owner and delegate paths (the owner returns early). This binds `id` to
the passed `account`, so a delegate is only ever authorized for the data of
the account it is registered on.

Adds a regression test asserting a delegate is denied an id scoped to an
unrelated owner (aborts ENoAccess). 48/48 tests pass. Code-only change; not
deployed.
Two bugs found while verifying PR #355's SUI_GRPC_URL write path:

- enoki.ts direct-sign fallback read `direct.digest` assuming
  SuiJsonRpcClient's flat SuiTransactionBlockResponse shape. Under
  SuiGrpcClient, signAndExecuteTransaction resolves to the core-API
  union `{Transaction: {digest}} | {FailedTransaction: {digest}}` with
  no top-level `.digest`, so this silently returned undefined once
  SUI_GRPC_URL was set. Added extractTransactionDigest() to handle
  both shapes explicitly.

- retry/rpc.ts's isRetryableRpcError only matched JSON-RPC-style error
  text ("429", "503", "timeout"). gRPC errors from SuiGrpcClient carry
  a status code (RpcError.code) instead, so transient gRPC failures
  (UNAVAILABLE, RESOURCE_EXHAUSTED, DEADLINE_EXCEEDED, ABORTED) were
  not retried on the write path. Added a code-based check alongside
  the existing string matching.

Verified both fixes locally: sidecar boots clean on JSON-RPC and gRPC
(real mainnet fullnode), getSuiBalanceMist resolves identically on
both, and a simulated gRPC transient error now retries and recovers.
Not part of PR #355's own diff (services/server/scripts/sidecar/*) — these
are pre-existing bugs in apps/app surfaced only because testnet's public
JSON-RPC endpoint is returning HTTP 404 right now (confirmed live), ahead
of the documented 2026-07-31 sunset. @mysten/dapp-kit's SuiClientProvider
defaults every network to JSON-RPC, so every useSuiClient() consumer broke
identically once exercised against testnet.

- App.tsx: SuiClientProvider now uses a SuiGrpcClient for testnet via the
  createClient override (mainnet untouched, still JSON-RPC-healthy).
- SetupWizard.tsx / Dashboard.tsx: account/delegate-key lookups used
  JSON-RPC-shaped getObject/getDynamicFieldObject calls (RpcError:
  INVALID_ARGUMENT under gRPC). Rewritten for gRPC's flatter .json shape
  and base64-encoded public_key (both verified live against real testnet
  objects, not guessed from docs).
- useSponsoredTransaction.ts: the direct-sign fallback reused the same
  Transaction object already .build()'d with onlyTransactionKind:true (no
  sender attached), causing wrong-sender execution (ENotOwner on-chain,
  reproduced across two different wallets). Now rebuilds via
  Transaction.fromKind(). Also added an execute() override since dapp-kit's
  default calls the JSON-RPC-only client.executeTransactionBlock(), which
  doesn't exist on SuiGrpcClient (core API only has executeTransaction(),
  different response shape).
- vite.config.ts: dev-only proxy for /api, /health, /version, /config,
  /sponsor to the real dev backend — its CORS allowlist has no localhost
  entry, so local testing needs a same-origin proxy (no path rewrite, so
  signed-request signatures stay valid).
Real, live incident: testnet's public JSON-RPC endpoint (fullnode.testnet.sui.io)
already returns HTTP 404 today, ahead of the documented 2026-07-31 sunset.
verify_delegate_key_onchain (storage/sui.rs) called this endpoint directly via
raw sui_getObject JSON-RPC for every signed request's account resolution. Any
failure there — including a dead endpoint — gets mapped to a uniform 401 by
auth.rs's constant_time_reject() (deliberate: prevents timing oracles between
"wrong key" and "account not found"), so the real cause was indistinguishable
from bad credentials. In practice this means relayer.dev.memwal.ai currently
cannot authenticate any signed request on testnet, regardless of whether the
caller's account/delegate key is correct.

This is the same JSON-RPC sunset PR #355 addresses for the sidecar's Walrus
write path — just in the Rust backend's auth path, which #355 didn't touch.

- Config: add SUI_GRPC_URL (opt-in, mirrors the sidecar/web-app pattern —
  empty keeps the existing JSON-RPC behavior unchanged).
- storage/sui.rs: verify_delegate_key_onchain now branches to a gRPC
  implementation (LedgerService.GetObject via the sui-rpc crate) when
  SUI_GRPC_URL is set. gRPC's object.json representation is flatter than
  JSON-RPC's parsed .fields shape and encodes delegate key public_key as
  base64 (not a byte array) — verified against real, live testnet objects
  with a real registered delegate key (not guessed from docs, which for
  this crate were largely unhelpful — see the two live #[ignore] tests).
- find_account_by_delegate_key (registry-scan fallback, used only when the
  caller sends no x-account-id hint) stays JSON-RPC-only: gRPC has no
  single-key dynamic-field lookup, only paginated ListDynamicFields, and
  modern SDKs always send the account-id hint, which resolve_account's
  Strategy 2 checks first — that's the path this fix actually needed to
  cover for the live incident.

Verified: cargo check clean, cargo clippy clean (no new warnings), full test
suite passes (294/299 — the 5 failures are pre-existing, unrelated jobs.rs
tests needing a local Postgres connection, not present in this environment).
The two new #[ignore] tests hit real testnet gRPC and pass.
Code review of the prior frontend fix (e89788c) found it introduced a real
regression: SetupWizard.tsx and Dashboard.tsx were rewritten to call gRPC's
getObject/getDynamicField shapes unconditionally, but App.tsx's
createClientForNetwork only swaps testnet to SuiGrpcClient — mainnet still
gets SuiJsonRpcClient. Those two files would have broken on mainnet (wrong
getObject params, and getDynamicField doesn't exist on SuiJsonRpcClient at
all). Separately, ConnectMcp.tsx was missed entirely and still used the
original JSON-RPC-only shape, breaking on testnet (the default network) —
the exact bug class this whole fix line was meant to close, just in a
fourth file nobody looked at.

Replaced all three call sites with shared helpers in the new
utils/suiClientCompat.ts that duck-type the client (`typeof
client.getDynamicField === 'function'`) and branch accordingly, instead of
assuming one transport. fetchObjectJson's JSON-RPC branch recursively
unwraps Move's {fields: {...}} struct envelopes so both transports return
the same flat shape to callers — matches gRPC's .json representation at
every nesting level, not just the top one. publicKeyToHex handles both
delegate-key encodings (JSON-RPC's number[], gRPC's base64 string).

Verified: tsc --noEmit clean, dev server HMR-reloaded all three files with
no runtime errors.
Both were explicitly marked "TEMPORARY LOCAL PATCH (not for upstream)" —
resolving that before this could reasonably be considered for merge.

- config.ts / App.tsx: VITE_SUI_GRPC_URL (opt-in, empty keeps JSON-RPC
  unchanged) replaces the hardcoded testnet-only fullnode URL.
  createClientForNetwork now applies to whichever network is actually
  configured (config.suiNetwork), not a hardcoded 'testnet' string.
- vite.config.ts: DEV_BACKEND_PROXY_TARGET (opt-in, unset by default — no
  proxy configured, matching upstream's actual behavior) replaces the
  hardcoded relayer.dev.memwal.ai target. Uses loadEnv() since vite.config.ts
  needs .env.local values in its own Node-side execution, not just the
  client bundle.

Verified: tsc --noEmit clean, dev server boots clean with the new env-driven
config (.env.local updated to set VITE_SUI_GRPC_URL explicitly).
Four parallel cleanup reviews (reuse, simplification, efficiency, altitude)
over the fix commits; net -21 lines. Fixes applied:

- [efficiency, HIGH] services/server: sui_rpc::Client was constructed on
  every auth verification — sui_rpc's Client::new parses the OS root-cert
  store and opens a fresh TLS channel per call, on the per-request auth hot
  path, the opposite of how the JSON-RPC path reuses the pooled
  reqwest::Client. Now built once at startup into AppState (fails fast on a
  bad SUI_GRPC_URL) and cloned per request — clones share the tonic channel.
  Both live testnet gRPC tests still pass against the refactored path.
- [efficiency] suiClientCompat.fetchAccountIdForOwner: the registry's inner
  Table ID is an immutable on-chain constant but was re-fetched on every
  account lookup — now memoized per registryId, halving round trips on
  repeat lookups.
- [reuse] suiClientCompat: dropped all hand-rolled hex/base64 encoders for
  toHex/fromHex/fromBase64/normalizeSuiAddress from @mysten/sui/utils
  (already a dependency).
- [reuse+altitude] useSponsoredTransaction: executeTransactionCompat moved
  into suiClientCompat next to the other transport-compat helpers, so the
  app has one shared isGrpcClient discriminator instead of two divergent
  method-presence heuristics in two files, and one base64 decode instead of
  a second inline atob copy.
- [simplification] deleted apps/app/src/utils/suiFields.ts — dead after the
  compat refactor removed its last importer, and its typed shapes had
  already drifted from the real on-chain encodings (public_key: number[]
  only, while gRPC returns base64 strings).
- [review nits] fixed the stale "testnet-only" header comment in
  suiClientCompat; publicKeyToHex now warns on an unrecognized encoding
  instead of silently returning ''.

Skipped (reviewed, deliberately not changed): macro-consolidating the four
3-line grpc_value_as_* accessors (idiomatic as-is); a transport trait for
verify_delegate_key_onchain (no such convention in storage/*, and the
registry-scan fallback genuinely needs per-call-site transport choice);
page-local bytesToHex/hexToBytes copies still in live use for key
derivation (pre-existing, out of diff scope).

Verified: cargo check --tests clean, clippy no new warnings, live gRPC
tests pass, tsc --noEmit clean, dev server HMR-reloads without errors.
… has 1.88)

Railway build failure: sui-sdk-types (transitive via sui-rpc) pulled in
roaring 0.11.4, which requires rustc >=1.90.0. The Dockerfile pins
rust:1.88-bookworm, so the build failed at `cargo build --release` before
reaching any of our own code:

  error: rustc 1.88.0 is not supported by the following package:
    roaring@0.11.4 requires rustc 1.90.0

0.11.3 requires rustc 1.82.0 (compatible) and is otherwise identical for our
purposes (roaring is a transitive dep neither our code nor sui-rpc/
sui-sdk-types code paths we hit exercise directly).

Not verifiable via local `docker build` on this machine: the pinned
linux/amd64 base image is emulated via QEMU on Apple Silicon, and QEMU
itself segfaults on rustc invocation before any dependency compiles
(pre-existing environment issue, unrelated to this fix — same crash occurs
on the pre-fix Cargo.lock too). `cargo update -p roaring --precise 0.11.3`
+ `cargo check` locally (rustc 1.92) confirms the resolution is valid; the
actual rustc-1.88 constraint can only be verified by Railway's real amd64
build.
Dockerfile only declared ARG/ENV for a fixed list of VITE_ vars, so
Railway's VITE_SUI_GRPC_URL service variable never reached the Vite
build — the bundle always baked in an empty value and silently fell
back to the dead JSON-RPC client.
…ontent

docs: remove Seal encryption claims from Walrus Memory overview
The restore path still called suix_queryTransactionBlocks and
getDynamicFieldObject over JSON-RPC, which testnet no longer serves —
POST /api/restore 500'd with 'returned non-JSON (404)'. The stage-2
GraphQL note is obsolete: the current SDK's gRPC client covers this
with listOwnedObjects (server-side type filter + json content) and
getDynamicField.

With SUI_GRPC_URL set, one paginated listOwnedObjects call replaces
both the transaction scan and multiGetObjects, and blob metadata is
BCS-decoded from getDynamicField (Metadata = VecMap<String,String>).
Unset, the original JSON-RPC path still runs — same reversible opt-in
as the write path.

Verified against live testnet gRPC: real account returns all blobs
with correct memwal_* metadata; namespace filtering, full-scan and
empty-owner paths OK; 78/78 unit tests pass.
feat(relayer): gRPC write path for the JSON-RPC sunset (2026-07-31)
Resolve conflict in useSponsoredTransaction.ts: keep the no-fallback retry
version. dev's changes to this file (executeTransactionCompat wiring and the
Transaction.fromKind fallback rebuild from the gRPC migration) only served
the self-pay fallback path this PR deletes, so they are dropped along with
the now-dead executeTransactionCompat helper and its stale App.tsx comment
reference.
…llback

fix(app): sponsor all txs, drop self-pay fallback masking "No valid gas coins"
…mories

docs: add guide for deleting old memories
Remove the Guides tab (which only contained the delete-old-memories
guide) from the docs navigation so the page is no longer surfaced in
the site. The guide's markdown file is kept in place so the content is
preserved and can be re-enabled by restoring the navigation entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ARRUn69UZpxyvaFFUfuQ
…de-3hfo7a

docs: hide delete-old-memories guide from navigation
Reverse of the navigation change that hid the Delete old memories
guide: re-adds the Guides tab so the page is surfaced again. Also
renames the "At risk" memory status to "Stored".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ARRUn69UZpxyvaFFUfuQ
Add a new "Delete memories programmatically" guide covering the
Security Delete API flow (challenge/verify auth, list deletable blobs,
prepare sponsored deletion, submit signed transaction), and list it in
the Guides tab alongside the dashboard guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ARRUn69UZpxyvaFFUfuQ
Remove the sentence linking to /api/security-delete, which does not
exist yet, to avoid a broken link. Keep the cross-link to the dashboard
guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4ARRUn69UZpxyvaFFUfuQ
…guide-3hfo7a

docs: restore delete-old-memories guide
Rename the SDK tab to "TypeScript SDK" (paralleling the Python SDK tab)
and restructure its sidebar into a logical progression:
intro -> get started -> usage -> advanced -> recipes -> examples -> reference.

Surface the existing but previously unlisted pages (overview, advanced
usage, @ai-sdk integration, example map, examples, research app example)
and move reference material (API Reference, Changelog) to the end so the
section reads top to bottom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTyNVbzD3xVivzLTARZNdY
…nxzl3

docs: reorder TypeScript SDK nav for better flow
…ontend

The environment variables page only documented the self-hosted relayer,
so developers integrating the client SDK or MCP server had no reference
for the MEMWAL_* names the examples use. This adds Client SDK, MCP
server, and frontend app sections, clarifies that the SDK reads a config
object rather than env vars automatically, and notes that
MEMWAL_PRIVATE_KEY and MEMWAL_KEY are the same delegate key.

Fixes BEDU-775
- MEMWAL_SERVER_URL: split TS (relayer.memwal.ai) vs Python (localhost:8000
  unless env=prod) defaults instead of a single default
- OPENAI_API_KEY/OPENAI_BASE_URL: scope to the MemWalManual client and
  optional middleware; the standard MemWal and Python SDKs let the relayer
  handle embeddings
- MEMWAL_CLIENT_LABEL: default is 'MCP Client' / 'Walrus Memory MCP'
- frontend: add VITE_MEMWAL_SERVER_URL and the NEXT_PUBLIC_MEMWAL_*
  package/registry/server-url variables
@ducnmm
ducnmm requested a review from harrymove-ctrl August 13, 2026 06:15
@ducnmm

ducnmm commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@harrymove-ctrl This production promotion PR is preparation-only. Please complete and post the staging checklist evidence first. Do not approve/merge until staging OAuth discovery, RFC 9728 challenge, Claude connector tool calls, refresh/revocation checks, and exact-head CI all pass.

hien-p and others added 12 commits August 13, 2026 15:24
execute_durable_upload's error path returned an unclassified
WalletJobError::Transient without ever writing to remember_jobs — every
other WalletOperation arm in execute_wallet_job classifies its sidecar
errors and updates the row on failure, but the UploadAndTransfer path
just propagated the bare error straight through.

With no terminal (or even intermediate) state ever persisted, a job
whose durable-upload step failed or timed out sat at status='running'
until the unrelated 10-minute staleness sweep force-failed it — up to
~25 minutes (MAX_ATTEMPTS retries at the sidecar's 300s timeout) with
no visibility into why. Route the failure through
classify_sidecar_error + update_remember_job_after_wallet_error,
mirroring the pattern already used for SetMetadataAndTransfer and the
base64-decode-failure case a few lines above this call site.
…urning the attempt budget in ~124ms

execute_upload_and_transfer's per-job advisory-lock defer path (lock_outcome
Defer) returned a bare Transient error, relying on a comment's assumption
that 'Apalis re-queues with exponential backoff' — no WorkerBuilder in
main.rs actually attaches a retry/backoff layer, so the re-queue has no
delay. Observed on staging: all 5 attempts of job 355df946 exhausted in
~124ms, well before the actual lock-holding attempt (a real Walrus upload)
had any chance to finish, leaving remember_jobs stuck at status='running'
with no error_msg until the unrelated 10-minute staleness sweep force-failed
it.

This is a separate code path from the one e079598 fixed: that patch wraps
execute_durable_upload from inside execute_upload_and_transfer_locked, but
the lock-defer return happens one level up, in execute_upload_and_transfer,
before _locked is ever called — so it never went through that patch.

Wires up the exponential backoff_duration() that already existed (dead code)
for this purpose, and persists an error_msg on every deferred attempt so the
row is not silently blank while it waits.

Compiled clean (cargo check --bin memwal-server); 55/55 jobs.rs tests pass including a new lock_defer_backoff_matches_documented_schedule regression test.
…quest.url's own host

isSameOriginRequest compared the Origin header against new URL(request.url)
.origin. Behind Railway's proxy, request.url reflects whatever internal host
the standalone Next.js server constructed its request object from, not the
public domain the browser's Origin header names — confirmed on staging by
sending a POST to /api/auth/enoki/challenge with Origin set to the exact
public origin (https://researcher-demo-staging.memory.walrus.xyz) and still
getting 403 'Invalid request origin'. This blocked all 4 auth routes
(enoki/challenge, enoki, key, signout) for every login method.

Host/X-Forwarded-Host are the two headers a reverse proxy is expected to
forward, so comparing Origin against those instead is proxy-topology
independent — the standard OWASP CSRF 'verify Origin against Host' pattern.

6/6 researcher unit tests pass (test:unit), including two new regression tests reproducing the exact bug pattern (request.url host diverging from the forwarded Host).
… response

Isolated via controlled A/B reproduction on chatbot-demo-staging: a brand-new
chat (titlePromise set, since generateTitleFromUserMessage only runs for new
chats) always produced a premature UI-message-stream 'error' part right after
'start', before the real answer streamed. The identical prompt in an existing
chat (titlePromise null) never did. Toggling Memory on/off made no difference
either way, since generateTitleFromUserMessage is unrelated to useMemWal —
explains why the earlier report couldn't tie this to the memory layer.

'await titlePromise' was unguarded, so a title-model failure (actions.ts's
generateText call, model 'google/gemini-2.0-flash-001') threw straight into
createUIMessageStream's onError, which mislabeled a non-fatal, unrelated
title-generation failure as a fatal chat error — even though the real answer
above it (dataStream.merge(result.toUIMessageStream(...))) kept streaming
successfully underneath. Wraps the title block in its own try/catch: a title
failure now just means the chat has no auto-generated title, not a crash.

Also added console.error logging to onError itself, which previously logged
nothing — the only reason this took live A/B reproduction instead of a
one-line log read.

tsc --noEmit clean (no errors in the changed file; two remaining errors are pre-existing, unrelated to a workspace package not built in this filtered install).
…freshness-automation-broader

ci: add docs freshness check (BEDU-287)
…tence

fix(remember,researcher): lock-contention backoff, error persistence, and same-origin check
… winner already finished

Found by an independent re-review of PR #615 after ducnmm's fix landed —
not caught by that review since it targeted a different code path.

The lock-contention backoff commit (61fe781) added a call to
update_remember_job_after_wallet_error() on the LockOutcome::Defer path —
the one call site that runs specifically when this worker does NOT hold
the per-job advisory lock, i.e. exactly while another attempt of the same
job may be concurrently writing 'uploaded' (persist_uploaded_state) or
'done' (insert_vector_and_mark_remember_done). Every other call site of
this helper only ever runs while holding the lock, so this race window
did not previously exist.

update_remember_job_after_wallet_error's UPDATE had no WHERE-clause status
guard. If the lock-losing worker's write landed after the winner's, it
could stamp a genuinely-succeeding row back to status='running' with a
stale 'another attempt of upload job X is in progress' error_msg — no data
loss (blob_id/blob_object_id are untouched), but GET /api/remember/:job_id
would show a misleadingly stuck row to a polling client until the
unrelated 10-minute staleness sweep caught it, on exactly the job's last
retry in the worst case.

Adds 'AND status NOT IN (\'uploaded\', \'done\')' to the UPDATE. Also
refactors the function to take &sqlx::PgPool instead of &AppState (it only
ever used state.db.pool(); matches every sibling helper in this file, e.g.
persist_uploaded_state) so it can be exercised directly in a test instead
of only indirectly through heavier integration paths. Also fixes a related
cosmetic inconsistency the same re-review caught: the Defer branch was the
only call site persisting the Display-prefixed error text (err.to_string())
instead of the raw message every other site uses (err.message()).

Adds wallet_error_persist_does_not_clobber_a_row_a_concurrent_attempt_already_finished — inserts a row at status='uploaded', calls the function with a Transient error, asserts the row is untouched. 57/57 jobs.rs tests pass.
…ing prompt

Found by an independent re-review of PR #615. This PR's own title-generation
fix (334cf91) added the first console.error call this route ever made on
these two paths (execute() errors previously weren't logged at all), and
logged the raw error object in both places.

Errors reaching either site are realistically AI SDK APICallError-shaped
(thrown by the underlying OpenRouter provider call, or the title model's
generateText call) and carry requestBodyValues/responseBody as own
enumerable properties — the actual outgoing request body. console.error
prints an Error's extra own properties alongside its message, so logging
the raw object put the full prompt into server logs on every upstream
provider hiccup (rate limit, 5xx, timeout) — for the main chat path,
that's the whole conversation plus whatever memory content withMemWal's
transformParams injected via recall. Not a credential leak (OPENROUTER_API_KEY
and MEMWAL_PRIVATE_KEY/memwalKey never reach this object — save-memory.ts's
tool and withMemWal's own recall/save paths already catch their own errors
internally), but a real conversation/PII-into-logs exposure for a product
whose stated purpose is storing users' personal memories.

Adds summarizeErrorForLogging(): name/message/statusCode only, never the
raw object. Applied to both the onError handler and the title-generation
catch this same PR added.

tsc --noEmit clean.
Both renewal notes named Oyster as the component that extends storage for
console uploads. Oyster appears nowhere in the published docs, its own
documentation tickets are still open, and Walrus Console remains a closed
invite-only beta, so merging would have made this the first public
mention of an internal component ahead of the product it belongs to.

The user-facing behavior is unchanged and still stated: assets uploaded
through the console renew automatically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoBWxqLzd9hAxrhoqv3iSv
docs: Walrus Memory lifecycle and console guides (BEDU-904)
…tence

fix(remember,chatbot): lock-contention status clobber + error-log PII exposure
@ducnmm
ducnmm deployed to benchmark-staging August 14, 2026 03:09 — with GitHub Actions Active
@railway-app
railway-app Bot temporarily deployed to Walrus Memory / staging August 14, 2026 03:09 Inactive
@harrymove-ctrl
harrymove-ctrl deployed to benchmark-staging August 14, 2026 08:06 — with GitHub Actions Active
@railway-app
railway-app Bot temporarily deployed to Walrus Memory / staging August 14, 2026 08:06 Inactive
@ducnmm
ducnmm requested a review from nikola0x0 August 14, 2026 08:12
@harrymove-ctrl
harrymove-ctrl merged commit b59020e into main Aug 14, 2026
30 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.

6 participants