# 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>`).