Skip to content

v1.54.0 - Private Is Not A Suggestion

Latest

Choose a tag to compare

@solaitken solaitken released this 28 Aug 00:59
dc55259

Open Second Brain v1.54.0 - Private Is Not A Suggestion

Open Second Brain has shipped a visibility: frontmatter field, a predicate that reads it, and a filter that applies it - and until this release the field was decorative. The filter was wired into exactly one call site, and the scope it filtered against came from the caller, so a remote caller that asked for the private scope was handed it. The previous wave measured that rather than closing it: v1.52.0 shipped the surface census and an o2b search check finding that stated, in the operator's own diagnostics, that this boundary did not exist. This release is the enforcement that census was built as the coverage map for.

Private Is Not A Suggestion

What ships

  • One reserved token, denied by default at remote reach. isRemotelyReadable denies exactly one token - REMOTE_DENY_VISIBILITY_TOKEN, written once in the normal form pageVisibility reads tags into, so a page cannot spell it a second way and mean something else - and answers true for everything else at every reach. Every other token keeps the caller-liftable semantics isVisible has always given it. The caller's own visibility argument still works and can still narrow; what it can no longer do is name the reserved token and be handed the page. There is no configuration key to switch this on, because a boundary an operator has to enable is the defect the previous release's honesty finding named.

  • The reach a request arrived at is minted by the transport and refused from the caller. TransportReach is a closed two-member vocabulary - local and remote - and it is a transport fact rather than a request field. stdio and the CLI mint local because the caller already started the process and holds whatever filesystem access it runs with; an HTTP bind mints from isLoopbackHost alone, keyed on the bind rather than on a caller-supplied Host header. A caller that puts a reach-shaped argument in a tools/call is refused with -32602 and the token caller-supplied-reach, ahead of the unknown-argument gate and without consulting the tool's schema, so an open schema is covered too. Names are compared case-folded and separator-free, so reach, Reach, transport_reach, transport-reach and transportReach are one argument rather than five holes.

  • Enforcement at three read roots, and a sweep that proves there is no fourth. Root A is the ranked search pipeline, Root B is listVaultPages - where the reach is a required option rather than a defaulted one, so each of its call sites states which caller it walks for and the compiler is what asks the question - and Root C is the key-addressed reads, where both key spaces are enumerable, so a withheld read answers with the message an absent one produces, byte for byte, rather than with a distinguishable refusal that would be an existence oracle over exactly the population being hidden. DIRECT_VAULT_READ_REGISTRY and its architecture sweep make the closure claim checkable instead of prose: every file under src/mcp/, src/cli/ and src/openclaw/ that opens a vault path through node:fs rather than a root must carry a row with a closed category and a written reason, and the sweep fails in both directions. It found two on its first run, and both now ask the rule at the site of the read.

  • documents.visibility: what the index measured, backfilled by schema 12. Three distinct states - a JSON array of the page's normalised tokens, [] for a page that declares none, and NULL for a document the index holds no frontmatter chunk for. The backfill is the whole of why this is more than an ALTER TABLE: a column added without one is NULL for every existing row, and the only fail-closed reading of unmeasured is reserved, which would withhold every document in every vault on upgrade. The frontmatter is already in the index as its own chunk, so the backfill is one table scan with no vault walk and no operator action. It runs inside the migration transaction for atomicity, since a half-filled column leaves measurable rows reading as unmeasured.

  • The OpenClaw page walker joins the boundary and the census. src/openclaw/index.ts was the one surface the previous census named as un-swept, which meant the place already identified as a gap was the place the sweep could not have found it. Its page search takes the narrowest reach, and the distinction from the CLI is the caller rather than the machine: a CLI verb is typed by the operator, while these tools are called by a model over a conversation the plugin cannot see.

What this closes that was quietly open

  • The query cache is partitioned by the reach that computed it. The reach was not in buildCacheKey, so a local outcome and a remote outcome collided on one row of the shared query_cache table that the operator's CLI and the HTTP server both write: whichever warmed the row first served the other, handing a remote caller the reserved pages it had just been denied.
  • The retrieval trail stopped counting withheld rows. scope-filters-dropped-rows rides out on every brain_search response. Its baseline was taken in front of both rules, so a remote caller searching a term nobody public wrote about got zero results and a trail saying two rows had been removed - an existence oracle enumerable one query term at a time.
  • The successor pull-in re-asks the rule. Relation polarity resolves typed superseded_by edges to documents that were never in the filtered pool and appends them with full path, title and content, so a public page declaring a reserved successor was a route to that successor's body.
  • Root B fails closed on a page it could not read. The parser resolves an unreadable file to an empty map - byte-indistinguishable from a page that declares nothing - so listVaultPages listed it at remote reach with its path and its filename-derived title. Toggling a reserved page's readability was itself the oracle.
  • A withheld preference names the id an absent one names. Both refusals claimed byte-identity with the absent form in their own comments and neither had it; for the bare-slug key shape, the presence of the pref- prefix was a one-bit existence oracle over the whole reserved population.
  • The reference grammar stopped failing open on spelling and on shape. A reference the resolver could not place was read as naming nothing and let its row through - a fail-open on a rule whose whole posture is fail-closed.

Refuted premises, recorded rather than designed around

Both source cards had their premises checked against the live source before anything was designed, and the verdicts ship as deliverables.

  • The first card's claim that no visibility: handling existed in the read surfaces is refuted by src/core/graph/visibility.ts and src/core/search/result-filters.ts. The carrier it proposed - extending gatedOwnerScopeView - is refused as an approach: that view short-circuits every predicate to true unless integrity.owner_scope_delivery is set to fail, so riding it would have shipped privacy that is off in every default install.
  • The second card's claim that no registry-driven harness and no fail-closed gate existed is refuted by the agent-scope matrix and the surface census, both of which already fail on an unclassified newcomer. What survives is that the harness's axis is ownership rather than visibility, which is the axis this release adds.
  • The wave's own stated risk is corrected too: indexRevision is a query-cache generation counter consumed by computeCorpusGeneration, not a schema or index-format revision, and moving it forces no reindex on anyone.

Honest remainder

  • A reserved page's body stays in the index, so anyone with file access to brain.sqlite reads it - the same trust boundary as the vault's own Markdown files. Excluding it would take an operator's private notes out of the operator's own local search, so this release does not close it.
  • 24 of the 76 enumerated callable surfaces now consult the field, up from 3 of 71: 13 MCP tools, 7 CLI verbs and the 4 templated osb:// readers, each with a written reason naming which root covers it. The 52 that remain are the honest remainder, each carrying what it actually discloses rather than being waved through. The visibility matrix asserts that list in both directions, so a surface leaving it is the wave that covered it and one joining it is a regression.
  • The local bypass proves filesystem-equivalent access, or a request that originated on this host. It does not prove operator intent, since a remote host can spawn a stdio subprocess. The alternative - no bypass at all - would take an operator's private notes away from the operator's own shell.
  • A vault that never wrote the token sees no change at all, and the schema-12 backfill is what guarantees that rather than an assumption.

Quality record

12,069 tests across 1,204 files with 0 failures, TypeScript clean, lint at 0, formatting clean, manifest version sync verified, and the Python plugin suite at 146 tests - all green on the merged commit. The version bump to 1.54.0 shipped inside the feature pull request (#183), per the project rule in CLAUDE.md.

Notes

  • The design called the closed vocabulary disclosure. That word was already taken in this tree - SearchOptions.disclosure is the progressive result-depth mode - and this value rides on the same options bag, so the transport fact is named for what it measures instead. Two fields with one name meaning different things is the defect this suite exists to remove.
  • The new visibility matrix is a sibling of the owner matrix rather than a second axis inside it: the two cannot share one fixture, because the owner matrix builds its contexts without a reach. The recipes are shared through one catalogue rather than copied. Every probe goes through the real tools/call path and asserts over the whole serialised response.
  • A pre-existing test flake was root-caused and fixed on the way. dream progress > attaching an observer changes nothing the pass writes failed about once in twelve loaded runs and never in isolation; the whole difference was the snapshot entry's size_bytes and whether the copy straddled a second. Verified against main's own source at the same rate before being fixed.
  • Release image: the interaction-diagram layout inside the canonical terminal style (animated GIF in this body; static PNG, 2x PNG and the SVG source attached as assets).