fix(postgres): scrub every byte class Postgres refuses, at every bind site (#411) - #453
Merged
Merged
Conversation
… 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>
|
Warning Review limit reachedNext included review available in 57 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (8)
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. Comment |
jphein
added a commit
that referenced
this pull request
Sep 11, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
backends/postgres.pynow 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.pyhas 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.pyonly 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:json.dumpsescapes it to a\udXXXsequence that the::jsonbcast rejects as an unsupported Unicode escape._replace_nul_byteslooked at top-levelstrvalues 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.textcolumn as theON CONFLICTkey.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
mainbefore the fix went in (theget/deletepair was re-verified red by reverting just that call).How
_replace_nul_bytesis widened into_scrub_unstorable(documents=, ids=, metadatas=):nul_bytes_replacedin the drawer's metadata, so the substitution stays provenanced and never silent — and the symmetriclone_surrogates_replacedis added. Counts describe the document, which is the verbatim payload; metadata is scrubbed too but is bookkeeping, not the user's words.add/upsert:update()serializes metadata into its own::jsonbcast, andget()/delete()bind ids intotextcomparisons. 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.pyis byte-identical toupstream/develop(git diff upstream/develop -- mempalace/backends/pgvector.pyis empty) andpostgres.pyis 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_nulandstrip_lone_surrogatesmust 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:add,update,get,delete, asserted on the bound parameters through a fake connection)tests/test_postgres_nul_bytes.pyupdated 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_pythonpathfailures reproduce on the unmodified branch in any git worktree — filed separately, unrelated to this change.)ruff checkandruff format --checkclean;scripts/check-docs.shgreen after all three renderers.Part of #411