omgbase-sync 2.2.13

omgbase sync: workspace discovery and repo selection, the source registry and settings, checkpoints and the filesystem freshness sweep, the adapter stdio protocol client, the coordinator over an engine client, the writer lock and watch lease — Rust implementation
Documentation
# Changelog

All notable changes to `omgbase-sync` are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/) (pre-1.0: a minor bump may break).

## [2.2.13] - 2026-10-17

### Patch
- Dependency pins moved: omgbase-store 15.0.2 → 15.0.3.

## [2.2.12] - 2026-10-17

### Patch
- Dependency pins moved: omgbase-store 15.0.1 → 15.0.2.

## [2.2.11] - 2026-10-17

### Patch
- Dependency pins moved: omgbase-store 15.0.0 → 15.0.1.

## [2.2.10] - 2026-10-15

### Patch
- History retention (spec/store 14.2 → 15.0, spec/surface 4.8 → 4.9, spec/cli 4.5 → 4.6): `omg history plan | export [--prune] | attach | detach | list`. A retention policy (`history.retention` settings: `keep_days`, a `ladder` of `one per day|week|month|year` rungs, `per_doc_max`, `keep_origins`, per-glob `overrides`) selects the revisions whose bodies move to an **archive segment** — one new SQLite file per export under `.omgbase/archives/` (`spec/store/archive.sql`, with a manifest) — and `--prune` squashes the store in one transaction: an archived revision keeps its `revisions`/`commits` rows (the `revisions` target, `docs_history` and `changes_since` keep listing it) and loses `root_tree`/`frontmatter_blob`, gaining `archive`; wholly archived commits' dispositions go, their block changes go except each block's earliest row (the mint oracle's); GC sweeps the bodies; the segment is recorded in the new `archives` table. Reads at an archived revision (`docs_read { at }`, `docs_read_at`, `docs_diff`, `docs_diff_unified`, `omg diff`) go through the attached segment byte for byte, or fail with the new `revision_archived { rev, archive, path? }`; the `revisions` target and `docs_history` gain `archived`. Schema v15 migration rebuilds `revisions` (nullable body columns, `archive`) and creates `archives`; `doctor --size` gains `archived` and the retention advice. Fixtures: new `spec/store/cases/retention.json`, `reads::archived`, `spec/cli` `history.json` 42 → 70; every store/mutate/sync projection gained `archive: null` / `archives: []`.

## [2.2.9] - 2026-10-14

### Patch
- Dependency pins moved: omgbase-store 14.1.1 → 14.2.0.

### Notes
- `same` dispositions are no longer persisted (spec/store 14.2, spec/cli 4.5). The matcher still produces a `same` verdict for every untouched block — and the observe outcome still counts it — but the store writes `dispositions`/`block_changes` rows only for verdicts that changed a block (`inserted`, `edited`, `moved`, `edited_moved`, `deleted`, the split/merge/copy lineage, `resurrected`, `bulk_rewrite`, the cross-document kinds). Measured on a real repository, nine in ten of the ~18.5 rows written per revision were `same`: 11 MB of dispositions plus an estimated 5–7 MB of block changes nothing read. Consequences, chosen on purpose: a block's `updated_at` (both readers: the OQX `blocks` binding and the legacy filter compiler) is now the block's own last change — inserted, edited, moved, split/merge lineage — not its siblings'; `blocks_history` lists the commits that changed the block (its tool description says so); `changes_since` summaries over api commits no longer inflate `<n> edited` with untouched blocks. The api path (`mutate/known-ids.ts`) classifies every carried block against its live row exactly as `spec/reconcile` §2 classifies a carry — raw hash, parent, sibling index — so a one-paragraph `plan`/`execute`/`blocks_update` edit writes one row instead of stamping every carried block `edited`, and a position-only change is `moved`. The first revision's `inserted` rows are untouched (the mint oracle depends on them). What is lost: the tables can no longer say "present and unchanged in commit X" — derivable by diffing two revisions (`docs_diff`). A pre-14.2 database keeps its historical `same` rows until `omg gc` runs: the default sweep now deletes them from both tables and reports `sameDispositionsSwept` (stderr `swept <b> blobs, <t> tree nodes, <s> same dispositions`; `--dry-run` counts and deletes nothing — through 4.4 it ran the empty mark-and-sweep for real), and `doctor --size` carries `same_dispositions` with an advice line past 25 % of the table. Not a migration (no `user_version` for a data-only change; opening a database never bulk-deletes). Fixtures: every store/mutate/sync/surface/cli case that projected dispositions lost exactly its `same` rows (store 694 → 552 rows over the two tables; mutate 1235 → 894, 81 api carries reclassified `moved`/`edited_moved`); new store step kinds `gc` and `exec`; new cases `identity::edit-one-of-three-writes-one-row`, `identity::move-only-change-writes-moved-rows`, `garbage::sweep-removes-pre-14.2-same-rows`, `commit::api-edit-one-of-three-leaves-siblings-same`, `commit::api-move-only-writes-moved-rows`, `reads::sibling-updated-at-does-not-move`.

## [2.2.8] - 2026-10-13

### Patch
- Dependency pins moved: omgbase-store 14.1.0 → 14.1.1.

## [2.2.7] - 2026-10-13

### Patch
- Dependency pins moved: omgbase-store 14.0.6 → 14.1.0.

## [2.2.6] - 2026-10-13

### Patch
- Minimal MCP tools profile (spec/surface 4.3, spec/cli 4.2). `omg mcp --tools <full|minimal>` (or the `mcp.tools` setting; a bad flag value is a usage error, a bad setting value is warned and `full` served) chooses which tools the server LISTS and ACCEPTS: `minimal` is ten tools — `docs_read`, `docs_tree`, `query_syntax`, `query`, `blocks_resolve`, `apply`, `plan`, `execute`, `changes_since`, `version` — and calling an unlisted one is the JSON-RPC error `-32602 unknown tool <name>`; semantics, schemas and fixtures are the profile-independent full catalog, filtered in registration order (`spec/surface/cases/profiles.json` pins both lists). `version` gains a last `profile` key (`"full"`/`"minimal"`; the CLI prints a `profile` line). The ten descriptions are rewritten as example ladders — a one-line summary, literal JSON rungs with a trailing comment, a last line naming `query_syntax` — and every rung is executed against the alchemy corpus by the new `spec/surface/cases/examples.json` suite, which a bytewise drift gate regenerates from the description text (`SURFACE_SPEC_UPDATE=1`), so a description cannot drift from what the engine does; `query`'s long-form prose moved into the `query_syntax` reference. Interop cases may carry `server_args` (reader only) and `{ method: "tools/list" }` steps, and record JSON-RPC errors as `{ rpc_error: { code, message } }` (`profile-minimal` is the 29th case, green in all four pairings). `omg --server` sugar verbs fall back to `apply { effects }` when the remote server does not list the sugar tool (a `#` locator among the refs is a usage error there, since resolution needs `blocks_resolve`); `@omgbase/sync`'s MCP engine client lists tools once per connection (`hasTool`).

## [2.2.5] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 14.0.4 → 14.0.5.

## [2.2.4] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 14.0.3 → 14.0.4.

## [2.2.3] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 14.0.2 → 14.0.3.

## [2.2.2] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 14.0.1 → 14.0.2.

## [2.2.1] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 14.0.0 → 14.0.1.

## [2.2.0] - 2026-10-12

### Minor
- DB-first materialization with recorded intent (`spec/store` 14.0 §5.4 "The api path", new §5.7; `spec/mutate` 2.2 §4 step 5; `spec/sync` 2.2 §4.1/§4.3). Schema 14 adds `pending_materializations` (one row per path: write / rename / remove, the revision, the hash the file must have, the commit) with migration 13 → 14; an older engine refuses a 14 database as before. The write order becomes read → one IMMEDIATE transaction (validate, commit, insert the pending rows) → materialize (write / rename / remove each file, fsync before rename, delete its row), so a conflict found inside the transaction never touches a file, and a crash between the commit and the file write is replayed — by the next write, the freshness sweep's step 0 or `recoverRepo` — with the api intent, commit and change-feed digest intact, instead of being healed as an observed commit that lost them. An observation of a pending path treats bytes hashing to the recorded hash as an echo, a missing file as not-yet-written (no tombstone), and anything else as a human edit that wins (ADR-004). `docs_delete` unlinks only a file whose bytes still match; `docs_move` falls back to writing the destination when the rename cannot happen. The guarantee the specs now state: all-or-nothing in the database, files eventually consistent through recorded intent, no cross-resource atomicity claimed. Fixture projections gain a `pending_materializations` key; new `crash_after_commit` and `materialize` observation steps. `omg sync` / `omgbase sync` JSON gains `materialized: [path…]` (the files the sweep's step 0 wrote; `spec/cli` 3.1, an additive key).

## [2.1.1] - 2026-10-12

### Patch
- Dependency pins moved: omgbase-store 13.6.0 → 13.7.0.

## [2.1.0] - 2026-10-12

### Minor
- Writer-lock discipline (`spec/sync` 2.1 §7): every host takes `writer.lock` once, at its outermost write boundary, for the write's whole duration. `omg mcp` now takes it around every write tool call (dry runs included) — before, the TypeScript server took none and relied on in-process serialization, which a concurrent one-shot CLI writer or a Rust `omgbase mcp` on the same workspace defeats — and `docs_move` holds one lock across the rename and the inbound-link retarget instead of two with a gap. The TypeScript watcher's priming sweep and checkpoint ingests are under the lock as in Rust, and in both engines a one-shot command's pre-verb freshness sweep and a one-shot `sync` now take it too (they ingest observed checkpoints and ran unlocked). The Rust binary gains a test seam, `OMGBASE_WRITER_LOCK_TIMEOUT_MS`, honoured by the CLI and `omgbase mcp`. `withWriterLock` is re-entrant within a process (a nested acquisition is a no-op; the outermost holder releases), so library callers that pass `omgbaseDir` keep working under a boundary lock. The Rust CLI's local write verbs now take the lock as the TypeScript CLI always did (`--server` leaves locking to the remote server). The spec's §9 pins the scope and records the fixed divergences.

### Patch
- Transaction hygiene on the observe path (`spec/store` 13.6, `spec/sync` 2.1 §4.1): the `conflicted` flag is written inside the member's ingest transaction with the pointers update instead of as a separate autocommit afterwards; the observed tombstone (`spec/store` §5.6) is one transaction in the TypeScript engine too (it ran bare autocommit statements); a checkpoint's `checkpoints` row and the resurrection-pool sweep are one transaction in both engines. No projection or fixture changes — a crash between any of these statements can no longer leave a committed revision with a stale flag, a pooled-but-live doc, or a swept pool without its checkpoint row.

## [2.0.0] - 2026-10-11

### Major
- **Surface 3.0 / CLI 3.0 — one name for every value: engine attributes are bare and win on collision, `$` belongs to OQX, user data that collides is reached through a handle, and every tool result spells a row the way the language does.** The rule (`spec/surface` §1.2, normative): `$it $key $depth $stop $leaf $frontier $ordinal` are values the LANGUAGE injects into a scope and omgbase adds **no `$` names** — a `$id` or `$path` in a query is now an unknown OQX identifier (absent). Every engine attribute is a bare snake_case name that **wins on collision**, the convention the relations (`children`, `blocks`, `out_links`, `doc`, `frontmatter`) always followed: docs `id path raw hash updated_at title tags format`, blocks `id path raw hash updated_at doc_id type text attrs depth ordinal`, nodes `id path doc_id block_id kind name value attrs`, links `id path doc_id block_id via_id dst_id dst_path uri field dst_kind predicate provenance anchor`, the root row `^id` (the repository). User data is bare too; where an authored key collides with an engine name it is reached through a **source handle** — `data` (NEW: the merged authored view, frontmatter + inline by the property rule, exactly the read a bare non-colliding key performs), `frontmatter`, `inline` — so `data.path` is the author's `path:` key, `data.tags` the authored list, `data.title` the authored title, and `entries(data)` lists every authored top-level key (frontmatter ∪ inline, key order). The loud-error set is **gone**: a bare `id`/`path`/`updated_at`/`hash`/`raw` is simply the attribute (2.x refused it with "did you mean `$path`?"). `title` is the computed title (the first H1, `spec/properties` §3.3); **`tags` is the union of the authored tags and the body `#hashtags`** (deduped, authored first, then hashtags in document order; null when both are absent) — the stored computed `$tags` row is unchanged, the surface changes the attribute, so `spec/properties` did not move. Handle reads **push down**: `data.<k>` is the bare property's SQL, `frontmatter.<k>`/`inline.<k>` the same single-scalar-row subquery scoped by `p.source` (before, every handle member fell back to in-memory evaluation while the bare key was pushed); `entries(<handle>)` and a whole-handle reference stay residual. Contextual words (`limit order offset follow collect count first distinct desc by in`) parse as fields in expression position; `select from where is not and or true false null` never do.

  **Results — same value, same name, everywhere.** A query hit is `{ id, path, …projection }` (the 2.x spelling) and a row surfacing as a value `{ id, path }`; **a projection may not overwrite the envelope** — `select id, path` is simply the envelope (no duplicate columns), and any other projection whose output key would be `id` or `path` (`select data.path`, `select path: doc.layer`) is `filter_invalid` "projection `path` collides with the hit envelope — alias it (`p: data.path`)" (`values` mode has no envelope and is exempt). Every tool result that renders a document, block, node or edge row names it the same way (`spec/surface` §2–§4): `docs_read`/`docs_read_at`/`docs_read_many.items[]` `{ id, path, rev, properties, raw, ids?, hashes?, parents? }` with `properties.computed` keyed `title`/`tags`; `blocks_read`/`blocks_read_many` `{ id, type, text, raw, hash, attrs, label, ordinal, depth, parent_id, children }`; `graph.documents[]`/`frontier[]` `{ id, path, degree, frontier, …select }` (an `id`/`path` selection collapses onto the envelope, any other bare name is its own column, an expression is `sel_<i>`), `graph.links[]` `{ id, doc_id, block_id, via_id, dst_id, dst_path, uri, dst_kind, predicate, provenance, anchor, field }`; `blocks_search.hits[]` `{ id, doc_id, path, type, text, score }`; `blocks_resolve[]` `{ id, locator, preview, evidence }`; `docs_list.items[]` and `docs_tree` doc entries `{ id, path, updated_at, blocks }` (dir entries `{ path, kind, docs, blocks, updated_at }`); every `revisions[]` (`apply`, the mutating tools, `changes_since` digests) `{ id, path, hash }`; `docs_history` `{ id, path, deleted, currentRev, versions: [{ rev, seq, commit, updated_at, origin, actor, hash, isCurrent }] }`; `docs_create`/`docs_move`/`docs_delete`/`docs_set_meta`/`observe`/`observe_many[]`/`observe_delete` `{ id, path, … }`; `docs_move.dangling[]` and `links_stale.stale[]` as link rows `{ doc_id, path, block_id, … }` (`links_stale.summary.bySource[].path`); `docs_diff[]` `{ kind, id, before?, after? }`; `docs_diff_unified` `{ id, path, from, to, diff }`; `links_retarget`/`links_repair` `hits[]` `{ id, path, oldRaw, newRaw }`; `blocks_update` `{ id, ids, … }`; `docs_update` opset `target: { id, path }`, `precondition: { id, path, baseRevision, hash }`; `repos_status` `{ id, slug, … }`; error data `stale_expectation.current { hash, markdown }`, `stale_plan.current { revision, hash }`, `ambiguous_heading.candidates[{ id, doc_id }]`. Role keys keep their names (`rev`, `from`/`to`, `before`/`after`, `locator`, `baseRevision`, `cursor`, `expected_content_hash`). Unchanged: `blocks_history[]`, `results[].ids/removed/mergedInto`, `graph.roots[]`, `blocks_read_many.unresolved[]`, `docs_read_many.errors[].ref`, `retargeted{}`, the `diffs{}` keys, every cursor (the keyset carries values, so a 2.x cursor stays valid), `version`, `repos`, `sync_status`, and `spec/store`'s own observe outcome `{ doc, commit, rev }`.

  **Language renames from 2.x (both engines, byte-identical):** `$id`→`id`, `$path`→`path`, `$updated_at`→`updated_at`, `$title`→`title` (computed; `data.title` for the authored key), `$tags`→`tags` (now authored ∪ hashtags; `data.tags` for the authored list), `^$id`→`^id`; `$body` → `raw` (a block's `raw` is its raw source bytes, not its text — `text` stays a field), `$content_hash` → `hash`; blocks `$doc` → `doc_id`, `$ordinal`/`$depth` → `ordinal`/`depth` (they collided with the `follow` recursion intrinsics; inside a `follow` `$depth` is always the walk's); nodes `$doc_id`/`$block_id` → `doc_id`/`block_id`, the `$node_id` synonym dropped; links (the `edges` target, renamed below) `$src` → `doc_id`, `$src_block` → `block_id`, `$via` → `via_id`, `$dst` → `dst_id`, `$dst_path` → `dst_path`, `$dst_uri` → `uri`, `src_field` → `field`, `$from_commit` removed (a query sees open links only; the commit is history). **Foreign-key elimination** in both planners: `doc.id`/`doc.path` on a block, node or link and `block.id`/`block.path`/`block.doc_id` on a node read the row's own FK column (or the scan's joined column) with no owner lookup — in SQL `b.doc_id`/`e.src_doc`/`n.block_id` — so `doc.id` and `doc_id` cost the same statement. The `query_syntax` text, both attribute switches and both translators carry the rule as a comment.

  **Arguments (`spec/surface` §4 "Arguments", normative) and the catalog:** a tool's prefix names the row kind it addresses and `id` / `path` in its arguments are that kind's attributes — every `docs_*` tool that took `doc` (id-or-path) and/or `path` takes `id?` / `path?` (at least one; both must name the same document, else `filter_invalid` "id "…" and path "…" name different documents"); `blocks_update.block`, `blocks_split.block`, `node_set.node` → `id`; list and role arguments keep their names (`ids`, `docs`, `roots`, `to`, `blocks`, `at`, `docs_tree.path`, `*.path_glob`, `docs_create.path`, the `observe*` file paths); no tool refers to a document of another kind (the name pass below made `diff`/`diff_unified` `docs_*` tools); the kernel-op grammar (`apply.ops[].block/blocks/to/at/expect`) is spec/mutate's and is unchanged; the bare argument name `doc` no longer exists. Every call is shape-checked ahead of its handler, in both engines alike: an unknown name gets the nearest valid one ("unknown argument `q`; did you mean `query`?"; the retired names name their replacement — "`doc` is no longer an argument — pass `id` or `path`", `block`/`node` → `id`, `heading` → `section`, `markdown` → `text`), a missing one "`query` is required", a mistyped one "`limit` must be an integer", each followed by the tool's shape; `data: { reason, hint: "arguments" }`. Errors spell the attribute: `doc_missing` "no document with id or path "<value>"", `block_missing` "no block with id "…"", a node "no node with id …"; a `docs_*` call with neither locator is `filter_invalid` "pass id or path". **Fossil renames** (blocks used to be called nodes in the read tools): `nodes_get { doc?, path?, id, resolution? }` → `blocks_read { id, resolution? }`, `nodes_get_many { doc?, path?, ids, … }` → `blocks_read_many { ids, … }` (the document scope arguments are dropped — a block id is global), `history_node { id, limit? }` → `blocks_history { id, limit? }`. **The append fold:** `sections_append` is removed — `docs_append { id? | path?, text, section?, dry_run? }` appends at the document's end or, with `section` (a heading's TEXT, resolved inside the named document; none → `parent_missing`, several → `ambiguous_heading` with `candidates[{ id, doc_id }]`), at the end of that section; the new `blocks_append { id, text, dry_run? }` appends INTO a block by what it is — a heading → the end of its section, a container → the end of its children, a leaf → `block_not_container` (`data: { id, type }`). `omg append <block>` renders `blocks_append`. **Loud `semantic()`:** `semantic_unavailable` is `provider_unavailable` at the surface and in `omg query`/`run`; a configured provider whose model has embedded nothing in the repository is `provider_unavailable` too (`data: { provider, model }`) instead of path-ordered rows with no score. **Description parity:** `spec/surface/tool-descriptions.json` is the one source of the 45 tool descriptions; `scripts/gen-tool-descriptions.mjs` generates `crates/omgbase-surface/src/descriptions.rs` from it and both spec runners pin it.

  **The tool-name pass (`spec/surface` §4 "Names and arguments", normative; §9 Decisions).** A tool's prefix names the row kind it addresses — `docs_`, `blocks_`, `nodes_`, `links_` — and `id`/`path` in its arguments are that kind's attributes; the repo-level operations carry no prefix (`query`, `query_syntax`, `graph`, `changes_since`, `apply`, `observe*`, `repos*`, `sync_status`, `version`); one verb returns the row: **`read`**. **The row kind `edges` is `links`** at the surface: `from links` (`from edges` is refused, naming the new spelling — and any other unknown top-level source is now a loud error instead of an empty result), the doc and block relations `out_links`/`in_links`, the root collection `^links`, `entries(^$it)` → `docs blocks nodes links`, the `graph` result's `links[]`; the attributes on the kind are unchanged, and the `md:link` NODE (a link's occurrence in the text, `via_id` points at it) is kept apart from the `links` ROW (the relation; a frontmatter link has a row and no node). The storage table and `spec/graph`/`spec/store` still say `edges` (`omg rebuild-index --edges` names the table). **Renamed tools** (46 → 44): `diff` → `docs_diff { id?, path?, from_rev, to_rev }`, `diff_unified` → `docs_diff_unified { id?, path?, from_rev?, to_rev? }` (the `doc_id`/`doc_path` foreign keys of batch 2 are gone — a diff is a document operation), `blocks_get` → `blocks_read` (its `id` is a BLOCK id — an `n_` node id is `block_missing` naming `nodes_block_read`, below), `blocks_get_many` → `blocks_read_many`, `docs_get_many` → `docs_read_many`, `node_set` → `nodes_set`, `text_search` → `blocks_search`, `resolve` → `blocks_resolve`, `tasks_complete { blocks }` → `blocks_check { ids }`. **Removed**: `read_ref` (ids carry their kind and a path is a document — call `docs_read { id | path }`, `blocks_read { id }` or, for an `n_` id, `nodes_block_read { id }`; `omg cat`/`show` dispatch client-side on the ref's shape, `show --json` still prints `{ kind: "document", ...docs_read }`) and `docs_plan_update` (`docs_update { dry_run: true }` is the plan-only call, `{ opset, plan, result: null }`). The final catalog (45 tools), in registration order: `docs_outline docs_read docs_read_many blocks_read nodes_block_read blocks_read_many docs_tree docs_list query_syntax query graph blocks_search blocks_resolve apply blocks_insert blocks_update blocks_move blocks_remove blocks_split blocks_merge blocks_check nodes_set blocks_append docs_append links_retarget links_stale links_repair docs_create docs_move docs_delete docs_set_meta docs_update observe observe_many observe_delete blocks_history docs_diff docs_diff_unified docs_read_at docs_history changes_since repos_status sync_status repos version`. The `query_syntax` text, both CLIs' help cards and verb→tool mappings (`cat`/`show`/`diff`/`find`/`done`/`node set`/`update --dry-run`), `@omgbase/oqx-syntax`'s host vocabulary (`links`, `out_links`, `in_links`), the docs, the OQX tutorial and both demos (which switch the names by the server's surface version) follow; `spec/surface/cases/query-edges.json` is `query-links.json`.

  **CLI 3.0:** every verb's `--json`/`--jsonl` carries the tool result verbatim, so the shapes above are the CLI's; `ls --ids` prints the ids (was the paths), `find -1`/`--ids` read `id`; the shell's row ref for `ls` is the `id` (label `path`), for `links` the far node's `doc_id`; the `show` card's link section is labelled `out links` (`links` out/in rows `{ predicate, doc_id, kind, count, samples? | block_id }`); `.field` reads any projected key. Human renderings are unchanged.

  **Lower specs that own a renamed shape take a major:** `spec/search` 2.0 (`text_search`/`resolve` hits — the layer's operation names; the tools are `blocks_search`/`blocks_resolve`), `spec/mutate` 2.0 (`revisions: [{ id, path, hash }]`, the opset `target`/`precondition`, `stale_expectation`/`stale_plan` `current.hash`), `spec/sync` 2.0 (the change-feed revision `{ id, path, hash }`; `@omgbase/sync` and `omgbase-sync`'s `McpEngineClient` read the new keys). `spec/store`, `spec/graph` and `spec/properties` are unchanged (the graph fixtures pin the `edges` table — `src_field` stays the column; the properties table keeps its computed `$title`/`$tags` rows, re-keyed only at the surface). `omgbase-store` is patched for the shapes it renders for the mutate and search layers. `@omgbase/oqx-syntax`'s omgbase host vocabulary lists the roots (`docs blocks nodes links`), the relations and the bare attributes per kind (for completion providers; its `$`-highlighting is OQX's own `$it`/`$key`/… only).

  **The follow-up to the name pass (`spec/surface` §1.2, §2, §4, §9 Decisions 2026-10-11; Brendan's three decisions).** **(1) `nodes_block_read { id, resolution? }`** (44 → 45 tools): `id` is a NODE id (`n_…`); it dereferences to the node's owning block and returns exactly what `blocks_read` returns for that block (same shape, same `resolution` default `full`). `blocks_read`'s `id` is a block id only — an `n_` id is `block_missing` "no block with id … (an n_ id names a node — nodes_block_read reads its block)"; an unknown node is `block_missing` "no node with id …", a block-less node "node … has no block". Each tool's `id` is unambiguously its namespace's kind; `omg cat`/`show` send an `n_` ref to `nodes_block_read`. **(2) The doc relations `out` and `in` are dropped** (the distinct linked / linking documents): docs relations are `blocks nodes out_links in_links`, and `out`/`in` on a doc reads an undefined property like any other unknown name. `in`/`out` were non-descript, `in` doubles as the contextual `in` keyword, and the link rows express both with `distinct`: the linked documents are `out_links collect distinct { from dst }`, the backlinks `in_links collect distinct { from doc }`, the citation walk `follow out_links collect { from dst }` / `follow in_links collect { from doc }` with the options in a second brace (`… { from dst } { depth 2 }`) and a predicate restriction inside the block (`follow out_links collect { from dst where predicate == "depends_on" }`); `from` over an absent `dst` (an external link, a dangling `[[wikilink]]`) contributes no successor. The `graph` tool compiles to those destination blocks; every `query-follow` fixture respelled with byte-identical expectations. **(3) `dst` on link rows**: a reach-through to the destination DOCUMENT row when `dst_kind == "document"` and the document exists (resolved by `dst_id`, which `dst_path` already names), absent for a `block` or `external` destination and for a phantom — it never reaches into a block; `doc` on a link row (the source) is unchanged. `dst.title`, `dst { title }` (a one-element list or empty), `dst values` (`{ id, path }` or null), `where out_links exists { where dst.layer == "draft" }`; `dst.<k>` pushes down like `doc.<k>` in both translators (the destination's properties by the row's own `dst_node`; `dst.id`/`dst.path`/`dst.hash`/`dst.updated_at`/`dst.format` a primary-key probe of the destination, NULL wherever `dst` is absent), and a literal compared with `dst.path` is rooted like one compared with `dst_path`. `query_syntax`, the §1.2 table (links relations `doc`, `dst`), `@omgbase/oqx-syntax`'s vocabulary (`dst` in, `out`/`in` out), the docs, the OQX tutorial and the graph-ui demo follow.

  **Migration from 2.x.**

  | 2.x | 3.0 |
  | --- | --- |
  | `$id`, `$path`, `$updated_at` in a query | `id`, `path`, `updated_at` |
  | `$title`, `$tags` | `title` (computed), `tags` (authored ∪ hashtags); the authored keys alone: `data.title`, `data.tags` |
  | `$body` (docs) / `$body` (blocks, the text) / `$content_hash` | `raw` / `text` / `hash` |
  | `$doc`, `$ordinal`, `$depth` on blocks | `doc_id`, `ordinal`, `depth` |
  | `$doc_id`, `$block_id`, `$node_id` on nodes | `doc_id`, `block_id`, `id` |
  | `from edges`, `out_edges`/`in_edges`, `^edges`, `entries(^$it)` → `edges`, `graph.edges[]` | `from links`, `out_links`/`in_links`, `^links`, `links`, `graph.links[]` (`from edges` is an error naming the new spelling; the storage table is still `edges`) |
  | `$src`, `$src_block`, `$via`, `$dst`, `$dst_path`, `$dst_uri`, `src_field`, `$from_commit` on edges | `doc_id`, `block_id`, `via_id`, `dst_id`, `dst_path`, `uri`, `field`, — (removed) — on `links` |
  | `^$id` / `0^$id` | `^id` / `0^id` |
  | a bare `id`/`path` was "did you mean `$path`?" | the attribute; the author's key is `data.path` |
  | `frontmatter.path` to force an authored key | still works; `data.path` is the merged read; all three handles now push down |
  | `select $path` (duplicate `path` column) / `select id: …` overwriting the envelope | `select path` is the envelope; `select path: …`/`select data.path` must be aliased (`p: …`) |
  | result readers: `docId`/`doc`/`blockId`/`repoId` → … | `id`; `path`/`srcPath` → `path`; `content`/`raw` → `raw`; `contentHash`/`content_hash`/`baseContentHash` → `hash`; `ts` on a doc row or version → `updated_at`; `placement.{parent,ordinal,depth}` → `parent_id`/`ordinal`/`depth`; `srcDoc`/`src` → `doc_id`, `srcBlock`/`block` → `block_id`, `dst` → `dst_id`, `dst_uri` → `uri`, `src_field` → `field`, `node` (CLI `links`) → `doc_id`; `properties.computed.$title/$tags` → `title`/`tags` |
  | inputs: `doc: <ref>` on `docs_*`, `diff`, `diff_unified` | `id: <d_ id>` or `path: <path>`; `block`/`node` → `id` on `blocks_update`/`blocks_split`/`nodes_set` |
  | `nodes_get` / `nodes_get_many` / `history_node` | `blocks_read` / `blocks_read_many` / `blocks_history` (drop the `doc`/`path` scope) |
  | `docs_get_many` | `docs_read_many` |
  | `diff { doc, from_rev, to_rev }` / `diff_unified { doc, … }` | `docs_diff { id \| path, … }` / `docs_diff_unified { id \| path, … }` |
  | `text_search { q }` / `resolve { query }` | `blocks_search { q }` / `blocks_resolve { query }` (same hits) |
  | `tasks_complete { blocks }` / `node_set { node }` | `blocks_check { ids }` / `nodes_set { id }` |
  | `read_ref { ref }` | `docs_read { id \| path }` for a `d_` id or a path, `blocks_read { id }` for a `b_` id, `nodes_block_read { id }` for an `n_` id (the node's block, in `blocks_read`'s shape; no result has a `kind`) |
  | `doc.out` / `doc.in` (the linked / linking documents) | `out_links collect distinct { from dst }` / `in_links collect distinct { from doc }` (no `out`/`in` relation: a bare `out`/`in` on a doc is an absent property) |
  | `follow doc.out` / `follow doc.in` (`{ depth n }` after the relation) | `follow out_links collect { from dst }` / `follow in_links collect { from doc }` (the options in a second brace: `… { from dst } { depth n }`) |
  | a link row's destination document (a second query by `dst_id`) | `dst` on the row — `dst.title`, `dst { title }`, `dst values`; absent for external/block destinations and dangling targets |
  | `docs_plan_update { id \| path, content }` | `docs_update { id \| path, content, dry_run: true }` → `{ opset, plan, result: null }` |
  | `sections_append { heading: <id>, markdown }` / `{ heading: <text>, doc\|path, markdown }` | `blocks_append { id, text }` / `docs_append { id\|path, section: <text>, text }` |
  | `semantic_unavailable` | `provider_unavailable` |
  | `ls --ids` consumers that wanted paths | `--jsonl` and read `path` |
  | cursors | nothing |

  The graph-ui and live-tail demos read both generations and send both argument spellings and tool names (`from links`/`from edges`, `docs_diff_unified`/`diff_unified`, `graph.links[]`/`edges[]`) by the server's `specs.surface`.

## [1.3.3] - 2026-10-10

### Patch
- **Paths on the surface are `/`-rooted** (`spec/surface` 2.0, `spec/cli` 2.0; Brendan's decision of 2026-10-10). omgbase has two path forms — storage (`docs.path`, the adapters, every store/sync spec) uses git's repo-relative form (`projects/oqx.md`), while every reference an author writes is root-absolute (`/projects/oqx.md`). The surface now speaks the reference form: every path a query, an MCP tool or an `omg` verb **returns** is rooted — `$path`/`$dst_path`, every hit's `path`, rows rendered as `{ id, path }`, `docs_list`/`docs_tree` (the tree's `prefix` is `/` for the root), `docs_read`/`docs_read_at`/`docs_get_many`/`read_ref`, `docs_history`, `diff_unified`, `changes_since` (revisions, deletions, moves and the `summary`), `links_stale`/`links_repair`/`links_retarget`, `text_search`, `resolve` locators, `graph`, `docs_create`/`docs_move`/`docs_delete`/`docs_set_meta` (incl. `dangling[].path` and the dry-run `diffs` keys), `apply`, `docs_update`/`docs_plan_update` opsets, `observe*`, and the CLI's `ls`/`query`/`outline`/`show`/`new`/`mv`/`rm --doc`/`meta`/`log`/`find`/`sync`/`retarget` renderings — and every path they **accept** tolerates both forms (a missing leading `/` is added, an extra one stripped). A string literal compared with `$path`/`$dst_path` by `==`/`!=`, or passed to their `startsWith`, is rooted before evaluation, so `$path == "a.md"` keeps matching; nothing else is rewritten (a property's value is the author's: `where ^$path in list(after)` replaces `"/" + ^$path`). The keyset cursor carries the rooted path; a cursor from a 1.x surface is refused as `filter_invalid` naming the cause. Storage is unchanged — no migration.

  Both engines: one normalization module each (`packages/core/src/core/paths.ts` + `surface-paths.ts` for the other-spec result shapes; `omgbase_surface::paths`), the SQL translator renders `$path` as `'/' || d.path` with an indexed fast path for a rooted equality literal, the store-index probes de-root their value, and the conformance suites, the surface fixtures (new `path-*`, `dst-path-*`, `refs-both-forms-resolve`, `reverse-reference-by-path-membership`, `cursor-from-1x-rejected` cases, `reads::alchemy` steps) and the interop suite prove the two agree byte for byte. `@omgbase/sync` / `omgbase-sync`: the coordinator de-roots the engine's paths before handing them to a source (`sync_out`, the reconcile summary). Rust `omgbase-sync` is otherwise untouched; `spec/sync` does not move.

  **Migration:** add the slash — `$path.startsWith("projects/")` → `"/projects/"`, `$path.matches("^texts/")` → `"^/texts/"`, `"/" + ^$path` → `^$path`; a `links_stale` `target` is now paste-ready as a repair `from`; a client parsing `docs_tree`'s `prefix` reads `/` for the root; discard stored cursors (`query`, `docs_list`, `docs_tree`) and start the page sequence again.

## [1.3.2] - 2026-09-28

### Patch
- Dependency pins moved: omgbase-store 13.5.2 → 13.5.3.

## [1.3.1] - 2026-09-27

### Patch
- `version` tool (`spec/surface` §4, 1.4) in the Rust catalog and `omgbase version [--json]` (`spec/cli` §6, 1.1): which engine and which versions — `engine: "rust"`, the binary's own version, `components` (the `omgbase` crate, every `omgbase-*` crate it is built from and `oqx`, keys sorted bytewise), `specs` (each crate's `SPEC_VERSION`, `oqx`'s `LANGUAGE_VERSION`, the binary's new `cli::SPEC_VERSION` pinned to `spec/cli/VERSION`), `schema` (`PRAGMA user_version`, `null` without a database), `mcp: { protocol }` (no SDK), `runtime` (`rustc <version>`), `commit` and `built`. The verb needs no repo, opens the workspace only for `schema`, and with `--server` returns the remote engine's answer — how a user learns whether a remote is the TypeScript or the Rust engine. `--version` is unchanged.
- `omgbase-surface`: `BuildInfo` + `Surface::with_build_info` + `version_info(store, build)`; `MCP_PROTOCOL_VERSION` (`2025-11-25`, what the reference's SDK serves) is now the transport's default at `initialize` (a client's own revision is still echoed).
- `crates/omgbase/build.rs` records `OMGBASE_COMMIT` (`git rev-parse --short HEAD` from a checkout, else the `sha1` of the packaged `.cargo_vcs_info.json`, else unset → `null`), `OMGBASE_BUILT` (RFC 3339, `SOURCE_DATE_EPOCH` honored) and `OMGBASE_RUSTC`.
- Every spec crate and `oqx` gain a `pub const VERSION` (their `CARGO_PKG_VERSION`) so the surface can report component versions.
- Runners: `spec/surface` §6 records a `version` read by shape (leaves → type names, `components` → `"<object>"`, `mcp.sdk` dropped); `spec/cli` §8 applies the same to the `version` verb's stdout (JSON leaves; human value cells; the component lines collapse to `  <object>`).