Skip to content

fix(postgres): scrub every byte class Postgres refuses, at every bind site (#411) - #453

Merged
jphein merged 2 commits into
mainfrom
fix/411-postgres-unstorable-bytes
Sep 11, 2026
Merged

jphein merged 2 commits into
mainfrom
fix/411-postgres-unstorable-bytes

Conversation

@jphein

@jphein jphein commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What

backends/postgres.py now scrubs both byte classes Postgres refuses, at all four bind sites, over nested metadata and ids — not just top-level NULs in documents.

Why

backends/pgvector.py has stripped NUL (upstream MemPalace#1829) and lone UTF-16 surrogates (upstream MemPalace#1833) before binding since those landed, because one stray byte anywhere in a mined corpus aborts the whole batch. postgres.py only ever grew the NUL half (#417), and only over documents and top-level metadata values. Three ways a single transcript still took down a mine survived:

  1. Lone surrogates were never handled at all. psycopg cannot UTF-8-encode the parameter, and json.dumps escapes it to a \udXXX sequence that the ::jsonb cast rejects as an unsupported Unicode escape.
  2. Nested metadata was never walked. _replace_nul_bytes looked at top-level str values only, so a NUL one level down inside a list, a sub-dict, or a dict key serialized to an escape and aborted the batch exactly as an unscrubbed top-level one would.
  3. ids were never scrubbed, though they bind into a text column as the ON CONFLICT key.

The measured incident

The issue was filed off a carried local patch: mining arbitrary conversation transcripts reliably encounters both byte classes, and on the postgres backend a single occurrence raises and takes down the whole batch — so one bad byte blocks ingestion until the offending source is found by hand, where the pgvector backend would have absorbed it. The 21 new tests reproduce every surviving route in-memory; each was watched failing against main before the fix went in (the get/delete pair was re-verified red by reverting just that call).

How

_replace_nul_bytes is widened into _scrub_unstorable(documents=, ids=, metadatas=):

  • fix(postgres): replace NUL bytes at the write boundary instead of crashing the mine #417's contract is kept exactly — U+FFFD substitution plus a counted nul_bytes_replaced in the drawer's metadata, so the substitution stays provenanced and never silent — and the symmetric lone_surrogates_replaced is added. Counts describe the document, which is the verbatim payload; metadata is scrubbed too but is bookkeeping, not the user's words.
  • Both substitutions are one character wide, so the scrub is length-preserving and the surrogate count falls out of a zip-diff rather than a second regex.
  • It runs at all four bind sites, not only add/upsert: update() serializes metadata into its own ::jsonb cast, and get()/delete() bind ids into text comparisons. Scrubbing writes alone would have filed a drawer under an id its own caller could no longer look up — a new unreachable-drawer state, so the read seams are part of the fix rather than scope creep.

On "reuse the helper so the two backends can't drift again"

The issue asks for a shared helper. pgvector.py is byte-identical to upstream/develop (git diff upstream/develop -- mempalace/backends/pgvector.py is empty) and postgres.py is fork-only — so importing a shared helper into pgvector would trade this drift risk for a guaranteed conflict on every future upstream sync of a file we currently carry clean.

The drift is caught instead by a parity test driven by pgvector's own sanitizers: for eight hostile strings, _strip_nul and strip_lone_surrogates must both find nothing left to do after our scrub. That fails if either backend grows a class the other lacks, in either direction, and costs no upstream divergence. The substitution policies differ on purpose (pgvector deletes the byte; this fork replaces and counts it, per #417), so the compared invariant is storability, not equality.

Tests

tests/test_postgres_unstorable_bytes.py — 21 cases:

  • lone surrogate in a document, replaced and counted
  • lone surrogate in a metadata value
  • valid astral surrogate pairs left intact (emoji must not be mangled)
  • ids scrubbed for both classes
  • nested metadata: list items, sub-dict values, and dict keys
  • non-string JSON scalars pass through with types intact
  • the fix(postgres): replace NUL bytes at the write boundary instead of crashing the mine #417 NUL contract preserved verbatim
  • clean input gains no provenance keys
  • the four bind sites actually call it (add, update, get, delete, asserted on the bound parameters through a fake connection)
  • 8-case pgvector parity sweep

tests/test_postgres_nul_bytes.py updated to the new helper name; #417's two cases kept as-is.

Full suite: 6815 passed, 82 skipped, 115 deselected. (5 pre-existing test_init_filters_sys_path_from_leaked_pythonpath failures reproduce on the unmodified branch in any git worktree — filed separately, unrelated to this change.)

ruff check and ruff format --check clean; scripts/check-docs.sh green after all three renderers.

Part of #411

jphein and others added 2 commits September 10, 2026 17:22
… site

pgvector.py has stripped both unstorable classes since MemPalace#1829/MemPalace#1833 — NUL and
lone UTF-16 surrogates — because one stray byte in a mined transcript aborts
the whole batch. postgres.py only ever grew the NUL half (#417), and only over
documents and top-level metadata values. Three gaps remained, each of which
still takes down a mine:

- lone surrogates were never handled at all (psycopg cannot UTF-8-encode the
  parameter; json.dumps escapes it to a \udXXX sequence and the ::jsonb cast
  rejects it);
- nested metadata was never walked, so a NUL one level down inside a list or
  a sub-dict serialized to a NUL escape and aborted the batch exactly as a
  top-level one would — dict keys too;
- ids were never scrubbed, though they bind into a text column.

_replace_nul_bytes is widened into _scrub_unstorable(documents=, ids=,
metadatas=), which keeps #417's contract exactly — U+FFFD substitution plus a
counted nul_bytes_replaced so the change is provenanced, never silent — and
adds the symmetric lone_surrogates_replaced. The scrub runs at all four bind
sites, not just add/upsert: update() serializes metadata into its own ::jsonb
cast, and get()/delete() bind ids into text comparisons, so scrubbing writes
alone would have stored a drawer under an id its own caller could no longer
look up.

pgvector.py is byte-identical to upstream/develop and stays that way — sharing
a helper would make every future upstream sync conflict on it. The drift the
issue is worried about is caught instead by a parity test driven by pgvector's
own sanitizers: after our scrub, _strip_nul and strip_lone_surrogates must
both find nothing left to do. Substitution policy differs on purpose (pgvector
deletes the byte, this fork replaces and counts it), so the invariant compared
is storability, not equality.

Tests: tests/test_postgres_unstorable_bytes.py, 21 cases — surrogate
replacement + counting, valid astral pairs left intact, id scrub, nested
metadata and metadata keys, non-string scalars unchanged, the #417 contract
preserved, clean input gaining no provenance keys, the four bind sites
actually calling it, and the 8-case pgvector parity sweep.

Part of #411
Fixes #411

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entry + the three renderers, plus the README/CLAUDE test count moved
6794 -> 6815 for the 21 new cases.

Part of #411

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 11, 2026 00:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 57 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 3b4fb79d-a207-4ade-9ea5-589660e0a15e

📥 Commits

Reviewing files that changed from the base of the PR and between 1a61cec and 251c3bd.

📒 Files selected for processing (8)
  • CLAUDE.md
  • FORK_CHANGELOG.md
  • README.md
  • docs/fork-changes.yaml
  • mempalace/backends/postgres.py
  • tests/test_postgres_nul_bytes.py
  • tests/test_postgres_unstorable_bytes.py
  • website/public/llms-full.txt

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jphein
jphein merged commit 4963eda into main Sep 11, 2026
14 of 17 checks passed
@jphein
jphein deleted the fix/411-postgres-unstorable-bytes branch September 11, 2026 00:44
jphein added a commit that referenced this pull request Sep 11, 2026
Entry + the three renderers, plus the README/CLAUDE test count moved
6815 -> 6822 for the 7 new cases. Rebased onto main after #453 landed.

Part of #385

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
main is red: the bind-site tests inherited from #453 import psycopg
unconditionally and the test-linux job installs mempalace without the
postgres extra. Carried here so this branch's CI is green before the two land
in either order; the duplicate drops cleanly on rebase once #464 merges.

Part of #413

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Post-rebase fixups, no prose changes:
- fork-changes entry commit hash 19b84d0 -> fbc5d34 (the rebase rewrote it;
  a stale hash resolves via git cat-file but points off-branch).
- test count re-derived on the rebased tree: 6815 -> 6876 in README.md and
  CLAUDE.md (#453 landed 21, this branch adds 61).
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…bsent (#464)

main is red. The four bind-site tests added in #453 call real collection
methods that compose SQL through psycopg, and the fixture imported psycopg
unconditionally. The test-linux CI job installs mempalace without the
postgres extra, so all three matrix legs failed with:

    ModuleNotFoundError: No module named 'psycopg'
    tests/test_postgres_unstorable_bytes.py:202: in _real_sql

It passed locally because this workstation's venv has the extra installed --
the gap is between the dev environment and the CI matrix, not between
branches, which is why the PR run looked clean enough to merge.

pytest.importorskip on psycopg.sql skips exactly those four and leaves the
other seventeen running everywhere. That is the right split: the scrub
behaviour under test is pure string/dict logic and needs no driver; only the
"did this actually reach the bind" assertions do, and test-postgres covers
that leg with the extra installed.

Verified the mechanism directly -- importorskip inside a fixture yields a
skip, not a collection error. The shadow-module trick normally used to prove
this cannot work in this repo: mempalace/__init__ filters leaked PYTHONPATH
entries out of sys.path (the behaviour #454 is about), so a fake psycopg
placed on PYTHONPATH is removed before the import is attempted.

Part of #411

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Entry + the three renderers, plus the README/CLAUDE test count moved
6815 -> 6822 for the 7 new cases. Rebased onto main after #453 and #464
landed; the entry's commit hash tracks the rebased commit so check-docs's
hash-resolution check stays green.

Part of #385

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Post-rebase fixups, no prose changes:
- fork-changes entry commit hash 19b84d0 -> fbc5d34 (the rebase rewrote it;
  a stale hash resolves via git cat-file but points off-branch).
- test count re-derived on the rebased tree: 6815 -> 6876 in README.md and
  CLAUDE.md (#453 landed 21, this branch adds 61).
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Entry + the three renderers, plus the README/CLAUDE test count moved
6815 -> 6821 for the 6 new cases. Rebased onto main after #453 and #464
landed; the entry's commit hash tracks the rebased commit so check-docs's
hash-resolution check stays green.

Part of #413

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Post-rebase fixups, no prose changes:
- fork-changes entry commit hash 19b84d0 -> fbc5d34 (the rebase rewrote it;
  a stale hash resolves via git cat-file but points off-branch).
- test count re-derived on the rebased tree: 6815 -> 6876 in README.md and
  CLAUDE.md (#453 landed 21, this branch adds 61).
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Entry + the three renderers, plus the README/CLAUDE test count moved
6815 -> 6822 for the 7 new cases. Rebased onto main after #453 and #464
landed; the entry's commit hash tracks the rebased commit so check-docs's
hash-resolution check stays green.

The entry's retry-safety paragraph carries the reviewer's correction with
the code: a connection error does not imply the statement never ran, so the
justification is the per-statement idempotency invariant, not autocommit.

Part of #385

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…it (#452)

* feat(search): source_kind + source_stale provenance on every search hit

A palace search returns the indexed copy of whatever was mined, and
transcripts are the only thing mined continuously (Stop / PreCompact
hooks) — curated project documents are re-indexed only when someone runs
`mempalace mine` by hand. So the copy that comes back for a project fact
is usually a session transcript quoting a claim, never the document that
later corrected it, and nothing on the hit said which of the two the
reader was holding.

Measured: 2g/CLAUDE.md added `FIVE HANDSETS REFUSE THIS NETWORK` on
2026-09-03 and a REFUTED banner on 2026-09-05; the indexed card is from
2026-09-01 and contains neither. A wing-wide keyword query at limit 40
returned 21 hits — 20 .jsonl, 1 diary, 0 CLAUDE.md. Two peer sessions
read the transcript copy as current.

New `mempalace/provenance.py` — pure predicates, one os.stat:

  source_kind(hit)      transcript | memory | diary | file | unknown
  source_stale(hit)     True/False/None; None means undecidable, which
                        is NOT a denial — a daemon hit carries a
                        basename only, so it can never be located
  annotate(results)     stamps both in place; idempotent, never raises
  provenance_note(hit)  the human caveat
  all_transcript()      / no_curated_source() for the header line

Wired into every surface the fleet reads: the CLI's `_daemon_search_fast`
/ `_daemon_search_hybrid` / MCP-envelope routes, all three `searcher`
result-assembly sites (so MCP `mempalace_search` carries the fields from
the daemon host), and the table / compact / header renderers. JSON output
carries `source_kind` and, where decidable, `source_stale` +
`source_indexed_at`.

`auto_query.runner._is_curated` now delegates to `source_kind(...) ==
"memory"` — the ranking that prefers curated hits and the caveat the CLI
prints must not be able to disagree about which hits are curated.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): search source provenance entry

Renders FORK_CHANGELOG.md, the README fork-change-queue table,
website/public/llms-full.txt and the python-api reference (new
provenance.md page). README + CLAUDE.md test counts re-derived:
6794 -> 6855 (61 new tests).

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(provenance): correct the basename-only claim; note tzinfo is load-bearing

Review correction. Two docstring claims were wrong, and one load-bearing
dependency was undocumented.

1. "daemon/MCP hits carry a basename only and can never be located on
   disk" is false. `searcher` writes the basename to `source_file` for
   display and keeps the FULL path in `source_path`; only
   `_source_file_full` / `_chunk_index` are stripped before a public
   return, so most hits carry both and staleness DOES resolve. The one
   real exception is `_bm25_only_via_postgres`, which sets no
   `source_path`. Verified against production via the MCP envelope
   route: hits come back with `source_path` and `created_at`, and
   `source_stale` resolves.

   Documented with it: which host's filesystem answers. Under MCP that
   is the palace host, and its copy is the right comparison — it is the
   copy the miner read. The CLI then re-annotates locally, and since
   `annotate` writes `source_stale` only when it can decide, a second
   host refines the answer rather than clobbering it with None. Where
   the file is Syncthing-replicated both agree (mtime is preserved);
   missing on both yields None.

2. `parse_date_bound` dropping tzinfo is load-bearing, not tidiness.
   Production `filed_at` is naive but `diary_ingest` writes an aware
   UTC one, and both downstream comparisons are against naive datetimes
   (`datetime.fromtimestamp`, `datetime.now()`). An aware value reaching
   either raises TypeError from inside a search the caller already paid
   for — normalising on the way in is what lets `source_stale` promise
   it never raises.

Also states the `now` parameter's naive precondition, which the "never
raises" contract depends on.

Docstrings and comments only — no behaviour change.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: re-render after rebase onto main (#453)

Post-rebase fixups, no prose changes:
- fork-changes entry commit hash 19b84d0 -> fbc5d34 (the rebase rewrote it;
  a stale hash resolves via git cat-file but points off-branch).
- test count re-derived on the rebased tree: 6815 -> 6876 in README.md and
  CLAUDE.md (#453 landed 21, this branch adds 61).
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(search): don't render the staleness caveat on transcripts

Measured over 48 production transcript hits: 15 stale, 21 not, 12
undecidable. The 31% is real but it is mostly an artefact of what a
transcript IS — a live session's file is appended to continuously, so
any wing with an open session reads stale. The note would fire on
every open session while saying nothing the transcript caveat already
says ("quoted copy … verify at the curated source").

Staleness is the interesting signal for a CURATED document, which is
the case #451 was actually filed about: a project CLAUDE.md that grew
a REFUTED banner after its drawer was indexed.

So `provenance_note` emits only the transcript wording for a stale
transcript, and the compact tag drops the ⟨stale⟩ half for transcripts
to match. Curated files and memory files are unaffected and still
carry both.

This is a RENDERING rule, not a measurement one — `annotate` still
stamps `source_stale` on every kind, so JSON and MCP consumers keep
the raw field and can apply their own policy.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: re-render after rebase onto main (#464)

- fork-changes entry hash fbc5d34 -> 28518f2 (rebase rewrote it).
- entry body records the transcript staleness-caveat rule and the
  measurement behind it (48 production transcript hits: 15/21/12).
- test count 61 -> 65 for this branch; README + CLAUDE.md re-derived
  on the rebased tree, 6876 -> 6880.
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: re-render after rebase onto main (#455)

- fork-changes entry hash 28518f2 -> 5632dc2, and verified with
  `git merge-base --is-ancestor <hash> HEAD` rather than existence
  alone — the check-docs gap filed as #472.
- test count re-derived on the rebased tree: 6880 -> 6916.
- all three renderers re-run; scripts/check-docs.sh clean 7/7.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants