feat: nothing writes silently, nothing degrades silently (v1.52.0) - #178
Conversation
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
|
Important Review skippedToo 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (129)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
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. |
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 checkmeasures its pending-vector count instead of inferring it and audits the embedder record against the stored data; every structured Brain record carries a server-derivedorigin_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 thatvisibility: privateis 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]Concrete benefits
updated: true. That path now raisestarget_unreadableby name, and blanking a non-empty note requires an explicitallow_empty.o2b search checktold 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.origin_channelis 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 unverifiableagentstring - this wave declined to add claim number twenty-six.What ships
src/core/reconciliation-report.tscountChunksWithoutEmbeddings,IndexCheckReport.pendingVectorsstore/embedder-audit.ts,o2b search restampvec_versionrepair, dry-run defaultnotes/blank-overwrite-guard.ts,target_unreadableallow_emptyfor deliberate clearssrc/core/origin-channel.tsstatus.tsmaintenance_debtlog_events_since_dream, named for what it measures; lower bound on unreadable shardsnotes/link-candidates.ts,dead-letter.tsimport-census.ts,sessions/checkpoint.ts,checkpoint-store.tsvisibility-surface-census.test.ts,search checkfindingTest plan
env HOME=$(mktemp -d) bun test- 11693 pass / 0 fail across 1193 filesbun run typecheck- cleanbun 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.0search checkrenders measuredpending_vectors, honestembedder_record: unrecorded, and thevisibility_honestyfinding;search restamprefuses by name with semantic disabled