Files
gbrain/test/facts-fence.test.ts
T
a73108b26f v0.32.2 feat: facts join system-of-record + 3-layer privacy + CI invariant gate (#885)
* schema: migration v51 facts_fence_columns + fresh-install parity

v0.32.2 commit 1/11.

Facts become FS-canonical via a `## Facts` fence on entity pages (mirror of
takes-fence). row_num + source_markdown_slug are the round-trip columns the
fence parser uses to reconcile markdown → DB.

Schema changes:
- ALTER TABLE facts ADD COLUMN IF NOT EXISTS row_num INTEGER
- ALTER TABLE facts ADD COLUMN IF NOT EXISTS source_markdown_slug TEXT
- CREATE UNIQUE INDEX idx_facts_fence_key (source_id, source_markdown_slug,
  row_num) WHERE row_num IS NOT NULL

Both columns nullable: pre-v0.32 rows don't have them until commit 6's
v0_32_2 orchestrator backfills via fence-append. The partial WHERE clause is
the Codex R2 collision guard — without it, two pre-v51 NULL-row_num rows on
the same (source_id, source_markdown_slug) coordinate would collide and fail
the migration on any populated v0.31 brain.

Fresh-install parity: the v40 CREATE TABLE block now declares the columns
from the start, so a brand-new install hits a single CREATE that already
has them and the v51 ALTERs no-op via IF NOT EXISTS. Existing brains pick
them up through the v51 migration.

Idempotent under all states (re-runs are no-ops). Metadata-only ALTERs on
PG 11+ and PGLite — no table rewrite. Partial-index syntax verified
against v40's existing idx_facts_unconsolidated precedent.

Tests:
- 6 new v51 cases in test/migrate.test.ts covering name, ADD COLUMN shape,
  nullable contract, partial-unique-index keys, the WHERE-NULL collision
  guard, and LATEST_VERSION progression.
- All 109 migration tests pass (was 103); schema walks 15 → 51 cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: facts-fence.ts + extract shared escape helpers from takes-fence

v0.32.2 commit 2/11.

New: src/core/facts-fence.ts — structural mirror of src/core/takes-fence.ts.
10 data columns + leading `#` (`# | claim | kind | confidence | visibility |
notability | valid_from | valid_until | source | context |`). API mirrors
takes: parseFactsFence, renderFactsTable, upsertFactRow, stripFactsFence.

Strikethrough parse contract (Codex R2-#3): `~~claim~~` + `context:
"superseded by #N"` → supersededBy populated; `~~claim~~` + `context:
"forgotten: <reason>"` → forgotten=true. The semantic distinction lets
commit 3's extract-from-fence map forgotten rows to `valid_until = today`
so the DB's `expired_at = valid_until + now()` derivation rebuilds the
forget state on `gbrain rebuild` (v0.32.3 follow-up).

Refactor: extracted shared primitives to src/core/fence-shared.ts —
parseRowCells, isSeparatorRow, stripStrikethrough, parseStringCell,
escapeFenceCell. takes-fence now imports them; behavior byte-identical
(all 25 takes-fence tests still pass).

stripFactsFence has two modes per Codex Q5 + R2-#1 design:
- keepVisibility: ['world'] — retain world rows, drop private. The mode
  both the chunker (Layer A) and get_page over remote MCP (Layer B) use.
  Private fact bytes never reach content_chunks.chunk_text, embeddings,
  or search; remote MCP callers see world facts only.
- default / empty array — drop the entire fence block. Defensive deny-
  by-default at the privacy boundary.

Tests: 36 new cases in test/facts-fence.test.ts mirror takes-fence
patterns — canonical happy path (single + multi row, all kinds, both
visibility tiers, all notability tiers), strikethrough semantics
(superseded vs forgotten with case-insensitive parse, the
"no-strikethrough-keeps-active-even-if-context-mentions-superseded"
regression guard), lenient hand-edits (whitespace, 9-cell shape),
malformed-row surfacing (unknown kind/visibility/notability,
non-numeric confidence, duplicate row_num, unbalanced fence),
renderFactsTable (header + separator + rows, strikethrough rendering,
pipe escape, confidence formatting), round-trip (render+parse identity
including strikethrough state), upsertFactRow (empty body, max+1
sequencing, F3-style hand-edit preservation), and stripFactsFence
(no-fence pass-through, whole-fence strip, keepVisibility filter,
empty-after-filter shape, empty-array defensive default).

76/76 tests across facts-fence + takes-fence + chunker-recursive pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: src/core/facts/extract-from-fence.ts — pure ParsedFact → NewFact mapper

v0.32.2 commit 3/11.

The boundary between markdown-shaped fence rows (ParsedFact from
facts-fence.ts) and DB-shaped engine rows (NewFact). Pure function, no
I/O. Resolves Codex Q7: engines stay markdown-unaware. The cycle phase
(commit 7) and the backstop rewrite (commit 5) call this to convert
parsed fences into engine-ready rows.

FenceExtractedFact = NewFact ∪ { row_num, source_markdown_slug } — a
structural superset that carries the v51 fence columns. Commit 4
widens the engine surface to accept this shape; commits 5 and 7
consume the function.

Strikethrough → date derivation contract:
- explicit validUntil in fence → honored as-is
- forgotten row (strikethrough + "forgotten:" context) → valid_until =
  today UTC; the DB's existing expired_at = valid_until + now() rule
  rebuilds the forget state on gbrain rebuild (v0.32.3 follow-up)
- supersededBy row without explicit validUntil → null; consolidator
  phase fills this in from the newer row's valid_from
- inactive-unrecognized (strikethrough + neither flag) → today; honors
  the user's strikethrough intent for unrecognized contexts

Determinism guard: nowOverride opt makes the today-stamping testable
without freezing global Date. Production callers use UTC midnight today
so the bisect E2E sees byte-identical DB state after re-extract across
timezones.

FENCE_SOURCE_DEFAULT = 'fence:reconcile' for rows fenced without an
original source (the migration backfill in commit 6 reuses this).

Tests: 21 cases covering all-field happy path, all 5 FactKind values,
both visibilities, the four date-derivation branches with explicit-wins
sanity checks, source defaulting, ISO date lenient parsing (empty +
invalid → undefined), 30-row bulk, and the source_markdown_slug
threading invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: engine.insertFacts batch + deleteFactsForPage on both engines

v0.32.2 commit 4/11.

New BrainEngine surface for the reconciliation path:

  insertFacts(
    rows: Array<NewFact & { row_num: number; source_markdown_slug: string }>,
    ctx: { source_id: string },
  ): Promise<{ inserted: number; ids: number[] }>

  deleteFactsForPage(slug: string, source_id: string): Promise<{ deleted: number }>

insertFacts is the only entry point that persists v51 columns
(row_num, source_markdown_slug). Single transaction commits all rows
atomically; the v51 partial UNIQUE index rolls back the whole batch on
collision. Per-row INSERTs (not multi-row VALUES) keep the embedding-
vs-no-embedding branching readable; batch sizes 5-30 in practice. No
supersede flow in this path — fence reconciliation is canonical-source-
of-truth direction.

deleteFactsForPage scopes by (source_id, source_markdown_slug). Hard
DELETE (not soft-delete via expired_at) — a fence row that disappears
from markdown corresponds to a fact the user removed entirely; the DB
mirrors that. Forgotten facts that stay in the fence as strikethrough
rows survive the wipe because re-insert puts them back with valid_until
= today per the extract-from-fence derivation contract. Pre-v51 rows
(NULL source_markdown_slug) live in a different keyspace and are never
deleted by this call.

Both engines implemented:
- PGLite: transaction with per-row INSERT, conditional vector binding
- Postgres: sql.begin() transaction, postgres.js tagged template

Tests (13 new cases in test/insert-facts-batch.test.ts):
- empty batch returns inserted:0
- single-row + multi-row persistence, ids in input-order
- all NewFact + v51 columns round-trip
- v51 partial UNIQUE rolls back whole batch on collision
- different source_markdown_slug + different source_id values don't
  collide on same row_num
- deleteFactsForPage scoping (same source different page; same page
  different source; pre-v51 NULL-source_markdown_slug rows untouched)
- delete-then-reinsert round-trip (the cycle-phase pattern)

226 tests pass across facts surface + migrate + takes-fence (no
regressions in adjacent code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: markdown-first fact write path in src/core/facts/backstop.ts

v0.32.2 commit 5/11.

THE rewrite. Both runFactsBackstop (page-shape entry, called from
put_page / sync / file_upload / code_import) AND runFactsPipeline (raw-
turn-text entry, called from the explicit extract_facts MCP op) route
through runPipelineWithBody. Modifying that one inner function makes
both entry points markdown-first without changing either signature.
Resolves Codex R2-#2 surface gap.

New: src/core/facts/fence-write.ts — writeFactsToFence orchestrator +
lookupSourceLocalPath helper.

Pipeline (post-dedup, per entity_slug group):
1. Acquire FS page-lock via src/core/page-lock.ts (5s retry, PID-liveness
   stale detection; multi-process safe through the kernel-visible
   ~/.gbrain/page-locks/<sha-of-slug>.lock file)
2. Read entity page from <source.local_path>/<slug>.md, or stub-create
   with min frontmatter (type inferred from slug prefix, title humanized
   from tail)
3. upsertFactRow each new fact onto the `## Facts` fence in-memory,
   collecting assigned row_nums (monotonic append-only per the takes
   precedent)
4. Atomic write: writeFileSync(.tmp) → re-readFileSync(.tmp) →
   parseFactsFence(.tmp) → on warnings: leave .tmp + JSONL surface +
   NO DB write; on clean: renameSync(.tmp → file). Codex Q7
   atomic-recovery semantics: extract-from-fence runs BEFORE rename,
   so a parse failure quarantines the .tmp without corrupting the
   canonical file
5. extractFactsFromFenceText (commit 3) maps re-parsed ParsedFact[] →
   FenceExtractedFact[]; filter to NEW row_nums; stitch back embedding +
   sessionId (not stored in fence text); engine.insertFacts batch
   (commit 4)

Three structural fallbacks to legacy DB-only insertFact:
- sources.local_path is NULL (thin-client install) — once-per-process
  stderr warning names the missing config; all post-dedup facts go to
  legacy path. Documented as named exception in the architecture doc
  (commit 11)
- f.entity_slug couldn't resolve to a canonical slug — structurally
  unfenceable (no entity page to fence onto); legacy single-row insert
  preserves the v0.31 semantic
- Fence parse-validation fails on a .tmp — that page's facts skip; do
  NOT fall through to legacy DB-only because the DB index for that
  page would be inconsistent with a broken fence

No re-entrancy guard needed: writeFactsToFence uses writeFileSync +
renameSync directly, NOT engine.putPage. No code path can re-trigger
runFactsBackstop on the markdown write. The architecture self-prevents
the recursion concern Codex Q7 raised. Documented in fence-write.ts
so a future refactor that swaps writeFileSync for putPage sees the
constraint.

Dedup unchanged: cosine similarity @ 0.95 against DB candidates, before
fence write. Codex Q7 design: fence rows have no embeddings (not stored
in markdown text); the FS lock + sync invariant means DB == fence at
write time, so DB is the correct dedup oracle.

Tests (11 new cases in test/fence-write.test.ts):
- Happy path: stub-create + fence write + DB v51 columns persisted
- Existing-page append preserves body
- Multi-fact batch assigns consecutive row_nums
- Re-write picks up at max+1 row_num (append-only)
- Nested slug stub-creates parent dirs (companies/acme → mkdir companies)
- legacyFallback:true when localPath is null (no FS, no DB write)
- Empty facts array no-ops without stub-creating the file
- Atomic recovery: no .tmp file left after success
- lookupSourceLocalPath: existing source, unknown source, NULL local_path

The multi-process FS lock contention test lives in
test/e2e/facts-lock-contention.test.ts (commit 10's invariant capstone,
since Bun.spawn is an E2E concern). These cover the in-process happy
and recovery paths.

242 tests pass across the facts surface + adjacent files (no
regressions in facts-backstop / facts-canonicality / takes-fence /
migrate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: migration orchestrator v0_32_2.ts — backfill v0.31 facts to fences

v0.32.2 commit 6/11.

Schema migration v51 (commit 1) added the row_num + source_markdown_slug
columns. This orchestrator's job is the data half: walk every existing
pre-v51 row in the facts table (row_num IS NULL = legacy keyspace) and
append it to its entity page's `## Facts` fence, atomically + idempotently.

Critical sequencing per Codex R2-#7: this commit lands BEFORE commit 7's
extract_facts cycle phase so existing v0.31 facts get fenced before any
destructive reconciliation can see "empty fence" as authoritative. The
cycle phase in commit 7 adds an empty-fence-guard as a structural belt
to back up these suspenders.

Three phases:
- phaseASchema: assert migration v51 applied + columns exist
- phaseBFenceFacts: per (source_id, entity_slug) group, atomic .tmp +
  parse + rename appends legacy DB rows to entity-page fence; UPDATEs
  the row's v51 columns. Dry-run by default; refuses if any
  source.local_path is a dirty git tree (mirrors src/core/dry-fix.ts
  safety posture). Idempotent re-run: matches existing fence rows by
  (claim, source) and reuses their row_num instead of appending
  duplicates.
- phaseCVerify: re-parse every touched page's fence, compare row counts
  to DB; partial status on mismatch so user runs --force-retry 51

Three skip cases (each surfaced in the detail string):
- NULL entity_slug → structurally unfenceable; row stays in legacy
  keyspace permanently. Operator decides hand-curate vs delete.
- sources.local_path is NULL → thin-client / read-only brain; nothing
  to fence onto.
- Fence parse-validate fails on the .tmp → .tmp stays as quarantine
  evidence; the operator inspects.

Stub-create with type inferred from slug prefix (people→person,
companies→company, deals→deal, others→concept) so freshly-fenced pages
import cleanly via existing sync.

Tests (14 new cases in test/migrations-v0_32_2.test.ts):
- phaseASchema: complete + dry-run + no-engine
- phaseBFenceFacts: dry-run reporting without side-effects, multi-row
  backfill with row_num assignment, multi-entity batch touches multiple
  files, append to existing entity page preserves body, idempotent
  re-run (matches by claim+source, reuses row_num), NULL entity_slug
  skip, missing local_path skip
- phaseCVerify: clean state passes, fence drift fails with the slug
  named in detail
- Orchestrator end-to-end: clean run returns 3 complete phases; dry-run
  returns 3 skipped phases with zero side-effects

216 tests pass across migrations + facts surface (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extract_facts cycle phase + empty-fence guard (Codex R2-#7)

v0.32.2 commit 7/11.

New cycle phase reconciles the DB facts index from the `## Facts`
fence on each affected entity page. Placement: between `extract`
(materializes links + timeline) and `patterns`/`recompute_emotional_
weight` so downstream phases read fresh DB facts.

Source-of-truth contract per page: parseFactsFence → wipe via
deleteFactsForPage → re-insert via engine.insertFacts. After the
phase, the DB index byte-matches the fence (modulo embeddings +
runtime-derived fields). A removed-from-fence row is removed from
DB; a hand-edited fence row updates the DB cleanly.

Pre-v51 NULL-source_markdown_slug legacy rows are structurally
protected — deleteFactsForPage targets (source_id,
source_markdown_slug) only, so the partial-UNIQUE-index keyspace
keeps legacy rows untouched.

Empty-fence guard (Codex R2-#7): pre-check `COUNT(*) FROM facts
WHERE row_num IS NULL AND entity_slug IS NOT NULL`. If > 0, the
phase returns status:'warn' with a hint pointing at
`gbrain apply-migrations --yes`. Prevents the silent-misreport
scenario where an interrupted upgrade leaves v0.31 legacy rows
in the DB while the cycle reports "0 facts on people/alice"
because the fence is empty. Belt to the runtime backstop's
suspenders in commit 5.

Wired in src/core/cycle.ts:
- Added 'extract_facts' to CyclePhase enum + ALL_PHASES + NEEDS_LOCK_PHASES
- Added runPhaseExtractFacts dispatch helper with PhaseResult shape
- Phase 5b runs between extract (5) and patterns (6); inherits
  syncPagesAffected for incremental mode

Tests (10 new cases in test/extract-facts-phase.test.ts):
- Happy path: single + multi page reconciliation
- Idempotent: second run produces same DB state as first
- Removed-from-fence row gets deleted from DB
- Empty fence reconciles to empty DB for that page
- Dry-run does not touch DB
- Full walk (no slugs filter) covers every brain page
- Guard fires when legacy v0.31 rows pending backfill
- Guard releases after backfill (row_num populated)
- NULL entity_slug legacy rows do NOT trigger the guard
- Multi-source isolation: other source's DB rows survive

226 tests pass across the facts surface + cycle + migrations
(no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: 3-layer privacy strip + forget-as-fence (Codex R2 #1/#3/#5)

v0.32.2 commit 8/11.

Layer A — chunker strip (Codex R2-#1 P0):
src/core/chunkers/recursive.ts now calls stripFactsFence({keepVisibility:
['world']}) alongside the existing stripTakesFence before chunking.
Private fact text NEVER reaches content_chunks.chunk_text, embeddings,
or search. World facts remain searchable (public knowledge by
definition). Closes the leak Codex round 2 caught: get_page's strip
alone wasn't enough because chunks carry the same body text into the
search surface.

Layer B — get_page strip trigger flipped (Codex R2-#5):
src/core/operations.ts:413 strip trigger changes from `ctx.takesHolders-
AllowList` to `ctx.remote === true`. Closes the pre-existing takes hole
where subagent callers (remote:true but no allow-list) bypassed the
strip. Subagent + remote MCP + scope-restricted-token callers all get
the strip now; local CLI (remote:false) keeps the full fence visible.
Both stripTakesFence AND stripFactsFence({keepVisibility:['world']})
fire in the same code path.

Forget-as-fence (Codex R2-#3):
New src/core/facts/forget.ts forgetFactInFence({factId, reason}). When
the row has v51 columns + source.local_path set, rewrites the entity
page's fence to strike out the claim, set valid_until=today, append
"forgotten: <reason>" to context. The DB's existing
`expired_at = valid_until + now()` derivation reconstructs the forget
state on rebuild because the fence is canonical.

Two-tier fallback for cross-state safety:
- Fence path: v51 columns + sources.local_path set + fence file exists +
  fence row matches DB row_num → atomic .tmp + parse + rename, then
  DB UPDATE to match
- Legacy DB-only: every other case (pre-v51 row, NULL entity_slug,
  thin-client install, file deleted, row_num drift). DB-only forgets
  do NOT survive gbrain rebuild — named exception in the architecture
  doc.

MCP forget_fact op + gbrain forget CLI both rewired through
forgetFactInFence. New optional `--reason` flag on the CLI; new
`reason` param on the MCP op. Response carries `path: 'fence' |
'legacy_db'` so callers can surface the degraded mode loudly.

Extended strikethrough parse contract from commit 2:
- `~~claim~~` + `context: "superseded by #N"` → supersededBy=N
- `~~claim~~` + `context: "forgotten: <reason>"` → forgotten=true
- `~~claim~~` + anything else → active=false, both flags null
Both encodings use the same strikethrough marker; the parser
distinguishes via context.

Tests (38 new cases in test/privacy-strip-and-forget.test.ts):
- Layer A: 4 cases — public survives, private dropped, private-only
  fence preserves prose, no-fence pass-through, takes-fence regression
- Layer B: 1 case — stripFactsFence({keepVisibility:['world']}) shape;
  full operations-dispatch E2E lives in commit 10
- Forget-as-fence: 12 cases — fence path (strikethrough + valid_until +
  context append + default reason + existing-context preservation),
  legacy fallback (NULL row_num, NULL local_path, missing file, row_num
  drift, unknown id, already-expired)

266 tests pass across the facts + privacy + chunker + operations
surface (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: scripts/check-system-of-record.sh CI gate + function-scoped allow-list

v0.32.2 commit 9/11.

New CI invariant gate enforcing the system-of-record contract: direct
writes to derived DB tables (facts, takes, links, timeline_entries) must
go through the extract / reconcile / migration layer. Direct writes from
arbitrary code paths would bypass the markdown source-of-truth contract
— the next `gbrain rebuild` (v0.32.3) would lose the data because the
fence wasn't updated.

Banned methods (the v0.32.2 derived-write surface):
- engine.insertFact, engine.insertFacts
- engine.addLink, engine.addLinksBatch
- engine.addTimelineEntry
- engine.upsertTake
- engine.expireFact

Scoped to src/ + scripts/ per Codex R2-#8 — test/ is deliberately
excluded because tests legitimately call these methods to seed fixtures
and gating tests would break the test surface without protecting any
invariant.

Function-scoped allow-list (not file-scoped per Codex Q7): add
`// gbrain-allow-direct-insert: <reason>` on the SAME LINE as the
banned call. The grep parses the trailing comment; a different-line
comment does NOT exempt the call (regression-tested explicitly).
Comment lines (JSDoc, line-comments, backtick mentions in docstrings)
are filtered out so the gate doesn't false-positive on prose.

Wired into `bun run verify` (the canonical CI pre-test gate set).
Failure mode: gate exits 1, names every offending file:line, prints
hint pointing at the architecture doc.

Annotated 18 legitimate call sites:
- src/core/cycle/extract-facts.ts: reconcile fence → DB
- src/core/facts/backstop.ts: legacy DB-only fallback for unparented /
  thin-client facts
- src/core/facts/fence-write.ts: markdown-first reconcile path
- src/core/facts/forget.ts: 6 legacy fallback paths inside
  forgetFactInFence
- src/core/enrichment-service.ts: 2 auto-timeline / auto-link
  reconciliation sites
- src/core/output/writer.ts: 3 BrainWriter synthesize-phase sites
- src/core/operations.ts: 2 explicit MCP op sites (add_link,
  add_timeline_entry)
- src/commands/extract.ts: 5 canonical extract command sites
- src/commands/reconcile-links.ts: 2 code-graph reconciliation sites

Tests (6 new cases in test/check-system-of-record.test.ts):
- Positive: real repo passes (regression guard — the allow-list
  comments + the gate together must keep CI green)
- Negative: synthetic violator file → gate exits 1 + names the path
- Allow-list comment on SAME LINE exempts
- Allow-list comment on DIFFERENT line does NOT exempt
- Gate does NOT scan test/ (Codex R2-#8 — tests legitimately seed
  fixtures via direct insertFact calls)
- Gate DOES scan scripts/ alongside src/

163 tests pass across the gate + facts surface + operations + cycle
(no regressions). typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: system-of-record invariant E2E capstone

v0.32.2 commit 10/11.

The architectural rule prove-out. Hermetic PGLite + tempdir filesystem
(no DATABASE_URL needed; runs in standard bun test). Exercises the full
delete-and-rebuild round-trip the system-of-record contract promises.

Capstone test (full round-trip):
1. Seed 6 fixture markdown files: 3 person pages with takes + facts +
   inline links, 3 plain pages. Facts include both world + private
   visibility per page (the PRIVATE_DETAIL_PROOF canary).
2. importFromFile every page → DB; run extract (links + timeline) +
   extractTakes + runExtractFacts to reconcile all derived tables.
3. Snapshot facts + takes derived state.
4. DELETE FROM facts + takes + links + timeline_entries. Simulates the
   "DB lost; rebuild from repo" disaster scenario v0.32.3's
   `gbrain rebuild` will execute.
5. Re-import every file + re-reconcile. Re-import rebuilds tags
   (per Codex R2-#6: tags is reconciled by import-file.ts:315, NOT
   by extract phases).
6. Snapshot + diff. Assert facts + takes row sets match by content
   (entity_slug, fact) for facts and (page_slug, row_num) for takes.

Plus three supporting tests:
- v51 reconcile-key invariant: every fact row carries non-null
  row_num + source_markdown_slug after the reconcile.
- Layer A chunker strip (Codex R2-#1 P0): search for verbatim
  PRIVATE_DETAIL_PROOF text in content_chunks returns 0 matches;
  world facts ("Founded Acme in 2017") DO appear in chunks.
- Layer B get_page strip (Codex R2-#5): stripFactsFence with
  {keepVisibility:['world']} drops private rows from the response
  body while keeping world rows.

Trim from original plan: links + timeline coverage left to existing
Tier 1 E2E (sync.test.ts + backlinks.test.ts). The v0.32.2-novel
reconcile surface is facts + takes — those are what this invariant
proves. Cuts ~half the test runtime + scope without losing
v0.32.2 coverage.

4/4 pass in 2.23s. 291 tests pass across the full facts + privacy +
chunker + operations + migrate + cycle surface (no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.32.2 chore: VERSION + package.json + CHANGELOG manifesto + docs + migration guide

v0.32.2 commit 11/11. Release ceremony.

VERSION + package.json + bun.lock all aligned at 0.32.2.

CHANGELOG.md entry leads with the manifesto:

  > The GitHub repo is the system of record. The database is a derived
  > cache. We do not back up the database — we rebuild it from the repo.

Followed by the BEFORE/AFTER table showing facts newly meeting the
FS-canonical bar, the gbrain forget behavior change, the privacy
strip layers, and the CI gate. Itemized changes section enumerates the
14 source files modified + 9 new test files + 132 new test cases.

docs/architecture/system-of-record.md (new, ~250 lines): the canonical
contract doc. Three-category table (FS-canonical / Derived from FS but
not user-authored / DB-only by design), named DB-only exceptions, the
3-layer privacy boundary, the forget contract, disaster-recovery flow,
and the rule for new user-knowledge categories (parser + writer + engine
method + reconciler + round-trip test).

skills/migrations/v0.32.2.md (new): agent-facing guide describing what
the v0_32_2 orchestrator does, the surface changes (forget rewrites
markdown; get_page strips for ctx.remote; chunker strips private; CI
gate; new extract_facts cycle phase), the verify steps, and the things
NOT to do (don't manually edit v51 columns; don't bypass the CI gate
without an allow-list comment).

Closes the 11-commit bisect plan. Every commit leaves the tree green.
Each commit does one conceptual thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: v0.32.2 follow-up — update 5 tests that v0.32.2 surface changes broke, plus fix 2 pre-existing flakes

Five test updates for changes v0.32.2 introduced:

- test/core/cycle.serial.test.ts: yieldBetweenPhases hook count bumped
  11 → 12 to account for the new extract_facts cycle phase. Two cases
  affected (hook is called between every phase; hook exceptions do not
  abort the cycle).

- test/apply-migrations.test.ts: buildPlan skippedFuture expectation
  lists v0.32.2 alongside v0.31.0 at the end. Two cases affected (fresh
  install with v0.11.1 installed; Codex H9 regression with v0.12.0).

- test/facts-mcp-allowlist.serial.test.ts: forget_fact dispatch idempotent
  case now expects `fact_already_expired` instead of `fact_not_found`
  on the second call. v0.32.2's forgetFactInFence introduces the more
  precise discriminator — the first call expires the fact; the second
  call sees expired_at NOT NULL and surfaces the more accurate error
  code instead of the older opaque `fact_not_found`.

Plus two pre-existing flakes that were biting the full-suite CI run
on dev boxes (both unrelated to v0.32.2; both confirmed flaking on
master before v0.32.2 work began):

- test/eval-longmemeval.test.ts warm-create speed gate: threshold
  bumped from p50<500ms → p50<1500ms. Solo run shows p50 ~25ms; under
  8-way parallel test shard load p50 spikes transiently to 500-1200ms.
  The new threshold still catches order-of-magnitude regressions (10x
  slowdown to 250ms baseline would fail at 2.5s) without flaking under
  legitimate parallel CPU contention.

- test/brain-registry.serial.test.ts empty/null/undefined id routes
  to host: the original test asserted the call rejects with
  not-UnknownBrainError, but on a dev box with `~/.gbrain/config.json`
  present (typical for anyone running gbrain locally) the host init
  succeeds and the promise resolves. Rewrote to assert the routing
  property regardless of resolve-vs-reject: catch the error if it
  throws, and check it's not UnknownBrainError. Resolved cleanly is
  also acceptable because it proves the routing went to host.

Full unit suite: 5517 pass, 0 fail (up from 5316 pass, 7 fail before
these fixes). `bun run verify` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: e2e — update 3 tests that v0.32.2 surface changes broke

- test/e2e/dream-cycle-phase-order-pglite.test.ts: EXPECTED_PHASES
  array gains 'extract_facts' between 'extract' and 'patterns' to
  match the new v0.32.2 cycle phase order.

- test/e2e/cycle.test.ts: phase count bumped 11 → 12 (the new
  extract_facts phase increments the canonical full-cycle phase count).

- test/e2e/facts-forget.test.ts: idempotent-on-re-call case now
  expects 'fact_already_expired' instead of 'fact_not_found'. v0.32.2's
  forgetFactInFence introduces the more precise discriminator — first
  call expires the fact; second call sees expired_at NOT NULL and
  surfaces the more accurate error code.

Full E2E suite (DATABASE_URL set, sequential via scripts/run-e2e.sh)
now: 78/78 files pass, 531/531 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:25:48 -07:00

537 lines
21 KiB
TypeScript

/**
* v0.32.2 — facts-fence parser/renderer tests.
*
* Mirrors test/takes-fence.test.ts coverage: canonical happy path, lenient
* hand-edits, malformed-row recovery, strikethrough semantics (superseded
* vs forgotten — the Codex R2-#3 contract), pipe-escape round-trip, and
* the privacy-aware stripFactsFence contract that the chunker (Layer A)
* and get_page (Layer B) both consume.
*/
import { describe, test, expect } from 'bun:test';
import {
parseFactsFence,
renderFactsTable,
upsertFactRow,
stripFactsFence,
FACTS_FENCE_BEGIN,
FACTS_FENCE_END,
type ParsedFact,
type FactKind,
type FactNotability,
} from '../src/core/facts-fence.ts';
// ─────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────
const minimalFact = (rowNum: number, overrides: Partial<ParsedFact> = {}): ParsedFact => ({
rowNum,
claim: 'A claim about something',
kind: 'fact',
confidence: 1.0,
visibility: 'world',
notability: 'medium',
validFrom: '2026-01-01',
active: true,
...overrides,
});
const wrapFenceBody = (rows: string): string => `# Page
Some preamble.
## Facts
${FACTS_FENCE_BEGIN}
| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |
|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|
${rows}
${FACTS_FENCE_END}
`;
// ─────────────────────────────────────────────────────────────────
// parseFactsFence
// ─────────────────────────────────────────────────────────────────
describe('parseFactsFence — canonical happy path', () => {
test('returns empty + empty warnings when no fence is present', () => {
const r = parseFactsFence('# Some page\n\nJust prose, no fence.');
expect(r.facts).toEqual([]);
expect(r.warnings).toEqual([]);
});
test('parses a clean single-row fence', () => {
const body = wrapFenceBody(
`| 1 | Founded Acme in 2017 | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
);
const r = parseFactsFence(body);
expect(r.warnings).toEqual([]);
expect(r.facts).toHaveLength(1);
expect(r.facts[0]).toMatchObject({
rowNum: 1,
claim: 'Founded Acme in 2017',
kind: 'fact',
confidence: 1.0,
visibility: 'world',
notability: 'high',
validFrom: '2017-01-01',
validUntil: undefined,
source: 'linkedin',
active: true,
});
});
test('parses multi-row fence preserving row order', () => {
const body = wrapFenceBody(
`| 1 | First claim | fact | 1.0 | world | high | 2026-01-01 | | src1 | |
| 2 | Second claim | preference | 0.85 | private | medium | 2026-01-02 | | src2 | |
| 3 | Third claim | commitment | 0.5 | world | low | 2026-01-03 | 2026-12-31 | src3 | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(3);
expect(r.facts.map(f => f.rowNum)).toEqual([1, 2, 3]);
expect(r.facts[2]).toMatchObject({
kind: 'commitment',
validUntil: '2026-12-31',
});
});
test('all five kinds parse', () => {
const kinds: FactKind[] = ['event', 'preference', 'commitment', 'belief', 'fact'];
const body = wrapFenceBody(
kinds.map((k, i) =>
`| ${i + 1} | claim${i} | ${k} | 1.0 | world | medium | 2026-01-01 | | src | |`,
).join('\n'),
);
const r = parseFactsFence(body);
expect(r.facts.map(f => f.kind)).toEqual(kinds);
expect(r.warnings).toEqual([]);
});
test('both visibility values parse', () => {
const body = wrapFenceBody(
`| 1 | private one | fact | 1.0 | private | medium | 2026-01-01 | | src | |
| 2 | world one | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts.map(f => f.visibility)).toEqual(['private', 'world']);
});
test('all three notability values parse', () => {
const tiers: FactNotability[] = ['high', 'medium', 'low'];
const body = wrapFenceBody(
tiers.map((n, i) =>
`| ${i + 1} | claim | fact | 1.0 | world | ${n} | 2026-01-01 | | src | |`,
).join('\n'),
);
const r = parseFactsFence(body);
expect(r.facts.map(f => f.notability)).toEqual(tiers);
});
});
describe('parseFactsFence — strikethrough semantics (Codex R2-#3 contract)', () => {
test('strikethrough + "superseded by #N" context → supersededBy populated', () => {
const body = wrapFenceBody(
`| 1 | ~~Old claim~~ | fact | 0.8 | world | medium | 2026-01-01 | | src | superseded by #2 |
| 2 | New claim | fact | 0.9 | world | medium | 2026-06-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts[0]).toMatchObject({
claim: 'Old claim', // strikethrough markers stripped
active: false,
supersededBy: 2,
forgotten: false,
});
expect(r.facts[1].active).toBe(true);
});
test('strikethrough + "forgotten: <reason>" context → forgotten=true', () => {
const body = wrapFenceBody(
`| 1 | ~~Stale fact~~ | fact | 1.0 | private | low | 2018-01-01 | 2026-05-10 | inferred | forgotten: user asked to remove |`,
);
const r = parseFactsFence(body);
expect(r.facts[0]).toMatchObject({
claim: 'Stale fact',
active: false,
forgotten: true,
supersededBy: undefined,
context: 'forgotten: user asked to remove',
validUntil: '2026-05-10',
});
});
test('strikethrough with unrecognized context → inactive but no superseded/forgotten flag', () => {
const body = wrapFenceBody(
`| 1 | ~~Something~~ | fact | 1.0 | world | medium | 2026-01-01 | | src | random note |`,
);
const r = parseFactsFence(body);
expect(r.facts[0]).toMatchObject({
claim: 'Something',
active: false,
supersededBy: undefined,
forgotten: false,
});
});
test('NO strikethrough but context contains "superseded by #N" → active stays true, supersededBy NOT populated', () => {
// The strikethrough is the trigger. A row whose claim text mentions
// superseded but isn't struck-through stays active. Prevents a stray
// mention in `context` from inadvertently marking a row inactive.
const body = wrapFenceBody(
`| 1 | Talked about the superseded by #3 issue | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts[0].active).toBe(true);
expect(r.facts[0].supersededBy).toBeUndefined();
});
test('case-insensitive "Superseded By #N" still parses', () => {
const body = wrapFenceBody(
`| 1 | ~~Old~~ | fact | 0.8 | world | medium | 2026-01-01 | | src | Superseded By #42 |`,
);
const r = parseFactsFence(body);
expect(r.facts[0].supersededBy).toBe(42);
});
});
describe('parseFactsFence — lenient hand-edits', () => {
test('skips separator row (just dashes)', () => {
const body = wrapFenceBody(
`| 1 | claim | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(1);
});
test('tolerates extra whitespace inside cells', () => {
const body = wrapFenceBody(
`| 1 | claim | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts[0]).toMatchObject({
claim: 'claim',
source: 'src',
});
});
test('missing trailing context cell (9 cells instead of 10) still parses', () => {
// Markdown editors often drop empty trailing pipes. The parser tolerates
// this by treating context as defaulted-empty.
const inner = `| 1 | claim | fact | 1.0 | world | medium | 2026-01-01 | | src |`;
const body = `${FACTS_FENCE_BEGIN}\n| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |\n|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|\n${inner}\n${FACTS_FENCE_END}`;
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(1);
expect(r.facts[0].context).toBeUndefined();
});
});
describe('parseFactsFence — malformed rows surface warnings', () => {
test('unknown kind → warning, row skipped', () => {
const body = wrapFenceBody(
`| 1 | claim | bogus | 1.0 | world | medium | 2026-01-01 | | src | |
| 2 | ok | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(1);
expect(r.facts[0].rowNum).toBe(2);
expect(r.warnings.some(w => w.includes('unknown kind "bogus"'))).toBe(true);
});
test('unknown visibility → warning, row skipped', () => {
const body = wrapFenceBody(
`| 1 | claim | fact | 1.0 | restricted | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(0);
expect(r.warnings.some(w => w.includes('unknown visibility "restricted"'))).toBe(true);
});
test('unknown notability → warning, row skipped', () => {
const body = wrapFenceBody(
`| 1 | claim | fact | 1.0 | world | critical | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(0);
expect(r.warnings.some(w => w.includes('unknown notability "critical"'))).toBe(true);
});
test('non-numeric confidence → warning, row skipped', () => {
const body = wrapFenceBody(
`| 1 | claim | fact | high | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(0);
expect(r.warnings.some(w => w.includes('non-numeric confidence "high"'))).toBe(true);
});
test('duplicate row_num → warning, second occurrence skipped', () => {
const body = wrapFenceBody(
`| 1 | first | fact | 1.0 | world | medium | 2026-01-01 | | src | |
| 1 | second | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(1);
expect(r.facts[0].claim).toBe('first');
expect(r.warnings.some(w => w.includes('FACTS_ROW_NUM_COLLISION'))).toBe(true);
});
test('invalid row_num (zero) → warning, row skipped', () => {
const body = wrapFenceBody(
`| 0 | claim | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const r = parseFactsFence(body);
expect(r.facts).toHaveLength(0);
expect(r.warnings.some(w => w.includes('invalid row_num'))).toBe(true);
});
test('unbalanced fence (begin without end) → warning, empty facts', () => {
const body = `# Page\n\n${FACTS_FENCE_BEGIN}\n| 1 | claim | fact | 1.0 | world | medium | 2026-01-01 | | src | |\n`;
const r = parseFactsFence(body);
expect(r.facts).toEqual([]);
expect(r.warnings.some(w => w.includes('FACTS_FENCE_UNBALANCED'))).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────
// renderFactsTable
// ─────────────────────────────────────────────────────────────────
describe('renderFactsTable', () => {
test('produces a canonical-shape fence with header + separator + rows', () => {
const out = renderFactsTable([
minimalFact(1, { claim: 'C1', source: 's1' }),
minimalFact(2, { claim: 'C2', kind: 'preference', confidence: 0.85, visibility: 'private' }),
]);
expect(out).toContain(FACTS_FENCE_BEGIN);
expect(out).toContain(FACTS_FENCE_END);
expect(out).toContain('| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |');
expect(out).toContain('| 1 | C1 | fact | 1.0 | world | medium | 2026-01-01 |');
expect(out).toContain('| 2 | C2 | preference | 0.85 | private | medium |');
});
test('inactive rows render with strikethrough on claim', () => {
const out = renderFactsTable([
minimalFact(1, { claim: 'Old', active: false, context: 'superseded by #2' }),
minimalFact(2, { claim: 'New' }),
]);
expect(out).toContain('~~Old~~');
expect(out).toContain('superseded by #2');
});
test('escapes literal pipes in claim/source/context cells', () => {
const out = renderFactsTable([
minimalFact(1, {
claim: 'Has | a | pipe',
source: 'src | with pipes',
context: 'context | with pipes',
}),
]);
expect(out).toContain('Has \\| a \\| pipe');
expect(out).toContain('src \\| with pipes');
expect(out).toContain('context \\| with pipes');
});
test('confidence formatting: integer .0, fractional trim trailing zeros', () => {
const out = renderFactsTable([
minimalFact(1, { confidence: 1.0 }),
minimalFact(2, { confidence: 0.85 }),
minimalFact(3, { confidence: 0.5 }),
]);
expect(out).toContain('| 1.0 |');
expect(out).toContain('| 0.85 |');
expect(out).toContain('| 0.5 |');
});
});
// ─────────────────────────────────────────────────────────────────
// Round-trip: render → parse → identical (modulo escape decoding)
// ─────────────────────────────────────────────────────────────────
describe('round-trip: render then parse returns equivalent rows', () => {
test('canonical row survives render+parse with all fields intact', () => {
const original: ParsedFact = minimalFact(1, {
claim: 'Founded Acme in 2017',
kind: 'event',
confidence: 0.95,
visibility: 'world',
notability: 'high',
validFrom: '2017-01-01',
validUntil: undefined,
source: 'linkedin',
context: 'Founder bio',
active: true,
});
const rendered = renderFactsTable([original]);
const reparsed = parseFactsFence(rendered);
expect(reparsed.warnings).toEqual([]);
expect(reparsed.facts).toHaveLength(1);
expect(reparsed.facts[0]).toMatchObject({
rowNum: original.rowNum,
claim: original.claim,
kind: original.kind,
confidence: original.confidence,
visibility: original.visibility,
notability: original.notability,
validFrom: original.validFrom,
source: original.source,
context: original.context,
active: true,
});
});
test('strikethrough-superseded row round-trips with supersededBy preserved', () => {
const original: ParsedFact = minimalFact(1, {
claim: 'Old',
active: false,
context: 'superseded by #2',
});
const rendered = renderFactsTable([original]);
const reparsed = parseFactsFence(rendered);
expect(reparsed.facts[0]).toMatchObject({
claim: 'Old',
active: false,
supersededBy: 2,
});
});
test('strikethrough-forgotten row round-trips with forgotten flag', () => {
const original: ParsedFact = minimalFact(1, {
claim: 'Stale',
active: false,
context: 'forgotten: user removed',
});
const rendered = renderFactsTable([original]);
const reparsed = parseFactsFence(rendered);
expect(reparsed.facts[0]).toMatchObject({
claim: 'Stale',
active: false,
forgotten: true,
});
});
});
// ─────────────────────────────────────────────────────────────────
// upsertFactRow
// ─────────────────────────────────────────────────────────────────
describe('upsertFactRow', () => {
test('appends to empty body by creating ## Facts section + fence', () => {
const body = '# Some Entity\n\nProse here.\n';
const { body: out, rowNum } = upsertFactRow(body, {
claim: 'A new fact',
kind: 'fact',
confidence: 1.0,
visibility: 'world',
notability: 'medium',
});
expect(rowNum).toBe(1);
expect(out).toContain('## Facts');
expect(out).toContain(FACTS_FENCE_BEGIN);
expect(out).toContain('| 1 | A new fact | fact');
expect(out).toContain('Prose here.'); // preamble preserved
});
test('appends to existing fence with row_num = max + 1', () => {
const body = wrapFenceBody(
`| 1 | First | fact | 1.0 | world | medium | 2026-01-01 | | src | |
| 7 | Seventh | fact | 1.0 | world | medium | 2026-01-01 | | src | |`,
);
const { body: out, rowNum } = upsertFactRow(body, {
claim: 'New row',
kind: 'fact',
confidence: 1.0,
visibility: 'world',
notability: 'medium',
});
expect(rowNum).toBe(8);
expect(out).toContain('| 8 | New row | fact');
// Existing rows preserved
expect(out).toContain('| 1 | First |');
expect(out).toContain('| 7 | Seventh |');
});
test('round-trip-preservation: hand-edited strikethrough row survives an unrelated append', () => {
// Regression guard for the codex F3-style silent-data-loss bug class.
// If upsertFactRow blindly serialized fresh state without re-parsing
// existing rows through parseFactsFence, strikethrough on row 1 would
// disappear when we add row 2.
const body = wrapFenceBody(
`| 1 | ~~Old~~ | fact | 0.8 | world | medium | 2026-01-01 | | src | superseded by #2 |`,
);
const { body: out } = upsertFactRow(body, {
claim: 'Replacement',
kind: 'fact',
confidence: 0.9,
visibility: 'world',
notability: 'medium',
context: undefined,
});
// The strikethrough on row 1 must still be there.
expect(out).toContain('~~Old~~');
expect(out).toContain('superseded by #2');
expect(out).toContain('Replacement');
});
});
// ─────────────────────────────────────────────────────────────────
// stripFactsFence — privacy boundary (Codex R2-#1 + Q5)
// ─────────────────────────────────────────────────────────────────
describe('stripFactsFence', () => {
test('no-fence body returns unchanged', () => {
const body = '# Page\n\nNo fence here.\n';
expect(stripFactsFence(body)).toBe(body);
});
test('default (no opts) drops the entire fence block — used by the chunker', () => {
const body = wrapFenceBody(
`| 1 | private fact | fact | 1.0 | private | high | 2026-01-01 | | src | |
| 2 | world fact | fact | 1.0 | world | high | 2026-01-01 | | src | |`,
);
const stripped = stripFactsFence(body);
expect(stripped).not.toContain(FACTS_FENCE_BEGIN);
expect(stripped).not.toContain(FACTS_FENCE_END);
expect(stripped).not.toContain('private fact');
expect(stripped).not.toContain('world fact');
// Preamble + Facts heading still present (we only stripped the fence body)
expect(stripped).toContain('Some preamble');
});
test('keepVisibility:["world"] keeps world rows, drops private rows', () => {
const body = wrapFenceBody(
`| 1 | PRIVATE_TEXT_PROOF | fact | 1.0 | private | high | 2026-01-01 | | src | |
| 2 | WORLD_TEXT_PROOF | fact | 1.0 | world | high | 2026-01-01 | | src | |`,
);
const stripped = stripFactsFence(body, { keepVisibility: ['world'] });
expect(stripped).toContain('WORLD_TEXT_PROOF');
expect(stripped).not.toContain('PRIVATE_TEXT_PROOF');
// Fence shape preserved so callers can still parse/round-trip
expect(stripped).toContain(FACTS_FENCE_BEGIN);
expect(stripped).toContain(FACTS_FENCE_END);
});
test('keepVisibility with NO world rows produces an empty-but-well-formed fence', () => {
const body = wrapFenceBody(
`| 1 | private only | fact | 1.0 | private | high | 2026-01-01 | | src | |`,
);
const stripped = stripFactsFence(body, { keepVisibility: ['world'] });
expect(stripped).not.toContain('private only');
expect(stripped).toContain(FACTS_FENCE_BEGIN);
expect(stripped).toContain(FACTS_FENCE_END);
});
test('keepVisibility:[] (empty array) is treated as whole-fence strip — defensive at the boundary', () => {
// If a caller accidentally passes an empty list, the safe behavior is
// "strip everything" rather than "keep everything." Privacy boundaries
// default to deny.
const body = wrapFenceBody(
`| 1 | something | fact | 1.0 | world | high | 2026-01-01 | | src | |`,
);
const stripped = stripFactsFence(body, { keepVisibility: [] });
expect(stripped).not.toContain('something');
expect(stripped).not.toContain(FACTS_FENCE_BEGIN);
});
});