Skip to content

feat: nothing writes silently, nothing degrades silently (v1.52.0) - #178

Merged
solaitken merged 16 commits into
mainfrom
feat/nothing-writes-silently
Aug 23, 2026
Merged

solaitken merged 16 commits into
mainfrom
feat/nothing-writes-silently

Conversation

@solaitken

Copy link
Copy Markdown
Collaborator

Summary

Nine tracker cards, one rule applied eight ways: nothing writes silently, nothing degrades silently. A note update now refuses to destroy what it could not read; o2b search check measures its pending-vector count instead of inferring it and audits the embedder record against the stored data; every structured Brain record carries a server-derived origin_channel; imports read back what they claim and resume at the turn boundary; failed multi-artifact writes leave a durable dead letter; and a standing census documents that visibility: private is a view filter, not a privacy boundary. One card closed as already shipped, one kept open with its redesign verdict - both on purpose.

Why the reading pays off

Reconnaissance against the live source before design corrected or refused six of the nine cards - the wave shipped what the source could support, not what the cards described.

flowchart LR
    A[9 tracker cards] --> B{Recon vs live source}
    B -->|premise holds| C[3 built as asked]
    B -->|premise corrected| D[4 rebuilt on real seams]
    B -->|already shipped| E[1 closed with anchors]
    B -->|inert by design| F[1 redesigned to honesty]
    C --> G[8 units, one shared vocabulary]
    D --> G
    F --> G
    G --> H{Independent review, fresh context}
    H -->|2 blocking + 11 should-fix| I[All applied, pinned by tests]
    I --> J[11693 tests green, one PR, v1.52.0]
Loading

Concrete benefits

  • A silent data-loss class is closed. An existing-but-unreadable note used to parse as empty, so an update destroyed frontmatter and body while reporting updated: true. That path now raises target_unreadable by name, and blanking a non-empty note requires an explicit allow_empty.
  • A false statement on the healthy path is gone. o2b search check told fully embedded vaults to "compute the first vectors" - unpinned by any test. The recommendation is now driven by a measured COUNT(*) anti-join; an absent index reports unrecorded, never zero.
  • Provenance no caller can forge. origin_channel is resolved once at the process entry point and threaded as an option; a census test proves no MCP schema and no CLI flag can name it. Twenty-five schemas already accept an unverifiable agent string - this wave declined to add claim number twenty-six.
  • Partial writes have a durable record. The one lane committing several artifacts from one payload accounts for itself in a shared attempted/found/missing vocabulary (missing keys always named) and leaves a content-addressed dead letter a dropped response cannot erase.
  • Resume that never loses data. An interrupted sessions import continues at the turn boundary; a resumed run re-collects what its recall analysis needs; a malformed checkpoint discards itself by name instead of resetting to zero.
  • Honesty as a standing gate, not a one-off audit. Three new architecture censuses (visibility surfaces, origin-channel boundary, reconciliation vocabulary registration) fail the build when a new surface appears unclassified.

What ships

Unit Artifact Role
Reconciliation vocabulary src/core/reconciliation-report.ts One report shape - attempted / found / missing, keys named - for accounting, census, audit
Measured pending vectors countChunksWithoutEmbeddings, IndexCheckReport.pendingVectors Replaces the false proxy recommendation
Embedder record audit + restamp store/embedder-audit.ts, o2b search restamp Record-vs-data third state; free vec_version repair, dry-run default
Blank/unreadable write guard notes/blank-overwrite-guard.ts, target_unreadable One seam covers both update callers; allow_empty for deliberate clears
Origin channel src/core/origin-channel.ts Server-derived stamp on log events, signals, continuity records, caller-named notes
Maintenance debt status.ts maintenance_debt Derived log_events_since_dream, named for what it measures; lower bound on unreadable shards
Link candidates + accounting + dead letter notes/link-candidates.ts, dead-letter.ts Bounded ownership-filtered manifests on three envelopes; durable partial-write record
Import read-back census + resume import-census.ts, sessions/checkpoint.ts, checkpoint-store.ts Claimed/found/missing after every import; turn-boundary resume on the shared substrate
Visibility surface census visibility-surface-census.test.ts, search check finding 71 note-returning surfaces enumerated; the boundary's absence stated where operators look

Test plan

  • Full suite on clean HOME: env HOME=$(mktemp -d) bun test - 11693 pass / 0 fail across 1193 files
  • bun run typecheck - clean
  • bun run lint - 0 errors (146 pre-existing warnings, byte-identical to main)
  • bun run scripts/sync-version.ts --check - all seven mirrored manifests at 1.52.0
  • Smoke on a fresh vault: search check renders measured pending_vectors, honest embedder_record: unrecorded, and the visibility_honesty finding; search restamp refuses by name with semantic disabled
  • Independent fresh-context review: 2 blocking + 11 should-fix + 6 nits - all applied in two dedicated commits

solaitken and others added 16 commits August 23, 2026 09:16
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Adds src/core/reconciliation-report.ts, the pure attempted/found/missing
report shape the nothing-writes-silently wave shares across three later
lanes: the import read-back census (Unit F), multi-artifact envelope write
accounting (Unit E), and the embedder record-vs-data audit's third state
(Unit G). `missing` always names its keys as a ReadonlyArray<string> - no
constructor accepts a bare count in its place, which is the misleading
no-op this wave removes. buildReconciliationReport validates attempted and
found (NaN, negative, non-integer), refuses found > attempted and an
unaccounted gap (attempted !== found + missing.length) as distinct named
cases, and refuses a duplicate missing key, all via the typed
ReconciliationReportError (code + details), matching the WriteBatchError
house idiom.

The closed RECONCILIATION_OUTCOME vocabulary (complete | partial |
contradicted) follows the project's frozen-object + membership-list +
unknown-typed-guard trio (BRAIN_SNAPSHOT_REASON's shape).
deriveReconciliationOutcome maps a report to its outcome deterministically;
an optional recorded-claim argument lets a caller name the third state
- a recorded number that disagrees with what the report itself measured,
distinct from a plain shortfall.

Registers RECONCILIATION_OUTCOME in the verdict-vocabulary census
(tests/core/architecture/verdict-vocabulary-census.test.ts): new import,
one CENSUS entry, and VOCABULARY_POPULATION bumped 71 -> 72 for the one
new four-piece vocabulary this unit adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Add computeBrainStatus's maintenance_debt block, derived on every call
from the existing Brain/log/ JSONL shards - no stored counter, no
STATE_SURFACES row. The field is named log_events_since_dream, not
writes_since_dream: write-batch.ts's note executors (backing
brain_create_note/update_note/append_note and the corresponding
brain_write_batch ops) never call appendLogEvent, so a caller-named
note write leaves no log event to count. The docblock states this and
why it makes the name honest.

A never-dreamed vault reports status: "never_dreamed" with the true
count of every log event ever recorded, not a zero that would read as
"no debt". Once a dream event exists, status becomes "counted" and the
count is strictly events after the dream's own timestamp, walking log
days newest-first and stopping once a day predates the dream so the
scan stays bounded to "since the last dream" rather than full history.

open_conflicts and pending_triggers are deliberately absent, not
forgotten: open_conflicts needs a semantic-health run this path does
not already pay for, and the triggers store (src/core/brain/triggers/
store.ts) has no status-only or count-only accessor - its only readers
fully parse every trigger file's frontmatter and body before a status
filter even applies, and terminal triggers are never pruned from the
directory, so the read cost grows with the vault's whole trigger
history. Both reasons are named in the module docblock.

The block rides second_brain_status's brain field and osb://status's
rendered markdown (new "## Maintenance debt" section). brain_context,
the always-loaded reader, gets only a new maintenance_overdue boolean
bounded to the single newest log day (computeMaintenanceOverdueFlag) -
never the full derivation - with the cost bound stated in its docblock.

Wet-only clearing needed no new mechanism: dream.ts's dry-run path
already emits no `dream` log event (writeDreamLog only runs when
!dryRun), so last_dream_at and maintenance_debt cannot move on a
preview. Pinned with a test that seeds a real state change, runs
--dry-run, and asserts the ledger is untouched, then runs the same
seed wet and asserts it does move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Two ways a note write could destroy a page and report success.

The sharper one: `readExistingNote` parsed the target with
`parseFrontmatter`, which resolves an unreadable file to `[{}, ""]` and
reports the failure through a notice channel the caller discarded. A
note that exists and cannot be read - a permission bit, a directory in
its place, a transient I/O fault, a file mid-sync - was therefore
projected as an EMPTY note: an update wrote the caller's body under an
empty frontmatter map and answered `updated: true`, and an append wrote
just the appended text over a body nobody had read and answered
`appended: true`. It now parses with `parseFrontmatterWithNotices` and
raises a typed `target_unreadable` naming the path and the read error.
The projection runs before any commit, so the file is untouched and, in
a batch, no earlier operation lands either.

The second: an update could replace a body that carries text with a
blank one, which at the byte level is indistinguishable from "delete
everything this page says". New pure module
`core/brain/notes/blank-overwrite-guard.ts` decides that in one place
and is wired at exactly one seam, `projectUpdateNote` - which covers
both callers, `brain_update_note` and `brain_write_batch`'s update op.
A blank replacement over a non-blank body is refused as
`blank_overwrite_refused`, naming the note; `allow_empty` is the way
through for a deliberate clear. Whitespace-only counts as blank because
the frontmatter parser trims the body it reads, so those bytes are read
back as an empty body on the next pass. A create of a genuinely new
empty note is not an update and is unaffected. The guard's docblock
records the three placements that were examined and refused - fs-atomic
(writes bytes, not notes), host-memory-write (append-only, already
refuses empty content), and inline/inline-rewrite (a parser, and a
line-preserving insert-only writer) - so nobody re-opens the question.

Two defects found while wiring this and fixed here:

- `brain_update_note` read `content` through `coerceStr`, which
  collapses an empty or whitespace-only string to `null` - the same
  value that means "argument absent". So `{path, frontmatter,
  content: ""}` asked for the body to be cleared, kept it, and reported
  `updated: true`, while `{path, content: ""}` was refused as if no
  argument had arrived. Presence is now read directly, so an explicit
  empty body reaches the kernel and is either refused by name or
  honoured under `allow_empty`.
- `target_unreadable` joins `config_invalid` in `OPERATOR_FAULT_CODES`,
  so it maps to a typed INTERNAL_ERROR rather than INVALID_PARAMS: the
  caller's path is right and the host is what is broken, and reporting
  it as bad parameters would send the agent rewriting a correct request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
`o2b search check` decided its reindex recommendation from a proxy: a
reachable embedding provider plus a loaded sqlite-vec extension. Those
two facts say the machine is ready to embed. They say nothing about
whether anything is waiting to be embedded, and the branch carried no
term for stored vectors at all - so a fully embedded, perfectly healthy
vault was told to "compute the first vectors". That is a false statement
emitted on the healthy path, and nothing in the suite pinned it, which
is why it survived: the recommendation had tests for its platform arms
and none for the arm that always fired.

The proxy is replaced by a measurement. `countChunksWithoutEmbeddings`
runs the same `LEFT JOIN embeddings ... IS NULL` anti-join that
`findChunksWithoutEmbeddings` walks, as a `COUNT(*)`, so the diagnostic
never materialises a pending chunk's body the way the indexer's work
queue must. `indexCheck` reads it through the same explicit read-only
peek the ABI tokens already use - it probes an in-memory database and
never touches the real index otherwise - so nothing lands on the query
hot path.

The count reaches `IndexCheckReport` as `pendingVectors`, a two-state
census. An absent or unreadable index is UNRECORDED, never zero: zero is
exactly the answer a caller reads as "nothing is waiting", and a count
nobody could take proves nothing of the sort. Both `search check`
shapes render it in every state, on the same terms `provider_probe`
already follows.

What the report recommends now follows the count: a fully embedded vault
gets nothing, an index whose chunks hold no vectors keeps the
first-vectors recipe verbatim, a partially embedded index names the
measured count and points at `o2b search vector-backfill`, and an
unrecorded census says so before naming the same build command. The
report prices nothing itself - the backfill dry run already counts
tokens at the model's rate, and a second estimator here could quote a
different number for the same work.

Shared pin moved: the `search check --json` key-sequence assertion in
tests/cli/search-check-integrity.test.ts gains `pending_vectors`
between `provider_reason` and `warnings`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
… what a restamp can fix

Two comparisons over embedder identity already existed and neither asked
the question this one does. The ABI stamp compares what the index
recorded against what this build would write; `staleEmbeddings` compares
the stored rows against the configured model. Both take the record's
word for what the index contains. So an `index_state` row claiming
dimension 8 over 4-wide stored vectors was invisible on every surface:
the record is not drifted from anything, it is simply wrong - evidence
of nothing rather than evidence of a gap.

`auditEmbedderRecord` is that missing comparison, in pure SQL and with
no provider contact: the recorded dimension against the DISTINCT widths
in `embeddings` and against the width `chunk_vec` declares, read out of
`sqlite_master` so the audit needs no extension load. Its verdict rides
the wave's shared reconciliation vocabulary - the record's own claim
(every observation matches me) is handed to `deriveReconciliationOutcome`
as the recorded claim, so a disagreement comes back as `contradicted`
rather than as a plain shortfall, and `missing` NAMES the observation
that disagrees instead of counting it. Everything with nothing to
compare - no index, an index that will not open, no recorded dimension,
no stored vector - is `unrecorded`, extending the distinction
`contradictedAbiFields` already draws rather than inventing a second one.
It reaches `IndexCheckReport` beside the pending-vector census, with one
shared sentence for the warning and the recommendation.

`o2b search restamp` is the other half: the repair for a drift confined
to `embedding_vec_version`. That token marks the sqlite-vec build the
storage was written under, not anything about a vector - two peers on
different builds each read the other's index as drifted, which is the
whole reason the ABI gate defaults to `warn` - and the only remedy on
offer was a full re-embed at provider prices. The verb is dry-run by
default, `--apply` writes that one cell, and it refuses BY NAME when the
recorded model or dimension disagrees, saying that repair is deferred by
design. It deliberately does not write through `Store.open(write)`:
that path runs `ensureEmbeddingModel`, which clears every stored vector
on a model or dimension change - exactly the destructive repair being
refused. It takes the writer lock and sets the one key, the way
`search check --integrity` records its verdict.

CLI-only, as the recon recommended: the MCP tool count stays at 113.
`EMBEDDING_ABI_FIX_COMMAND` now routes through `embeddingAbiFixCommand`,
which names the restamp verb when every drifted field is the vec_version
and the rebuild otherwise - the refusal, the status warning and the
check recommendation all read the same chooser, so none of them can send
an operator to a re-embed for a version string.

Shared pins moved:
- tests/cli/search-check-integrity.test.ts key sequence gains
  `embedder_record` after `pending_vectors`.
- tests/cli/search-query-flag-manifest.test.ts COVERED gains the
  `restamp` row (4 declared flags, `--apply` as its witness).
- src/cli/command-manifest.ts gains one `o2b search` child row; the
  human help text is derived from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Every import surface reported what it believed it had done and nothing
ever went back to check. `signals_created: 40` and a `reconcile` block
listing forty ingested sources are claims; when a write is lost between
the claim and the disk - a crash, a sync conflict resolved the other way,
a directory someone cleaned - the counter still says forty and the loss
stays invisible until somebody misses the note.

This adds the second look. `src/core/brain/import-census.ts` re-reads the
vault through the keys each lane ALREADY uses and reports attempted /
found / missing in the wave's shared reconciliation vocabulary, so
`missing` NAMES its keys. There is deliberately no path here that emits a
bare count: "40 attempted, 38 found" reads as accounted for while nobody
can say which two vanished, and that is the misleading no-op this unit
exists to remove.

No new index and no new hash scheme. The sessions lane reads back the
signal dedup hashes through the same `buildDedupIndex` walk the import
consults before every write; the ingest lane reads back through
`classifyPaths` over the content manifest, where a claimed source counts
as found only when the manifest recorded it and the bytes on disk still
hash to what was recorded. One serializer feeds every surface so a CLI
and an MCP reading of the same census cannot drift.

The census rides the existing result payloads rather than a new report:
`ImportSessionResult.census` (rendered by `o2b brain import-session`, JSON
and text) and `ReconcileReport.census` (rendered inside the existing
`reconcile` block of `o2b brain batch-plan --reconcile` and
`brain_ingest_batch_plan`). Both remain read-only and idempotent.

Scope is named rather than implied: the sessions census covers the signals
the import writes itself, which always land in `Brain/inbox/`. Extracted
facts are excluded because `routeExtractedFacts` moves its write target to
`Brain/pending/` under the write-approval gate, and a read-back over the
inbox alone would report every staged fact as lost - a census that quietly
covered two thirds of a run would be the same lie in a smaller font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Resume is not introduced here. `ingest/checkpoint.ts` has shipped
plan-scoped, atomic, vault-identity-asserted resume for the source-ingest
lane since the Ingestion & Import Robustness suite; what this extends it
to is the sessions lane, which had none. Any release note must say
extended, not introduced - claiming the capability as new would claim an
earlier release's work.

What the sessions lane had instead was dedup: `importSession` rebuilt a
dedup index from the inbox at the start of every run, so a second run over
the same transcript found every hash already present and discarded its
writes. That is re-do-and-discard, not resume. An interrupted 50k-turn
import re-read and re-hashed all fifty thousand turns to conclude it had
nothing to write.

The refactor shape. The ingest checkpoint did not generalize as it stood -
its record is a set of completed paths, the sessions record is a turn
count - so what the two share was extracted rather than copied:
`src/core/brain/checkpoint-store.ts` now owns the opt-out env var, the id
validation, the location under `<vault>/.open-second-brain/`, the read
that refuses an unknown `schema_version` instead of silently resetting,
the lock, the vault-identity guard, the atomic write and the removal.
Each lane keeps its own schema, serializer and no-op rule, because those
are the parts that genuinely differ. `ingest/checkpoint.ts` keeps its
whole public surface and its exact refusal messages.

`sessions/checkpoint.ts` keys on (file identity, turn boundary). File
identity is two cheap facts - the SHA-256 of the transcript's first line
and its size at the boundary - not a content hash, because re-reading
every byte is the cost being avoided and a whole-file hash would
invalidate on every append to a log that is append-only by construction.
The limit that buys is written down in the module: a transcript whose
first line differs or which has shrunk is refused; one rewritten in place
to the same-or-greater length with the same first line would not be
caught, and no shipped adapter's format produces that. The id also folds
the import's filters in, so a differently scoped run can never resume off
another run's boundary.

A boundary lands every 250 turns - a write per turn would take the lock
and land an atomic write fifty thousand times, which costs more than the
extraction it protects. A run that drains its file clears its checkpoint,
the same settle the ingest lane performs on a drained plan. A refused
checkpoint is reported as `resume_discarded` with its reason rather than
quietly ignored, because a boundary silently dropped is a full re-import
nobody was told about. What resume saves is the extraction work - the
marker scan, the dedup hashing, the fact pass, the writes; the lines are
still read, because a JSONL transcript has no index to seek into, and the
docblock says so rather than implying more.

Registration. The sessions checkpoint is new durable state at a new
location, and the existing `ingest_checkpoints` STATE_SURFACES row does
not cover it - that row derives `ingest-checkpoints/`, is attributed to
the ingest module, and describes one resume point per ingest PLAN. So a
`session_import_checkpoints` row is added (40 -> 41 declared surfaces,
with the architecture prose count moved to match), the lock-site and
destructive-site declarations move from `ingest/checkpoint.ts` to the
shared substrate that now performs those calls, and no new direct-`fs`
site appears: the count of measured direct writers is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Four record families now carry the channel of the process that wrote
them - log events, signals, continuity records and caller-named notes -
resolved once per process at the entry point and never from an argument.

The channel is a closed three-member vocabulary (`mcp-tool`, `cli`,
`import`) in a new `src/core/origin-channel.ts`. The CLI dispatcher
derives it from the command line before dispatch; the MCP stdio and HTTP
transports claim `mcp-tool` so an embedded host is covered too; the
brain import verbs are classified from one list that lives beside the
`switch` that dispatches them. Nothing else can set it: there is no tool
schema property, no CLI flag, and no field on any of the four writers'
input types, and `tests/core/architecture/origin-channel-census.test.ts`
asserts all three rather than asserting them in prose. That is the point
of the unit - 25 MCP schemas already take a caller-supplied `agent`
string verbatim, and a channel a caller could name would be the same
unverifiable claim with a schema in front of it.

Reading the channel before an entry point claimed it fails loud
(`resolveOriginChannel` names itself and the setter). The record writers
use `originChannelStamp` instead, which absorbs that state into the
explicit `unset` literal and stamps it - the same decision, for the same
reason, that `appendLogEvent` already makes for a device id it cannot
resolve, and with the same discipline: the absorbed value is documented
and outside the vocabulary, never a guessed channel.

Existing records are NOT backfilled. Rewriting history to add provenance
is the opposite of provenance, and the resolver docblock says so.

Per family: log events fold the field into the body beside `agent`, so
the markdown block and the JSONL sidecar stay one-to-one and `parseLogDay`
needs no change; signals carry it as a SIBLING of `source_type` (the
writeSignal docblock maps the two vocabularies and records why the rename
was refused) and `parseSignal` reads it back so it is not write-only like
`origin_vault` was; continuity records carry `originChannel` outside
`recordId()` so provenance cannot re-identify a record; the one shared
`resolveNoteTarget` envelope resolves it for all four caller-named note
tools and `createNote` stamps it last, so a caller-supplied key of the
same name loses. Notes are stamped on CREATION only - an update or append
rewriting a note it did not author must not overwrite the creating
channel.

The write-site census gains the boundary assertion: exactly four modules
stamp, one of them is among the 68 excused direct-`fs` sites and three
among the shared-helper class, and the uncovered remainder (67 and 96) is
measured from the same walk rather than described.

Two defects fixed in passing, both introduced-and-caught by this change:

- `validateArtifact` counted the stamp as authored frontmatter, which
  made its `frontmatter-missing` violation unreachable from `createNote`,
  the surface that produces most of the documents it judges. It now
  counts authored keys only, and `createNote` runs strict validation on
  the caller's frontmatter before merging the stamp.
- The channel is process-scoped and Bun runs many test files in one
  process, so a file invoking `main()` would have left `cli` claimed for
  every file after it and made every byte-identity golden order-dependent.
  The suite preload unclaims before each test.

Moved goldens, each re-measured and explained where it lives: the
create-note byte goldens, the continuity record envelope (eight fields to
nine), the two dream-report goldens, the write-binding absent-binding
bytes, the log JSONL sidecar row, the `brain query --json` pin, the
context-pack outcome line, and the snapshot-gate dream digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Three of the six needs-llm-step lanes ask the calling agent for a note
that cites the vault - the rollup ladder ("cite the items you fold in"),
the diarization profile, the design note - and none of them told it which
wikilink targets exist. Every link the agent wrote was a guess, and a
guess that misses lands a dangling link in a note nobody re-reads.

`notes/link-candidates.ts` is the answer the three share: a bounded
manifest of the basenames a `[[wikilink]]` resolves to, built from the
readdir-only basename walker. Zero embed by construction - no provider,
no vector store, no index is reachable from it by any import path, and
the suite walks the whole import closure to keep that true.

The manifest is not a bare array, because a forty-name list out of a
five-hundred-note vault reads exactly like a complete one: it reports the
measured `total` beside the subset it carries, whether it truncated, and
how the subset was chosen (whole vault, ranked by the vault's own token
overlap against a query, or alphabetical when the lane has no query to
rank with). `linkCandidateSchemaHint` turns those numbers into the one
CONSTRAINT line the envelope's `schema_hints` carries - the data stays in
`link_candidates`, because the hint channel is constraints and smuggling
a payload into it makes both unreadable.

The spine does not move. `LlmStepFields` and `NEEDS_LLM_STEP_KEYS` are
byte-identical: the field rides on each consumer's own type, in the
consumer position `tier`/`produces` already occupy, appended after the
spine's own keys so no existing serialized position changes. The three
lanes whose artifact carries no wikilink - skill page drafts,
extract-signals, the durable write session - carry nothing new, and a
test reads their sources to keep it that way.

The rollup ladder stays pure: its manifest arrives on `RollupLadderInput`
rather than being walked from a vault inside the planner, so the one
directory walk belongs to `dream.ts`, which already has the vault. It is
required rather than optional, because a lane that forgot it would emit
an envelope claiming the vault has nothing to cite.

The bound (40) is set by the MCP preview budget rather than by taste:
these envelopes return through a 2000-character budget, and a manifest
that pushed a lane's own answer into the artifact store would cost more
than it gave.

Shared pin moved: the rollup envelope's byte-for-byte JSON pin in
`tests/core/brain/llm-step.test.ts` gains the appended field and its
hint line, and is renamed from ROLLUP_ENVELOPE_JSON_BEFORE_SPINE (the
"before the spine" framing no longer describes what it holds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
`commitExtractedSignals` is the one lane in this tree that commits
several artifacts from one validated envelope payload - a file per mined
signal - so it is the one lane where a failure leaves a state nobody
asked for: three signals on disk, a payload half applied, and an error
that lives exactly as long as the response carrying it. A caller that
dropped the response left no record at all.

Two changes, both in the wave's shared vocabulary rather than a fourth
dialect of the same three numbers.

Accounting. Every commit now reports `reconciliation` - `attempted`,
`found`, `missing` with the missing keys NAMED - built through
`buildReconciliationReport`, on the success path and on the refusal
alike. `ExtractSignalsWriteError` carries the same report instead of
computing its own arithmetic in a message string, and its message now
states attempted/written/failed plus the first real error. Its existing
`topic`/`written`/`remaining` fields stay: they are the coordinates an
operator uses, and the report is the accounting - the two answer
different questions and the error was already the right place for both.
A deduped or durability-rejected item is deliberately NOT counted as an
attempted write; both are refusals the result already names, and folding
them into `missing` would report a decision the lane made as a loss it
suffered. Items the failing call never reached ARE named as missing:
whether they would have deduped is unknowable from there, and "not
written" is true of all of them.

Dead letter. `brain/dead-letter.ts` puts that same report on disk under
`<vault>/.open-second-brain/dead-letters/`, the machine-artifact root the
ingest checkpoint and content manifest already use, with the same atomic
write, vault-identity guard, and schema-version refusal. The id is
derived from the record's own content, so re-recording an identical
failure rewrites one file rather than accumulating one per retry. Reads
refuse rather than skip: a corrupt record or an unknown schema_version is
a hard error on the single read and the listing alike, because a skip
turns "the record of a partial write is unreadable" into "there was no
partial write". `tryRecordDeadLetter` is the variant for a caller already
inside a catch block - it never throws, and reports why it could not
write so the refusal can name that too.

Boundary, per the design amendment: multi-artifact commit lanes only.
A single-artifact lane's one failure IS its response, and a durable file
for every transient error would bury the case that matters.

Retention and inspection: nothing here deletes a dead letter. It is the
only surviving evidence of a partial write, so a sweep - or a clear on
the next successful run - would remove exactly what an operator has not
read yet; removal is a deliberate act on a named file. `listDeadLetters`
is the read API, and the new STATE_SURFACES row makes `o2b state status`
name the location. No new MCP tool: the surface stays at 113.

Shared pins moved (bumped by exactly this unit's own additions):
- STATE_SURFACES: 40 -> 41 rows (`write_dead_letters`), with its resolver
  binding in tests/core/state/surfaces.test.ts.
- state-surface-census SWEPT_POPULATION_SIZE 20 -> 21,
  DECLARED_SURFACE_COUNT 40 -> 41.
- write-site-census SHARED_HELPER_ROWS 99 -> 100 (a shared-helper writer,
  so no exclusion is owed).
No verdict-vocabulary census change: the lane name is a plain union type,
not a four-piece vocabulary, and RECONCILIATION_OUTCOME was registered
with the substrate.

Fixed in passing: the durability-denylist fixture added here matched
nothing (`/scratch/` against "Scratch this later.") and was rewritten to
the lowercase form the existing denylist test already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
…rule

The manifest shipped in the previous commit was built from the whole
vault, unfiltered, and a candidate list is a disclosure: the basenames of
`Brain/preferences/`, `Brain/retired/`, the inbox and the entity registry
ARE the ids the ownership rule governs, and an owner-tagged page under
`notes/` is governed by the same rule through its own frontmatter. The
agent-scope matrix caught it: `brain_design_note` handed one agent the
preference ids of another under `owner_scope_delivery: fail`. This is the
same leak `a-label-is-not-a-boundary` closed on fourteen report surfaces,
reopened by a new field, so it is closed the same way rather than a
second way.

`buildLinkCandidateManifest` now REQUIRES a `visible` predicate and has
no default - a manifest built without asking would leak in silence, which
is the only failure mode the option exists for. The predicate takes the
vault-relative PATH, not the basename: `ownerScopeView.visible` resolves a
bare basename only for the handful of `Brain/` trees whose files are
id-addressable, so a basename check on `notes/owned-by-someone-else.md`
would find no artifact of that id and fail open. `listVaultNotePaths` is
the path-shaped sibling of `listVaultBasenames` - the same readdir-only
walk, no frontmatter parsing - and `listVaultBasenames` is now derived
from it, so there is one walk rather than two.

The rule itself stays out of `link-candidates.ts`: `ownerScopeView`
reaches the search layer, and importing it would end the leaf status the
zero-embed proof rests on. Each lane binds the predicate instead, from
the gated view its surface already resolves - `gatedOwnerScopeView(vault,
ctx.agentName)` on the MCP side and `resolveBrainAgent(flags, config)` on
the CLI side, exactly as `brain_backlinks` and its verb already do. The
three lanes therefore take an explicit `ownerScope` option, required so a
new surface cannot forget it.

A withheld candidate is dropped BEFORE `total` is counted, per the
IDENTICAL TO ABSENT convention every owner-filtered surface obeys: a
total that counted hidden pages would tell one agent how many of
another's memories exist. On a vault that never enabled owner-scope
delivery the view is the shared no-op and the walk is unchanged.

Fixed in passing: `pathStem` in `fs-utils.ts`, because `stem` applied to
a path keeps the folder segments - a value that looks like a basename,
passes review, and fails every lookup. That was a live defect for one
commit in `listVaultBasenames` and the starter suite caught it.

Shared pins moved:
- agent-scope-matrix PROBE_TWO_SIDED_COUNT 31 -> 32 and the
  `brain_design_note` plan recipe reclassified from `ownerlessLane` to
  `ownerFiltered`: it now DOES reach owner-taggable content and filters
  it, which is the classification the probe's own doctrine demands
  ("filter the surface and move it to ownerFiltered - never widen the
  reason").
- docs/architecture.md: the same two numbers in prose (31 -> 32,
  194 -> 193).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Unit H, form B (nothing-writes-silently): visibility: frontmatter is
caller-liftable view scoping, not a privacy boundary, and it is wired
into exactly one place - search()'s pool-filters pipeline. Ships
honesty about that gap, not enforcement.

tests/core/architecture/visibility-surface-census.test.ts enumerates
every surface that can hand a vault note's path, title, or body back
to a caller and classifies it visibility-COVERED or EXCLUDED with a
written reason, mirroring write-site-census's shape: a closed category
vocabulary, a population pin, and a mechanism that fails on an
unclassified newcomer. The population is mechanically swept from
src/mcp/ (every real, currently-registered tool defined in a file that
imports a known note-content primitive) and from src/mcp/resources.ts
(listResources/listResourceTemplates, read directly); the CLI-verb
half is hand-enumerated and cross-checked for existence against
command-manifest.ts. Verifying against source rather than the prior
recon corrected two of its claims: brain_file_context is actually
covered (it calls search() internally), and brain_search_expand is
NOT covered (expandHit() never calls applyVisibilityScope, only an
optional agent-scope check) - both cited with source line references
in the registry.

src/core/search/visibility-surface-registry.ts holds the classified
list as data (71 entries: 42 MCP tools, 20 CLI verbs, 8 MCP resources,
1 index-store fact), consumed by both the census test and the new
`search check` finding, so the two cannot drift apart.

src/core/search/store/visibility-tag.ts adds a cheap, index-only
signal for whether the vault has ever used visibility: at all,
without a vault walk: the chunker already emits a page's frontmatter
block as its own chunk at chunk_index 0, so one indexed LIKE scan over
that one row per document answers the question. The `search check`
finding (JSON `visibility_honesty`, human `visibility_honesty:` line,
beside `pending_vectors`/`embedder_record`) is emitted only when that
signal is positive, with counts read from the registry's own exported
list rather than hand-written.

No enforcement change: graph/visibility.ts, owner-scope-view.ts, the
indexer's write path, and listVaultPages are untouched. Tool count
(113) and CLI manifest are unchanged - all changes are additive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Nine refusals and honesty fixes across the lanes this wave touched. Each
one is a case where the code knew something and did not say it.

Resume with `--recall` (blocking). `recallTurns` is an in-memory
accumulator committed AFTER the import loop, so an interrupted run wrote
none of it - and the resumed run skipped exactly those turns before they
could be collected. A 600-turn transcript interrupted at 250 reported
`recall_turns_imported: 350`, no errors, and left the head of the
conversation permanently absent from the recall DAG. The resumed run now
re-collects the turns before the boundary, through the same admission
filters the main body applies, and the recall importer's content dedupe
key keeps that idempotent.

Checkpoint payloads. The sessions reader coerced a non-integer
`turns_completed` to 0 - indistinguishable from "there was no
checkpoint", the module docblock's own anti-goal - and honoured a
negative one verbatim. Both are now refused into the documented
`resume_discarded` channel under a new `payload_invalid` reason, and the
CLI explains each reason rather than asserting one sentence for all
three.

Frontmatter the scanner cannot round-trip. `readExistingNote` already
refused an unreadable target; it held the parser's
`frontmatter-line-dropped` notice from the same call and wrote anyway, so
a frontmatter-only update deleted the line and answered `updated: true`.
New `target_frontmatter_lossy` refusal, operator-fault like its sibling.

Dead letters. The id embedded a second-resolution stamp, so a
deterministically-failing payload retried twenty times left twenty
byte-identical files in a directory nothing prunes - against the module's
own "rewrites one file" claim. The id is now purely content-derived and
`listDeadLetters` orders by `recorded_at`, which is the chronology the
filename no longer carries.

Maintenance debt. An unreadable or unparsable log shard vanished from
`log_events_since_dream` while the block still said `counted`; there is
now an `undercounted` state and `osb://status` prints the figure as the
floor it is. `computeMaintenanceOverdueFlag` returned `true` for a newest
day holding no readable event - overdue maintenance inferred from a file
nobody could read - and now answers `null`, matching its docblock.

Dream. The whole-vault link-candidate manifest was built before the
ladder decided anything, so every dry run that fired nothing paid a full
walk (a frontmatter read per note under owner-scope delivery), twice on a
runId collision. `planRollupLadder` now takes a supplier and calls it at
most once, only for a rung that fires.

Ingest plan ids. `brain_ingest_source` takes `plan_id` verbatim, and
`assertCheckpointId`'s refusal was swallowed by a bare `catch {}`: the
ingest succeeded, no checkpoint was ever written, and `--reconcile`
reported every source as never ingested. Validated at the MCP boundary
(INVALID_PARAMS) and at the top of `ingestSource`, before any write; the
swallow now covers only the transient half it was justified for. The
`reconcile` fixture that passed "seed-plan" - inert for the same reason -
uses a valid id.

Wording. The visibility-tag probe's docblock claimed an indexed
one-row-per-document scan; it is a full `chunks` scan that no index can
serve, now stated with what makes it affordable. Plus subject-verb
agreement in the embedder-record contradiction, a link-candidate hint
that called distinct visible basenames "notes this vault holds", and an
unreadable-index reason that blamed the open for a query failure (now one
shared formatter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
Six census and honesty corrections. Every one is a measurement that
described itself as stricter, wider, or more complete than it was.

`ORIGIN_CHANNEL` was the wave's only closed vocabulary shipped as a bare
`as const`. The verdict-vocabulary census enumerates FROZEN bindings, so
the scan never saw it: a rename that missed the guard would have failed
the census for every other vocabulary in the tree and passed for this
one. Frozen and registered; population pin 72 -> 73.

The visibility census read `name: "literal"` registrations only. About a
dozen MCP files register their tools through constants, and one of them -
`src/mcp/tools.ts`, in population since the census shipped - registers
`second_brain_capabilities` as `name: CAPABILITY_DIAGNOSTIC_TOOL`. The
census passed with a population of 42 that was 43 and no registry row for
that tool. The sweep now resolves constants through the declarations a
file makes and the ones it imports (one hop, stated), the row is written,
the pin is 43, and the docblock's "what this cannot see" names the two
shapes constant resolution still cannot reach.

The `visibility_honesty` line called the registry's row count "N
note-returning surfaces", which reads as the product's whole surface
area. It is the census's enumerated population - a mechanically swept
MCP half with stated blind spots and a hand-enumerated CLI half - and one
of the rows was the `index_store` fact the registry's own vocabulary
comment calls "not a callable surface". The line now names its scope, and
both counts come from `callableVisibilitySurfaces()`, with a test pinning
that the non-callable row is out of the denominator.

The origin-channel census's `agent` recount was `toBeGreaterThanOrEqual`
under two docblocks saying it fails when the surface GROWS. It fails only
on a shrink: ten new schemas taking `agent` verbatim would have passed at
35 >= 25. Pinned as an equality, matching the write-site census's
convention - and the write-binding docblock the count is argued from,
which still said twenty-two, now cites the census instead of keeping its
own copy.

The write-site census's "measured remainder" expects were
`68 - 1 === 68 - 1`: both sides reduced to pins already asserted, so they
could not fail independently. They now count the unstamped rows off the
sweep and compare against measured numbers.

`docs/architecture.md` said "the other 193" against a pinned 226 recipes
and 32 two-sided; 226 - 32 is 194. The branch had moved the number in the
wrong direction, preserving an off-by-one rather than correcting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
CHANGELOG entry and README lead for the wave; version stamps on the two
new docs/mcp.md bullets; version 1.52.0 propagated through
sync-version.ts across the seven mirrored manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dYpaSgWTK5L6o6AzcLcBd
@solaitken
solaitken enabled auto-merge (squash) August 23, 2026 12:53
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 129 files, which is 29 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 507c5311-3c95-4477-8b5e-b2c95c930ca8

📥 Commits

Reviewing files that changed from the base of the PR and between 6d3e8b2 and 8e6deec.

📒 Files selected for processing (129)
  • .claude-plugin/plugin.json
  • .codex-plugin/plugin.json
  • CHANGELOG.md
  • README.md
  • docs/architecture.md
  • docs/brainstorm/nothing-writes-silently/cli-output/claude.md
  • docs/brainstorm/nothing-writes-silently/cli-output/prompt.md
  • docs/brainstorm/nothing-writes-silently/design.md
  • docs/brainstorm/nothing-writes-silently/plan.md
  • docs/brainstorm/nothing-writes-silently/variants.md
  • docs/cli-reference.md
  • docs/mcp.md
  • openclaw.plugin.json
  • package.json
  • plugin.yaml
  • plugins/codex/.codex-plugin/plugin.json
  • plugins/hermes/plugin.yaml
  • pyproject.toml
  • src/cli/brain.ts
  • src/cli/brain/verbs/batch-plan.ts
  • src/cli/brain/verbs/design-note.ts
  • src/cli/brain/verbs/diarize.ts
  • src/cli/brain/verbs/import-session.ts
  • src/cli/command-manifest.ts
  • src/cli/main.ts
  • src/cli/search.ts
  • src/cli/search/verbs/check.ts
  • src/cli/search/verbs/restamp.ts
  • src/core/brain/checkpoint-store.ts
  • src/core/brain/continuity/store.ts
  • src/core/brain/continuity/types.ts
  • src/core/brain/dead-letter.ts
  • src/core/brain/design-note.ts
  • src/core/brain/destructive-sites.ts
  • src/core/brain/diarization.ts
  • src/core/brain/dream.ts
  • src/core/brain/extract-signals.ts
  • src/core/brain/import-census.ts
  • src/core/brain/ingest/checkpoint.ts
  • src/core/brain/ingest/ingest.ts
  • src/core/brain/ingest/reconcile.ts
  • src/core/brain/log.ts
  • src/core/brain/notes/blank-overwrite-guard.ts
  • src/core/brain/notes/create-note.ts
  • src/core/brain/notes/link-candidates.ts
  • src/core/brain/rollup-ladder.ts
  • src/core/brain/sessions/checkpoint.ts
  • src/core/brain/sessions/import.ts
  • src/core/brain/signal.ts
  • src/core/brain/status.ts
  • src/core/brain/types.ts
  • src/core/brain/write-batch.ts
  • src/core/brain/write-session/validate.ts
  • src/core/fs-utils.ts
  • src/core/origin-channel.ts
  • src/core/reconciliation-report.ts
  • src/core/search/index.ts
  • src/core/search/indexer.ts
  • src/core/search/serialize.ts
  • src/core/search/store.ts
  • src/core/search/store/chunks.ts
  • src/core/search/store/counts.ts
  • src/core/search/store/embedder-audit.ts
  • src/core/search/store/embedding-abi.ts
  • src/core/search/store/state.ts
  • src/core/search/store/visibility-tag.ts
  • src/core/search/types.ts
  • src/core/search/visibility-surface-registry.ts
  • src/core/state/surfaces.ts
  • src/core/vault.ts
  • src/core/write-binding/index.ts
  • src/mcp/brain/context-tools.ts
  • src/mcp/brain/design-note-tools.ts
  • src/mcp/brain/ingest-tools.ts
  • src/mcp/brain/knowledge-tools.ts
  • src/mcp/brain/notes-tools.ts
  • src/mcp/brain/write-batch-tools.ts
  • src/mcp/http.ts
  • src/mcp/resources.ts
  • src/mcp/stdio.ts
  • tests/cli/brain-query-as-of.test.ts
  • tests/cli/search-check-integrity.test.ts
  • tests/cli/search-check-visibility-honesty.test.ts
  • tests/cli/search-query-flag-manifest.test.ts
  • tests/cli/search-restamp.test.ts
  • tests/core/architecture/origin-channel-census.test.ts
  • tests/core/architecture/state-surface-census.test.ts
  • tests/core/architecture/verdict-vocabulary-census.test.ts
  • tests/core/architecture/visibility-surface-census.test.ts
  • tests/core/architecture/write-site-census.test.ts
  • tests/core/brain.log.test.ts
  • tests/core/brain/continuity/record-envelope.test.ts
  • tests/core/brain/dead-letter.test.ts
  • tests/core/brain/design-note.test.ts
  • tests/core/brain/diarization.test.ts
  • tests/core/brain/dream-topic-folding.test.ts
  • tests/core/brain/extract-signals.test.ts
  • tests/core/brain/import-census.test.ts
  • tests/core/brain/ingest/ingest.test.ts
  • tests/core/brain/ingest/reconcile.test.ts
  • tests/core/brain/link-candidate-envelopes.test.ts
  • tests/core/brain/llm-step.test.ts
  • tests/core/brain/notes/blank-overwrite-guard.test.ts
  • tests/core/brain/notes/create-note-modes.test.ts
  • tests/core/brain/notes/link-candidates.test.ts
  • tests/core/brain/origin-channel-stamp.test.ts
  • tests/core/brain/rollup-ladder.test.ts
  • tests/core/brain/sessions/import-resume.test.ts
  • tests/core/brain/snapshot-gate-coverage.test.ts
  • tests/core/brain/status-maintenance-debt.test.ts
  • tests/core/brain/sync-lockfile.test.ts
  • tests/core/brain/write-batch.test.ts
  • tests/core/origin-channel.test.ts
  • tests/core/reconciliation-report.test.ts
  • tests/core/search/embedder-record-audit.test.ts
  • tests/core/search/pending-vectors.test.ts
  • tests/core/search/visibility-honesty-finding.test.ts
  • tests/core/search/visibility-tag-presence.test.ts
  • tests/core/state/surfaces.test.ts
  • tests/core/write-binding.test.ts
  • tests/mcp/agent-scope-matrix.test.ts
  • tests/mcp/brain-context.test.ts
  • tests/mcp/brain-update-append-note.test.ts
  • tests/mcp/brain-write-batch.test.ts
  • tests/mcp/context-pack-evidence-tool.test.ts
  • tests/mcp/ingest-tool.test.ts
  • tests/mcp/mcp.test.ts
  • tests/mcp/resources.test.ts
  • tests/setup.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

@solaitken

Copy link
Copy Markdown
Collaborator Author

CodeRabbit skipped this PR (129 files exceed its 100-file limit). Compensating review, run before the PR was opened: an independent fresh-context reviewer examined the full diff against main with no knowledge of how it was built and returned 2 blocking, 11 should-fix, and 6 nit findings - every one applied and pinned by a regression test in the two dedicated fix commits (29a28d3, e38b7a8). Full suite after fixes: 11693 pass / 0 fail on a clean HOME; typecheck clean; lint 0 errors.

@solaitken
solaitken merged commit 323e2f0 into main Aug 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants