Skip to content

Fix/verify source containment#158

Closed
philluiz2323 wants to merge 83 commits into
vouchdev:mainfrom
philluiz2323:fix/verify-source-containment
Closed

Fix/verify source containment#158
philluiz2323 wants to merge 83 commits into
vouchdev:mainfrom
philluiz2323:fix/verify-source-containment

Conversation

@philluiz2323

Copy link
Copy Markdown

What changed

verify.verify_source (src/vouch/verify.py) now reads the external file at source.locator through KBStore.read_under_root instead of Path(source.locator).read_bytes(). The new path inherits the same containment guard the write side (register_source_from_pathread_under_root) has had since #28: Path.resolve() + is_relative_to(self.root) + O_NOFOLLOW (POSIX) + fstat S_ISREG. Off-root locators surface as external_status="missing" with a note attached; in-project locators behave exactly as before (drift detection unchanged). Adds a Source._locator_non_empty @field_validator so the model layer rejects empty / whitespace-only locators (parallel to the Claim._text_non_empty / Entity._name_non_empty / Page._title_non_empty validators in the sibling fix that closes #155). No new public method, no on-disk-layout or MCP/JSONL surface change.
Fixes #157

Why

Fixes [BUG]. Source.locator is set verbatim from caller-supplied input on both transports — kb.register_source(content, url=..., source_type="file") routes url straight into locator (src/vouch/server.py:267, src/vouch/jsonl_server.py:205), and bundle / sync import accepts any locator string because the Source model has no locator-format validator. The verify-side read at src/vouch/verify.py:44-56 then did Path(source.locator).read_bytes() with no containment, no symlink defence, and no size cap. Three paths to plant an off-tree locator, one path to read it:

Then vouch source verify / vouch doctor / kb.source_verify opens the planted path, hashes it, and returns match / drift / missing. That's a usable file-existence + file-readability + hash-confirmation side channel against any path the vouch process can read — composable into filesystem-layout probing (does /etc/cron.d/X exist?), specific-file-content confirmation (register id=<known-target-hash>, locator=<target-path>; external_status="match" is the oracle), and unbounded-read latency / memory amplification via /dev/urandom or large files.

The matching write surface — register_source_from_path — was hardened in #28 with exactly this pattern (read_under_root, post-CVE-2007-4559). This PR closes the parallel read surface. Same structural lane as #81 / #82 (claim citations), #123 / #124 (graph integrity), #125 / #126 (source content-addressing), and #149 (artifact ids): an invariant articulated at one boundary, missing at the parallel boundary, with a fix that just routes the latter through the helper the former already uses.

What might break

Nothing on-disk, schema, bundle-format, MCP/JSONL surface, or audit-log shape — strictly tightening read-time containment.

  • Honest sources registered via kb.register_source_from_path already pass through read_under_root, so their locators are scoped to the project root by design. They continue to drift-detect normally.
  • A Source whose locator points outside store.root (planted via the un-scoped kb.register_source URL field, a malicious bundle, or hand-editing) now reports external_status="missing" with note "unreadable: path must be inside project root (...)" instead of "match" / "drift". The off-tree file is no longer opened.
  • A Source whose locator is empty / whitespace-only now raises pydantic.ValidationError at construction / model_validate time. Bundle / sync import paths surface this as schema validation failed: ...locator must be a non-empty... via _validate_content's existing pydantic path (same shape as bug: Claim model has no min-evidence validator — uncited claims land via bundle import, put_claim, update_claim #81 / fix(models): require Claim.evidence to be non-empty at the model layer #82 surface uncited claims).
  • The existing post-fix(models): require Claim.evidence to be non-empty at the model layer #82-review per-file resilience in vouch lint surfaces a legacy pre-fix on-disk source with an empty locator as an invalid_source-style finding rather than crashing the sweep.
  • Existing tests pass unchanged: test_verify_detects_external_drift, test_verify_handles_missing_stored_content, test_verify_handles_unreadable_stored_content, test_verify_all_continues_after_one_failure — all use tmp_path/<file> locators where tmp_path IS the KBStore root, so they flow through read_under_root exactly as they used to flow through Path.read_bytes().

VEP

Not required. No change to the object model field set, kb.* method surface, on-disk layout, bundle format, or audit-log shape — this only tightens read-time containment in verify_source to match the containment register_source_from_path and read_under_root already document. The new locator validator is the same @field_validator pattern the codebase already uses for Claim._at_least_one_citation.

Tests

  • make check passes locally — ruff check src tests clean, mypy src reports Success: no issues found in 33 source files, full pytest green on Windows / Python 3.13 except the pre-existing tests/test_jsonl_server.py::test_read_under_root_rejects_symlink_at_resolved_path which needs symlink-create privilege (unrelated; same env-only skip noted in fix(models): require Claim.evidence to be non-empty at the model layer #82 / fix: reject dangling Relation/Page references at every write path #124 / fix: enforce Source content-addressing on bundle/sync import #126).

  • New / changed behaviour has tests — 4 new regressions in tests/test_verify.py:

    • test_verify_refuses_to_read_off_project_locator — plants an off-tree file whose content sha256 matches the registered source's id. If the buggy Path(locator).read_bytes() path were still alive, external_status would be "match" (proving the off-tree read happened — exactly the hash-confirmation oracle). After the fix, external_status="missing" with "must be inside project root" in the note. The hash-equality framing means the test detects any future regression that re-introduces the off-tree read, regardless of whether the mocking story changes.
    • test_verify_refuses_to_read_absolute_system_path/etc/passwd on POSIX / C:\Windows\win.ini on Windows. Same containment guarantee, no dependency on whether the target file exists (the guard fires on is_relative_to(root) before the open).
    • test_verify_in_project_locator_still_detects_drift — positive guard. An in-project locator continues to drift-detect; rules out the containment fix accidentally breaking honest register_source_from_path flows.
    • test_verify_empty_locator_caught_by_modelSource(locator="") and Source(locator=" ") raise ValidationError matching "locator must be a non-empty".
  • CHANGELOG.md updated under ## [Unreleased]add before merge (suggested entry):

    - `verify.verify_source` now reads the external file at `source.locator`
      through `KBStore.read_under_root` instead of raw `Path(locator).read_bytes()`,
      inheriting the same project-root containment + `O_NOFOLLOW` + `fstat S_ISREG`
      guard the write side has had since #28 / CVE-2007-4559. Closes the
      file-existence + hash-confirmation side channel triggered by `vouch source
      verify` / `vouch doctor` / `kb.source_verify` on any path the vouch
      process could reach: previously an off-tree `locator` planted via
      `kb.register_source(url=..., source_type="file")` (which routes the
      URL string verbatim into `locator`) or a malicious bundle (no model-
      layer locator validator) would be opened, read, hashed, and reported
      as `match`/`drift`/`missing`; that read is now refused on containment.
      Honest in-project locators (those produced by `register_source_from_path`)
      drift-detect exactly as before. Adds `Source._locator_non_empty` to
      reject empty / whitespace-only locators at the model layer.
    

galuis116 and others added 30 commits May 25, 2026 04:28
…ouchdev#76)

crystallize wrote a durable Page directly through store.put_page,
embedding sess.task, sess.note, and sess.agent verbatim into the
rendered markdown body. The body was never gated by propose_page +
approve, so an agent calling kb.session_start(task=<payload>) and
getting any one claim approved via crystallize could land arbitrary
content into pages/. The page surfaces in kb.read_page, kb.list_pages,
kb.context, and (once vouchdev#60 is fixed) kb.search.

Restrict the summary body to fields the proposing agent cannot
influence: session id (server-generated), timestamps (server clock),
and the list of approved artifact ids. The agent-controlled fields
remain on the Session model itself and are still queryable, but no
longer promoted into a durable Page.

Also include summary_page_id in the session.crystallize audit
event's object_ids when a page is written, so vouch audit
truthfully attributes the write.

Adds two regression tests:
- test_crystallize_summary_page_does_not_leak_agent_controlled_fields
- test_crystallize_audit_event_records_summary_page_id
Leftover from the merge of main (which brought PR vouchdev#62's
test_sessions.py changes); ruff F401 was failing CI on PR vouchdev#77.
…index FTS5 on update (vouchdev#78)

Two compounding bugs let archived / superseded / redacted claims keep
flowing back to agents through kb.context:

  1. build_context_pack had no claim.status filter, so any retrieval
     hit was appended to the pack regardless of subsequent lifecycle
     mutations.

  2. store.update_claim refreshed the embedding cache but never
     re-indexed the FTS5 row, so claims_fts.status stayed frozen at
     first-index time. Every lifecycle op (archive, supersede,
     contradict, confirm) routes through update_claim and was
     therefore invisible to FTS5 / semantic search.

This made ClaimStatus.{ARCHIVED, SUPERSEDED, REDACTED} purely
decorative on the read side — agents kept quoting retracted
knowledge as if it were live.

Fix:

  - storage.update_claim now also calls index_db.index_claim under a
    short-lived sqlite connection (mirroring proposals.approve's
    first-index pattern). FTS5 errors are logged-and-skipped so a
    lifecycle op never fails because the embedding/FTS5 layer is
    misconfigured.

  - context.build_context_pack resolves each claim hit, drops it if
    the on-disk status is in {ARCHIVED, SUPERSEDED, REDACTED}, and
    drops it if the claim YAML has been deleted between index time
    and now. CONTESTED claims keep surfacing so contradictions
    remain visible.

Tests:

  - test_context_pack_excludes_archived_claims
  - test_context_pack_excludes_superseded_claims
  - test_update_claim_refreshes_fts5_status (asserts the FTS5 status
    column via direct SQL against state.db)

No on-disk-layout, schema, or bundle-format change.
Resolves conflicts in CHANGELOG.md, tests/test_sessions.py, and
auto-merges src/vouch/sessions.py with the recently-merged:

  - vouchdev#61 (FTS5 indexing of crystallize summary page) — adds
    index_db.index_page(...) right after store.put_page(...) inside
    crystallize. Composes cleanly with this PR's strict-derivation
    _build_summary_body and the summary_page_id audit fix.
  - vouchdev#62 (test_crystallize_collects_approval_failures) — preserved as
    a sibling test.
  - vouchdev#75 (bundle import sha256 verification) — CHANGELOG entry only.
  - vouchdev#73 (bundle POSIX separators) — CHANGELOG entry only.

Both regression tests added by this PR
(test_crystallize_summary_page_does_not_leak_agent_controlled_fields
and test_crystallize_audit_event_records_summary_page_id) still pass
against the merged sessions.crystallize.
vouchdev#81)

The 'claims must cite sources' guarantee (README §'Why this exists'
point 3; CONTRIBUTING §'Things we won't merge') used to live only in
proposals.propose_claim, so every other write path silently accepted
Claim(evidence=[]) and landed an uncited claim:

  - store.put_claim direct: existence-check loop iterates zero times.
  - store.update_claim: writes the YAML without re-validating.
  - bundle.import_apply via _validate_content: defers to
    Claim.model_validate, which accepted evidence=[] because the
    model had no min-length constraint.

Add @field_validator('evidence') on Claim — raises ValueError when
the list is empty. Closes all three bypass paths in one place.

store.update_claim additionally re-validates via
Claim.model_validate(claim.model_dump()) before persisting, so
in-place mutation (c.evidence = []; store.update_claim(c)) raises
before the YAML hits disk — the field validator only fires at
construction time, not on attribute assignment.

Four regression tests:

  - test_claim_model_rejects_empty_evidence (tests/test_storage.py)
    — Claim(evidence=[]) raises pydantic.ValidationError.
  - test_put_claim_rejects_empty_evidence — store.put_claim raises;
    no claims/<id>.yaml is written.
  - test_update_claim_rejects_empty_evidence — in-place mutation
    + update_claim raises; the on-disk YAML is unchanged.
  - test_import_rejects_uncited_claim (tests/test_bundle.py) — a
    schema-valid bundle whose claim YAML has evidence: [] is
    rejected by import_check (schema validation issue) and
    import_apply raises before writing.

The existing guard in proposals.propose_claim becomes a redundant
user-facing error message and is left in place for the friendlier
CLI/JSONL error string. No on-disk-layout, schema, or
bundle-format change; data the model never should have accepted
now raises.
…ing from tarball (vouchdev#80)

import_check previously only verified that tar members listed in the
manifest had matching hashes, but never checked the reverse: whether
every manifest entry had a corresponding tar member. A bundle whose
manifest.json referenced claims/c1.yaml but whose tarball contained
only manifest.json would pass import_check with ok=True, and
import_apply would silently write nothing — no exception, no audit
event indicating data loss.

Add the missing-member pass (mirroring the existing check in
export_check) so that manifest entries without a matching tar member
produce a "manifest lists missing file" issue. import_apply then
refuses to import because check.issues is non-empty.
read-only `vouch diff <old> <new>` that shows what changed between two
claim or two page revisions: field-level changes plus a line-diff of the
long text/body. auto-detects kind, hides churning metadata.
new read-only `vouch diff <old> <new>` compares two claims or two pages by
id and shows what changed: scalar/list fields as old → new, plus a line-diff
of the long text/body. auto-detects kind, hides churning metadata
(timestamps, approved_by), supports --json. closes a roadmap 0.1 item.
mypy inferred old/new as Claim from the first branch, so the page branch's
reassignment tripped an incompatible-assignment error in CI. annotate the
union up front.
feat(diff): add `vouch diff` for claim/page revisions
…hdev#82 review)

The new Claim.evidence min-citation validator (vouchdev#81) also fires when
claims are read back from disk. A KB that has a pre-existing
uncited claims/<id>.yaml from before the fix would otherwise crash
vouch lint / vouch doctor with a bare pydantic.ValidationError
deep in store.list_claims().

Add _load_claims_for_lint(), a per-file iteration that catches
pydantic.ValidationError (and any other load error) and surfaces
each bad file as a Finding with code='invalid_claim' and an
explicit repair hint: 'edit the YAML to add a citation, or delete
the file'. lint() also stops calling status() to populate counts —
status() calls the strict store.list_claims() which would re-raise
on the same files — and builds the counts dict inline from the
safely-loaded valid claims.

Regression test in tests/test_health.py:
  - test_lint_surfaces_legacy_uncited_claim_yaml_without_crashing
    hand-crafts a claims/legacy.yaml with evidence: []  (matches
    the on-disk shape an older buggy write path would have left),
    asserts vouch lint runs to completion, surfaces invalid_claim
    in findings with the repair-hint message, and that the
    well-formed sibling claim is still discovered.

CHANGELOG migration note expanded to describe the repair hint.
The new inline-built counts in lint() (from 5c881de) is a literal
dict with mixed value types (str/int/bool), which mypy correctly
inferred as dict[str, object] and rejected against the
HealthReport.counts: dict[str, int] annotation. The original
status() returned the same mixed dict via an untyped 'dict' return,
which masked the mismatch — the narrow type was effectively never
checked at the call site.

Widen counts to dict[str, Any] to match runtime reality. Also
tighten status()'s return annotation from 'dict' to 'dict[str, Any]'
for consistency. No caller does arithmetic on counts values; they
just echo or pass through, so the widening is risk-free.
…ummary-page-bypass

fix(sessions): close crystallize review-gate bypass via summary page
…s-citation

fix(models): require Claim.evidence to be non-empty at the model layer
…nd-config

fix(context): honor retrieval.backend config instead of hardcoding embeddings
feat(cli): vouch approve accepts multiple ids for scriptable bulk approval
plind-junior and others added 27 commits May 29, 2026 01:44
generalize init seeding into a named-template registry and ship a gittensor
(SN74) starter pack as the first non-default template. complements gittensory
rather than cloning its live-data signals.
generalize init seeding into a small in-code template registry. default
'starter' is unchanged; new 'gittensor' template seeds a cited, approved
pack (1 source, 1 entity, 4 claims) about SN74 contribution scoring so a
fresh kb in a gittensor repo has retrievable context immediately. unknown
--template gives a clean error listing available packs.

complements gittensory's live-data signals rather than cloning them.
# Conflicts:
#	src/vouch/health.py
#	tests/test_cli.py
feat(init): add --template with a Gittensor (SN74) starter pack
…late

feat(init): add --template with a gittensor seed pack
…chdev#97)

Adds a `vouch.logging_config` module with a `JsonFormatter` and an
idempotent `configure_logging()` that the CLI, MCP server, and JSONL
server entry points call at startup.

When `VOUCH_LOG_FORMAT=json`, the `vouch` logger emits one JSON object
per line with `level`, `logger`, `event`, and any structured extras
(`actor`, `object_ids`, ...) passed via the stdlib `extra=` argument.
Any other value (including unset) is a no-op - vouch's loggers keep
stdlib default behaviour, so callers who don't opt in see no change.

Scoped to the `vouch` logger namespace with propagate=False in JSON
mode, so this never reformats logs emitted by libraries vouch happens
to depend on.
…tion-docs

docs(gittensor): adoption guide (init, MCP wiring, decision workflow)
…le-proposals

Feat/vouch expire stale proposals
the var was already documented in adapters/generic-mcp/README ('force a
specific KB root, skip discovery') but never wired into the code. now
`discover_root()` reads VOUCH_KB_PATH first and short-circuits the upward
walk, with a clear error if the value isn't an existing .vouch directory.

closes the doc-vs-code drift and removes the per-host 'cwd: ...' ceremony
needed by Claude Desktop (and any other host that launches the stdio
server from a default working directory).
fix(storage): honour VOUCH_KB_PATH env var in discover_root
Address review on vouchdev#112.

- fsck() now loads claims via _load_claims_for_lint() like lint(), so a
  single invalid YAML (e.g. a legacy uncited claim from before vouchdev#81)
  becomes an `invalid_claim` finding instead of aborting the whole check
  with a traceback — the exact inconsistency a deep checker should
  surface. Counts are built from the safely-loaded claims via a shared
  _safe_counts() helper so the report can't re-trip the strict loader.

- Add _drift_findings(kind, indexed_ids, on_disk_ids, findings) to emit
  the orphan + missing-row findings, collapsing the three near-identical
  blocks in _check_index_drift. Codes and messages are unchanged; adding
  a kind is now a one-liner.

Add test_fsck_surfaces_invalid_claim_yaml_without_crashing.
Replace the _vouch_managed attribute (and its type: ignore[attr-defined])
with a _VouchManagedHandler(StreamHandler) subclass. configure_logging()
identifies its handler by isinstance for install/reuse/remove, which reads
cleaner and drops the attribute hack. Tests updated to match.
feat(health): vouch fsck — deep consistency checks beyond doctor
…t-json

feat: structured JSON logging via VOUCH_LOG_FORMAT=json
…ent-addressing

fix: enforce Source content-addressing on bundle/sync import
Co-authored-by: Cursor <cursoragent@cursor.com>
feat: add vouch migrate for safe on-disk KB upgrades
tiered adoption story under adapters/claude-code/: .mcp.json (T1),
fenced CLAUDE.md snippet (T2), four slash commands (T3), settings.json
with SessionStart hook + read-only auto-allow (T4). new vouch install-mcp
claude-code [--tier T1..T4] writes them idempotently into a project.
docs: plan for Claude Code adapter (4 tiers + install-mcp CLI)
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@philluiz2323, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 18 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1a5d684-723f-4dd0-b41f-d370249b36e9

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and 4cbbf99.

📒 Files selected for processing (43)
  • CHANGELOG.md
  • README.md
  • ROADMAP.md
  • docs/PROJECT-INDEX.md
  • docs/README.md
  • docs/gittensor.md
  • docs/multi-agent.md
  • docs/review-gate.md
  • docs/superpowers/plans/2026-05-27-claude-code-adapter.md
  • docs/superpowers/specs/2026-05-25-vouch-diff-design.md
  • docs/superpowers/specs/2026-05-27-init-templates-design.md
  • src/vouch/bundle.py
  • src/vouch/capabilities.py
  • src/vouch/cli.py
  • src/vouch/context.py
  • src/vouch/diff.py
  • src/vouch/health.py
  • src/vouch/jsonl_server.py
  • src/vouch/logging_config.py
  • src/vouch/migrations.py
  • src/vouch/models.py
  • src/vouch/onboarding.py
  • src/vouch/proposals.py
  • src/vouch/server.py
  • src/vouch/sessions.py
  • src/vouch/storage.py
  • src/vouch/sync.py
  • src/vouch/verify.py
  • tests/test_bundle.py
  • tests/test_cli.py
  • tests/test_cli_output.py
  • tests/test_context.py
  • tests/test_diff.py
  • tests/test_expire.py
  • tests/test_health.py
  • tests/test_logging.py
  • tests/test_migrations.py
  • tests/test_retrieval_backend.py
  • tests/test_sessions.py
  • tests/test_storage.py
  • tests/test_sync.py
  • tests/test_templates.py
  • tests/test_verify.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@plind-junior

Copy link
Copy Markdown
Member

Review

Summary: Routes verify_source's external-file read through KBStore.read_under_root (the same containment helper the write side has had since #28) and adds a Source._locator_non_empty model validator. This closes the file-existence + hash-confirmation side channel documented in #157. The fix itself is minimal and correct; the PR body is detailed and the test strategy is sound. My main note is a scope observation and one minor wording nit in a test message, both non-blocking.

What works

  • src/vouch/verify.py:41-129 — The containment re-route is exactly what the issue prescribes. ValueError (out-of-root) and OSError (I/O failure) are both caught and surface as external_status="missing" with a note, which is the correct graceful-miss behaviour. The else branch keeps drift detection for honest in-project locators.
  • src/vouch/models.py:4083-4095_locator_non_empty mirrors the existing _text_non_empty / _name_non_empty / _title_non_empty family. Cheap, closes the degenerate empty-string on-disk-poison case.
  • src/vouch/models.py:4105-4119_at_least_one_citation on Claim.evidence closes the silent uncited-claim bypass on put_claim / update_claim / bundle import. The validator being on the model rather than only in propose_claim is the right layer.
  • src/vouch/bundle.py:2181-2228_check_source_content_address closes the import-side content-addressing gap (validation gap: bundle/sync import doesn't enforce Source content-addressing (sha256(content) == id) #125's write-time companion). The check runs in import_check, and import_apply gates on check.ok before writing anything, so the protection is end-to-end.
  • tests/test_verify.py:7439-7485 — The oracle-detection strategy in test_verify_refuses_to_read_off_project_locator (register a source whose id == sha256(victim bytes), assert the result is not match) is clever and robust: any future regression that re-opens the raw read path will flip the assertion regardless of how the mocking story changes.
  • tests/test_verify.py:7489-7504test_verify_refuses_to_read_absolute_system_path doesn't depend on /etc/passwd existing, so it passes on every CI platform.
  • tests/test_verify.py:7512-7528 — The positive guard (test_verify_in_project_locator_still_detects_drift) is important; it rules out the containment fix accidentally breaking legitimate register_source_from_path flows.

Suggestions

  • [non-blocking] tests/test_verify.py:7478-7481 — The assertion message "must be inside project root" in result.note or "unreadable" in result.note passes as long as any note is attached, which makes the test less diagnostic on regression. Tightening to "must be inside project root" in result.note (dropping the or clause) would make future failures point directly at the containment guard rather than a generic OSError. Low priority; the surrounding assertion result.note is not None already catches the total-absence case.

  • [non-blocking] The issue (security: verify_source reads Path(source.locator).read_bytes() for any type=file source without containment — file-existence + hash-confirmation primitive via vouch source verify / doctor #157) suggested a third layer — rejecting type=file sources at kb.register_source intake when the locator looks path-shaped (server.py:255-273, jsonl_server.py:199-210). The PR body marks this as "optional belt-and-suspenders" and skips it, which is a defensible call (the verify-side guard alone closes the read primitive). Worth a follow-up issue if the team wants defence-in-depth at the write surface. Not a blocker here.

Verdict

approve — The core fix is correct, minimal, and well-tested. The two new validators (_locator_non_empty, _at_least_one_citation) and the bundle content-address check are appropriate companion hardening. Existing drift-detection behaviour is preserved by the positive regression test. The out-of-scope items (intake-side guard, CHANGELOG.md update already added by this PR) are handled or explicitly deferred with rationale. Ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment