Commit Graph

5 Commits

Author SHA1 Message Date
Hongming Wang
656a02fae4 fix(textutil): SSOT for rune-safe string truncation, fix 3 audit-gap bugs
Closes #2962.

## Why

Six per-package `truncate` helpers had drifted into independent
re-implementations of the same idea. Three of them (delegation.go,
memory/client/client.go, memory-backfill/verify.go) used
`s[:max] + "…"` byte-slice form, which on a multi-byte codepoint at
byte `max` produces invalid UTF-8 → Postgres `text`/`jsonb` rejects
the INSERT silently → `delegation` / `activity_logs` row never lands
→ audit gap.

Three other helpers (delegation_ledger.go #2962, agent_message_writer.go
#2959, scheduler.go #2026) had each been fixed in isolation with three
slightly different rune-safe shapes — confirming this is a class of
bug, not a single instance.

## What

New package `internal/textutil` with three rune-safe functions:

- `TruncateBytes(s, maxBytes)` — byte-cap, "…" marker. Used by 5
  callers writing into byte-bounded columns / log lines.
- `TruncateBytesNoMarker(s, maxBytes)` — byte-cap, no marker. Used by
  delegation_ledger.go where the storage already conveys "preview"
  and an extra ellipsis would push the result over the column cap.
- `TruncateRunes(s, maxRunes)` — rune-cap, "…" marker. Used by
  agent_message_writer.go where the cap is in display chars (UI
  summary), not bytes.

All three guarantee `utf8.ValidString(out)` for any `utf8.ValidString(in)`.
Inputs already invalid go through `sanitizeUTF8` at the call site
boundary (scheduler.go preserved this defense-in-depth).

## Migration map

| Old | New | Behavior change |
|---|---|---|
| `delegation_ledger.truncatePreview` | `textutil.TruncateBytesNoMarker(s, 4096)` | none |
| `agent_message_writer.truncatePreviewRunes` | `textutil.TruncateRunes(s, n)` | none |
| `scheduler.truncate` | `textutil.TruncateBytes(s, n)` | "..." → "…" (3 bytes either way; single-glyph display) |
| `delegation.truncate` | `textutil.TruncateBytes(s, n)` | bug fix + ellipsis swap |
| `memory/client.truncate` | `textutil.TruncateBytes(s, n)` | bug fix |
| `memory-backfill.truncate` | `textutil.TruncateBytes(s, n)` | bug fix |

Five separate `truncate*` helpers + their per-package tests removed.
Net: 12 files / +427 / -255.

## Tests

- `internal/textutil/truncate_test.go` — 27 table-test cases + 145
  fuzz-invariant cases asserting `utf8.ValidString` and byte-cap
  invariants on every output.
- `delegation_ledger_test.go TestLedgerInsert_TruncatesOversizedPreview`
  strengthened with `capValidUTF8Matcher` so the SQL-write argument
  is asserted to be valid UTF-8 + within cap (not just `AnyArg()`).
  Mutation-tested: replacing the SSOT call with byte-slice form makes
  this test fail loud.

## Compatibility

- All callers internal; no external API surface change.
- Ellipsis swap "..." → "…": same byte budget (3 bytes), single-glyph
  display. No alerting/grep on either marker in this codebase
  (verified). Canvas renders both correctly.
- DB column widths unchanged (4096 / 80 / 200 / 256 / 300 — all
  preserved in the migrations).

## Security

Fixes a silent INSERT-failure mode that hid `activity_logs` /
`delegations` rows containing peer-controlled text. The class of input
that triggered it (CJK, emoji, accented Latin) is normal user content,
not malicious — but the symptom (audit gap) makes incident
reconstruction harder. Helper is pure-function over `string`; no
secrets / PII / auth handling involved. Untrusted input is handled
identically to before, just rune-aligned now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:01:21 -07:00
Hongming Wang
6fc328ef44
Merge pull request #2747 from Molecule-AI/fix/memory-v2-c2-backfill-verify
Memory v2 fixup C2: backfill -verify mode (parity check)
2026-05-04 16:08:27 +00:00
Hongming Wang
4b6373861c Memory v2 fixup C2: backfill -verify mode (parity check)
Self-review missed deliverable from PR-7's task spec. Operators had
no way to confirm a -apply produced equivalent search results to the
legacy agent_memories direct queries; this PR ships that.

Usage:
  memory-backfill -verify                      # 50-workspace random sample
  memory-backfill -verify -verify-sample=200   # bigger sample
  memory-backfill -verify -workspace=<uuid>    # one specific workspace

Algorithm:
  1. Pick N random workspaces (or use -workspace if specified)
  2. For each: query agent_memories direct, query plugin search via
     the workspace's readable namespace list
  3. Multiset-compare contents: every legacy row must have a matching
     plugin row. Plugin having MORE rows is OK (team-shared content
     may be visible from sibling workspaces).
  4. Print mismatches with content excerpt; non-zero mismatches/errors
     yields a non-zero exit so CI can gate cutover.

Sql:
  - Sampling uses ORDER BY random() LIMIT N (TABLESAMPLE has surprising
    distribution at small populations).
  - Filters out status='removed' workspaces.

Test coverage:
  * pickWorkspaceSample: single-ws short-circuit, random sampling,
    query error, scan error
  * queryLegacyMemories: happy path, error path
  * verifyParity:
      - all match → 1 match, 0 mismatch
      - missing-from-plugin → 1 mismatch with content excerpt
      - plugin-extra rows → 1 match (legacy is subset of plugin)
      - legacy query error → 1 error counter
      - resolver error → 1 error counter
      - plugin search error → 1 error counter
      - no readable namespaces + empty legacy → match
      - no readable namespaces + non-empty legacy → mismatch
      - pickSample error → propagated up
  * CLI: -verify+-apply rejected as mutually exclusive; -verify alone
    is a valid mode

Note: namespaceResolverAdapter bridges *namespace.Resolver to the
verify package's verifyResolver interface so verify.go has zero
dependency on the namespace package — keeps test stubs minimal.
2026-05-04 09:01:31 -07:00
Hongming Wang
1e97fb9a16 Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
Self-review (post-merge) flagged that the backfill claimed to be
idempotent on re-run but actually duplicates every row because the
plugin's INSERT uses gen_random_uuid() and ignores any id passed in.

Fix is contract-level: extend MemoryWrite with an optional `id`
idempotency key. When supplied, the plugin MUST treat the write as
upsert keyed on this id; when omitted, the plugin generates a fresh
UUID (production agent commits keep working unchanged).

Changes:
  * docs/api-protocol/memory-plugin-v1.yaml: add id field with
    description that flags it as idempotency key
  * internal/memory/contract/contract.go: add ID to MemoryWrite struct,
    update memory_write_minimal golden vector
  * internal/memory/pgplugin/store.go: split CommitMemory into two
    paths — upsert when body.ID set (INSERT ... ON CONFLICT (id) DO
    UPDATE), plain INSERT otherwise
  * cmd/memory-backfill/main.go: pass agent_memories.id to MemoryWrite,
    fix the false comment about 409 deduplication

New tests:
  * pgplugin: TestCommitMemory_WithIDUpserts pins the upsert SQL is
    used when id is set; TestCommitMemory_UpsertScanError covers the
    error branch
  * backfill: TestBackfill_PassesSourceUUIDAsIdempotencyKey pins the
    forwarding behavior; TestBackfill_RerunIsIdempotent simulates a
    retry and asserts both runs pass the same uuid (plugin upsert is
    what makes this safe)

Why this matters: operators retrying a failed backfill (which they
will — networks fail, transactions abort) would otherwise create N
duplicates per memory. The duplicates aren't visible until search
results show obvious dupes — debugging that under prod load is bad.

Production agent commits are unaffected: they leave id empty, the
plugin generates a fresh UUID via gen_random_uuid(), zero behavior
change for the hot path.
2026-05-04 08:54:13 -07:00
Hongming Wang
c5322f318a Memory v2 PR-7: one-shot backfill CLI (dry-run + apply)
Builds on merged PR-1..6. Operator runs this once at cutover to copy
agent_memories rows into the v2 plugin's storage.

Usage:
  memory-backfill -dry-run                    # count + diff, no writes
  memory-backfill -apply                      # actually copy
  memory-backfill -apply -limit=10000         # cap rows per run
  memory-backfill -apply -workspace=<uuid>    # one workspace only

Required env: DATABASE_URL + MEMORY_PLUGIN_URL.

Translation matches the PR-6 legacy shim:
  LOCAL  → workspace:<workspace_id>
  TEAM   → team:<root_id> (resolved via the same namespace.Resolver
                           the runtime uses)
  GLOBAL → org:<root_id>

Idempotent: each row is keyed by its UUID; re-running the backfill
does not duplicate writes (plugin handles deduplication).

What ships:
  * cmd/memory-backfill/main.go: CLI entry, run() driver,
    backfill() workhorse, mapScopeToNamespace + namespaceKindFromString
    helpers
  * main_test.go: 100% on the functional logic (mapScopeToNamespace,
    namespaceKindFromString, backfill(), all CLI validation paths)

Coverage: 80.2% of statements. The 19.8% gap is main()'s body
(log.Fatalf — not unit-testable) and run()'s real-DB integration
(sql.Open + db.PingContext + new client/resolver — requires a live
postgres). Integration coverage for this path lives in PR-11
(E2E plugin-swap test).

Edge cases pinned (in functional logic):
  * Every legacy scope → namespace mapping
  * Unknown scope → skip with diagnostic, increment skipped counter
  * Resolver error → propagate, abort run
  * No-matching-kind in writable list → skip with error message
  * Plugin UpsertNamespace error → increment errors, continue
  * Plugin CommitMemory error → increment errors, continue
  * Query error → propagate, abort
  * Scan error → increment errors, continue
  * Mid-iteration row error → propagate, abort
  * Workspace filter passes through to SQL WHERE clause
  * Dry-run mode never calls plugin
  * CLI: rejects both/neither modes, missing env vars, bad flags
2026-05-04 08:04:07 -07:00