diff --git a/CLAUDE.md b/CLAUDE.md
index 0897628..61c78eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,7 @@
# MonkeyLLM agent guide
Knowledge forest navigable by an SLM: markdown + indexes, traversed through
-**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.78.md` is normative
+**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.79.md` is normative
(earlier versions are archived) **the spec is the truth**; any contract
change requires a new spec version before code.
@@ -77,6 +77,38 @@ Local models (llama.cpp on the 3090): see `docs/local-inference.md`.
## Conventions and pitfalls
+- **The walk had no clock and no window (spec J.10.5 r3/r4 + C.13.1 r7 +
+ C.13.3 + J.10.7 + J.5.19, v0.79)**: an operator asked a walk for "the two
+ screenshots I uploaded today"; `coverage` counted two media nodes in hop
+ 1, the walk then read the root, scanned it FLAT (0), grepped every body
+ for the literal word "media" (four task docs) and answered that the
+ product keeps no pictures. Nothing was missing on the wire: `since`/
+ `until`/`date_field` on `locate`/`sniff`/`scan`, `type_filter`,
+ `filter: {type}`, `recursive` all existed and the loop forwards the
+ model's args verbatim — the MENU named none of them (v0.67's rule about
+ tools, applied to arguments), `calendar` was not on the whitelist, and
+ NO prompt said what day it was, so "hoje" was unresolvable with every
+ window parameter in the world. Now: `FORAGE_TOOLS` gains `calendar`
+ (bounded by the caller's window like the searching calls; `coverage`
+ stays unbounded), the menu names every parameter, `FORAGE_CLOCK` states
+ `host_today()` — `date.today()`, the SAME clock that stamps
+ `created`/`updated`, never a second one — and that date enters the
+ WALK's J.10.7 key only (`build_key(today=)`; the sweep's prompt states
+ no date and a date in its key would expire every stored answer
+ nightly). The caller's bound lands LAST (`{**args, **bounds}`), so a
+ model-authored `since` is replaced, not merged, and the prompt says so;
+ the record shows the NORMALISED bound (`2020-01-01`, C.13.1 r6). `HOP_ARGS`
+ gains `filter`/`type_filter`/`since`/`until`/`date_field`/`recursive`/
+ `granularity` — the arguments that decide whether a listing is EMPTY,
+ which the console could not show; `_outcome` reports `buckets` for
+ `calendar`. Gotcha: `consult_walk_store` is a closure that never
+ imported `inference` — a bare name there is a 400 "answer failed: name
+ 'inference' is not defined" on every walk, caught by the store suite.
+ Ask console (J.5.19): two date inputs, `since`/`until` sent only when
+ set, read from the ADDRESS at mount like `q` and never a browser
+ preference (v0.76's lesson), answer labelled by the response's
+ `window` echo, history badges a bounded run. F.173 in
+ `tests/test_v079_walk_window.py` (+ `apps/studio/check-ask-window.mjs`).
- **The passport travels with the bytes (spec J.8.4, v0.78)**: the agent
that followed v0.77's path uploaded a screenshot it had already looked
at, and the scent it knew had nowhere to go until a second call and a
diff --git a/apps/station/monkeyllm_station/answer_store.py b/apps/station/monkeyllm_station/answer_store.py
index 4e4f7bb..6c19712 100644
--- a/apps/station/monkeyllm_station/answer_store.py
+++ b/apps/station/monkeyllm_station/answer_store.py
@@ -83,7 +83,8 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool,
binding: dict, policy, head: str | None = None,
reply_tokens: int | None = None,
window: dict | None = None,
- include_superseded: bool = False) -> str:
+ include_superseded: bool = False,
+ today: str | None = None) -> str:
"""The closed list of J.10.7 — and nothing off it.
Every component here can change the answer; nothing else may enter,
@@ -103,6 +104,11 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool,
that same reason: the flag decides whether a replaced document is in the
material at all, so the history view and the current view are two
questions — and off, which is the default, keys exactly as before.
+ `today` (J.10.5 rule 3, v0.79) is the walk's alone: its prompt states
+ the host's date, so a hunt asked on two days is two hunts and an entry
+ served across midnight would answer "today" with yesterday's walk. A
+ sweep passes None — its prompt states no date, and a date in its key
+ would expire every stored answer nightly for nothing.
"""
material = json.dumps({
"question": normalize(question),
@@ -130,6 +136,7 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool,
"date_field": window.get("date_field")}}
if window else {}),
**({"include_superseded": True} if include_superseded else {}),
+ **({"today": str(today)} if today else {}),
}, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(material.encode("utf-8")).hexdigest()
diff --git a/apps/station/monkeyllm_station/app.py b/apps/station/monkeyllm_station/app.py
index 945cf85..c7a04c0 100644
--- a/apps/station/monkeyllm_station/app.py
+++ b/apps/station/monkeyllm_station/app.py
@@ -2157,18 +2157,23 @@ def consult_walk_store(sample, forest, vine, policy, binding, payload,
return None
from monkeyllm.harvest import derive_terms
+ from monkeyllm_station import inference
+
t0 = time.perf_counter()
head = _git(Path(vine.forest.root), "rev-parse", "HEAD")
# The walk's `k` is not capped by C.6c and keys as given. Its
# effective terms are the derived ones and can be nothing else:
# `terms` beside `hops` is refused before this point (J.10.3), so
# this IS the "or the sweep derived them" half of J.10.7 rule 2.
+ # J.10.5 rule 3 (v0.79): the walk's prompt states the host's date,
+ # so the date is part of what the model was asked — read off the
+ # same clock the prompt reads, never a second one.
key = answer_store.build_key(
question=question, terms=derive_terms(question),
k=k, hops=budget, window=window,
hybrid=bool(getattr(vine, "hybrid_locate", False)),
binding=binding, policy=policy, head=head,
- reply_tokens=reply_tokens)
+ reply_tokens=reply_tokens, today=inference.host_today())
store = answer_store.AnswerStore(Path(vine.forest.root))
sample["cache_store"] = {"store": store, "key": key,
"question": question,
diff --git a/apps/station/monkeyllm_station/inference.py b/apps/station/monkeyllm_station/inference.py
index 9d57468..2b1b72c 100644
--- a/apps/station/monkeyllm_station/inference.py
+++ b/apps/station/monkeyllm_station/inference.py
@@ -15,6 +15,7 @@
from __future__ import annotations
+import datetime as dt
import json
import logging
import time
@@ -356,8 +357,38 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3,
# decide to call it, which left a walk with no move but to read one document
# and describe the corpus from it. Nothing widens: it opens no body (C.17
# rule 1) and every number in it is the calling policy's own (rule 7).
+#
+# `calendar` joined in v0.79 (J.10.5) for the same reason: a question with a
+# period in it — today, this week, since the last release — is decided by
+# where the material sits in time, and C.13.3 is the read built for that:
+# periods and counts off the catalog, no body opened, every count the
+# calling policy's own. Without it a walk asked for "today" could only guess
+# a date, or read the whole forest to find one.
FORAGE_TOOLS = ("locate", "sniff", "look", "move", "pick", "scan", "query",
- "coverage")
+ "coverage", "calendar")
+
+
+def host_today() -> str:
+ """The host's date, as the loop's prompt states it (J.10.5 rule 3).
+
+ One clock, deliberately: `date.today()` is what stamps `created` and
+ `updated` on every node this host writes, so "today" in the question
+ and "today" in the passports are one day. The walk's store key reads
+ the same function (J.10.7, v0.79), never a second clock.
+ """
+ return dt.date.today().isoformat()
+
+
+# J.10.5 rule 3 (v0.79). A model holding every window parameter there is can
+# write `since` and has nothing to write in it: no prompt said what day it
+# was, so "today" was unresolvable. Appended per call, because the constant
+# below is served for as long as the process lives.
+FORAGE_CLOCK = (
+ "\n\nToday is {today} — the host's date, the same clock that stamps "
+ "`created` and `updated` on every node this host writes. Resolve a "
+ "relative period in the question (today, yesterday, this week, last "
+ "month) against it, and pass the result as `since`/`until`."
+)
MAX_HOPS = 16
@@ -365,16 +396,24 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3,
question using ONLY the tools below. Never invent facts: navigate, read, answer.
Always respond with a SINGLE JSON object, nothing else:
-- {"tool": "locate", "args": {"query": "...", "k": 5}} -> entry points by curated metadata
-- {"tool": "sniff", "args": {"terms": ["..."], "scope": null}} -> literal grep on BODIES: an exact
- term (code, name, number) -> node + section + snippet. `scope` restricts to a branch or node.
+- {"tool": "locate", "args": {"query": "...", "k": 5, "type_filter": null, "since": null,
+ "until": null}} -> entry points by curated metadata (titles, summaries, tags; never bodies)
+- {"tool": "sniff", "args": {"terms": ["..."], "scope": null, "type_filter": null, "since": null,
+ "until": null}} -> literal grep on BODIES: an exact term (code, name, number) -> node + section
+ + snippet. `scope` restricts to a branch or node.
- {"tool": "look", "args": {"id": "..."}} -> cheap digest of a node: summary, neighbours, outline
- {"tool": "move", "args": {"id": "...", "rel": null}} -> neighbours ("children" lists a branch's)
- {"tool": "pick", "args": {"id": "...", "section": null}} -> the body, or one section of it
-- {"tool": "scan", "args": {"parent_id": "...", "filter": {}}} -> filter children by metadata
+- {"tool": "scan", "args": {"parent_id": "...", "filter": {"type": "media"}, "recursive": false,
+ "since": null, "until": null}} -> list a branch's children by metadata. `filter` keys are
+ passport fields (type, tags, ...); `recursive: true` walks the whole subtree — without it only
+ the DIRECT children are listed, and a root's direct children are its branches, not documents.
- {"tool": "query", "args": {"id": "...", "sql": "SELECT ..."}} -> read-only SQL on type:dataset nodes
- {"tool": "coverage", "args": {}} -> what this forest HOLDS: every root with its node count, date
range and source, plus totals by type. Counts and curated metadata, no search and no bodies.
+- {"tool": "calendar", "args": {"granularity": "month", "scope": null, "since": null,
+ "until": null}} -> which periods hold material and how much (day|week|month|year), most recent
+ first; every bucket carries the exact `since`/`until` a search takes. Counts only, no bodies.
- {"tool": "answer", "args": {"text": "...", "answer_nodes": ["full/id"]}} -> the final answer
Strategy: an exact rare term (code, proper name, number)? sniff first — it lands in the section and
@@ -388,6 +427,16 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3,
dropped, never that they failed the filter: never present a truncated result as the complete
set, and never state a count from one. Read the "hint" and ask again, narrower.
- Repeating the same call with the same arguments returns the same result. Change tool or terms.
+- Time: `since`/`until` on locate, sniff, scan and calendar take YYYY, YYYY-MM or YYYY-MM-DD
+ (inclusive) and bound by each node's `created` date (`"date_field": "updated"` for the other).
+ A question about a period — today, this week, last month, since X — is a window: resolve it
+ from the date stated below, use calendar when you need to see which periods hold anything, and
+ pass the window on the searching calls. An empty windowed read says whether the window was the
+ reason (`matched_window`), so read that before concluding nothing exists.
+- A KIND of node — the pictures, the datasets, the decisions — is found by its filter:
+ `type_filter` on locate/sniff, `filter: {"type": "..."}` on scan (with `recursive: true` when
+ it may sit anywhere in the branch). Sniffing for the type's NAME is not that: it greps bodies
+ for a word and returns every document that merely mentions it.
- type:dataset nodes answer through SQL: read the manual in look, then query. Aggregates are not
in the prose. A "notes" field on a dataset is what its operator wrote about how to read it —
follow it. Never `SELECT *` on a wide table: results are token-budgeted, so name the columns
@@ -444,8 +493,15 @@ def json_block(text: str) -> str | None:
# What a hop is allowed to report about itself. Small scalars the model
# chose, so a reader can see the *decision*, not just the tool name — "sniff"
# says nothing; `sniff terms=[architecture]` says what it was thinking.
+# J.10.5 (v0.79): the second row is the arguments that decide whether a
+# listing is EMPTY. Without them `scan _index -> 0 node(s)` read on a console
+# as an empty root, when it was a flat scan with a type filter over five
+# branches — the record was hiding the one argument that explained it. An
+# argument the model did not set is absent, never a default written in.
HOP_ARGS = ("query", "terms", "sql", "section", "rel", "scope", "parent_id",
- "direction", "k")
+ "direction", "k",
+ "filter", "type_filter", "since", "until", "date_field",
+ "recursive", "granularity")
def _hop_args(args: dict) -> dict:
@@ -521,6 +577,10 @@ def _outcome(tool: str, result: dict) -> dict:
# under the field a reader already knows, rather than as a word only
# this one tool would ever emit.
return {"nodes": result.get("total")}
+ if tool == "calendar":
+ # C.13.3's answer is periods, not nodes: the number that says what
+ # came back is how many buckets hold anything.
+ return {"buckets": len(result.get("buckets") or [])}
if tool == "pick":
return {"tokens": result.get("body_tokens")}
if tool == "query":
@@ -654,7 +714,7 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3,
the hunt — a deadline turn forces an answer from what was already read.
"""
max_hops = max(1, min(int(max_hops or 1), MAX_HOPS))
- system = FORAGE_SYSTEM
+ system = FORAGE_SYSTEM + FORAGE_CLOCK.format(today=host_today())
# J.10.8 (amended v0.63): the cap bounds every turn and the note aims at
# the answer, which is the turn it exists for. A navigating turn is short;
# the ANSWER turn is an object carrying the text AND `answer_nodes`, and
@@ -678,7 +738,8 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3,
f"{bounds.get('since') or 'the beginning'} … "
f"{bounds.get('until') or 'now'} "
f"({bounds.get('date_field', 'created')} date). Material outside "
- "that window is not available to you; say so if the answer needs it.")
+ "that window is not available to you; say so if the answer needs it. "
+ "That bound replaces any since/until you send.")
messages = [
{"role": "system", "content": system},
{"role": "user", "content":
@@ -757,11 +818,15 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3,
asked.add(key)
h0 = time.perf_counter()
- # The searching calls, and only those: `coverage` takes no window
- # (C.17 counts a whole scope, and `date_field` is the caller's, not
- # the hunt's), and `harvest` is not on the whitelist — it was refused
- # above long before this line could ever see it.
- if bounds and tool in ("locate", "sniff", "scan"):
+ # The searching calls and the map, and only those: `coverage` takes
+ # no window (C.17 counts a whole scope, and `date_field` is the
+ # caller's, not the hunt's), and `harvest` is not on the whitelist —
+ # it was refused above long before this line could ever see it.
+ # `calendar` IS bounded (v0.79): the map the model sees must be the
+ # map of what it may reach. The caller's bound lands LAST, so a
+ # window the model authored on the same call is replaced, not
+ # merged — C.13.1 rule 7, and the prompt says so.
+ if bounds and tool in ("locate", "sniff", "scan", "calendar"):
args = {**args, **bounds}
result = scoped_vine.call(tool, **args)
hop_ms = (time.perf_counter() - h0) * 1000
diff --git a/apps/station/monkeyllm_station/mcp_surface.py b/apps/station/monkeyllm_station/mcp_surface.py
index 2f3cc1d..b223f59 100644
--- a/apps/station/monkeyllm_station/mcp_surface.py
+++ b/apps/station/monkeyllm_station/mcp_surface.py
@@ -367,7 +367,8 @@ async def locate(forest: str, query: str, k: int = 5, scope: str = "all",
anything: an empty window says so explicitly rather than looking
like an empty forest. `lang` filters by the node's declared
language tag (A.3.2), exact match — a node that declares none is
- in no language filter."""
+ in no language filter. `type_filter` narrows to one node type: the
+ pictures are `type_filter="media"`, not a query for the word."""
return await call(forest, "locate", query=query, k=k, scope=scope,
type_filter=type_filter, include=include,
since=since, until=until, date_field=date_field,
@@ -467,7 +468,11 @@ async def scan(forest: str, parent_id: str, filter: dict | None = None,
id/type/summary/body_tokens) — and it is the PAGE lever: the token
budget cuts the page, so fewer fields per item means more items
per page (`fields=["id"]` enumerates a large forest in a fraction
- of the calls). `since`/`until` bound it by date."""
+ of the calls). `filter` matches passport fields: `{"type": "media"}`
+ lists the pictures, `{"source": "agent"}` what agents wrote. Without
+ `recursive` only the DIRECT children are listed, and a root's direct
+ children are its branches, not its documents. `since`/`until` bound
+ it by date."""
return await call(forest, "scan", parent_id=parent_id, filter=filter,
fields=fields, recursive=recursive, limit=limit,
after=after, since=since,
@@ -475,7 +480,8 @@ async def scan(forest: str, parent_id: str, filter: dict | None = None,
@mcp.tool()
async def sniff(forest: str, terms: list[str], scope: str | None = None,
- k: int = 5, since: str | None = None,
+ k: int = 5, type_filter: str | None = None,
+ since: str | None = None,
until: str | None = None,
date_field: str | None = None,
lang: str | None = None):
@@ -485,8 +491,14 @@ async def sniff(forest: str, terms: list[str], scope: str | None = None,
name it; omit it to search the whole forest. `_meta/` is the
dialect, not content — pick("_meta/schema") reads it. `since`/`until`
bound it by date, and here that is also the cheapest thing you can
- do: a windowed sniff opens the files of those days and no others."""
+ do: a windowed sniff opens the files of those days and no others.
+
+ A KIND of node — the pictures, the datasets, the decisions — is
+ `type_filter` ("media", "dataset", "note"), never a term: sniffing
+ for the word "media" greps bodies for that word and returns every
+ document that mentions it, and not one picture."""
return await call(forest, "sniff", terms=terms, scope=scope, k=k,
+ type_filter=type_filter,
since=since, until=until, date_field=date_field,
lang=lang)
diff --git a/apps/studio/check-ask-window.mjs b/apps/studio/check-ask-window.mjs
new file mode 100644
index 0000000..3ed4d2a
--- /dev/null
+++ b/apps/studio/check-ask-window.mjs
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// Copyright 2026 Jimmy Wesley
+
+/* J.5.19 acceptance (F.173, the console's half): the question's period,
+ * checked.
+ *
+ * Studio has no test runner and this file is not one — it reads the Ask
+ * console's source and asks it F.173's questions: that `since`/`until` are
+ * offered, sent as C.13.1's own parameters on `answer`, prefilled from the
+ * address, and written to no browser preference. A Python test runs it; a
+ * non-zero exit is a failed criterion, named on stdout.
+ *
+ * The boundary is F.137's. What a reader of the source can see is the
+ * decision layer: where the two values come from, which call carries them
+ * and under what condition, what the label beside the answer is computed
+ * from, and where they are NOT written. What it cannot see is a rendered
+ * date picker or a badge on a screen — those want a browser, and asserting
+ * them from the source would only assert that a string is present. Every
+ * check that asserts a PRESENCE was verified to fail with the v0.78 view
+ * put back; the ones that assert an absence (nothing written to storage or
+ * the address, nothing validated by the console) hold on that source too
+ * and are kept because they are what the next edit could break. Pass
+ * another Ask source as the first argument to repeat the control. */
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const askPath = process.argv[2] || join(here, 'src/views/Ask.jsx')
+const src = readFileSync(askPath, 'utf8')
+const locale = (lang) => JSON.parse(
+ readFileSync(join(here, `src/locales/ask/${lang}.json`), 'utf8'))
+
+let failed = 0
+const ok = (n, c, extra = '') => {
+ if (!c) failed++
+ console.log(`${c ? 'PASS' : 'FAIL'} ${n}${extra ? ' ' + extra : ''}`)
+}
+
+/* One function's body, found by its declaration and closed by brace depth,
+ so a check reads THAT function and not a coincidence elsewhere. */
+const bodyOf = (signature) => {
+ const start = src.indexOf(signature)
+ if (start < 0) return ''
+ let depth = 0
+ // The signature ends with the body's own brace: a destructured parameter
+ // list carries braces of its own, and opening on the first one found
+ // would return the parameter list and call it the function.
+ let i = src.indexOf('{', start + signature.length - 1)
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++
+ else if (src[i] === '}' && --depth === 0) break
+ }
+ return src.slice(start, i + 1)
+}
+/* A slice between two markers, for the things that are not functions. */
+const between = (from, to) => {
+ const a = src.indexOf(from)
+ if (a < 0) return ''
+ const b = src.indexOf(to, a)
+ return b < 0 ? '' : src.slice(a, b)
+}
+const count = (re) => (src.match(re) || []).length
+
+/* -- (a) read from the address at mount, exactly as `q` is --------------- */
+
+const fromAddress = (name) => new RegExp(
+ `const \\[${name}, set\\w+\\] = useState\\(\\s*\\(\\) => new URLSearchParams\\(window\\.location\\.search\\)\\.get\\('${name}'\\) \\|\\| ''\\)`)
+ok('F.173 `since` is read from the address once, at mount, like `q`',
+ fromAddress('since').test(src))
+ok('F.173 `until` is read from the address once, at mount, like `q`',
+ fromAddress('until').test(src))
+ok('F.173 the console never writes the address (J.5.8: it restores a page, never a call)',
+ !/useRouteState|router\.js|history\.(push|replace)|searchParams\.set|location\.search\s*=/.test(src))
+
+/* -- (b) sent as C.13.1's parameters on `answer`, only when set ---------- */
+
+const askBody = bodyOf('async function ask(text) {')
+/* The object the `answer` POST is built from, and nothing before it: the
+ fallback harvest in the same function carries the same spread, and a
+ check that found the first occurrence would pass on the wrong call. */
+const paramsBlock = between('const params = {', 'const t0 = performance.now()')
+const sinceSpread = /\.\.\.\(since \? \{ since \} : \{\}\)/
+const untilSpread = /\.\.\.\(until \? \{ until \} : \{\}\)/
+ok('F.173 the `answer` request carries `since` only when set',
+ askBody.includes('const params = {') && sinceSpread.test(paramsBlock))
+ok('F.173 the `answer` request carries `until` only when set',
+ askBody.includes('const params = {') && untilSpread.test(paramsBlock))
+ok('F.173 `date_field` is never sent — `created` is the console\'s question',
+ !/date_field\s*:/.test(askBody) && !/date_field/.test(paramsBlock.replace(/\/\/.*$/gm, '')))
+const fallback = between("api.call(forest, 'harvest'", '.then(')
+ok('F.173 the J.5.15 fallback harvest is bounded like the answer it stands in for',
+ fallback.length > 0 && sinceSpread.test(fallback) && untilSpread.test(fallback))
+ok('F.173 the request is the one place the window is sent from (one `answer` call)',
+ count(/api\.timedCall\(forest, 'answer'/g) === 1)
+
+/* -- (c) never a browser preference ---------------------------------------- */
+
+ok('F.173 no `savePrefs(` call carries `since` or `until`',
+ !/savePrefs\(\{[^}]*\b(since|until)\b/.test(src))
+ok('F.173 `loadPrefs()` is never read for `since` or `until`',
+ !/loadPrefs\(\)\.(since|until)/.test(src)
+ && !/\b(since|until)\b/.test(bodyOf('function loadPrefs() {')))
+ok('F.173 no browser-storage write names the window at all',
+ !/localStorage\.setItem\([^)]*\b(since|until)\b/.test(src)
+ && !/sessionStorage/.test(src))
+ok('F.173 restoring a saved run puts the run\'s own window back (J.5.9), not a preference',
+ /setSince\(run\.params\?\.since \|\| ''\)/.test(bodyOf('async function restore(id) {'))
+ && /setUntil\(run\.params\?\.until \|\| ''\)/.test(bodyOf('async function restore(id) {')))
+
+/* -- (d) two date controls, each named -------------------------------------- */
+
+const sinceInput = / ]*value=\{since\}[^>]*aria-label=\{t\('ask\.window_since'\)\}/
+const untilInput = / ]*value=\{until\}[^>]*aria-label=\{t\('ask\.window_until'\)\}/
+ok('F.173 the console offers `since` as a real date input, named',
+ sinceInput.test(src))
+ok('F.173 the console offers `until` as a real date input, named',
+ untilInput.test(src))
+ok('F.173 the two controls are named once each, not shared',
+ count(/ask\.window_since/g) === 1 && count(/ask\.window_until/g) === 1)
+ok('F.173 the pair is grouped under the period\'s name for assistive technology',
+ /role="group"\s+aria-label=\{t\('ask\.window'\)\}/.test(src))
+ok('F.173 the inputs validate nothing of their own (a bad bound is C.13.1 rule 4\'s refusal)',
+ !/ ]*\b(min|max|pattern|required)=/.test(src)
+ && !/E_SCHEMA[^\n]*(since|until)|(since|until)[^\n]*E_SCHEMA/.test(src.replace(/\/\/.*$/gm, '').replace(/\/\*[^]*?\*\//g, '')))
+
+/* -- (e) the cURL carries the window ---------------------------------------- */
+
+const curl = between('const curl = `curl', '`\n')
+ok('F.173 the cURL the console offers carries `since` and `until` when set',
+ /\.\.\.\(since \? \{ since \} : \{\}\)/.test(curl)
+ && /\.\.\.\(until \? \{ until \} : \{\}\)/.test(curl))
+
+/* -- (f) the label is the echo, never the inputs ---------------------------- */
+
+const usedCall = between("t('ask.window_used'", '})')
+ok('F.173 the "window used" label exists and is gated on the response\'s `window`',
+ /result\.window && \(/.test(src) && usedCall.length > 0)
+ok('F.173 the label reads `result.window.since` / `result.window.until` (C.13.1 rule 6)',
+ /since: result\.window\.since/.test(usedCall) && /until: result\.window\.until/.test(usedCall))
+ok('F.173 the label never reads the inputs\' own strings',
+ !/since: since|until: until|\bsince,|\buntil,/.test(usedCall))
+ok('F.173 the exported .md is labelled by the same echo',
+ /result\.window\s*\?\s*\[`- Window: \$\{result\.window\.since/.test(
+ bodyOf('function downloadMarkdown(result, question, forest) {')))
+
+/* -- (g) a bounded run is badged in the history ----------------------------- */
+
+const historyBody = bodyOf('function History({ open, onClose, principal, forest, onPick }) {')
+ok('F.173 the run history badges a bounded run off what was SENT',
+ /\(run\.params\?\.since \|\| run\.params\?\.until\) && \(/.test(historyBody)
+ && /t\('ask\.history_window'\)/.test(historyBody))
+
+/* -- the hop panel reads v0.79's record (J.10.5) ---------------------------- */
+
+const pathBody = bodyOf('function Path({ hops }) {')
+ok('F.173 a hop\'s object argument (`filter`) renders as compact JSON, arrays still join',
+ /typeof v === 'object' \? JSON\.stringify\(v\)/.test(pathBody)
+ && /Array\.isArray\(v\) \? v\.join\(', '\)/.test(pathBody))
+ok('F.173 a `calendar` hop\'s `buckets` outcome is labelled',
+ /\['buckets', 'ask\.hop_buckets'\]/.test(pathBody)
+ && ['en', 'pt', 'es'].every((lang) => /\{n\}/.test(locale(lang)['ask.hop_buckets'] || '')))
+
+/* -- (h) the words exist in the three languages ----------------------------- */
+
+const KEYS = ['ask.window', 'ask.window_hint', 'ask.window_since', 'ask.window_until',
+ 'ask.window_used', 'ask.window_clear', 'ask.history_window']
+for (const lang of ['en', 'pt', 'es']) {
+ const d = locale(lang)
+ ok(`F.173 every window key exists and is non-empty in ${lang}`,
+ KEYS.every((k) => typeof d[k] === 'string' && d[k].trim().length > 0),
+ KEYS.filter((k) => !d[k]).join(' '))
+ ok(`F.173 ${lang}'s "window used" carries both placeholders`,
+ /\{since\}/.test(d['ask.window_used'] || '') && /\{until\}/.test(d['ask.window_used'] || ''))
+ ok(`F.173 ${lang} names the two ends differently`,
+ d['ask.window_since'] !== d['ask.window_until'])
+}
+
+if (failed) {
+ console.log(`\n${failed} criterion(s) failed`)
+ process.exit(1)
+}
+console.log('\nall F.173 (console) criteria hold')
diff --git a/apps/studio/check-skill.mjs b/apps/studio/check-skill.mjs
index 25b1248..f0d05fc 100644
--- a/apps/studio/check-skill.mjs
+++ b/apps/studio/check-skill.mjs
@@ -147,6 +147,8 @@ ok('F.172 the saving block teaches the passport beside the bytes',
all.some((f) => f.path.endsWith('saving.md')
&& f.text.includes('passport:') && f.text.includes('`## Notes`')
&& f.text.includes('never sent')))
+ok('F.173 the core teaches that a kind of node is a filter, not a word',
+ core1.includes('type_filter: "media"') && core1.includes('filter: {"type": "media"}'))
ok('F.127 the core states the min_score pairing',
core1.includes('below_min_score') && core1.includes('min_evidence: 1'))
ok('F.127 two selections, two names',
diff --git a/apps/studio/src/locales/ask/en.json b/apps/studio/src/locales/ask/en.json
index 7cec096..13b5510 100644
--- a/apps/studio/src/locales/ask/en.json
+++ b/apps/studio/src/locales/ask/en.json
@@ -31,7 +31,9 @@
"ask.history_title": "Questions already asked",
"ask.history_today": "Today",
"ask.history_unavailable": "This browser is not keeping runs: storage is unavailable (a private window, or storage turned off). Answers are unaffected.",
+ "ask.history_window": "windowed",
"ask.history_yesterday": "Yesterday",
+ "ask.hop_buckets": "{n} period(s)",
"ask.hop_children": "{n} child(ren)",
"ask.hop_edges": "{n} edge(s)",
"ask.hop_error": "refused · {code}",
@@ -90,6 +92,12 @@
"ask.trail_sub": "Where this question went, on the forest it came out of.",
"ask.trail_view_hint": "Scroll to zoom, drag to pan, double-click to fit",
"ask.trail_waiting": "Searching — the first stages appear before the reply does.",
+ "ask.window": "Period",
+ "ask.window_clear": "Whole forest (clear the period)",
+ "ask.window_hint": "Bounds the question to material created between these dates; either end may be left open. Only what arrived inside the window is searched, and the answer names the window actually used. Part of the question, not a preference: it travels with the link and is not remembered.",
+ "ask.window_since": "Created from",
+ "ask.window_until": "Created until",
+ "ask.window_used": "Window used: {since} → {until}",
"ask.working_hops": "Foraging searching, reading, deciding where next…",
"ask.working_sweep": "Searching the forest…"
}
diff --git a/apps/studio/src/locales/ask/es.json b/apps/studio/src/locales/ask/es.json
index 32b53bd..d2dffbc 100644
--- a/apps/studio/src/locales/ask/es.json
+++ b/apps/studio/src/locales/ask/es.json
@@ -31,7 +31,9 @@
"ask.history_title": "Preguntas ya hechas",
"ask.history_today": "Hoy",
"ask.history_unavailable": "Este navegador no está guardando ejecuciones: el almacenamiento no está disponible (una ventana privada, o el almacenamiento desactivado). Las respuestas no se ven afectadas.",
+ "ask.history_window": "con período",
"ask.history_yesterday": "Ayer",
+ "ask.hop_buckets": "{n} período(s)",
"ask.hop_children": "{n} hijo(s)",
"ask.hop_edges": "{n} arista(s)",
"ask.hop_error": "rechazado · {code}",
@@ -90,6 +92,12 @@
"ask.trail_sub": "Por dónde pasó esta pregunta, en el bosque del que salió.",
"ask.trail_view_hint": "Use la rueda para acercar, arrastre para mover, doble clic para encuadrar",
"ask.trail_waiting": "Buscando — las primeras etapas aparecen antes que la respuesta.",
+ "ask.window": "Período",
+ "ask.window_clear": "Bosque entero (quitar el período)",
+ "ask.window_hint": "Acota la pregunta al material creado entre estas fechas; cualquiera de los extremos puede quedar abierto. Solo se busca lo que llegó dentro de la ventana, y la respuesta dice la ventana realmente usada. Es parte de la pregunta, no una preferencia: viaja con el enlace y no se recuerda.",
+ "ask.window_since": "Creado desde",
+ "ask.window_until": "Creado hasta",
+ "ask.window_used": "Ventana usada: {since} → {until}",
"ask.working_hops": "Forrajeando buscando, leyendo, decidiendo a dónde ir…",
"ask.working_sweep": "Buscando en el bosque…"
}
diff --git a/apps/studio/src/locales/ask/pt.json b/apps/studio/src/locales/ask/pt.json
index 1f6c13f..7374ac0 100644
--- a/apps/studio/src/locales/ask/pt.json
+++ b/apps/studio/src/locales/ask/pt.json
@@ -31,7 +31,9 @@
"ask.history_title": "Perguntas já feitas",
"ask.history_today": "Hoje",
"ask.history_unavailable": "Este navegador não está guardando execuções: o armazenamento não está disponível (janela anônima, ou armazenamento desligado). As respostas seguem funcionando.",
+ "ask.history_window": "com período",
"ask.history_yesterday": "Ontem",
+ "ask.hop_buckets": "{n} período(s)",
"ask.hop_children": "{n} filho(s)",
"ask.hop_edges": "{n} aresta(s)",
"ask.hop_error": "recusado · {code}",
@@ -90,6 +92,12 @@
"ask.trail_sub": "Por onde esta pergunta passou, na floresta de onde ela saiu.",
"ask.trail_view_hint": "Role para dar zoom, arraste para mover, duplo clique para enquadrar",
"ask.trail_waiting": "Buscando — as primeiras etapas aparecem antes da resposta.",
+ "ask.window": "Período",
+ "ask.window_clear": "Floresta inteira (limpar o período)",
+ "ask.window_hint": "Limita a pergunta ao material criado entre estas datas; qualquer uma das pontas pode ficar aberta. Só o que chegou dentro da janela é buscado, e a resposta diz a janela de fato usada. É parte da pergunta, não uma preferência: viaja com o link e não fica lembrada.",
+ "ask.window_since": "Criado a partir de",
+ "ask.window_until": "Criado até",
+ "ask.window_used": "Janela usada: {since} → {until}",
"ask.working_hops": "Forrageando buscando, lendo, decidindo para onde ir…",
"ask.working_sweep": "Buscando na floresta…"
}
diff --git a/apps/studio/src/skill.js b/apps/studio/src/skill.js
index 89fe13b..1722df9 100644
--- a/apps/studio/src/skill.js
+++ b/apps/studio/src/skill.js
@@ -273,7 +273,10 @@ after:`)}
the concatenated pages are the body, byte for byte. \`look\` also says who
and when: \`source\`, \`created\`, \`updated\`, \`aliases\`, \`origin\`.
- \`sniff(forest, terms)\` — literal text search inside bodies (substring,
- not regex).
+ not regex). A KIND of node is a filter, not a word: \`type_filter: "media"\`
+ on \`locate\`/\`sniff\`, \`filter: {"type": "media"}\` on \`scan\` (with
+ \`recursive: true\` to reach every branch). Sniffing for the word "media"
+ greps bodies for that word and returns every document that mentions it.
- \`scan(forest, parent_id)\`, \`move(forest, id)\` — list a branch's nodes by
metadata; follow a node's typed edges. \`scan(forest, "_index",
recursive: true)\` maps a whole forest cheaply; to walk one completely,
diff --git a/apps/studio/src/views/Ask.jsx b/apps/studio/src/views/Ask.jsx
index 720c89d..7f7ecd9 100644
--- a/apps/studio/src/views/Ask.jsx
+++ b/apps/studio/src/views/Ask.jsx
@@ -14,7 +14,7 @@ import {
import { Markdown } from '../design/markdown.jsx'
import {
Ask as AskIcon, Clock, Collapse, Download, Expand, Eye, Graph as GraphIcon,
- Printer, Sparkle, Trash,
+ Printer, Sparkle, Trash, X,
} from '../design/icons.jsx'
import { PayloadImage } from './files.jsx'
import { evidenceFromHops, mergeEvidence } from '../trailmap.js'
@@ -75,6 +75,16 @@ export default function Ask({ forest, grant, me, goto }) {
// once at mount; typing never writes it back.
const [question, setQuestion] = useState(
() => new URLSearchParams(window.location.search).get('q') || '')
+ // J.5.19: the question has a period. `since`/`until` are PART of the
+ // question, not a taste — so they ride the address beside `q` (a shared
+ // link asks the same bounded question), are read exactly as `q` is (once
+ // at mount, nothing fires, typing writes nothing back), and go through
+ // loadPrefs/savePrefs NEVER: a window remembered from yesterday opens
+ // tomorrow's forest empty, which is v0.76's lesson in J.5.4 applied here.
+ const [since, setSince] = useState(
+ () => new URLSearchParams(window.location.search).get('since') || '')
+ const [until, setUntil] = useState(
+ () => new URLSearchParams(window.location.search).get('until') || '')
const [k, setK] = useState(3)
// J.10.8: how long an answer this person likes. Restored from the saved
// preference at mount; dragging the slider is what writes it back.
@@ -208,8 +218,13 @@ export default function Ask({ forest, grant, me, goto }) {
// draws — and a current one pays for no extra read at all.
fallback = setTimeout(() => {
if (arrived) return
+ // The window rides here too: the fallback draws what the answer
+ // WILL see (J.5.15 rule 1), and a sweep bounded to June drawn
+ // from an unbounded harvest is a picture of a retrieval that
+ // never ran — rule 3's invention, one panel over.
api.call(forest, 'harvest',
- { query: q, k, ...(hybrid ? { hybrid: true } : {}) })
+ { query: q, k, ...(hybrid ? { hybrid: true } : {}),
+ ...(since ? { since } : {}), ...(until ? { until } : {}) })
.then((data) => { if (!arrived) setPreview(data) })
.catch(() => {})
}, CHANNEL_GRACE_MS)
@@ -223,6 +238,13 @@ export default function Ask({ forest, grant, me, goto }) {
...(reply ? { reply_tokens: reply } : {}),
...(hybrid ? { hybrid: true } : {}), ...(hops ? { hops: true } : {}),
...(cache ? {} : { cache: false }),
+ // J.5.19: C.13.1's own parameters, sent only when set, so a call
+ // without them is byte-identical to the console's before this
+ // version. `date_field` is never sent — the console asks when
+ // material ARRIVED, which is `created`, the engine's default. And
+ // nothing is validated here: an unparseable bound is the engine's
+ // E_SCHEMA (C.13.1 rule 4), rendered like any other refusal.
+ ...(since ? { since } : {}), ...(until ? { until } : {}),
}
const t0 = performance.now()
try {
@@ -265,6 +287,12 @@ export default function Ask({ forest, grant, me, goto }) {
setHybrid(!!run.params?.hybrid)
setHops(!!run.params?.hops)
setCache(run.params?.cache !== false)
+ // The run's own window (J.5.19): a record of a bounded question put
+ // back unbounded would make "ask again" ask a different question.
+ // This is the record's, never a preference — a run with no window
+ // clears the inputs, exactly as it clears `hops`.
+ setSince(run.params?.since || '')
+ setUntil(run.params?.until || '')
setPreview(null); setLive(null)
setResult(run.result)
setRestored({ ts: run.ts, model: run.result?.model })
@@ -284,6 +312,9 @@ export default function Ask({ forest, grant, me, goto }) {
...(reply ? { reply_tokens: reply } : {}),
...(hybrid ? { hybrid: true } : {}), ...(hops ? { hops: true } : {}),
...(cache ? {} : { cache: false }),
+ // J.5.19: the window is carried because everything else the form
+ // would send is.
+ ...(since ? { since } : {}), ...(until ? { until } : {}),
})}'`
return (
@@ -327,6 +358,36 @@ export default function Ask({ forest, grant, me, goto }) {
{ value: 2, label: '2' }, { value: 3, label: '3' }, { value: 6, label: '6' },
]} />
+ {/* J.5.19: the question's period. Two dates on `created` —
+ when material ARRIVED, J.5.4's window one console over —
+ beside the depth because both decide what retrieval may
+ reach. Deliberately NOT a Toggle in the flags list below
+ and NOT a preference like the slider beside it: a shared
+ link must ask the same bounded question, and a window
+ remembered from yesterday is wrong tomorrow. The inputs
+ validate nothing (a bad bound is the engine's refusal),
+ and the answer is labelled by the engine's echo, never by
+ these strings. */}
+
+ {t('ask.window')}
+ setSince(e.target.value)} />
+ →
+ setUntil(e.target.value)} />
+ {/* A way back to the whole forest that does not depend on
+ the browser's own date widget having one. */}
+ {(since || until) && (
+ { setSince(''); setUntil('') }}>
+
+
+ )}
+
{/* J.10.8: dragged per person, remembered per person. "Auto"
sends nothing — the forest binding's own size rules. */}
@@ -455,6 +516,19 @@ export default function Ask({ forest, grant, me, goto }) {
{t('ask.cached')}
)}
+ {/* J.5.19: the label is the ECHO (C.13.1 rule 6), never
+ the inputs. The engine expands `2026-08` to the two
+ dates it actually searched, a restored run's window
+ is the run's and not the form's, and an open end is
+ shown as open rather than filled in by the console. */}
+ {result.window && (
+
+ {t('ask.window_used', {
+ since: result.window.since || '—',
+ until: result.window.until || '—',
+ })}
+
+ )}
{t('common.elapsed', { ms: result.ms })}
{/* Reading is the point; the instruments can wait. Widening
moves the panel below instead of hiding it. */}
@@ -601,6 +675,11 @@ function downloadMarkdown(result, question, forest) {
'---', '',
`- Forest: \`${forest}\``,
`- Model: \`${result.model}\``,
+ // J.5.19: a bounded answer leaves labelled — the echo, as on screen.
+ ...(result.window
+ ? [`- Window: ${result.window.since || '—'} → ${result.window.until || '—'}`
+ + ` (${result.window.date_field || 'created'})`]
+ : []),
...(result.hops?.length
? [`- Hops: ${result.hops.map((h) => `${h.tool}${h.id ? `(${h.id})` : ''}`).join(' → ')}`]
: []),
@@ -760,6 +839,15 @@ function History({ open, onClose, principal, forest, onPick }) {
{run.params?.hops && {t('ask.history_hops')} }
{run.params?.hybrid && {t('ask.history_hybrid')} }
+ {/* J.5.19: two runs of one question with different
+ windows are two runs. The badge reads what was
+ SENT (the row's rule); the echo lives on the
+ restored panel. */}
+ {(run.params?.since || run.params?.until) && (
+
+ {t('ask.history_window')}
+
+ )}
{run.model && {run.model} }
{/* No stopwatch on the row: the client's round trip
is not the cost of the call (J.10.6), and the
@@ -1012,13 +1100,26 @@ function Path({ hops }) {
['tokens', 'ask.hop_tokens'], ['nodes', 'ask.hop_nodes'],
['neighbors', 'ask.hop_neighbors'],
['children', 'ask.hop_children'],
- ['edges', 'ask.hop_edges']]) {
+ ['edges', 'ask.hop_edges'],
+ // v0.79: `calendar` joined the walk's
+ // whitelist and reports its C.13.3 buckets.
+ ['buckets', 'ask.hop_buckets']]) {
if (out[key] != null) return t(label, { n: out[key] })
}
return ''
}
+ /* J.10.5 (v0.79): the hop record names the argument that decided it, and
+ not every one is a scalar — `filter` is an object ({"type": "media"}),
+ `recursive` a boolean. A generic `${v}` prints an object as
+ "[object Object]", which says a filter was set and hides what it was:
+ the one thing the record was extended to say. Compact JSON, so the
+ panel shows the argument as the model wrote it. */
+ const argValue = (v) => (
+ Array.isArray(v) ? v.join(', ')
+ : v !== null && typeof v === 'object' ? JSON.stringify(v)
+ : String(v))
const args = (a = {}) => Object.entries(a)
- .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(', ') : v}`).join(' · ')
+ .map(([k, v]) => `${k}=${argValue(v)}`).join(' · ')
return (
diff --git a/docs/guide/en/using.md b/docs/guide/en/using.md
index 9007cf8..60114fa 100644
--- a/docs/guide/en/using.md
+++ b/docs/guide/en/using.md
@@ -52,6 +52,16 @@ A few controls sit beside the ask box:
instantly, without paying the model again. Turn it off to buy a fresh
run and replace the stored one.
+**The question can carry a period.** Two dates beside the controls, *from*
+and *until*, bound the question to material created between them (either
+end may be left open). Only what arrived inside the window is searched, on
+every hop when the model hops, and the answer shows the window actually
+used: the dates the engine applied, not the strings you typed. The period
+is part of the question, not a preference: it rides the link beside the
+question itself (`?since=…&until=…`, so a shared address asks the same
+bounded question) and is not remembered between visits. A date the engine
+cannot read is refused, like any other bad argument (spec J.5.19).
+
**Cached answers say so.** A served answer carries a **From the store**
badge, and the recorded cost is never re-billed. This is not a dumb
cache: retrieval still runs on every ask, and the stored reply is served
diff --git a/docs/guide/es/using.md b/docs/guide/es/using.md
index eccbb49..7f388d1 100644
--- a/docs/guide/es/using.md
+++ b/docs/guide/es/using.md
@@ -57,6 +57,18 @@ Unos pocos controles acompañan la caja de pregunta:
al instante, sin pagar el modelo otra vez. Apágalo para comprar una
ejecución nueva y reemplazar la guardada.
+**La pregunta puede llevar un período.** Dos fechas junto a los controles,
+*desde* y *hasta*, acotan la pregunta al material creado entre ellas
+(cualquiera de los extremos puede quedar abierto). Solo se busca lo que
+llegó dentro de la ventana, en cada salto cuando el modelo salta, y la
+respuesta muestra la ventana realmente usada: las fechas que aplicó el
+motor, no el texto que escribiste. El período es parte de la pregunta, no
+una preferencia: viaja en el enlace junto a la propia pregunta
+(`?since=…&until=…`, así que una dirección compartida hace la misma
+pregunta acotada) y no se recuerda entre visitas. Una fecha que el motor
+no puede leer se rechaza, como cualquier otro argumento inválido (spec
+J.5.19).
+
**Las respuestas servidas lo dicen.** Una respuesta servida lleva la
insignia **Del banco**, y el costo registrado nunca se vuelve a cobrar.
No es un caché tonto: la recuperación corre igual en cada pregunta, y la
diff --git a/docs/guide/pt/using.md b/docs/guide/pt/using.md
index a246fef..b22e44d 100644
--- a/docs/guide/pt/using.md
+++ b/docs/guide/pt/using.md
@@ -55,6 +55,17 @@ Alguns controles ficam ao lado da caixa de pergunta:
hora, sem pagar o modelo de novo. Desligue para comprar uma execução
nova e substituir a guardada.
+**A pergunta pode ter um período.** Duas datas ao lado dos controles, *de*
+e *até*, limitam a pergunta ao material criado entre elas (qualquer uma das
+pontas pode ficar aberta). Só o que chegou dentro da janela é buscado, em
+todo salto quando o modelo salta, e a resposta mostra a janela de fato
+usada: as datas que o motor aplicou, não o texto que você digitou. O
+período é parte da pergunta, não uma preferência: viaja no link ao lado da
+própria pergunta (`?since=…&until=…`, então um endereço compartilhado faz
+a mesma pergunta delimitada) e não fica lembrado entre visitas. Uma data
+que o motor não consegue ler é recusada, como qualquer outro argumento
+inválido (spec J.5.19).
+
**Respostas servidas do banco dizem que são.** Uma resposta servida
carrega o selo **Do banco**, e o custo registrado nunca é cobrado de
novo. Não é um
diff --git a/docs/monkeyllm-spec-v0.79.md b/docs/monkeyllm-spec-v0.79.md
new file mode 100644
index 0000000..306c91a
--- /dev/null
+++ b/docs/monkeyllm-spec-v0.79.md
@@ -0,0 +1,13709 @@
+# MonkeyLLM Technical Specification v0.79 (Phase 0/1/2 + host layer)
+
+**Audience:** development team.
+**Scope:** normative specification of the forest dialect (`schema.md`), the I/O contracts of the Vine protocol's primitives (MCP), the host layer that serves them to many principals (Part J), and the Phase 0 acceptance criteria.
+**Companion document:** `monkeyllm-arquitetura.md` (architectural view).
+**Convention:** the words MUST, MUST NOT, MAY follow the spirit of RFC 2119.
+
+> Language note: as of the T02 translation pass (2026-07-02) the entire
+> document is English. As of v0.5 every **contract token** (type/rel/enum
+> values, parsed section headings) is English regardless of prose language.
+
+**Changelog v0.78 → v0.79 — the walk had no clock and no window.**
+
+An operator asked their forest, on a walk (J.10.5), for the two
+screenshots they had uploaded that day. The forest held them: `coverage`
+said so in the walk's own first hop — two `media` nodes, a date range
+ending on that day. The walk then read the root, scanned it flat (zero),
+grepped every body for the literal word "media" (four task documents, no
+picture) and answered that the product is not a place for personal files.
+Every fact it needed was one parameter away, and not one of those
+parameters was written where the model reads.
+
+- **The window existed and the model was never told (amends J.10.5).**
+ `locate`, `sniff` and `scan` have taken `since`/`until`/`date_field`
+ since v0.52 (C.13.1); `locate` and `sniff` take `type_filter`; `scan`
+ takes `filter: {type}` and `recursive` — and the loop forwards the
+ model's arguments as they are, so every one of them would have worked.
+ The menu named none of them. v0.67's sentence about tools now covers
+ arguments: a parameter the model is never told about is a parameter the
+ whitelist did not admit. The menu MUST name them.
+- **`calendar` joins the whitelist (amends J.10.5, C.13.3).** "Today",
+ "this week", "in August" are questions about where the material sits in
+ time, and C.13.3 is the read built for exactly that: counts by period,
+ no bodies, every number the policy's own — the argument that admitted
+ `coverage`.
+- **The walk has a clock (new J.10.5 prompt rule 3; amends J.10.7).** No
+ prompt stated the date, so "today" was unresolvable even where a window
+ was reachable. The prompt states the host's date — the clock that stamps
+ `created` on a node this host plants, so "today" in the question and
+ "today" in the passports are one day. The date enters the walk's J.10.7
+ key on HEAD's terms (a walk is served whole and cannot be re-walked; a
+ hunt asked on two days is two hunts), and ONLY the walk's: the sweep's
+ retrieval is the caller's, its bounds are already stated (C.13.1 rule
+ 7), and a date in its key would expire every stored answer nightly for
+ nothing.
+- **A kind of node is found by its filter (new J.10.5 prompt rule 4).**
+ "The pictures", "the datasets", "the decisions" are `type_filter` on the
+ ranked reads and `filter: {type}` on the listing — never a `sniff` for
+ the type's name, which greps bodies for a word and returns every
+ document that mentions it.
+- **The caller's window still wins (amends C.13.1 rule 7).** Where the
+ caller bounded the hunt, the bound replaces whatever the model authored,
+ on every searching call and on `calendar`, and the prompt says so.
+ Absent a caller's window the model MAY author one per call.
+- **A hop names the argument that decided it (amends J.10.5).** The hop
+ record carried `query`, `terms`, `scope`, `parent_id`, `k` and not
+ `filter`, `type_filter`, `since`, `until`, `date_field`, `recursive` or
+ `granularity` — so a console showed `scan _index → 0` and could not show
+ the filter that made it zero. J.10.5 already required "the arguments
+ the model chose"; this is the record catching up with its own rule. A
+ record written before this version lacks them and a consumer reads the
+ absent field as *not set*, never inferred.
+- **The question has a period on the console (new J.5.19).** The Ask
+ console offers `since`/`until`, sent as C.13.1's own parameters on
+ `answer`; rule 7 does the rest. Prefilled from the address beside `q`,
+ never a browser preference — a window remembered from yesterday is
+ v0.76's lesson.
+- Acceptance: **F.173**.
+
+**Changelog v0.77 → v0.78 — the passport travels with the bytes.**
+
+- **Why.** v0.77 taught that bytes reach a forest through
+ `ingest(mode: "upload", files: [{name, b64}])` and named the path
+ everywhere an agent reads. The next report came from an agent that had
+ followed it: a screenshot it had already looked at, described in the
+ conversation, landed as a `media` node with a stub passport, and the
+ scent it knew — what the picture shows, which nodes it belongs beside —
+ had nowhere to go until a second call (`graft`) and a second commit,
+ with a window in between where the node existed and nothing could find
+ it. Bytes alone are a picture nobody can locate; a passport is what
+ `locate` searches.
+- **An upload entry MAY carry its passport (new J.8.4).**
+ `{name, b64|text, source_url?, passport?: {title?, summary?, tags?,
+ aliases?, links?, notes?}}`. Shape-checked for every entry before the
+ first byte stages (E_SCHEMA names the entry and the field); applied at
+ curation as an `on_curate` hook under exactly the rules a reviewed
+ draft gets (J.8.1): summary fitted to A.4, tags cleaned and capped,
+ links `related-to` only to existing in-scope non-branch nodes, at most
+ three, confidence 0.3; `aliases` join the derived ones under the C.8
+ cap; `notes` becomes the node's `## Notes` (C.2.1). An entry with a
+ passport is never sent to the curation model; the rest of the batch
+ keeps the bound curator. One commit, the ordinary plant.
+- **Not a second write path.** The passport enters where the approved
+ draft already enters (G.4.3), so converter, content policy, plant and
+ commit are untouched; `adopt`/`sync` are unaffected; `origin`, `view`
+ and `plant` are unchanged.
+- **`look` carries a media node's `## Notes` (amends C.2.1).** The
+ section the uploader wrote rides in `look`, in the sweep's material and
+ in the walk's entry for `type: media` exactly as it does for a dataset:
+ the path to a picture is `look` then `view`, the same shape as `look`
+ then `query`, with the same gap — a note only `pick` reaches is a note
+ nobody reads.
+- **Every refusal is counted, and an unapplied passport is named.** A
+ tag the rule refuses and an alias the cap clips are counted in the
+ report (`tags_dropped`, `aliases_clipped`) for the caller's passport
+ exactly as for the model's scent (G.4.2 rule 1). Bytes re-sent for a
+ node that already exists are refreshed and their passport is NOT
+ applied — a refresh never curates — and the report names every such
+ entry in `passports_ignored`, so the caller reaches for `graft`
+ instead of finding the old scent on the next `look`.
+- **The skill and the tool say so (amends J.5.12; J.1.2 rule 7 applied
+ to `ingest`).**
+- Acceptance: **F.172**.
+
+**Changelog v0.76 → v0.77 the path was there and nothing named it.**
+
+An agent operating a 1,877-node forest through MCP needed to keep a
+screenshot. It planted a `type: media` node with a careful description
+and an `origin` naming its own harness's file store, the plant
+succeeded, `view` answered `E_NOT_FOUND`, its client rendered the
+envelope as "tool call failed", and it filed a report whose first
+finding was that the product has no way to store a binary. Every fact
+it needed had been true since v0.48 — `ingest(mode: "upload")` takes
+`{name, b64}`, an image lands as a media node with its bytes under
+`_assets/`, `view` serves exactly those bytes — and not one of those
+facts was written where an agent reads. The tool description said
+`[{name, text}]`. The skill's saving block taught text. `plant` accepted
+a media passport that named no bytes at all. `look` had no way to say
+whether bytes existed. And `sniff(scope: "_meta")` refused with "node
+not found: `_meta/_index`", naming an id the caller never typed.
+
+The report's fixes were a new upload channel, a new error code and a
+tool to dereference `origin`. None of them is taken: the channel
+exists, the code would break J.3's oracle rule, and A.3 forbids the
+dereference for a reason. What is taken is the lesson under the report:
+**a surface whose every path exists and whose descriptions name none of
+them is, to the model reading it, a surface with no paths.**
+
+- **A tool description names the neighbour that does what it refuses
+ (new J.1.2 rule 7).** `ingest` names `b64` and what an image becomes;
+ `view` names `look`'s flag and `ingest`; `plant` says it carries no
+ bytes and where bytes go; `sniff` says what a scope is; `look` names
+ the flag. The `instructions` carry the same in a clause each, and the
+ suite checks the descriptions mechanically.
+- **A media passport names bytes the forest holds (new C.7.5).** Through
+ `plant`, a `type: media` node MUST carry `payload`; a local payload
+ MUST resolve inside the forest and exist. Refused as `E_SCHEMA` with a
+ hint naming `ingest` and `b64` — at the write, where the caller can
+ act, instead of three calls later behind an envelope that is anonymous
+ on purpose. The Gardener is exempt: under `archive: never` it
+ references bytes that stay at the source, which is G.7 and not this
+ failure.
+- **`look` says whether the bytes are there (amends C.2.2).** The flag a
+ dataset already carries, for the other node whose worth is a file:
+ `payload_missing`, or `payload_type` with `payload_bytes`. A stat,
+ never an open. It is the one place a caller can learn, before spending
+ the call, what `view` will do.
+- **A wrong scope is told what a scope is (amends C.6b).** The bare
+ `_meta` is `E_SCHEMA` naming the dialect; an unknown scope is
+ `E_NOT_FOUND` naming the scope the caller sent, with a hint saying
+ what a scope is.
+- **The skill teaches bytes (amends J.5.12).**
+- **Left as they are, and why.** `view`'s envelope stays byte-identical
+ for absent, out-of-scope and payload-less (C.6d rule 1, J.3): the
+ repair is the flag one call earlier, not a fourth code. `origin` stays
+ a pointer the engine never follows (A.3). J.1.2 rule 2 stays: the
+ client that dropped the envelope reads the flag alone, the client
+ rule 2 was written for read the body alone, and a Station cannot
+ write for both readings at once — the deployment's own client is the
+ operator's to know.
+- Acceptance: **F.171**.
+
+**Changelog v0.75 → v0.76 the timeline had one end.**
+
+The graph mode's docked timeline (J.5.4, v0.38) is a scrubber with a
+single thumb: drag it back and the forest stands as it stood on that day,
+press play and it grows again. That answers *what existed by then*. The
+question a forest asks once it is being fed is *what arrived between two
+days* — this week's ingest, the quarter's decisions, the batch somebody
+ran on Tuesday — and one thumb cannot say it: every node older than the
+day is in the picture, so the new material is a needle in the haystack it
+has just joined. On a 1,878-node forest "what was planted last week" is
+forty dots among eighteen hundred, and the scrubber has no way to show
+the forty alone.
+
+The obvious repair is a filter, and a filter is where it goes wrong. Hide
+the nodes outside the window the way the replay hides the unborn — out of
+the picture AND out of the physics — and the survivors are leaves whose
+branch was planted a year earlier: no spring left, nothing holding them
+where the forest put them, and the centring pull folds the constellation
+into the middle of the canvas. The picture then answers "what" and lies
+about "where" — and where the material of those days landed is the reason
+to draw it on a map at all.
+
+- **The timeline is a window (amends J.5.4).** A start beside the end, on
+ the same scale and in the same `created` order; a node is shown iff its
+ rank lies inside `[start, end)`, a trail iff both its ends are. The
+ start at its origin is the v0.38 scrubber, unchanged.
+- **Where, not only what.** A node outside the window leaves the picture
+ and never the layout: it goes on being stepped, so it goes on holding
+ the structure that positions what is shown. Explore's own split — a
+ hidden proposal still feeds the springs — applied to nodes.
+- **The readout states the window**: both days and the count inside over
+ the total. "Now" restores the whole forest. Play grows the window from
+ its start towards now, the start holding, and does not restage; the
+ reseed belongs to a replay from the origin.
+- **Presentation, never a call, never the address, never persisted.**
+ J.5.8's rule, and the quick search's: a window remembered from
+ yesterday opens tomorrow's forest empty. The scale is `created` and
+ never an indexed date (C.13's rule — `_derived/` is disposable).
+- Acceptance: **F.170**.
+
+**Changelog v0.74 → v0.75 the tags a non-English forest never got to keep.**
+
+`locate` searches curated metadata and nothing else (C.6b), and that
+metadata is four FTS columns: `title`, `aliases`, `tags`, `summary`. The
+Curator authors two of them, derives none of `aliases`, and one of the
+two it does author is being filtered on its way to disk.
+
+`_clean_tags` validates against `^[a-z0-9][a-z0-9_-]*$`. The Curator is
+instructed to work in the language of the content, so a Portuguese forest
+gets `produção`, `segurança`, `orçamento` — and every one of them is
+DROPPED. No error, no count, no line in the report. The node plants with
+whatever survived, usually two or three of the five allowed, and nothing
+anywhere says a filter ran. An operator watching that sees a model that
+will not write tags, and goes to tune a model that was doing its job.
+
+The rule buys nothing it was meant to buy. `nodes_fts` is tokenized
+`unicode61 remove_diacritics 2`, which folds diacritics on the way in AND
+on the way out: a tag stored as `produção` is matched by a query for
+`producao` (measured). The accent was never a matching problem. It was a
+matching CONCERN, written into an authoring rule, where it became a
+content filter that only bites forests that are not in English.
+
+The same measurement clears the identifiers. `be-291`, `p95` and
+`iso-27001` already pass `_TAG_RE`, and the tokenizer splits the
+separator on both sides, so a stored `iso-27001` is found by `"iso
+27001"`. Those tags have always been storable and always been findable.
+The only thing standing between a document and its own ticket number is
+the sentence in the prompt telling the model to write "single words" —
+next to a cap of five.
+
+So a forest ingests a document that says its ticket is BE-291, that the
+component is the rate limiter, that the standard is ISO 27001, and the
+node it plants carries three generic words nobody would type. The
+identifiers are all still in the body, so `sniff` finds them and `locate`
+cannot, because a body is the one thing `locate` does not read. That is
+C.1.1's failure arriving through ingest instead of through search.
+
+The project already fixed this from the other end without noticing the
+symmetry. v0.52 rewrote the derived-term rule so a QUESTION keeps any
+code-shaped token whatever its length — a digit, all caps, `-`/`_`/`.`/
+`/` — on the stated grounds that "those are the tokens a technical corpus
+is searched BY". The reader was taught to keep exactly what the writer is
+still told to throw away. Two halves of one match, and only one of them
+was ever measured.
+
+Three further gaps, each the same shape: something the engine can already
+do that nothing in the product ever asks it to.
+
+**The document's language is nowhere.** The Curator is told to write "in
+the SAME LANGUAGE as the content", so every ingest infers a language,
+spends it once and discards it. Nothing records the answer. A
+mixed-language forest cannot be searched, counted or curated by language;
+the next call infers it again from scratch; and the tag filter above
+could not have been noticed by any report, because no report knows which
+forests are the ones it hurts.
+
+**A proposal nobody walks cannot be accepted.** Edge proposals are born
+at link-level `confidence: 0.3` (G.4.2.1) and H.2 promotes on HEAT: both
+endpoints hot. That is the right rule for a link nobody vouched for, and
+it is the only rule there is, so a correct proposal between two cold
+nodes stays at 0.3 forever. The Health console shows how many exist,
+deliberately shows no ids, and offers no action. In a freshly ingested
+forest every node is cold by definition — which is when the proposals are
+most worth reading and exactly when nothing can act on them.
+
+**The operator has fewer hands than the agent it connected.** `prune` has
+been contracted since v0.56 and `transplant` since v0.58; both are
+dispatched, audited, evented, and taught to agents by the skill Studio
+itself generates. Neither is reachable from Studio. An operator who
+plants a node in the wrong place, or ingests a file they should not have,
+is told by their own console to go and find an MCP client.
+
+Reaching an already-ingested forest is part of this change and not a
+follow-up. `recurate` (J.13.6) re-derives aliases from the forest's own
+passports with no model, no converter and no source tree — the right
+guarantee for what it does, and incapable of producing a single one of
+the tags or aliases this changelog is about. Without a model-backed
+re-curation every fix here would apply only to documents nobody has
+ingested yet.
+
+- **A tag is bounded, never silently dropped (amends G.4.2).**
+- **The Curator writes a findable scent, not a tidy one (amends G.4.2).**
+- **The Curator proposes aliases from the document (new G.4.3).**
+- **A node MAY state its language (new A.3.2, amends C.6b/C.13/C.17).**
+- **An uncertain link can be accepted by a person (new H.2.1, new
+ J.18).**
+- **Re-curation MAY use the forest's ingest model (amends J.13.6).**
+- **The operator prunes and transplants from the console (new J.5.17).**
+- **Tags are edited, applied and browsed from the console (new J.5.18).**
+- Acceptance: **F.162 - F.169**.
+
+**Changelog v0.73 → v0.74 a snapshot that is not one file is not a
+snapshot.**
+
+Part I opens with "A forest snapshot is ONE file", and that sentence
+stopped being true the moment `--with-payloads` was added. A forest
+holding a dataset needed a bundle AND a sidecar, and the two were
+separate at every step: two files written, two links to click, two upload
+fields with the second one optional and defaulting to off. Nothing tied
+them together, so nothing stopped them coming apart — and the half that
+goes missing is precisely the half git cannot hold, because that is what
+the sidecar was invented to carry.
+
+It came apart in the field. A 1,877-node forest was imported from a bare
+bundle; the audit row reads `{"nodes": "1877", "payloads": "0"}`, status
+`ok`. Two dataset passports named a `payload:` that was not on the
+volume, the restore knew it — it had just rebuilt the catalog off those
+very passports — and it said nothing. The hole was found two days later,
+by a 404 in a console.
+
+That is the load-bearing failure, and it is not the packaging. Excluding
+payloads is a legitimate choice: a metadata-only snapshot is a real
+artifact, and a restore that refused to produce one would be worse. What
+is not legitimate is `payloads: 0` meaning both *this forest has none*
+and *you did not ask for them*, or a 200 that reports how many payloads
+were packed while never reporting how many nodes are now dead. Both
+counts are free — the producer walks the tree and the restore rebuilds
+the catalog — and neither was taken.
+
+So: the snapshot becomes a container, one file with the bundle and the
+payloads inside it and a README saying how to open it without this
+software; payloads travel by default; and both ends count what they left
+behind. The container also closes a wider hole nobody had reported:
+`_assets/` (the G.5.1 media bytes) is in the forest's own `.gitignore`
+and was in no sidecar either, so a `type: media` node survived no
+snapshot at all, `--with-payloads` included.
+
+- **The snapshot is a container, and the container is one file (amends
+ Part I).**
+- **Payloads travel by default, and an omission is stated (amends
+ Part I).**
+- **A restore counts the passports whose payload did not arrive (amends
+ Part I).**
+- **The payload set is the whole BONE tier, `_assets/` included (amends
+ Part I, A.3.1).**
+- **The shape is read from the file's content, never from its name
+ (amends J.13.2).**
+- **One snapshot is one row and one download (amends J.13.1).**
+- Acceptance: **F.158 - F.161**.
+
+**Changelog v0.72 → v0.73 the log that could not be read.**
+
+The Audit console is one table of one page: when, who, which call, which
+forest, `ok`, a byte count, a commit. An operator opens it holding two
+questions — *is anything wrong* and *what is this costing me* — and the
+screen answers neither, because the row does not carry the answer to either
+and the route has no way to ask for it.
+
+Three facts were missing from the row and one from the route.
+
+The row never carried a **cost**, which is stranger than it sounds: J.4 has
+said since v0.35 that an answer served from the store is audited with "the
+cost avoided, never a second spend" — a sentence about a column that does
+not exist. The Station computes that number (the provider's own usage
+against the provider's own catalogue), attaches it to the response, keeps it
+in the answer store, and drops it on the floor when it writes the row. A
+deployment paying per `answer` has its bill everywhere except in the account
+of what was done.
+
+The row never carried **which refusal**. `result` is `ok` or `error`, so a
+principal reaching for a scope they do not hold and a mistyped node id are
+the same row. That is precisely the distinction an access log exists to
+make.
+
+The row never carried **what it took**. The engine times every primitive
+(Part D) and the host serves that figure in a header on every call (J.10.6);
+none of it survives the request, so "which calls are slow, and since when"
+has no history at all.
+
+And the route took `limit` and `principal` and nothing else, so every other
+filter belonged to whoever was reading, applied over whatever page they had
+already fetched. That is not merely inconvenient: it makes every summary a
+summary of a page. A card counting the rows on screen changes when somebody
+changes the page size, and a number like that is worse than no number.
+
+- **An audit row carries the bill, the refusal and the clock (new J.4.2).**
+- **The audit is queried, and the totals describe the query (new J.4.3).**
+- **The Audit console reads what the row now holds (new J.5.16).**
+- Acceptance: **F.154 - F.157**.
+
+**Changelog v0.71 → v0.72 the helicopter does not land on the file.**
+
+The panel drew one line, from the root to each hit, up the `parent` chain.
+Rule 5 called that "the forest's own structure" and it is — but a reader
+does not see structure, a reader sees a journey, and the journey it showed
+was the agent arriving in one move on the exact document it needed. That is
+the claim this product does not make. The whole argument for an entry
+search is that it puts you NEAR: the helicopter drops you at the branch and
+the last step in is yours.
+
+Two attempts to animate that line made it worse before the cause was found.
+Marching dashes turned an address into footsteps trudging down from the
+root; splitting the reveal by depth made several hits look like several
+journeys taken in turn, when `locate` returns a ranked set and takes no
+journeys at all.
+
+So the line becomes two, and the split is a rule rather than a taste: **a
+segment whose destination is a marked node belongs to the walk; every
+segment above it is the flight.** The flight is drawn in its own colour and
+STOPS at the branch — it is structurally incapable of entering a document,
+which is the property that keeps the old claim from coming back. The walk
+is the agent's own movement and means that in both modes: on a sweep, the
+one step from the branch into the file that was opened; on a walk, the real
+hop sequence, where only a hop that names ONE node counts as a position and
+a hop that returned a set is the agent looking around from where it already
+stands.
+
+Three smaller corrections travel with it, each removing something the panel
+was saying on its own account. Every leg now advances on ONE clock, so
+several drops leave the base together instead of in an invented order.
+Every node the trail touches is named, not only the ones it stopped at — a
+route whose waypoints are anonymous is a shape, and the branch climbed
+through is half of "where did this answer go". And the camera leans on what
+has been REVEALED rather than on the final set, because a view that frames
+the answer before it arrives has already told the viewer there is nothing
+to discover.
+
+- **The trail is two journeys, and the flight stops at the branch (amends
+ J.5.15 rule 5).**
+- **Two journeys are two colours (amends J.5.15 rule 6).**
+- **Both lines move; the legs share one clock (amends J.5.15 rule 9).**
+- **Every node the trail touches is named (amends J.5.15 rule 5).**
+- **The camera follows the reveal (amends J.5.15 rule 10).**
+- Acceptance: **F.151 - F.153**.
+
+**Changelog v0.70 → v0.71 the scan is the other half of the bill.**
+
+v0.68 found a provider round trip inside `locate`'s span, named it
+`embed_ms`, and closed the case. It closed half of it. The same operator
+came back with the same complaint against a corpus that had grown, and the
+numbers say what the first pass did not look at:
+
+```
+locate raw 262.01 ms embed 166.20 dense — (cold query)
+locate raw 74.68 ms embed 0.13 dense — (same query again)
+```
+
+On the second call the embed is a K.6 memo hit worth **0.13 ms** and there
+is still **74 ms** charged to `locate`. It is not the network. Measured in
+isolation, `CanopyIndex.search` over 1,877 vectors of dimension 1024 costs
+**68 ms** — 36 µs per vector — while the catalog refill beside it costs
+0.35 ms and the fusion 0.01 ms. The scan is the whole of it.
+
+v0.42 recorded that "the vector scan was never the problem (0.044 ms per
+dim-1024 dot product)", and that measurement was right. It was a claim
+about ONE comparison, and a hybrid entry makes one per node: the sentence
+survives its own arithmetic only while the corpus is small. This changelog
+exists as much to date that one as to add the field.
+
+So the dense layer has two costs, an order of magnitude apart in either
+direction depending on whether the embed memo hits, and until now one had a
+name. Both do now, and they stay APART: an operator whose `embed_ms`
+dominates buys a closer embedder, and one whose `dense_ms` dominates needs
+an index instead of a scan. Merging them into "hybrid overhead" would tell
+that operator to fix the wrong thing.
+
+- **The Canopy scan's share is named (amends J.10.4/Part D).**
+- **Named, never subtracted by the host (amends J.10.4).**
+- Acceptance: **F.149 - F.150**.
+
+**Changelog v0.69 → v0.70 the question is not the query.**
+
+`locate` takes a question and hands it to FTS5 whole: split on whitespace,
+each token quoted, joined by `OR`. Every article, preposition and auxiliary
+verb in the sentence is therefore a search term with a vote. `harvest` has
+never done this — C.6c derives terms first, through a stopword set that
+speaks three languages — but the entry search that `harvest` itself calls
+was never given the same treatment, and nobody had measured what it costs
+because until now nothing could.
+
+What could not be measured is the point. Of the labelled question sets this
+project carries, two scored **recall@1 = 1.000 before any change** and the
+third resolves one question in eighteen. A change to the entry ranker could
+be neither approved nor refused. Measured against a new 60-question set over
+a 1,877-node corpus — four classes, `expected_nodes` written from reading
+documents rather than from running searches — the baseline is **0.711**, and
+deriving terms first moves it to **0.778**, MRR 0.734 to 0.791: **five
+questions up, none down**. Per class, the natural-language class carries the
+loss it was expected to carry: 0.533 today, the worst of the four, with
+seven of fifteen questions falling out of the top five entirely.
+
+On the old instrument the identical change was three up and three down. It
+is the same change. Only the instrument moved, and this changelog exists as
+much for that as for the rule below: **a ranking change measured on a
+saturated set has not been measured.**
+
+Two rules follow, and the second is not optional decoration. Derivation can
+return **nothing** — "api", "sql", "o que é isso" all derive to the empty
+list, because the floor that keeps grammar out also keeps three-letter
+lowercase tokens out. A `locate` that passed that straight through would
+answer nothing at all to a caller who typed one word, which is the failure
+C.1.1 exists to prevent, manufactured on purpose.
+
+- **The entry search searches derived terms, not the sentence (amends
+ C.1).**
+- **An empty derivation falls back to the sentence (amends C.1).**
+- Acceptance: **F.147 - F.148**.
+
+**Changelog v0.68 → v0.69 a path is not a syscall.**
+
+`Forest.path_for` turns an id into a file and, in the same breath, decides
+that the file is inside the forest. It decides it with `Path.resolve()` — a
+`realpath` walk, one syscall per component, symlinks followed — and a body
+scan calls it **once per node in scope**. Measured on a served 1,877-node
+forest: **72 ms of a ~230 ms cold `sniff`**, against **2.6 ms** for the same
+containment decided on the string. Thirty-one percent of the call, spent
+asking the filesystem a question the engine had already answered.
+
+The decomposition around it is worth stating, because it is the second time
+this call has been measured and each time the answer moved. v0.62 found
+629 ms and left it at 133 by fixing the fold loop and the frontmatter regex.
+What is left today, per cold call, is `path_for` at 31%, the fold at 32%,
+reading every file at 16%, `_split_raw` at 2% — and `str.find`, the work the
+primitive exists to do, at **1%**. The v0.62 changelog closed the trigram
+question with "reading the corpus is 5% of the call"; after v0.62's own
+fixes that number is 16%, and the sentence should not be quoted again
+without its date.
+
+The repair is not to stop checking. It is to notice that `path_for` answers
+two different questions wearing one name. When a caller hands the Station an
+id, containment is a **security** question about untrusted input and must
+resolve symlinks. When the engine reads an id out of its own catalog —
+which is what a scan does, for every node — containment is a question about
+a node the engine itself planted and whose path it itself wrote. Paying
+1,877 `realpath` walks to re-decide that is not caution; it is a boundary
+check that drifted into a loop.
+
+So the rule below is about **where** the check belongs, never whether it
+happens, and it comes with the obligation that makes it safe: the boundary
+set is written down and tested per surface, because the way this goes wrong
+is silent. Nothing fails. An id simply passes.
+
+- **Containment is enforced at every boundary that accepts an id from
+ outside the engine (amends A.3.1/C.6b.1).**
+- **An id read from the catalog is not such a boundary (amends C.6b.1).**
+- **A write always resolves (amends C.7/C.8/C.14/C.15).**
+- Acceptance: **F.145 – F.146**.
+
+> Numbering note: this version takes **C.6b.2**. The prefix leg for a thin
+> `locate` — drafted alongside this work — is deliberately NOT here and will
+> take C.6b.3 when it lands. It widens what an entry search can find, and
+> the labelled question sets available today contain no question whose
+> correct answer is silence, so its central refusal criterion cannot yet be
+> written. A widening shipped with its recall criterion provable and its
+> restraint criterion absent is exactly the asymmetry that makes widening
+> feel free.
+
+**Changelog v0.67 → v0.68 the gauntlet rides inside the forest's clock.**
+
+An operator asked a hosted question with hybrid entry on and read, on the
+very panel J.10.4 exists for: forest 8437 ms, of which `locate` was 8391 —
+beside sniffs and picks that each ran in fractions of a millisecond. The
+panel's own premise is that the usual suspicion "the forest is slow" is
+almost always wrong, and here the panel itself was the one raising it: the
+forest's whole work in that trace was some forty milliseconds. The other
+8.3 seconds were the K.2 query embed — one HTTP round trip to the embedding
+provider, run inside `locate`'s span because K.2 puts it there, and timed
+as `locate` because Part D times a primitive from outside, as it should.
+
+Nothing sums the answer model into the retrieval figure — J.10.4 has
+reported those apart since it existed. But the *other* model on the read
+path, the embedder, had no clock of its own, so its round trip is billed to
+whichever primitive it ran inside: `locate` when the entry embeds the
+query (hybrid on), a walk's first `look`/`move` when the goal embeds lazily
+(K.2). Two facts made the misattribution durable. The figure indicts the
+wrong half — the operator reads "the forest took eight seconds" and tunes
+an engine that spent forty milliseconds, while the provider, or its cold
+model load, goes unexamined. And the memo (K.6) makes the number
+unreproducible: the second ask of the same question embeds from
+`_derived`, so the figure vanishes on exactly the retry that was meant to
+confirm it.
+
+The repair is a named share, never a second stopwatch. The Part D event
+gains `embed_ms`: the milliseconds the call spent obtaining a query vector
+through K.2/K.6 — memo hits included, because a hit's near-zero is the memo
+working and is worth seeing — present only when an embed ran, so every
+other event is byte-identical. `elapsed_ms` stays the whole wall span,
+embed included: it is true, and a total that quietly excluded a slow
+provider would be the flattering version of the same lie. J.10.4 forwards
+the field on the step that paid it and sums it once as `trace.embed_ms`,
+present only when nonzero; `retrieval_ms` and `total_ms` keep their
+meaning to the byte, so no shipped number is redefined. And the console
+rule: a panel that leads with the engine figure MUST NOT present the
+embedder's round trip as the forest's — where `embed_ms` is nonzero, every
+step and the forest figure are shown net of their share, and the summed
+share is listed once at the tail beside the `model` step, in the model's
+own tone: provider spend sits with provider spend, and every primitive row
+is the engine's own smallest true number. The
+`Server-Timing` header (J.10.6) is unchanged: its clocks partition the
+host's span and `vine` still accounts the engine's wall time; the split
+rides the trace, which is the channel the console already reads.
+
+Acceptance: **F.143 – F.144**. The panel's rendering is normative text with
+no test behind it, on the boundary F.142 already states.
+
+**Changelog v0.66 → v0.67 a sample is not the corpus.**
+
+A served forest of about twelve hundred nodes was asked what it was about.
+In walk mode it read the readme and stopped; in sweep mode it generalised
+five ranked excerpts into a claim about the whole forest. Neither answer is
+a hallucination and neither is a defect in the loop: both are this
+specification working exactly as written, which is why the repair is here
+and not in a patch note.
+
+There are four causes and each of them is a sentence nobody wrote.
+
+**The walk's tool menu is closed** — "and nothing else" — and `coverage` is
+not on it. C.17 exists for precisely the question that was asked; its own
+motivating story is a faithful answer wrong about its subject, given because
+nothing had told the agent what the corpus holds. The one read built for
+that question class was barred from the one mode that could have chosen to
+call it. It is on the list now, and it widens nothing: `coverage` is
+metadata only and scope-filtered like every other read, so a walk that calls
+it learns what the same principal could already have asked for directly.
+
+**The sweep's prompt has no denominator.** It orders the model to answer
+strictly from the material it was handed, and nothing anywhere says what
+that material *is*. `searched` rides the empty path only — C.1.1's rule, and
+it is the right rule — so a non-empty bundle carries no corpus size at all,
+and five items out of twelve hundred arrive looking exactly like twelve
+hundred out of twelve hundred. Generalising them is not the model
+disobeying the prompt; it is the model obeying it. J.10.8 settled the shape
+of this repair for a different number in v0.63 — the cap is stated whatever
+chose it — and this is the same repair applied to the sample.
+
+**The walk's entry is a synthetic retrieval nobody admits to.** The first
+message is a `locate` of the raw question, labelled with the question, and
+the prompt never says that no model chose those terms. Re-authoring the
+retrieval — translating a Portuguese question into an English corpus's
+vocabulary, reaching for the rarer term — is a move the walk has had since
+hop 1 and was never told it was allowed to make. The machinery was there;
+the sentence was not. Prompt wording stays implementation freedom, as it has
+always been. What is stated here is what the prompt must not leave out.
+
+**And nothing could hand authored terms to a hosted sweep.** J.10.7's key
+text has read "whether the caller supplied them or the sweep derived them"
+since v0.33, describing a path no surface offered: `harvest` takes `terms`
+and `answer` did not, so both key builders re-derive from the question and
+the first half of that clause has never once been true. `answer` takes
+`terms` now, **sweep only** — a walk authors its own retrieval, and a
+parameter silently dropped is a lie about what ran — and the key text
+becomes a description instead of an aspiration. This is emphatically not a
+planning turn: no model runs before the retrieval, C.6c stays zero-LLM,
+J.10.11's phases stay in order, J.10.10's floor stays before the model, and
+a call that sends no `terms` keys and answers exactly as it did. The
+authoring happens in a client that already holds a model.
+
+The remaining change answers none of those four. It is v0.65's path panel,
+and its two defects are one defect twice: **it drew what did not happen.**
+On a walk it ran a sweep the walk never runs and painted those dots as the
+answer's retrieval, so the first picture an operator saw was of a retrieval
+that never occurred, and the live hops then displaced it — which reads,
+exactly and wrongly, as the model ignoring what it was shown. Rule 2's
+justification ("they are the same sweep") is a sweep-mode sentence; in walk
+mode there is no same anything, and rule 3 already forbids inventing a
+stage. So the preview is mode-aware: no harvest is fired for a walk, the
+panel starts empty, and it fills from J.10.12's `hop` events and, at the
+close, the response's own `read`. For those events to light anything they
+have to carry ids, and a hop record for `locate`/`sniff`/`scan`/`move` did
+not — it carried a count, and a count lights no node. The record gains
+`ids`, and F.138's event-equals-record comparison extends to cover them.
+
+The panel's other defect was its background. It painted the whole edge set
+of the J.11 projection, including the `confidence < 1` class Explore hides
+by default, and painted that class at **twice** the opacity of structure; on
+a curated forest carrying up to three proposals a node, that is the hairball
+the operator reported. Dots only now, coloured by home branch the way that
+operator's own Explore is set to colour them, with the full edge set still
+feeding the layout springs — paint and physics split, which is what Explore
+itself has shipped all along. The trail stays the only line drawn. And the
+panel moves below the answer, accepts zoom and pan, and keeps its own
+compact switch as a browser preference: the address carries the selection,
+not the taste.
+
+One thing in this version is not a contract and is named only so it is not
+mistaken for one. The derived-term stopword set gains the Portuguese and
+Spanish demonstratives beside the English ones it already carried: `esta`
+was absent while `this` and `that` were present, so the question above
+reached `sniff` as a substring search for `esta`, which matches inside
+`restart` and `timestamp`. C.6b does not enumerate that set and this version
+does not begin to — a list fixed in the specification is a specification
+amended in every language somebody asks a question in.
+
+Acceptance: **F.139 – F.142**.
+
+**Changelog v0.65 → v0.66 the answer arrives once; the work does not.**
+
+v0.65 drew the path an answer took and said, in its own changelog, what it
+could not do: the walk's hops arriving one at a time, live, needs the host
+to push. This is that push, and it is smaller than it looked.
+
+The reason it looked large was a wrong diagnosis. A walk holds its reader
+lane for its whole duration (J.10.11 says so), and the assumption was that
+emitting a hop mid-call therefore needed the J.10.11 treatment — the model
+lifted off the lane first. It does not. A lane is a thread in an executor
+and the loop is reachable from any thread, so a hop can be handed across
+without the walk changing where it runs at all. The lane question is real
+and is about throughput; it is not this section's question, and conflating
+them would have bought a large refactor to ship a small feature.
+
+What is added is **one route and one optional field**. A caller may put an
+opaque `run` on a `POST .../answer`; a caller that does not is byte-for-byte
+where it was. `GET .../answer/{run}/events` is a `text/event-stream` that
+carries the same call's progress: `retrieval` when the sweep's bundle exists
+(19 ms in, against a reply 10 s out), `hop` as each of a walk's steps
+completes, `done` at the close. Nothing about the answer's own response
+moves, and the MCP surface gains nothing at all.
+
+The rule that makes it safe is the one that also makes it simple, and it is
+NOT J.16's. A webhook leaves the Station's authority behind — whoever holds
+the URL reads it — so J.16 rations its payload down to identity. This
+stream is the opposite shape: it is **pulled**, by the same principal, under
+the same credential and the same scope, for a call that principal is already
+making. So the rule is not "carry less than the response"; it is **carry
+nothing the completed response would not have carried to this same
+principal**. An event is a PREFIX of the answer, never a second disclosure
+surface — `retrieval` is that response's own `harvest`, `hop` is its own
+`hops[n]` — which is checkable by comparing the two, and F.138 does.
+
+Three properties keep a spectator from costing the answer anything. Emission
+never blocks: the lane hands the event to the loop and returns, and a
+consumer too slow to keep up loses events rather than slowing the hunt.
+Nothing outlives its call: the buffer is host memory like a J.9 job record,
+a restart forgets it, and a channel closes rather than hangs — after a
+bounded grace, because a watcher opens its channel before firing the call
+(the only order that misses nothing) and a race is not an absence. And the stream is never the answer: the reply text
+arrives on the POST alone, so a client that ignores the channel loses
+nothing and a client that reads only the channel has no answer.
+
+**Changelog v0.64 → v0.65 the retrieval is done long before the reply is.**
+
+A hosted `answer` is two costs that differ by three orders of magnitude.
+The sweep's retrieval is milliseconds — 19 on the 82-node fixture, 448 on a
+1,902-node forest cold — and the provider round trip is seconds. J.10.11
+already separates them so the model does not hold a reader lane, and J.10.6
+already publishes them apart so a console can say which half was slow. What
+neither of them changed is that the Station answers **once**: the bundle
+exists as a value in the host at 19 ms and leaves the building at 10 s.
+
+So the Ask console spent that gap on a spinner. It has always been able to
+say WHAT was read — J.10.4 assembles the sweep's bundle and the walk's hops
+into one shape for exactly that — but only as a list, only afterwards, and
+never as a place. A forest is a graph (J.5.4 draws it), and a question that
+reached three nodes in one branch out of nine reached them somewhere.
+
+J.5.15 is that panel, and it is bounded by what can be known without a new
+contract. The console runs the sweep's retrieval ITSELF, in parallel with
+the answer, through the ordinary `harvest` primitive: same question, same
+`k`, same entry ranker, deterministic and read-only, so what it draws is
+what the answer will see rather than a guess at it — and it deposits no
+pheromone, because heat is the whisper's at the close of an answer (J.10.7)
+and never a read's. The map is the J.11 `graph` projection, already scoped,
+already filtered, and already what Explore reads.
+
+Two rules in J.5.15 exist because the honest version and the flattering
+version differ. On a **sweep** `evidence` is every id in the bundle — the
+reply is prose and names nothing — so there is no "the model chose these"
+stage to draw, and drawing one would show a selection that never happened;
+`cited` is a walk's stage, where `answer_nodes` is a real choice filtered to
+what was actually opened (J.10.5). And on a walk the entry `locate` marks
+nothing, because J.10.4 keeps only what carries text: the stage reads zero,
+which is true, rather than being filled from `sources` to look complete.
+
+The panel also states its own speed. The reveal takes seconds and the thing
+it depicts took milliseconds, so the real figure rides beside it off the
+Part D trace. A console whose subject is that retrieval is cheap must not
+leave an audience believing the animation is the measurement.
+
+What is NOT here: the walk's hops arriving one at a time, live. That needs
+the host to push, which is a contract, and this version does not add one.
+
+**Changelog v0.63 → v0.64 a transport method is not a capability.**
+
+J.1.2 rule 4 says an announced capability with nothing behind it is an
+instruction to every connecting client to spend a round trip learning
+"empty", and the implementation applied it by deleting handlers from the
+SDK's registry. It deleted one more than the rule names. Beside `prompts/*`
+and `resources/*` it deleted **`subscriptions/listen`**, which at the
+2026-07-28 era is not a feature of the resources family at all: it is the
+only server-to-client channel there is, the replacement for the standing GET
+stream of every earlier era.
+
+What that costs is not a wasted round trip. The Station answers
+`server/discover` at 2026-07-28 with a 200, which tells the client this era
+is spoken; the client then opens its `subscriptions/listen` stream, and the
+SDK answers an unregistered method with **HTTP 404**. In streamable HTTP a
+404 has a meaning of its own (2.5.3: the server MAY terminate a session, and
+must then answer 404 to requests carrying its id), so a conforming client
+reads it as *your session is gone* and tears the connection down. The next
+call dies with it. Measured against a released client (Antigravity, on the
+Go SDK), the whole surface reported **0 tools**, and the error it printed
+named a session id that was never issued rather than the method that was
+never served.
+
+Two facts follow, and they are separable. The first is that the deletion
+went past the rule and is withdrawn. The second is that serving the method
+changes one announced bit: at 2026-07-28 the SDK derives every list-changed
+flag from whether the listen handler is served, so `tools.listChanged`
+becomes `true` while this Station still publishes no such event. Under rule
+4's own reasoning that is an empty promise — but the cost rule 4 was written
+against does not exist here. A client that subscribes at this era opens one
+stream that stays quiet; it spends no round trip listing anything, and the
+alternative is not silence but a fatal 404. The earlier eras are unchanged to
+the bit: `tools.listChanged` stays `false` at 2025-06-18 and 2025-11-25,
+because there the flag is derived from notification options and not from the
+handler.
+
+- **A transport method is never withheld as an empty capability (amends
+ J.1.2 rule 4).**
+- **A refusal MUST NOT be spelled 404 on this transport (new J.1.4).**
+- Acceptance: **F.135 - F.136**.
+
+**Changelog v0.62 → v0.63 the budget nobody chose is still a budget.**
+
+Every model binding shipped at `max_tokens` 600, and J.10.8 stated the cap in
+the prompt only when a caller set `reply_tokens`. Both halves were sized for a
+reply that is prose. The `answer` turn is not prose: it is a JSON object
+carrying the answer text AND `answer_nodes`, so the budget pays for the
+citation apparatus before it pays for a sentence, and a client that also asks
+for a verbatim proof pays for that too.
+
+Measured on the 18-question suite, a local 12B scored **16/18** at 600, and
+neither miss was a navigation failure. One had already run `SELECT region,
+SUM(amount) ... GROUP BY region` against the right dataset and was cut
+mid-object. The other reached the right node in a single hop and held the
+right sentence, then lost it when the truncated `proof` failed its audit. At
+1500 both pass, and the wall time falls with them (139 s to 15 s, 149 s to
+11 s) because the rejected retries stop happening. What kept this invisible is
+the shape of the symptom: **a cut answer scores as a wrong answer**, never as
+a cut. The console blames the model, the operator tunes the model, and the
+model was right.
+
+Raising the default repairs nobody on its own. A binding is a stored row, so
+every deployment already on 600 stays there until somebody edits it by hand.
+Hence a one-time data repair, and hence a stamp on it: the property that
+matters is not that the repair runs but that it runs **once**. An operator who
+chooses 600 after the upgrade must keep it, and a deliberate 600 is
+byte-identical to the shipped one, so nothing but a version stamp can tell the
+two apart.
+
+The third rule is the one that would have made the first two unnecessary. A
+cap the model is never told about can only be discovered by being hit, and
+J.10.8 told it only when a caller had set `reply_tokens`, which is to say it
+fell silent in exactly the case where nobody had chosen and the shipped number
+was deciding alone.
+
+- **The `answer` role is bound at 1500 (amends J.10).**
+- **A shipped default is repaired once, and stamped (amends J.10).**
+- **The cap is said whatever chose it (amends J.10.8).**
+- Acceptance: **F.132 - F.134**.
+
+**Changelog v0.61 → v0.62 the cold scan was never the corpus.**
+
+C.6b.1's memo made a repeated `sniff` proportional to its matches and left
+the first one alone. A term the forest has never been asked for is scanned
+against every body in scope and no memo can help, because the fact it would
+remember is the one being computed. That cost was reported against v0.59,
+assumed to be the corpus, and deferred **on a condition** that it be
+measured before a fix was designed, because the fix on the table was a
+trigram index over bodies in `_derived/` a second search engine, to be
+kept honest against C.6b's literal semantics forever.
+
+The measurement is in, and it does not say what the deferral assumed. On a
+1,902-node forest holding 11.9 MB of markdown, a cold `sniff` for a term
+that matches nothing cost **629 ms of CPU**, of which reading all 1,902
+files was **31**. The other 598 were two things this document had never
+looked at:
+
+- The **fold** lowercase and strip diacritics, which is C.6b's matching
+ rule and therefore normative was a Python loop over every character of
+ every body, with a dict lookup and a list append per character: **387 ms**,
+ 62% of the call.
+- The **`content:` marker**, which decides whether a body is inline or lives
+ elsewhere (G.7), was matched with a MULTILINE regex over the whole file
+ to find a line that can only ever appear in the frontmatter: **71 ms**,
+ another 11%.
+
+Nearly three quarters of a "full corpus scan" was spent not reading the
+corpus. Both are now what they should have been the fold is one
+`str.translate` against a table built on first use, and the marker is
+looked for in the frontmatter and the same call costs **133 ms**. The
+sweep behind `answer` went from 933 ms to 448. Nothing about any answer
+changed: these are cost rules under C.6b.1's first rule, and the fold's
+identity to its own definition is verified over every code point Python can
+represent, not over a sample.
+
+The trigram index is **not** in this version, and its case is weaker than
+it looked. It was proposed to remove a corpus read that turns out to be 5%
+of the call it was blamed for. What remains true, and is stated here rather
+than implied, is the shape: a cold `sniff` is still proportional to the
+corpus and a warm one to its matches (the v0.59 rules below). A future
+version that wants to change the first of those now has an honest baseline
+to beat.
+
+- **The fold is a table, not a loop (amends C.6b.1).**
+- **A frontmatter marker is read in the frontmatter (amends C.6b.1/G.7).**
+- **What a cold scan costs, measured (amends C.6b.1).**
+- Acceptance: **F.129 – F.131**.
+
+**Changelog v0.60 → v0.61 the write means what it said.**
+
+Two rounds of outside verification (2026-08-21 and 2026-08-22) tested this
+Station against its own promises. The read side came back untouched: the
+grounded answer stayed faithful, the `min_score` floor kept refusing the
+question whose evidence does not clear it, and the trace measured the
+product's own regressions. Every finding that landed, landed on the other
+half — **whether a write means what it said afterwards.**
+
+`prune` removed a node, reported `pruned: true`, and the next upload to
+the same branch **replanted it**, with a fresh timestamp and an edge into
+the new material. The remover was told the removal happened; the forest
+disagreed a day later, in silence. In a memory that is worse than a write
+that fails: a failed write is retried, a resurrected one reintroduces
+material somebody decided to withdraw. Beside it, a dataset whose payload
+had gone missing took the whole `look` with it, so a node visible to
+`scan` was unreadable through the primitive that reads passports and the
+passport had nothing to do with the missing file.
+
+The rest of this version is the backlog those rounds left open — six items
+reported against v0.59 and still open in v0.60, which is the first time in
+the series the report-today-shipped-tomorrow cycle broke. Three of them
+turned out not to be what they looked like, and that is stated here rather
+than buried: `origin` on an upload was working as specified and undocumented
+(J.8 rule: an upload's origin is the `source_url` it declares, and nothing
+taught anyone to declare it); `supersedes` was in the engine's default
+dialect since v0.58 and absent from the deployed forests' own
+`_meta/schema.md`, which is A.2 working as designed with a refusal that
+failed to say so; and of the five reads said to ignore the waymark, four
+already honoured it and `pick` did not.
+
+- **An upload is a courier, not a mirror (new J.8.3, amends J.9).** The
+ bug reported was a `prune` that undid itself on somebody else's next
+ upload. Underneath it sat an assumption nobody had written down: that the
+ upload staging area is a source tree this forest *mirrors*. From that one
+ assumption came all of it — `adopt` recorded the staging path as the
+ forest's `source_root`, so **one upload repointed a forest that really
+ did mirror a folder** and the operator's Sync then described the courier;
+ the refresh walked the whole directory, so a file whose node had been
+ pruned was read as a new document and planted again; the report said
+ `sync` to a caller that had said `upload`; and nothing could ever be
+ removed from the area, so it accumulated where nobody could see it.
+ Uploaded bytes are now what they always were: how a document reaches a
+ Station, scoped to the entries of that request, recording nothing,
+ removed as they become nodes (`consumed`), kept when they fail, never
+ backing a `reference` body, and — for what legitimately remains —
+ countable and clearable through **J.13.7** instead of a shell.
+- **A missing payload is a fact about the payload (new C.2.2).** `look`
+ built a dataset's `query_manual` and `sample_rows` by opening the `.db`,
+ and an absent file raised `E_NOT_FOUND` for the entire digest. The
+ passport title, summary, tags, edges, notes never depended on that
+ file. The digest now degrades: the passport is returned, the two
+ payload-derived fields are omitted, and `payload_missing: true` says
+ which. `coverage` counts the same condition per root, so an operator
+ reads the damage in one call instead of discovering it one
+ `E_NOT_FOUND` at a time.
+- **A branch is addressed by its id (amends G.3/J.8).** `dest` was the
+ one place in the whole surface where a branch may **not** be named the
+ way every other place names it: `scan("tasks/_index")`, `parent:
+ "notes/_index"`, `coverage`'s roots all take the canonical form, and
+ `dest: "notes/_index"` produced `notes/_index/_index` and a refusal
+ whose "expected parent" was the exact string the caller had sent. The
+ advice was to do what had just been done. `dest` now accepts both
+ forms, and the skill teaches the canonical one.
+- **Every read by id answers the waymark (amends C.15 rule 4).** Stated
+ in v0.58, implemented in `look`, `move`, `history`, `view` and `query`,
+ and missing from `pick` the read an agent holding a written-down id
+ actually makes. Half a redirect is not a redirect.
+- **A derived alias is a name, not a leading digit (amends G.2.6).** The
+ number a file's stem starts with is derived as an alias, and the test
+ for "starts with digits" did not require the digits to END. A document
+ named `9router-free-ai-router.md` derived the alias `9`, which enters
+ the one index searched by metadata alone and ranks. The number must be
+ a whole leading segment, followed by a separator or by nothing.
+- **An unknown token names the set that would be accepted (amends
+ A.2/C.7/C.8).** A forest's dialect is its own file and a rel the engine
+ ships may be absent from it that is A.2 working. What is not working
+ is `E_SCHEMA: unknown rel 'supersedes'` with no `hint`, in the exact
+ case where naming the forest's declared rels answers the question
+ completely. C.12's envelope says every refusal carries an actionable
+ hint; this one did not.
+- **What ingest derives can be re-derived (new J.13.6, amends G.2.6
+ rule 4).** Aliases derive from the source path and the title, both
+ recorded in the passport so the repair for a forest ingested before
+ a derivation rule existed needs no source tree, no converter and no
+ model. It was nevertheless reachable only through `sync`, which needs
+ the recorded host root and `admin` over it. A maintenance pass now
+ re-derives from the forest's own passports and reports what changed;
+ a forest of 1,877 nodes stops being a forest where the delivered
+ feature is absent in practice.
+- **The floor says which half refused (amends J.10.10).** `min_evidence`
+ counts items that clear `min_score`, so with a threshold that means
+ anything the pair `(2, 0.02)` refuses questions the forest answers
+ well and the refusal reported `evidence_count: 1` without saying that
+ two further items were dropped by the threshold. `below_min_score`
+ names them, and the skill states the pairing that works.
+- **The skill names its forest, its origin and its useful floor (amends
+ J.5.12).** One skill per forest collided on `name:`; the saving block
+ taught the `dest` form the server refuses and never mentioned
+ `source_url`, the only way an uploaded document gets an `origin`.
+- Acceptance: **F.117 – F.128**.
+
+**Changelog v0.59 → v0.60 the skill fits the agent.**
+
+Every version that touched the Skills console made the file it hands out
+better and bigger. v0.49 gave a person a skill instead of asking them to
+write one; v0.56 taught it to state its age; v0.57 taught the anatomy of
+a node because a first-session agent would otherwise plant nodes nobody
+could find; v0.58 added the document's past; v0.59 added `coverage` and
+`min_score` — 471 tokens in one release, to a file that had reached
+3,921.
+
+None of those were wrong, and the sum is: an agent that fires this skill
+pays for all of it, once per session, forever. A key paired with J.2.6's
+default `{read, ingest}` carries about 1,400 tokens of `plant` anatomy it
+is not allowed to execute, and an agent asked only to read pays for the
+whole write surface. The product spent five versions teaching the model
+to ask narrower and never applied the lesson to its own instructions.
+
+The second half is smaller and worse. Every primitive here takes
+`forest` as its first argument. The generated skill contained twenty-three
+call examples and **not one of them passed it**: the forest's id lived in
+the title, in the `description` and in no call the model was ever shown.
+The console that exists so documentation cannot drift from its
+deployment was teaching a call shape the deployment does not have.
+
+- **A skill is a folder (amends J.5.12).** `SKILL.md` carries what every
+ agent needs; `references/{saving,writing,time,datasets,sharing}.md`
+ carry what only some do, each named in the core with the condition that
+ sends an agent to read it. A reference that is not read costs nothing,
+ which is the whole point: the runtime holds only the `description` in
+ context and loads the body on trigger, so the number to shrink is what
+ a firing costs. The core alone is a complete skill and never a teaser;
+ no instruction exists in two blocks; and several *installed* skills is
+ explicitly not the split — each would cost its `description` in every
+ session, and the runtime rather than the core would decide which one
+ loads, which a model about to make its first write cannot know in
+ advance.
+- **The key chooses the blocks (amends J.5.12).** The default selection
+ is the capabilities of the key on the selected forests — the console
+ already renders under that grant. A block may be included deliberately
+ for a capability the key lacks (a skill prepared for a colleague), and
+ then it names the capability it requires in its first line, which is
+ v0.49's conditional-teaching correction kept honest rather than
+ discarded. The same selection can be assembled as one inlined file for
+ runtimes that take no folder, and the two assemblies teach the same
+ surface.
+- **An example is a call (amends J.5.12).** Every example in every
+ generated file carries the forest argument in the shape the tool takes.
+- **The skill is for the forests it names (amends J.5.12).** The console
+ offers the forests the key reaches, the open one pre-selected, so an
+ agent configured for two forests is handed one skill. A baked id is
+ intent, never authority: `forests()` is taught as the first call
+ because capabilities, roots, `locked` and `station` are only true at
+ the moment of use, and a forest whose grant has lapsed answers
+ `E_NOT_FOUND` like anything else outside a key — the ordinary shape of
+ a narrowed key, not a defect to work around. More than one forest
+ carries a routing table (id, largest roots, capabilities held at
+ generation) built from C.17 `coverage`, because a model given several
+ forests and no map either sweeps them all or picks one in silence. One
+ forest carries no table: there `coverage()` is a single live call, and
+ a written-down copy of a forest's shape can only drift from it.
+- **The address is the way back (amends J.5.12/J.5.8).** Blocks, forests and
+ assembly ride the query, so the generated file can name the one link that
+ rebuilds itself against a newer Station — the repair for the staleness the
+ v0.56 stamp only detects. Installing stays the operator's act: a skill
+ outlives the connection that delivered it, which is exactly what a tool
+ description does not, so the Station gains neither an endpoint nor a
+ `skill()` tool for it.
+- Acceptance: **F.111 – F.116**.
+
+**Changelog v0.58 → v0.59 the forest says what it holds.**
+
+Every read in this product says what it did not do. `locate` returning
+nothing reports `searched` and names `sniff`; `look` names the field it
+clipped; `scan` returns `total` beside `returned`; the sweep counts what
+it suppressed. The discipline is mature and it is the reason the tool can
+be trusted by something that is not reading carefully.
+
+It had never reached the outermost layer. A consumer agent asked this
+forest for a mandatory rule, got a faithful answer citing a real
+document, and the answer was wrong about the thing it was asked — not
+because retrieval failed, but because the branch holding the rule had
+never been ingested, and **nothing on the surface could say so**. The
+partial answer arrives in the exact shape of the trustworthy one: with a
+citation, with a source, with a trace. That is the most expensive silence
+a memory can keep, and it is the last one left.
+
+v0.59 closes it, and pays a debt the load measurements made undeniable
+along the way.
+
+- **A forest can say what it holds (new C.17 `coverage`).** One cheap
+ call, answered from the catalog alone with no file opened and every
+ count grouped in SQLite: the roots the caller may start from, how many
+ nodes sit under each, where that material came from (`origin` as the
+ exact prefix `scan` takes) and how much of it carries no origin at all,
+ the dates it spans, and the totals by type and by source. Scoped like
+ every read — under a policy the roots are the principal's own roots and
+ every count is filtered by the policy's own prefixes as SQL (C.13.3's
+ rule, for the same reason: a global count here is a finer size oracle
+ than `locate` could ever be). It states what is present and never
+ guesses what is absent; seeing that a root is not there is the caller's
+ conclusion to draw, and the point is that they can now draw it in one
+ call instead of needing the source tree on disk.
+- **The document's own name resolves (amends G.2.6).** Alias derivation
+ required the operator to declare a folder→prefix map, and without one
+ ingest wrote no aliases at all — so in a forest where every document
+ has a canonical code, 1,877 of 1,877 nodes lacked the name they are
+ called by, and the most common access in the forest fell through to the
+ path that measured **~100× slower**. Ingest now derives from what the
+ source already states about itself: a code in the shape `LETTERS-DIGITS`
+ present in the title or the H1, the leading number of the file's stem,
+ the path form, and — when the containing folder's name is itself
+ compound — its initials as a prefix. None of that is content vocabulary
+ entering the engine: the engine invents no words, it reads back the ones
+ the document and the path already carry. The `aliases:` map keeps the
+ job only an operator can do — declaring a convention the material does
+ not state — and a hand-written alias still outranks every derived one.
+- **What came from a tree is listable (amends C.6).** `scan` gains the
+ filter key `origin_prefix`, matching a prefix of the node's `origin`
+ URI; the exact-match `origin` key stays, because "which node is this
+ file?" and "what came from that directory?" are different questions.
+ `coverage` publishes the prefix that `scan` takes, so there is no
+ arithmetic between finding a source and listing it — the same
+ contract C.13.3 gives windows.
+- **A floor that counts evidence, not items (amends J.10.10).** The
+ `min_evidence` floor counted retrieved items, and the sweep returns `k`
+ items whatever their score, so the floor was reached almost always and
+ the protection it advertises almost never fired: it guarded against an
+ empty forest, not against weak evidence. `answer` gains `min_score`,
+ applied before the count, and the refusal names both numbers. The
+ specification states plainly what the score is — an RRF rank artifact,
+ comparable within a deployment and not across corpora — so the lever is
+ tuned rather than believed.
+- **A citation carries its scope (amends J.10.4/J.10.5).** `sources[]`
+ carried `id`, `title`, `summary`, `type` while the `harvest` beside it
+ carried each item's `trail` — so the field designed to be read was the
+ one that lost the material's place in the forest. In a multi-product
+ forest, which is the normal case and not the exception, the trail is
+ what makes "according to `findleads/back-end`…" visible at a glance.
+ The data was already computed; it now crosses the last layer.
+- **A rehearsal names every problem (amends C.7.3).** `dry_run` exists to
+ turn trial-and-error into one call, and it validated in a chain — first
+ problem, stop — so it stayed trial-and-error, merely cheap. A failing
+ rehearsal now reports every problem it could determine, and a failing
+ batch rehearsal reports them for **every** node rather than up to the
+ first. The envelope's own code, message and hint are unchanged to the
+ byte; the full list rides in its `data`.
+- **The share link is one address with two representations (amends
+ J.17).** `/s/
` was a console route, served only to a request that
+ accepts HTML, so the first thing anybody does to debug a share — curl it
+ — answered 404 while the browser worked. It now content-negotiates:
+ a browser gets the reader page, everything else gets exactly what
+ `GET /v1/share/{token}` serves, from the same handler, with the same
+ authority re-read and the same byte-identical 404 for every dead state.
+- **A remembered non-match is a count, not a row (amends C.6b.1).** The
+ memoized scan removed the file I/O and left everything else: on a
+ 1,911-node forest a warm `sniff` carried a row out of SQLite for **every
+ node in the forest**, deserialized and recombined each one, and only then
+ discovered that ~95% of them matched nothing — after loading every
+ catalog row too, and asking SQLite for heat **one node at a time**, and
+ rendering snippet windows for thousands of lines of which at most fifteen
+ are ever returned. Measured: 94 ms of a 103 ms sweep, and throughput that
+ **falls** as concurrency rises, because the work is CPU-bound and the
+ reader pool of J.6.2 cannot multiply what the GIL serializes. Four cost
+ rules now bind the memo — the matching lines and the *uncovered* nodes
+ are what a read asks for (everything else in scope is one count), the
+ catalog rows loaded are the candidates', heat for them is one statement,
+ and a snippet is rendered only for a result that is answered. A warm
+ `sniff` for a term living in a handful of bodies went from 10.9 ms to
+ **1.5 ms** on that forest; a term living in most of the corpus stays
+ proportional to its matches, which is the honest floor and is stated as
+ one. The answer is byte-identical to the direct scan throughout;
+ C.6b.1's first rule was never in question and is not relaxed.
+- Acceptance: **F.103 – F.110**.
+
+**Changelog v0.57 → v0.58 the document has a past.**
+
+v0.56 made the forest replace the file; v0.57 made it serve a hundred
+readers. What remains is the knot every earlier changelog deferred by
+name: a document that cannot move, does not remember, and arrives one at
+a time. The pieces were always one design — moving a node needs a
+waymark, a waymark needs history to stay honest, history needs an
+author, and the author has been riding every write since J.4 stamped the
+`station-principal:` trailer. This version unties the knot, and closes
+the one regression v0.57 would otherwise have shipped: with the model
+calls parallel, identical concurrent misses each paid for a generation
+the first of them was already buying.
+
+- **A node can move, and the old address says where (new C.15
+ `transplant`).** The consumer team's words: "se eu errar a branch de
+ um documento, refazer é a única saída" — misplacement was permanent.
+ `transplant(id, new_id)` moves ONE leaf node: passport rewritten under
+ the new id in the same commit that removes the old file (git's rename
+ detection keeps `--follow` history whole), every backlink rewritten to
+ the new address (prune-force's discipline: refused when an anchor lies
+ outside the caller's scope), both parent indexes and coverages
+ refreshed, a local payload moved beside it. The old id becomes a
+ **waymark**: recorded as `moved_from` on the new passport (files are
+ the truth — a reindex rebuilds the redirect map from them) and joined
+ to `aliases`, so `locate` still finds the old name; a read of the
+ exact old id answers **`E_MOVED`** naming `moved_to` — unless the new
+ address lies outside the reader's scope, in which case the answer is
+ the byte-identical `E_NOT_FOUND` of a node that never existed (a
+ waymark must not be a periscope). Branches do not transplant (move
+ the leaves, one audited decision at a time — C.14's rule); root and
+ `_meta/` never; `graft`'s `set_parent` stays refused (an address is
+ not a field).
+- **A document remembers who did what, and when (new C.16 `history`).**
+ Every write was already a commit and the acting principal already rode
+ it (J.4, v0.57); nothing could read them back — the team accumulated
+ ten commits in one session and called git better at this than the
+ product. `history(id)` lists the node's commits, newest first, through
+ renames (`--follow`): full **timestamp** (day-precision frontmatter
+ finally has an intraday answer — D-01b closes here), the `action` (the
+ commit subject's own prefix: plant, graft, tend, transplant, gardener,
+ ranger…), and `by` — the attribution trailer's value when the commit
+ carries one. Read-capability, scoped like every read, budgeted like
+ every read.
+- **A batch is one plant (new C.7.4).** The team planted eight documents
+ in eight calls; two died mid-batch and left a graph half-built that
+ only `if_absent` retries could heal. `plant` now accepts a **list** (≤
+ 20): every node validated BEFORE anything is written — in order, so a
+ branch and its children may share one batch — and the whole batch
+ lands in **one commit** or none of it lands (`E_SCHEMA` names the
+ failing node). One commit is also one writer-lane occupation, which is
+ the write ceiling the load report measured, attacked from the other
+ flank. `if_absent` and `dry_run` compose with the list.
+- **A replacement suppresses what it replaced (amends A.2, new
+ C.6c.4).** The dialect gains `supersedes`/`superseded-by` — distinct
+ from `succeeds`, which orders moments without judging them. The sweep
+ now EXCLUDES a result that a live node supersedes, refills the seat,
+ and **counts what it hid** (`superseded_excluded` names id and
+ successor — nothing is silent); `include_superseded: true` restores
+ the history view. Navigation (`locate`, `sniff`, `move`, `scan`) shows
+ the forest as it is: suppression is a retrieval-for-answering rule,
+ never a map rule. Existing forests opt in by declaring the rel in
+ their own `_meta/schema` (A.2's rule since Phase 0).
+- **The Gardener records where it came from (new G.2.7).** v0.57 gave
+ the passport `origin` and only agents wrote it. Ingest now fills it:
+ the source file's URI on adopt and refresh, the upload's `source_url`
+ when one was declared (J.8) — and **only when absent**, because an
+ operator's hand-written origin outranks a derived one (G.2.6's union
+ rule, applied to one scalar). Staged uploads without a URL get none: a
+ path inside `_derived/` is a fact about plumbing.
+- **Identical questions in flight share one generation (amends
+ J.10.7).** v0.57 made misses parallel, which un-made an accident of
+ the old serialisation: queued identical misses used to hit the store
+ the leader had just filled. Now deliberately: concurrent sweep misses
+ with the same store key **coalesce** — followers await the leader,
+ then re-consult the store under their own reading fingerprint. A
+ follower whose reading matches is served the stored reply
+ (`cached: true`, the plain truth); one whose reading differs runs its
+ own model call, exactly as J.10.7 always ruled. `cache: false` opts
+ out of coalescing along with everything else.
+- `transplant` joins the J.16 events (`node.transplanted`, identity
+ only), the signature table, both MCP surfaces (20 tools; the J.1.2
+ parity test counts them) and the skill.
+- Deferred, named: branch transplant (move the leaves first),
+ reading a document *at* a historical commit (history lists; Part I
+ restores), `P-04` queue position (awaiting the load re-test's
+ numbers), and `sniff`'s scaling (same).
+- Acceptance: **F.95 – F.102**.
+
+**Changelog v0.56 → v0.57 the forest serves a hundred readers.**
+
+Two sources, one version. The first is an incident: the first production
+deployment under real agent load locked up — not when a model answered,
+but when an agent **planted**. The host serialises every touch of a forest
+on one thread (J.9), so a write's git ceremony (measured at 69× a
+`locate`, before container filesystems multiply it) and the Gardener's
+curation model call each held that thread while every read of the forest
+queued behind them. C.9 has promised "one writer, N readers: reads never
+block" since Phase 0 — the engine honours it (WAL), the host did not. The
+second source is the consumer team's seventh report, the first written
+after they stopped testing the product and started **using** it as memory
+— eight real documents, planted, linked and consulted — which surfaced
+the failures only real use can: a digest that drops its edges in silence,
+an answer that mixes rounds of the truth, and a write surface whose shape
+is documented nowhere an agent looks.
+
+- **Reads scale (new J.6.2; C.9 finally held by the host).** Each open
+ forest gains a **reader pool**: K read-only engine instances, each
+ confined to its own thread, serving every read primitive and the
+ sweep's retrieval. Writes, ingest steps and admin repairs keep the
+ single writer lane. Readers take no lock (C.9's lock is possession of
+ the *write*), deposit pheromone exactly as any read does, and see every
+ write that committed before their transaction began (WAL snapshot). A
+ read never again waits for a plant, a batch, or a model call.
+ `MONKEYLLM_STATION_READERS` sizes the pool (default 4; `0` restores the
+ single lane). Concurrent pheromone writers make SQLite contention real,
+ so the derived-layer tuning gains `busy_timeout` — a writer waits its
+ turn instead of failing with "database is locked".
+- **The provider is not a lane (new J.10.11).** The sweep `answer` held
+ its forest thread through the model round trip — seconds during which
+ a 0.2 ms read could not run. The consumer team's load report measured
+ it precisely: cold-cache throughput pinned at 0.3 req/s at every
+ concurrency, latency linear in the queue, and a cache hit the server
+ served in 104 ms arriving in 6.6 s because it waited behind
+ generations. It now runs in three phases: *prepare* on
+ a reader lane (retrieval, floor, store consult — and the trace slice is
+ captured there, so a concurrent call on the same lane cannot leak into
+ this call's `trace`), the *model call* on no lane at all, *settle* back
+ on the same reader lane (store deposit, whisper). Concurrent model
+ calls are admitted under `MONKEYLLM_STATION_MODEL_CONCURRENCY`
+ (default 8) — parallel because the lane hold is gone, bounded because
+ the provider is metered — and `/v1/health` publishes
+ `concurrency: {readers, model}`, so an agent can read the deployment's
+ shape instead of discovering it by experiment. The walk (`hops`) and
+ `recurate` stay lane-bound and say so: a walk interleaves reads with
+ model turns by design, and it is opt-in per call.
+- **The batch owns the writer lane, and only that (J.9 note).** The
+ Gardener's curation call runs inside an ingest step, on the writer
+ lane. With J.6.2 that is now the correct cost: during a batch, *writes*
+ queue behind the current document — reads and answers flow on the
+ reader pool. Splitting the curation call out of the step is deliberately
+ NOT done: G.10.1 stands (a step is a whole document), and the reader
+ pool removes the only starvation that was observed.
+- **The principal is stamped, never amended (amends J.4).** The host
+ attributed writes by amending the engine's commit — every write paid
+ for two commits and a log read. The engine now accepts **commit
+ trailers** for the host to set (`Vine.commit_trailers`, a J.0-style
+ public seam like `embedder`): the trailer rides the original commit.
+ The amend path remains only as fallback for engines older than the
+ host.
+- **The repo is tended too (new H.8).** Forest git repos accumulate loose
+ objects at one-commit-per-write; on overlay filesystems every git
+ operation slows with them. The Ranger's `run()` now finishes with
+ `git gc --auto` — git's own thresholds decide, the Ranger only asks;
+ reported in the run's report, never a commit.
+- **`look` never drops a field in silence (amends C.2).** Found by the
+ team in real use, and the response contradicted itself: `edges_out: []`
+ beside `stats.degree: 2`. The budget shrink took the edges — the small,
+ structural field — while a 28-item `outline` (the big, re-derivable
+ one) stayed. In a product whose philosophy is "a read says what it did
+ not do", this was the one silent omission left in the hot path. Now:
+ the budget clips in declared order — `outline` first (big, and
+ re-derivable through `pick`), then `children`, then
+ `edges_in`/`edges_out`, a dataset's `sample_rows` last — and **every
+ field the budget touched is named in `truncated_fields`**.
+- **The answer knows what time it is (amends C.6c, J.10.7).** Asked
+ "what is still open?", the hosted answer merged a two-rounds-old report
+ with the current one and reported as open what the newer document says
+ was fixed — while the `succeeds` edges stating the order sat unused in
+ the graph. A memory that treats every version of the truth as the same
+ present gets *less* reliable as it grows. Three changes, none of them a
+ new model call: every sweep item now carries `created`/`updated`
+ (read off the catalog row already in hand); equal-relevance fusion
+ breaks ties toward the more recently updated node; and when one
+ selected item `succeeds` another, both are annotated
+ (`supersedes`/`superseded_by`) so the model — instructed by the host's
+ prompt — reads the older one as history, not as the present. The
+ annotations and dates join the J.10.7 reading fingerprint: material
+ re-dated is material re-read.
+- **A write rehearsed (new C.7.3).** Two of the team's eight plants died
+ on a 61-token summary — after 33 KB of body crossed the network.
+ `plant(node, dry_run=true)` runs every validation the real plant runs
+ — id/parent chain, declared types and rels, summary ceiling, alias
+ bounds, dataset schema — writes nothing, commits nothing, and answers
+ `{valid: true, id}` or the exact error the real call would raise.
+- **A document says where it came from (amends A.3, C.2, C.6).** The
+ team stored documents that exist as files in a repository and had no
+ field to say so. Optional frontmatter `origin`: one free-form URI
+ (path, URL, commit — ≤ 2048 chars, no whitespace/control characters),
+ mutable, returned by `look` whenever present, filterable in `scan`.
+ The engine never dereferences it; it is provenance for reconciliation,
+ not a fetch instruction.
+- **A section answers by name (amends C.4.1).** `pick(section=[...])`
+ items each carry `header` — the header line that actually matched —
+ because prefix matching means the section served is not always the
+ string asked, and a list result identified only by order is a result
+ the caller re-derives.
+- **The subtree export exists, and the flag is never swallowed (amends
+ J.14.1).** `export?recursive=true` was accepted with `200` and
+ ignored — the exact defect C.8 just fixed for `graft`, on the route
+ beside it. `recursive=true` on a branch now returns a **zip** of the
+ subtree (every in-scope node, each member the byte-identical single
+ export, named by node id); an unknown query parameter on this route is
+ `E_SCHEMA`, never silence.
+- **The skill teaches writing (amends J.5.12).** The team planted
+ well-formed nodes only because they had read the source in earlier
+ rounds; the skill documents reading thoroughly and writing almost not
+ at all — and the `plant` tool's `node` parameter had an empty
+ description. The generated skill gains the **anatomy of a node**: the
+ node shape, `aliases` presented as the findability lever it is, the
+ 60-token summary ceiling, the id-determines-parent rule, reading
+ `_meta/schema` before the first write in an unknown forest, `fields`
+ as the paging lever in `look`/`scan` — and one footnote naming the
+ REST surfaces an agent will want the moment it writes: `export` and
+ share links (the team probed for A-01 by guessing URLs and filed as
+ missing a feature v0.56 had shipped; a surface the skill does not name
+ does not exist).
+- Deferred, named: rename/move and per-node history (the v0.58 knot,
+ unchanged), batch `plant` with atomicity (F-01), a `supersedes` rel
+ that suppresses its predecessor from retrieval by default (N-02's
+ fullest form — the annotation ships now, the suppression needs the
+ history design), and Gardener auto-fill of `origin` at adopt.
+- Acceptance: **F.87 – F.94**.
+
+**Changelog v0.55 → v0.56 the forest replaces the file.**
+
+The consumer team's sixth report opens with an inventory: six rounds of
+testing produced six reports, and every one lives as a `.md` on a disk the
+forest will never see. The storing half already held — a 19,420-character
+report plants, round-trips byte-identical, outlines into 28 sections, and
+every read finds it — so what keeps knowledge on the disk is the
+**surroundings** of the document: hand it to a person, reread it whole,
+take it back, and know who said it. As long as any of those four is
+missing, the rational agent writes to the disk — where `rm`, `cat` and a
+shareable path exist — and only promotes to the forest what is already
+finished, which is exactly the habit the product exists to end. This
+version is the surroundings. Underneath the findings sat one more: half of
+what the team asked for already existed, and the skill they navigated by
+was too old to say so — a stale skill is a stale map of the product, and
+nothing told them.
+
+- **A document is read back whole, in pages (amends C.4).** `pick` on an
+ over-budget body answered an empty body and a hint. The protection was
+ right; the dead end was not: the team's reports are all above the
+ ceiling, and rereading your own document in 28 `section=` calls is why
+ the local copy survives. `pick` now pages — paragraph blocks, each page
+ a byte-exact substring, an `after` cursor in `scan`'s idiom (`next`,
+ `returned`, `total`) — and pages concatenated in order reproduce the
+ body **to the byte**. A single block wider than the whole budget arrives
+ alone and cut, flagged, with the cursor still advancing: progress is
+ guaranteed, and so is the flag. `section` also accepts a list now:
+ `pick` already batched ids, and refusing two sections of one document in
+ one call while serving five documents was asymmetry, not protection.
+- **An unknown patch key is refused, never absorbed (amends C.8).**
+ `graft` with `{"regenerate_summary": true, "append_section": …}`
+ answered `200` and silently dropped the key it did not know — the caller
+ walks away believing the summary was regenerated. A patch key is a
+ claim about what the write does; an unknown one is now `E_SCHEMA`,
+ naming the key and listing the operations that exist (the same
+ discipline v0.54 gave unknown REST parameters).
+- **The passport says who and when (amends C.2).** `source`, `created`
+ and `aliases` were in every passport and in the indexed catalog — the
+ team probed for them and concluded the product could not answer "what
+ did I write here?". `look` now returns them, and `scan` takes
+ `source=` as a filter, which makes that question one enumeration.
+- **The metaphor stays in the prose (amends C.1/C.6).** The team's first
+ call of the session failed because documentation taught vocabulary the
+ wire does not speak — and then the wire spoke `"kind": "banana"` back.
+ Charm aimed at a human reader, in a field only machines read, is
+ friction both ways. Every wire emission of `kind` now says
+ `note`/`branch`; `locate`'s scope word is `notes` (`bananas` accepted,
+ deprecated, for one minor version). Prose, docs and index headings keep
+ the metaphor — it was always for people.
+- **A write you can take back (new C.14 `prune`).** The team left three
+ probe nodes as permanent garbage in a production forest, tagged
+ `delete-me` because that was the most deletion the product offered.
+ Their sentence stands: *an agent that cannot undo should not write
+ alone* — and its consequence is agents writing to disk, where `rm`
+ exists. `prune(id, force=false)` removes one node: passport removed
+ through git (history keeps it — recovery is an operator act, Part I),
+ parent index entry and coverage refreshed, catalog row gone, local
+ payload moved to `_derived/graveyard/`. A node with `edges_in` is
+ refused with **the list of what points at it** (`E_ANCHORED`) unless
+ `force: true`, which also strips those backlinks in the same commit. A
+ branch with children is never prunable — prune the children first; no
+ recursive deletion exists. A pruned id is free again: ids are immutable
+ while they exist.
+- **The document is a human surface (new J.14.1, J.5.14, J.17).** The #1
+ reason the team still writes `.md`: the only way to hand a forest
+ document to a person was to rebuild it outside the forest. Three
+ pieces, one per audience. `GET /v1/forests/{f}/export/{node}` returns
+ the document as `text/markdown` — no token budget, it is a download,
+ J.14's discipline verbatim (read cap, contained, byte-identical
+ `E_NOT_FOUND`). The Studio **reading console** (`/f/{forest}/read`)
+ renders a node for reading — full body via export, outline as a
+ navigable sidebar, media through the viewer's own credential (J.10.9),
+ copy and download of the raw markdown. And a **share link** (J.17)
+ hands one document to somebody with no account: a share is a key with
+ one room — one node, read-only, expiring, revocable, re-checked at
+ every serve against its issuer's own current reach (J.16's lesson: a
+ lapsed grant suspends what it issued).
+- **The skill states its age (amends J.1.2, J.5.11).** The team operated
+ a round without `scan` and filed a feature request for a thing that
+ existed: their downloaded skill predated it, and nothing anywhere said
+ so. The `forests()` reply — the first call every skill teaches — now
+ carries `station: ""`; the generated skill stamps the version
+ it was built against and teaches the comparison: server newer than
+ skill → tell the operator to re-download it from the Skills console.
+ The server cannot push a skill; it can make staleness visible in the
+ first reply of every session.
+- **The alias map refresh follows the config (amends G.2.6).** `sync`'s
+ fast-path skips unchanged sources, so adding an `aliases:` map to
+ `gardener.yaml` changed nothing already ingested — a config edit was
+ invisible exactly where it was aimed. The fast-path now still skips the
+ conversion but recomputes derived aliases and refreshes the passport
+ when they differ.
+- `prune` joins the J.16 webhook events (identity only, like every
+ event) and the MCP surface (17 tools; the J.1.2 parity test enforces
+ the instructions naming it).
+- Deferred, named: rename/move (`set_parent`) — an id is a path, so a
+ move rewrites every edge, trail and cache key that names it; and
+ per-node history with the writing principal as commit author — both
+ need the author design first (next version's work, with C-01's
+ graveyard as prior art).
+- Acceptance: **F.79 – F.86**.
+
+**Changelog v0.54 → v0.55 a lock is possession, not a file.**
+
+The 0.54.0 upgrade killed the container the way every upgrade kills a
+container, and the Station that came up could open nothing: two forests,
+every primitive, both surfaces, `E_LOCKED` — while `/v1/health` said `ok`,
+`writable: true`, and `forests()` listed both forests with full
+capabilities. The consumer team's fifth report is one incident wearing two
+findings, and both are contract defects, not operations mistakes.
+
+- **The lock is held, never merely present (amends C.9).** `.vine.lock`
+ used to be `O_EXCL`: the FILE was the lock, so a process that died
+ without deleting it — which is how processes die — left the forest
+ refusing every open forever, repaired only by shell access. Possession
+ is now the kernel's advisory lock on the open file, which the OS
+ releases when the holder exits, however it exits. The file's content
+ becomes the holder's card — pid, host, since — for the refusal to
+ quote; an orphan file (present, unheld) is reclaimed silently at the
+ next open. `E_LOCKED` now means what it says: a live writer exists,
+ named in the message. A filesystem that cannot hold the kernel lock
+ keeps the v0.54 existence semantics, stated rather than guessed.
+- **The lock is inspectable and an orphan is releasable over HTTP (new
+ J.13.5).** `GET /v1/admin/locks?forest=` answers free / orphan / held
+ (with the card); `POST /v1/admin/unlock {forest}` removes an orphan
+ file and REFUSES a held one — an endpoint must not be able to break a
+ live writer's lock, because two writers is the corruption C.9 exists
+ to prevent. Audited under J.4.1. The old hint — "remove the file
+ manually" — addressed a shell the caller of an API does not have; the
+ Studio Health console now shows the lock card and offers the release,
+ admin-gated, which is the button the incident asked for.
+- **The door tells the truth about the rooms (new J.1.3, the team's
+ S-20).** `/v1/health` carries `forests: {served, locked}` — counts,
+ never ids: health is unauthenticated and forest ids are J.3's to
+ disclose — and its `status` degrades to `"degraded"` when any forest
+ is held by a foreign writer. `forests()` and `GET /v1/forests` mark
+ each entry the key may see with `locked: true` when it cannot
+ currently serve, so the first call the instructions prescribe stops
+ sending agents into rooms that do not open. This is J.1.1's lesson
+ applied one level down: v0.52 taught health to report the MCP door,
+ and the next outage was behind the next door.
+- **The instructions name every tool (amends J.1.2, the team's S-06).**
+ The `instructions` served at `initialize` described 8 of 16 tools, and
+ an agent that trusts them uses half the product — the team operated a
+ whole round without `scan` while asking for exactly what `scan` does.
+ Every tool the surface registers MUST be named in the instructions,
+ and the two lists are compared mechanically in the suite (C.12's rule:
+ two descriptions of one contract agree only where somebody compared
+ them).
+- Acceptance: **F.75 – F.78**.
+
+**Changelog v0.53 → v0.54 the wire is for machines.**
+
+A team that consumes this product entirely through MCP and REST — the
+consumer is an LLM harness, never a person — measured four rounds against a
+served Station and reported where the contract still assumes a human is
+reading. Almost every finding is the same finding: a response that a person
+would understand and a machine cannot act on. Indentation a model pays for
+and never reads; an error flag that disagrees with the envelope; an invalid
+enum answered with an empty list; a truncation nobody was told about; a
+forest that cannot be enumerated by the surface that lists it. This version
+closes those, and the rule they share is C.12's, extended to its last
+consequences: **everything a response knows about itself, it says on the
+wire.**
+
+- **The block is for the model, so it is compact (new J.1.2).** Every MCP
+ tool result's text block is serialized with no indentation and no
+ formatting whitespace — measured waste was 15–30% of every read's budget.
+ Same keys, same values, same order; a console that wants pretty JSON
+ renders it client-side. The same rule sets `isError` whenever the result
+ carries the C.12 envelope: the protocol's flag and the envelope are two
+ spellings of one fact and MUST agree. The body of the refusal is
+ unchanged — `{code, message, hint}` remains the contract.
+- **An enum refuses what it does not accept (C.12 rule 7).**
+ `move(direction=…)` takes `out | in | both` and answers `E_SCHEMA` —
+ naming the parameter, the value and the accepted set — for anything else;
+ it MUST NOT answer an empty neighbour list, which is indistinguishable
+ from an isolated node. `locate(scope=…)` and `scan(fields=…)` follow the
+ same rule with their own sets. A parameter that silently falls back to a
+ default turns a typo into a wrong answer with a clean conscience.
+- **Size travels with every discovery (amends C.6, C.6b, C.6c).**
+ `body_tokens` — already on `locate` results since v0.52 — now rides
+ `sniff` results, `harvest` items and `scan`'s default fields, read off
+ the catalog row the search already loaded. The cost of opening a node is
+ known wherever a node is offered.
+- **The forest is enumerable (amends C.6).** `scan` takes `after` — an id
+ cursor: results in id order, strictly after the cursor, `toward`/
+ `gauntlet` refused beside it because an enumeration has one order. Every
+ `scan` response carries `total` (what the requested scope holds) and
+ `returned`; a cursored page that left something behind carries `next`.
+ `truncated: true` stops being a dead end — it now names the way to the
+ rest. The 800-token budget and the ≤ 50 item cap are stated in the
+ contract instead of discovered by binary search.
+- **The demotion is visible (amends C.6b).** An index hit carries
+ `demoted: true` beside its unadjusted `score`, so a client that fuses or
+ re-sorts by score can preserve the order the contract promised. The score
+ itself still never lies (v0.52 rule).
+- **The system node says so (amends C.6).** A `scan` item under `_meta/`
+ carries `system: true` — the one honest answer to "why does `scan` count
+ one more child than `look`".
+- **A write that outdates the scent says so (amends C.8).** `graft` that
+ changed the body without touching `summary` returns
+ `summary_stale: true`: the navigation layer is built on summaries, and
+ v0.52 taught callers to trust `locate` — so the layer the caller trusts
+ must not age silently. The repair was always one call —
+ `set_frontmatter: {summary}` — and is now stated in the contract.
+ `aliases` joins the mutable frontmatter set (≤ 16 entries, each a
+ non-empty string ≤ 80 chars), so a curated name can be taught after the
+ fact.
+- **The team's own name for a document resolves (new G.2.6).** Every project
+ has a vocabulary (`BE-291`) that is in no title and no body — it is in
+ the tree structure plus a convention. The Gardener derives `aliases` from
+ the operator's folder → prefix map in `gardener.yaml`; `locate` has
+ indexed aliases at weight 3 since the column existed. Without the map,
+ nothing is derived: the engine stays forest-agnostic, the convention is
+ the operator's to declare.
+- **An undescribed media node is counted (amends H.3).** The G.5.1 stub is
+ the floor, not the goal: `health` lists `needs_description`, so "media
+ nobody can find by content" is a number on a report instead of a
+ discovery in production.
+- **The provider's cut is reported (amends J.10.8).** The premise that "a
+ provider's cut carries no flag" was wrong for the OpenAI-compatible
+ surface this host speaks: `finish_reason` is right there, and is now
+ read. A reply the provider cut carries `truncated: true` and its
+ `finish_reason`; a truncated reply never enters the J.10.7 store (the
+ guard existed and nothing ever armed it); the response echoes the
+ effective `reply_tokens`, so a clamped request learns it was clamped.
+- **A citation carries the title (amends J.10.9).** The prompt teaches
+ `Title [id]`, because `[chatgpt--chatgpt-com-202608200332]` means nothing
+ to the person reading the answer — the id stays, machine-resolvable, and
+ the title makes it prose.
+- **Coverage is data (amends C.1/C.2).** `locate` and `look` report a
+ branch's coverage as `{notes, branches}` — counts, not the sentence
+ "4 bananas, 0 sub-branches", which forced every consumer to parse prose
+ and guess what a banana is. The metaphor keeps the index bodies and the
+ docs; machine fields carry numbers.
+- **The server states its version (amends J.1).** `serverInfo.version` is
+ the installed package's version — an integrator debugging against a
+ deployment must be able to ask which build answered, and a whole report
+ cycle was once spent against a build nobody could identify. `/v1/health`
+ carries the same `version`. MCP capabilities with nothing behind them
+ (`resources`, `prompts`) are not announced: an advertised capability is
+ a promise, and every client pays a round trip to discover an empty one.
+- Acceptance: **F.68 – F.74**.
+
+**Changelog v0.52 → v0.53 the forest can say something first.**
+
+One section is new. Part J has been a pull surface since it existed: a
+principal arrives, is scoped, and reads. Nothing in it can tell anybody
+that something happened, so a forest cannot take part in the automation
+built around it — the operator who wants a message when a contract lands,
+a rebuild when the corpus changes, or a page when the answer model starts
+refusing, has to ask again, and asking again is what this project's
+economics are against.
+
+**J.16 Webhooks** is the outbound half, and it is shaped by one fact:
+a delivery leaves the Station's authority behind. Inside, a read is
+scoped, budgeted and audited; once bytes are POSTed to a URL, whoever
+holds that URL reads them, under no scope, and a grant revoked afterwards
+reaches none of it. So the body of a webhook is an audit row with a
+destination — what happened, to what, by whom, when — and never content.
+
+- **The payload carries identity, never content (new J.16.1).** Ids,
+ types, counts, states, job ids, commit shas, error codes, costs. Never
+ a body, a snippet, a question, a reply, SQL, a dataset row or a
+ dataset's `## Notes`. One per-webhook opt-in adds `title` and
+ `summary` — the two fields `locate` already returns — and it never
+ widens further and never causes a read: it states what the act already
+ knew, so `plant` can carry a title and `graft` cannot.
+- **A scope is a ceiling, not a filter (new J.16.2).** A webhook belongs
+ to a forest, administered by that forest's admin, or to the deployment,
+ managed by whoever governs the deployment under J.10.2's reach rule.
+ A forest webhook cannot subscribe to a deployment event however its
+ list is written. Authority is re-read at **delivery**, so a webhook
+ whose owner's authority lapsed is suspended and says so, rather than
+ continuing to fire on a grant that is gone.
+- **A closed, served catalogue (new J.16.3).** Twenty-five events across
+ content, ingest, answer, access, config and maintenance, named as
+ contract tokens in English and returned by the API so no console
+ hard-codes them. Only events the Station can actually emit are named;
+ reads are deliberately absent, because a webhook per read would put an
+ outbound request on the path of the tightest budget in the system.
+- **The event never fails the act (new J.16.4).** Emission is
+ non-blocking, never on a forest lane, and O(1) when nothing subscribes.
+ One body across every attempt so a receiver deduplicates by `id`;
+ bounded backed-off retries that stop; a bounded queue that counts what
+ it drops; suspension after stated consecutive failure; a recorded,
+ redeliverable attempt log. Signed HMAC-SHA256 over
+ `.`, destination validated exactly as a provider's
+ (J.10.2), headers write-only, and the whole lifecycle audited under
+ J.4.1 by id and destination host.
+- **Webhooks is a console in Build (amends J.5.1).** The group becomes
+ the three directions a forest moves in: what comes in, who reads it,
+ what goes out.
+- Acceptance: **F.65, F.66, F.67**.
+
+**Changelog v0.51 → v0.52 a read says what it did not do.**
+
+An agent read a served forest end to end and wrote down every place it had
+to guess. Almost nothing it found was a wrong answer; nearly all of it was
+an answer that did not say enough about itself.
+
+`locate` returning `[]` is byte-identical whether the forest has nothing on
+the subject or has eight paragraphs about it under a summary that never
+mentions the word so the model that follows the documented order concludes
+the forest does not know, and answers from its own memory. That is the one
+failure this project exists to prevent, and it was reached by doing exactly
+what the documentation says. A malformed argument came back as `500
+Internal Server Error` with no code and no hint, which is indistinguishable
+from a forest that broke, and the three reactions those two situations
+demand are opposite ones. An auto-generated index outranked the note it
+points at, because `heat` was the only term separating them. A technical
+question lost `MCP`, `RAG` and `421` on the way in, because they are short.
+And a `421` from the MCP mount named neither the host it refused, nor the
+hosts it accepts, nor the variable that decides so a deployment whose
+every health signal was green had its main surface dark, and the operator's
+only clue was `Failed to connect`.
+
+Everything below is that class of defect: **the system knew, and did not
+say.** No primitive changes what it searches, no budget is loosened, and no
+guard is weakened.
+
+- **An empty read carries what it looked at (amended C.1).** `locate`
+ returns `body_tokens` on every result the number `look` already
+ reports, delivered at the one moment it changes a decision and takes
+ `include: ["outline"]` for the section list, both read from the catalog
+ row the search already loaded. When the result list is empty, and only
+ then, it carries `searched` and a `hint` naming the search it did not
+ perform. `harvest` says the same when both of its legs come back empty.
+- **A batch is one call, so it has one budget (new C.11).** `look` and
+ `pick` accept a list of ids. The saving is round trips, never tokens: the
+ batch is sized by one budget, whole items drop from the tail, and every
+ id the caller sent comes back accounted for in `nodes`, `missing` or
+ `dropped`.
+- **A pointer never outranks what it points at (amended C.6b).**
+ `match_count` enters the score instead of only breaking its ties, and an
+ index node ranks below every content node in the same result set. An
+ index carries the summary of every child, so it matches nearly any term
+ and accumulates heat by being the way through; its matches are evidence
+ about a child.
+- **Short is not the same as noise (amended C.6c).** Derived terms keep any
+ token that looks like code (a digit, all caps, `-`/`_`/`.`/`/`) whatever
+ its length, and order those first so the cap drops grammar before signal.
+ The four-character floor stays for ordinary words.
+- **Every exit is an envelope (new C.12).** One signature table, declared in
+ the engine, checkable against the MCP tool schemas mechanically; argument
+ shape is `E_SCHEMA` naming the parameter, what arrived and what was
+ expected; `null` is a missing parameter, never the string `"None"`; the
+ last resort is `E_INTERNAL` in the envelope shape rather than a bare 500;
+ and a missing parameter is refused as one, never as a denial.
+- **A write you can repeat (new C.7.2).** `plant(node, if_absent: true)`
+ answers `created: false` for an id already taken, writing nothing and
+ comparing nothing. The default is unchanged: a duplicate id is still a
+ refusal, because silently overwriting a node is the one failure a
+ knowledge base does not recover from.
+- **The dark surface says so (new J.1.1).** The MCP mount's `421` wears the
+ envelope and names the refused host and the variable that admits it; a
+ Station serving MCP with no explicit allow-list warns at boot; and
+ `/v1/health` reports `mcp.host_allowed` for **this request's own host**,
+ so the curl that reaches the domain answers the question about the
+ domain. The check itself is untouched, and the list is never disclosed.
+- **Where the material sits in time (new C.13).** `locate`, `sniff`, `scan`
+ and `harvest` take optional `since`/`until`/`date_field`, and `calendar`
+ reports which periods hold anything, so a window is a choice rather than
+ a guess. The window is decided from the catalog row before any body is
+ opened, which makes it the cheapest filter in the system; it is never a
+ default; a malformed bound is refused rather than ignored; and an empty
+ windowed read says whether the WINDOW was the reason, which is a
+ different mistake from a question that matched nothing.
+- **The answer it should not give (new J.10.10).** `answer(question,
+ min_evidence: n)` counts the sweep's material before the model call and,
+ below the floor, returns `answer: null` with the retrieval attached. Off
+ by default. A refusal decided before the provider is called is never
+ billed and never stored.
+- Acceptance: **F.56 - F.64**.
+
+**Changelog v0.50 → v0.51 a page is what a page is, not what fits on the screen.**
+
+One section moves. The Clipper's page capture (J.15) is the whole
+scrollable document rather than the viewport that happened to be showing.
+
+The reason is what a screenshot is *for* here. It is not a picture kept for
+its own sake: it is read once, at ingest, by the G.5.1 describer, and what
+that describer writes is the only thing `locate` and `sniff` will ever see
+of it. A capture bounded by the window therefore does not merely lose the
+bottom of the page it decides how much of that page is findable at all,
+by where somebody's scroll bar happened to rest.
+
+- **Capture offers the whole page and a dragged region (amended J.15).**
+ The page capture scrolls the document to its end, shooting each viewport,
+ and composes them into one image. Three constraints are normative because
+ each of them is a way the naive loop misleads: the page is re-measured at
+ every step (scrolling is what makes a lazily-loaded page grow, so its
+ height before the first move is not its height); viewport-fixed elements
+ are hidden after the first slice (they travel with the scroll and would
+ otherwise be stamped down the length of the image, over the content they
+ cover); and the person's scroll position is restored when it ends.
+ The capture is **bounded** by an explicit slice count, and a page that
+ does not advance ends the walk rather than repeating itself. The region
+ picker is unchanged, and remains a crop of the visible view — it is a
+ rectangle a person drew on what they were looking at.
+- No other section changes. `upload` still receives one entry, the media
+ node's body is still the server's to write, and the two-nodes shape of a
+ page-plus-screenshot clip is what it was.
+- Acceptance: **F.55**.
+
+**Changelog v0.49 → v0.50 the boundaries the system describes are the ones it enforces.**
+
+A consolidation release. No primitive gains a parameter an agent can
+reach, no budget moves, no console gains a page. What changes is that
+several boundaries this document already draws are now drawn by the
+component that can actually hold them, and that a few of them are stated
+here for the first time instead of being left to implementation.
+
+Three ideas run through all of it. **Decisions belong to whoever resolves
+the thing being decided** if a rule is about what a SQL statement
+touches, SQLite decides it, because a second reader of the same text
+agrees only where somebody thought to compare them. **Authority has to
+match reach** configuration shared by every forest is answerable to
+whoever answers for every forest, and a credential belongs to the address
+it was stored against. **What cannot be checked at the door has to be
+declared up front** the console's page is where untrusted text is
+rendered, and the browser is the only party present when it loads.
+
+- **The table allow-list is decided by the database (amended C.5, new
+ C.5.3; amended C.10).** J.3 already required it to be checked "against
+ the parsed statement"; C.5.3 now says how, and extends it to the write
+ path, reads included a scope that governs one direction and not the
+ other is not a scope. Schema-describing table-valued functions join the
+ refusal list beside the keyword they do not share a spelling with, and a
+ not-found hint under a scope names only permitted tables.
+- **An authorizer refusal is the guard's, not SQLite's (amended C.5.2).**
+ It keeps `E_QUERY_FORBIDDEN` / **403**, and it does not name what it
+ stopped at.
+- **Deployment-wide configuration answers to deployment-wide authority
+ (amended J.3.2).** Reading the provider list stays open to any
+ administrator; editing or testing one requires authority over every
+ forest. Expressed as reach, not as the owner bit, so break-glass (J.2.1)
+ and single-forest deployments are untouched. A stored provider
+ credential does not follow a changed endpoint and is never sent to a
+ caller-supplied destination, and a connection test validates where it is
+ about to connect.
+- **Governance leaves a trail (new J.4.1).** The mutations that decide who
+ may read and write grants, keys, passwords, providers, bindings,
+ forests, sign-in are recorded on the terms Part D already uses: the
+ act, never the secret. Rows belonging to no forest are the owner's to
+ read.
+- **The console declares what its page may load (new J.5.13).** A
+ content-security policy and the baseline response headers, served with
+ every response. The exfiltration path this closes never reaches the
+ Station, so no server-side check could be the control.
+- **Efficiency, unchanged semantics:** a table scope is now enforced
+ during statement preparation rather than by scanning text before it, and
+ the snapshot restore path validates sidecar members before extracting
+ rather than after writing them.
+- **New deployment variables:** `MONKEYLLM_STATION_PROVIDER_ALLOW_PRIVATE`
+ (J.10.2) and a default for `MONKEYLLM_STATION_IMPORT_MAX_MB` (J.13.2).
+- Acceptance: **F.54**.
+
+**Changelog v0.48 → v0.49 the door names what it opens, and the first minute says what this is.**
+
+Nothing below moves a primitive, a budget or a guard. This version is
+about what a person *believes* after their first ten minutes: that the
+Studio is the product, when it is a window into it. The product is a
+knowledge forest that external agents feed and read through MCP; the
+console exists so people can watch, govern and teach that brain. Three
+presentation contracts make the point unmissable, and a companion
+handbook writes it down.
+
+- **The integration manual's door names its surfaces (amended J.5.1).**
+ The console entry once labelled "Integrations" MUST be labelled
+ **MCP / API / Integrations**. A menu is read by somebody deciding what
+ the product *is*, and a vague noun at the bottom of the govern group
+ reads as an appendix when the thing behind it is the entire point.
+ The J.5.1 table now also names every console the Studio actually
+ ships, which it had quietly outgrown.
+- **The first minute is a contract (new J.5.11).** The console gains a
+ one-time, client-side presentation shown after the first sign-in: what
+ a forest is, that agents connect and feed it through MCP, where to
+ start. Presentation only it MUST NOT spend a model call, MUST NOT
+ write anything server-side, MUST NOT enter the address (J.5.8), and
+ MUST NOT exist before identity does: J.2.4's setup window and J.5.6's
+ gate are untouched.
+- **A skill is handed, not hunted (new J.5.12).** A Skills console —
+ self-service, `read`-gated, never admin-gated generates the
+ instruction file an agent runtime (Claude Code and its kin) installs
+ to use this Station as persistent memory: recall before answering,
+ save what is learned, cite node ids. Generated client-side with this
+ Station's own origin and the open forest baked in (Integrations' own
+ copy-ready rule), delivered by copy or file download: the Station
+ gains no endpoint, the skill teaches no write path the MCP surface
+ does not already publish (J.15's rule for every client).
+- **Companion, non-normative:** `docs/guide/` the operator handbook
+ (install, first access, using, feeding, connecting an AI, managing),
+ with screenshots, in the three console languages of J.5.3.
+- Acceptance: **F.52, F.53**.
+
+**Changelog v0.47 → v0.48 the browser is a source, and a key that narrows needs nobody's permission.**
+
+A browser extension the Clipper (J.15) turns the page a person is
+reading into an ingest source: the selection or the readable article as
+markdown through `compose`, a screenshot as a `media` node through
+`upload`. Everything it needs from the host is the three contracts this
+version adds plus one static artifact, the shared build the Station
+serves at `GET /clipper.zip` (J.15) so the console can offer the
+extension to everyone who may pair. No primitive's semantics, budget or
+guard moves.
+
+- **Pairing (new J.2.6).** The Clipper must hold a credential, and both
+ existing ones are wrong for it: a session is the principal's *whole*
+ authority with a short life (it dies mid-week in a toolbar), and an
+ admin-minted key makes an administrator the gatekeeper of every
+ browser. `POST /v1/auth/pair` turns a password into a key that carries
+ a **capability mask** effective authority is grants ∩ mask, `{read,
+ ingest}` by default enforced wherever the requesting principal's
+ authority is read, REST and MCP alike, the admin and owner bits
+ included. It needs no admin gate because it can only narrow: the key
+ reaches nothing the password could not already reach. A pair key MUST
+ expire, and `login`/`pair` MUST be rate-limited they are reachable
+ from every browser now, not only from the console.
+- **An image is never `unsupported` (new G.5.1).** The Gardener gains a
+ built-in stub converter: image and audio sources plant as `media`
+ nodes the original as payload, a stub body naming what is known —
+ instead of falling out of the report. A forest with a bound `vision`
+ model (new J.10 role) turns that body into a real description through
+ a host-injected converter, and a describer that fails falls back to
+ the stub: a broken model never aborts ingest. The stub is what makes
+ a screenshot land; the describer is what makes it findable.
+- **An upload's staging is not a durable source (amended G.7, in
+ G.5.1).** `archive: never` rightly keeps durable originals at the
+ source but an uploaded source lives under `_derived/`, which is
+ disposable by contract, so a node referencing it would outlive its
+ own bytes. Media adopted from inside the forest's `_derived/` MUST be
+ archived into `_assets/` regardless of the archive policy: the
+ payload is the only copy there is.
+- **Payload bytes get a read surface (new J.14).** `GET
+ /v1/forests/{forest}/payload/{node}` serves the payload file of an
+ in-scope node read capability, byte-identical `E_NOT_FOUND` for
+ out-of-scope and absent alike, resolved path contained in the forest
+ root, local payloads only. Until now a screenshot the Clipper
+ ingested was a node whose image no console could show.
+- **A multimodal client may view what it found (new C.6d; amended
+ G.5, J.14).** G.5 named serving payload bytes over MCP as a possible
+ future; it is now the `view` tool: the image payload of an in-scope
+ `media` node, returned as MCP image content beside a small JSON
+ header same resolution rules as J.14 (byte-identical `E_NOT_FOUND`,
+ local-only, contained), images only, bounded at the describer's own
+ 6 MiB. The line J.14 drew is *sharpened*, not crossed: material the
+ host assembles for a model still never carries bytes `view` is the
+ caller's model fetching the image deliberately, into its own context,
+ under its own budget, exactly as G.5 always intended a "multimodal
+ client that wants full fidelity" to do.
+- **The answer can show what it read (new J.10.9).** A media node's
+ body is a describer's prose about pixels the reader cannot see. The
+ answering model may now embed the image itself: a markdown image whose
+ address is `media:`, allowed only for ids present in its
+ material. The host neither fetches nor rewrites anything the console
+ resolves the reference through J.14, where scope is enforced, so an
+ invented id renders as its caption and nothing else. Evidence of type
+ `media` is rendered with its image beside the prose, and exports carry
+ what the reader saw.
+- **The reply has a stated size (new J.10.8; amended J.10.7).**
+ The only reply-length control was the binding's `max_tokens` per
+ forest, operator-set, and silent: a model that overran it was cut
+ mid-sentence. `answer` now accepts `reply_tokens` per call, clamped
+ to [64, 4000]; the effective value caps the model call AND is stated
+ in the prompt, so the model shapes the reply instead of being
+ truncated by it. It joins the J.10.7 key a short answer and a long
+ answer to one question are two entries and the console offers it as
+ a slider kept as the person's own preference, client-side, never in
+ the address.
+- **The fingerprint reads everything the model reads (amended
+ J.10.7).** v0.47 made a dataset's `notes` ride in every bundle;
+ the reading fingerprint was still hashing the six original fields, so
+ an operator editing the teaching did not invalidate the stored answer
+ built without it a stale hit, exactly what the fingerprint exists to
+ make impossible. The rule was always "the sweep fingerprints what it
+ would hand the model"; the `notes` now enter the hash like any other
+ handed field.
+- Acceptance: **F.47, F.48, F.49, F.50, F.51**.
+
+**Changelog v0.46 → v0.47 a wrong name is not a locked door, and a result is material like any other.**
+
+A walk was run against a 141-column export and cost 168k input tokens,
+37 seconds and 33× the sweep, to produce an answer whose *set* of rows was
+right and whose *order* was invented. Every step of that is a defect this
+version names, and none of them is the model being bad at SQL.
+
+- **`query` gets a token budget (amended C.5).** It was the only read
+ primitive without one: a row cap of 200 and no ceiling, so `SELECT *`
+ on that table measured **86,929 tokens for 15 rows** and 429,397 for
+ all 129. Every other read is bounded look 500, move 600,
+ locate/scan/sniff 800, pick 4000, harvest 4000 because a primitive
+ that can return unbounded text cannot be composed into anything. Rows
+ drop from the tail with `truncated: true`; `columns` **survives whole**,
+ because the names of the columns are exactly what a caller needs to ask
+ again, narrower. The bound is not only a cost control: an agent learns
+ to project from being told, in one hop, that it did not.
+- **A name that is not there is not a forbidden operation (amended C.5,
+ C.10; new `E_QUERY_INVALID`).** `no such table` came back as
+ `E_QUERY_FORBIDDEN` the code for attempting a write so a typo was
+ indistinguishable from a policy refusal, in the console and in the
+ audit. Refusal is what the guard decides; invalidity is what SQLite
+ decides. HTTP 400, not 403: the caller asked wrong, they were not
+ denied.
+- **A dataset's notes travel with the dataset, on every path (amended
+ C.2.1, J.10.5).** v0.46 put them in `look` and in `harvest`. The walk
+ enters through `locate`, and a model that goes straight to `query`
+ never sees them which is what happened. The rule is now the general
+ one: any material a host assembles for a model carries the notes of
+ every dataset in it. Otherwise teaching the agent depends on the agent
+ choosing to be taught.
+- **A hop's refusal reports what it was (amended J.10.5).** The loop
+ already feeds the whole error envelope back to the model; it kept only
+ the code for the console. The reader saw `E_QUERY_FORBIDDEN` twice and
+ could not tell that the engine had already answered both.
+- **The manual states the width (amended G.2.3).** A wide table's manual
+ says how many columns it has and that `SELECT *` will not fit. This is
+ arithmetic, not curation: the Gardener still MUST NOT decide which
+ columns matter that is meaning, and meaning is `## Notes`.
+- Acceptance: **F.46**.
+
+**Changelog v0.45 → v0.46 the map says what is there, a person says what it means.**
+
+Everything about a dataset that this system knows, it inferred. The map
+(G.2.3) reads structure and three rows; curation (G.4.6) reads the map.
+Nothing anywhere carries the one thing that decides whether generated SQL
+is *right*: that this column is USD and that one is BRL, that `status` is
+a one-letter code, that direct imports are the null-`arrendatario` rows.
+An agent without that writes a query that runs and answers wrongly, which
+is the worst failure available to it indistinguishable from success.
+
+- **`## Notes`, and `look` returns it (new C.2.1).** A dataset passport
+ may carry a section the operator writes and nothing else touches. It
+ comes back in the digest, bounded to 200 tokens and truncated out loud,
+ because the path an agent takes to a dataset is `look` then `query` —
+ a note reachable only through `pick` is a note nobody reads.
+- **The console has a place to write it (amended J.5.10).** A **Notes**
+ tab beside Rows, Structure and SQL, composing ONE `graft`. No second
+ write path, no side store: the teaching is part of the node, versioned
+ and attributed like its summary.
+- **Source is coloured wherever it is typed, not only SQL (amended
+ J.5.10).** The markdown body editor is the surface an operator falls
+ back to whenever a body holds a table which every dataset's does so
+ it was the plainest text in the console at exactly the moment structure
+ mattered most.
+- Acceptance: **F.45**.
+
+**Changelog v0.44 → v0.45 the map is the scent, and the wait has a name.**
+
+v0.44 put a bounded map of every dataset into its body and then did not
+read it. The Curator skips `type: dataset` by an older rule datasets had
+nothing but a column list to summarise, so a factual template was the
+honest answer and the consequence is a 10,000-row dataset whose scent is
+still *"Adopted from source; pending curation"*, which is the text an agent
+weighs when deciding whether this dataset can answer the question. The map
+fixed the input; this reads it.
+
+Two reporting failures travel with it. A batch that legitimately needed no
+model all datasets, or all `unchanged` produced zero LLM summaries with
+a model bound, and the console has no state for that, so it accused the
+model of answering and being rejected **zero times**. And a batch of one
+document is one G.10 step, so the progress bar stands at 0 until it is 1:
+the operator watching a large file is watching something indistinguishable
+from a hang.
+
+- **The dataset's scent comes from its map (new G.4.6).** The Curator
+ curates a dataset from the G.2.3 map structure and three rows per
+ table and never from the payload or the source. Bounded by
+ construction: a 5 MB CSV and a 5 GB database cost the model the same few
+ hundred tokens. The G.4.1 template stays the fallback, so ingest still
+ never blocks on a model.
+- **A stage is reported, never yielded (new G.10.1).** G.10's step
+ boundary is untouched a document is still one step, and nothing
+ suspends mid-document, which is exactly what v0.32 refused. What is new
+ is that the Gardener *names the phase it is in* through an observer, and
+ J.9's job record carries it beside `current`. Naming a phase suspends
+ nothing; that was never the objection.
+- **A count limit is a guard against invention, not a verdict on data
+ (new G.2.5).** C.7.1's ≤10 tables and ≤50 columns exist so a model
+ cannot declare nonsense DDL. They were also refusing a 141-column ERP
+ export the operator already owns the tool telling somebody their
+ spreadsheet is wrong. Adoption by the Gardener is exempt from the two
+ counts and from nothing else. The bound that actually matters moved to
+ where the cost is: the G.2.3 map now caps sampled **columns** too.
+- **A workbook's declared extent is not evidence (amended G.2.4).**
+ openpyxl in read-only mode trusts the file's `` record, and
+ files written by anything other than Excel routinely declare `A1:A1`.
+ A real 130-row sheet arrived as one row and was reported as a workbook
+ with no data. The extent is now inferred from the rows that are there.
+- **The report distinguishes "rejected" from "nothing to do" (amended
+ J.8).** Curation stats gain `skipped`. Bound, zero calls, zero retries
+ and something skipped is a batch that needed no model and the fix for
+ that is nothing, while the fix for a rejection is a different model,
+ prompt or budget. A console that shows one as the other sends the
+ operator to tune a model that was never asked anything.
+- Acceptance: **F.44**.
+
+**Changelog v0.43 → v0.44 a database is a source, and a table is a map.**
+
+A `.db` is the one file format this project already speaks natively and
+the only one the Gardener could not ingest. Worse, the tabular converters
+it *did* have produced datasets an agent cannot navigate: the passport's
+body carried a column list and nothing else, so `sniff` which searches
+bodies and only bodies (C.6b) could tell you a table has a `status`
+column and never that the values in it are `open` and `closed`. The map
+described the container and left out the contents.
+
+- **A SQLite file is adopted, not rebuilt (new G.2.2).** `.db`/`.sqlite`/
+ `.sqlite3` convert to a **payload** conversion: the file IS the dataset's
+ payload, copied into place beside its passport. Reading every row into
+ memory to re-`INSERT` it through C.7.1 would be unbounded in the source's
+ size and lossy in its types, and the destination of that round trip is
+ byte-for-byte what the source already was.
+- **Every dataset passport carries a sample map (new G.2.3).** The body
+ gets `## Query manual` as today followed by `## Sample rows`: per
+ table, the columns with their types and the **first 3 rows**, rendered as
+ a pipe table. Bounded by construction (3 rows, cells clipped, a stated
+ table cap), deterministic, no model involved. This is the surface `sniff`
+ reads, and it is what makes a dataset findable by what is *in* it.
+- **A workbook is multi-table (new G.2.4).** `.xlsx` (openpyxl) and the new
+ `.xls` (xlrd, BSD) convert **every** sheet to a table, not the first one
+ silently. Over the C.7.1 table limit the file is refused by name.
+- **The map follows the data (amended G.3).** A `sync` that rebuilds a
+ dataset payload rewrites the two map sections and leaves every other
+ section of the body alone a stale sample is a lie with a commit behind
+ it.
+- **C.7.1 rule 4 amended**: the auto manual includes the sample map when
+ the node is born with `rows`.
+- **J.5.10 (new)**: the Data console gains the three things a database
+ client is expected to have datasets **born** through one `plant`,
+ files **imported** through the J.8 upload surface (never a second write
+ path), and a connection that can be **left**. Its SQL editor is coloured
+ like every other source surface in the console.
+- Acceptance: **F.43**.
+
+**Changelog v0.42 → v0.43 the whole note in one commit.**
+
+The Explore console's editor works at the section's grain because that
+was the only grain C.8 offered: `replace_section`, one commit each. For
+a person, the note is the unit of thought an edit that touches three
+sections is one edit, and asking for three commits invites the
+half-applied note the section grain was meant to prevent. C.8 gains one
+operation and two refusals:
+
+- **`replace_body: string`** replaces the entire body, atomically, in
+ the same one-commit transaction as the rest of the patch. Combinable
+ with `set_frontmatter` and the link operations; the empty string is a
+ valid body (clearing a note is an edit, not an error).
+- **Not combinable with the section operations.** A patch carrying
+ `replace_body` alongside `replace_section` or `append_section` states
+ two truths about one body refused as `E_SCHEMA`, never resolved by
+ precedence.
+- **Index bodies are the engine's.** A branch index's body is rendered
+ by the indexer and parsed by contract headings; `replace_body` on an
+ index node is refused (`E_SCHEMA`). Section surgery on indexes stays
+ exactly as it was.
+- **The write validates before it commits**: the serialized node must
+ re-parse, so a body that would poison the next read is refused while
+ the file on disk is still the old one.
+- **J.5.4 (editing)**: a console MAY offer whole-note editing through
+ `replace_body`, and MUST NOT compose one from a truncated `pick` —
+ a body over the pick budget is edited at the section grain, because
+ writing back less than was read is how notes lose their tails.
+
+**Changelog v0.41 → v0.42 nobody's question pays for somebody's ingest.**
+
+`locate` carries a 100 ms p95 budget (F.6) and, with the dense layer on,
+two unbounded network operations. One is by contract the query must be
+embedded (K.2). The other is not: **lazy re-embedding ran inside the read
+path**, so every node an ingest marked stale was embedded by whichever
+question happened to arrive next. Ingest two hundred documents and the
+next person to ask anything pays for two hundred embeddings, inside a
+primitive whose entire budget is a tenth of a second. Measured on a live
+forest: one `locate`, 2.67 seconds. The vector scan was never the cost —
+a dot product over dim-1024 vectors is 0.044 ms each, so the whole
+wide-forest index searches in 11 ms.
+
+- **The read path never embeds a node (amended K.2, C.6).** `locate` and
+ the Gauntlet's goal embed **the query** and nothing else. Refreshing
+ the dense layer is maintenance, and this specification already has a
+ shape for maintenance the operator triggers and the console shows
+ (J.13.3). A node planted a second ago is found by BM25 immediately —
+ the catalog upsert is synchronous so the layer's debt costs recall
+ in the dense half, never findability.
+- **The debt is visible (amended K.4).** `canopy_status` gains `stale`:
+ how many nodes are waiting to be embedded. It is the number that
+ predicts what a refresh will cost, and an operator who cannot see it
+ cannot choose when to pay it.
+- **Refresh is an explicit act (new J.13.4).** `POST /v1/admin/canopy`
+ accepts `{refresh: true}`: embed the stale ones, leave the rest. It is
+ the cheap sibling of a full build, and it is offered beside the
+ catalog rebuild in the console's Optimize tab content, index, dense
+ layer, one errand told three times.
+- **Embedding one text is memoized (new K.6).** `embed(model, text)` is
+ a pure function, so the query half is cacheable exactly as the literal
+ scan is (C.6b.1): `_derived/`, keyed by model and normalized text,
+ disposable, bounded. A forest that is asked the same question twice
+ stops paying the round trip twice.
+- Acceptance: **F.42**.
+
+**Changelog v0.40 → v0.41 the repair is on the console.**
+
+`reindex` is the repair the whole derived layer is designed around: the
+files are the truth, `_derived/` is disposable, and every divergence
+anywhere in this document ends with "the files win and the catalog
+rebuilds". The console says so out loud Files prints *"no entry yet,
+reindex to rebuild it"* and then offers no way to do it. A hosted
+operator has a browser, not a shell (the premise J.13 already states and
+J.13.2 acted on), so the one instruction the console gives most often
+was the one thing it could not carry out. v0.40 sharpened the point: a
+forest imported over J.13.2, or one written by an earlier version,
+carries no `body_hash` and pays the direct scan on every ask until
+somebody opens a terminal.
+
+- **Rebuild (new J.13.3).** `POST /v1/admin/reindex` rebuilds one
+ forest's catalog from its files and answers with the node count. It
+ writes only `_derived/`: no commit, no model call, no pheromone so
+ it is offered even by a read-only Station, which would otherwise have
+ a permanently degraded index and no way back.
+- **It is offered where the operator already goes to keep a forest
+ current.** The ingest console's refresh tab becomes **Optimize**: keep
+ the content fresh (`sync`, Part G) and keep the indexes fast
+ (`reindex`, C.6.1) are the same errand told twice, and splitting them
+ across two consoles teaches nobody which one they needed.
+- Acceptance: **F.41**.
+
+**Changelog v0.39 → v0.40 the scan remembers what it read.**
+
+`sniff` was specified as a direct file scan on every call, and the
+implementation was faithful: one `open`+`read`+`close` per node, per
+call, forever. Measured on a 246-node forest, two thirds of a global
+sniff is the operating system opening files while `locate`, which
+lives in SQLite, answers the same query in half a millisecond. The cost
+is linear in the size of the forest and it is paid by every ask,
+including the ones the answer store (J.10.7) serves without a model,
+because the reading fingerprint needs a fresh reading before it can
+decide. A forest that grows by ingest therefore gets slower at
+answering, forever, which is the opposite of what a curated map is for.
+
+The scan is a **pure function** of a body and a folded term, so it is
+memoizable without touching semantics:
+
+- **The memo (new C.6b.1).** A Vine MAY cache the per-line result of the
+ literal scan in the derived layer, keyed by (folded term, node), valid
+ while the node's stored **body hash** matches. Output MUST be identical
+ to the direct scan, byte for byte this is memoization of the scan,
+ never its replacement by a tokenized index, so the C.6b split with
+ `locate` and the literal-substring contract are untouched. Rows record
+ **non-matches too**: without them the 95% of nodes that never match are
+ rescanned on every call and the memo buys nothing.
+- **Line granularity is normative, not an implementation taste.** The
+ scan emits one match per *line*, centred on the leftmost term that hit
+ it, so the union of two per-term memos is not the two-term result. A
+ memo MUST store enough per line to reproduce the combined snippet.
+- **Hash, not timestamp.** Validity is decided by content, so a
+ `reindex`, a `git pull` that changed nothing, or an edit reverted to
+ its original text do not invalidate what did not change. `mtime` is
+ coarse, platform-dependent and can move backwards.
+- **The catalog carries the hash (C.6.1).** One more column, written on
+ every upsert, rebuilt by `reindex` the disposable layer's usual
+ posture: if it diverges from the files, the files win.
+- **Only bodies the hash covers** (`content: inline`). A `reference`
+ body changes at its source and a `cached` body lives in
+ `_derived/bodies`, both with no write to the `.md` the hash digests —
+ they keep the direct scan.
+- Acceptance: **F.40**.
+
+**Changelog v0.38 → v0.39 the snapshot travels.**
+
+J.13 could take a snapshot and name it, and there it ended: the bundle
+was born on the Station's volume and stayed there, reachable only by
+the shell the hosted operator does not have. A forest came *back* the
+same way `vine snapshot restore` at a terminal. Part I's own use
+cases backup, distribution, the team that pulls the whole map in one
+small download were promised to exactly the people the host layer
+exists to serve, and the host layer did not serve them. Two additions
+to J.13, both owner-only:
+
+- **Download (J.13.1).** `GET /v1/admin/snapshots/{forest}/{file}`
+ streams a bundle or payload sidecar the J.13 listing already names.
+ A snapshot is the whole forest with its whole history every branch
+ scope a grant table enforces collapses the moment the bytes leave —
+ so the only principal a download cannot over-serve is the one whose
+ authority already spans everything: the owner bit. Contained after
+ resolution, audited, and it touches no forest no lane, no trace,
+ no pheromone, no commit.
+- **Import (J.13.2).** `POST /v1/admin/snapshots/import` accepts the
+ bundle (and optional sidecar) in the request body and restores it
+ into a forest id that does not exist yet J.7's name validation,
+ J.7's refuse-if-existing, J.7's grant-to-creator. The J.13 objection
+ to exposing restore was never restore itself: it was the live-forest
+ destination and the host path taken from a caller, and import has
+ neither. The imported forest arrives servable (`reindex` included)
+ and arrives cold: no model call, no curation, no canopy a bundle
+ is already forest and enters as-is, which is exactly why the door is
+ owner-only.
+- Part I gains the pointer: a hosted Station moves snapshots over HTTP
+ under J.13's rules. Restore into an *existing* forest stays a shell
+ act, as before. Acceptance: **F.39**.
+
+**Changelog v0.37 → v0.38 the map settles, groups, and replays.**
+
+The graph mode of J.5.4 drew a thousand-node forest as one trembling
+blob: every cluster collapsed onto every other, and a deliberate drift
+kept the picture moving forever, so the one console built to show the
+shape of the forest was the one console where the shape could not be
+seen. The encoding rules gain teeth, and J.11 carries one more passport
+fact:
+
+- **The layout must come to rest (J.5.4).** A map at rest holds still;
+ motion is spent only on change new data, the operator's hand, or an
+ explicit reorganize. A forest cannot be pointed at while it trembles,
+ and "pointing at it" is what a map is for. Distinct regions must read
+ as distinct: a layout that piles unrelated branches into one heap is
+ not a presentation choice, it is a map that answers the operator
+ wrongly.
+- **Colour is a choice between facts (J.5.4).** Colour MAY encode the
+ node's type (the dialect) or its home branch (the id) both are facts
+ the forest holds. A console MUST NOT colour by a category the forest
+ does not hold, and whichever fact colour encodes, the legend names it.
+- **View tuning belongs to the operator (J.5.4).** Filters, grouping,
+ label visibility, node scale, link width, force strengths all
+ presentation, all local. Tuning MAY persist in browser storage per
+ forest; it MUST NOT enter the address (J.5.8: the address carries the
+ selection, not the taste) and it MUST NOT spend a call or a write.
+- **Growth replay (J.5.4).** A console MAY replay the region in
+ `created` order: nodes appear as they were planted, trails appear when
+ both ends exist. Replay is presentation over the projection already in
+ hand no second call, no write, and under reduced motion it is a
+ scrubber, not an animation.
+- **`created` joins the projection (J.11).** The passport has always
+ held it and the projection already carries `updated`; a replay of the
+ forest's growth is a shape question, and shape questions are what J.11
+ exists to answer in one call.
+
+**Changelog v0.36 → v0.37 the batch is visible from every console.**
+
+v0.36 stopped the ingest console from forgetting a running batch; every
+other console still could not say one existed. The operator J.9 freed to
+look elsewhere had to come back to know their 1800 documents were still
+landing presence was the price of awareness. New section **J.9.3**:
+
+- **A small indicator on every console of the forest** announces the
+ running batch and the waiting queue, expanding on demand into the job
+ record's progress done over total, the document in hand, errors so
+ far with the cancel and the way to the ingest console.
+- **It reads the job board and nothing else.** No browser-storage copy
+ of a record the host already keeps: a stored id goes stale in both
+ directions it survives the restart that forgot the record, and it
+ misses the batch another principal started. Entering a forest asks the
+ board once; everything after is the watch.
+- **One watcher per forest, its cadence following the attention**: the
+ order of a minute collapsed, the order of seconds expanded or with the
+ ingest console open, settle-detection pace while a queue waits.
+ Watching is free in every ledger (J.9), but free is not a licence to
+ be noisy.
+
+**Changelog v0.35 → v0.36 the console keeps sight of the batch.**
+
+J.9.1 put the running job in the address so a reload could not lose it —
+and then the operator moved to another console, whose address the query
+does not follow, and came back to an empty form while 1800 documents
+ingested on. The record was on the board the whole time; only the console
+stopped looking. And under that running batch, the submit button told the
+operator with the next folder already in hand to come back later a wait
+the console could have held for them. Two amendments, both console-side;
+the host's contract (one batch per forest, refusal over queueing, J.9)
+does not move:
+
+- **Returning rediscovers the running job (J.9.1).** Entering the ingest
+ console with no `?job=` reads the job list a record, never a call —
+ and puts a running job's id back in the address, replacing. What the
+ operator started is on screen whenever they stand where it runs.
+- **The next batch waits in the console, never in the host (new J.9.2).**
+ The console may stage batches while one runs and submit each as an
+ ordinary batch POST when the board frees, first in first out. The queue
+ is tab memory: visible where it waits, never in the address, dead with
+ the tab which is exactly why it does not reopen the door J.9 closed,
+ whose danger was *invisible* work that *outlives* its asker. A cancel
+ holds the queue (stop means everything); a refusal other than
+ `E_LOCKED` holds it too, shown; `E_LOCKED` means another client won
+ the race, and the queue simply waits for the job the refusal names.
+
+**Changelog v0.34 → v0.35 the second hash: the reading decides the
+model.**
+
+v0.33 keyed every answer-store entry by the forest's HEAD, and HEAD is a
+hammer. Every write moves it a `tend` in a sales table emptied the
+stored answer about architecture, one Ranger promotion emptied the whole
+store so on a forest that is actually alive, the store spent its life
+empty. The invalidation was never wrong. It was indiscriminate, and
+indiscriminate is expensive at exactly the scale the store exists for.
+
+The fix restates what the store is for. What must never go stale is not
+"the forest as of a commit" it is **the model's reading**: the material
+the sweep put in front of the provider. The retrieval that assembles that
+material is the cheap half by five orders of magnitude, so the sweep now
+runs it on **every** ask, hit or miss, and the store fronts the model and
+never the search. Two digests, two jobs: the first the question under
+its configuration finds the entry; the second the **reading
+fingerprint** decides whether the model owes a fresh pass.
+
+- **The sweep's key loses HEAD; the entry gains the reading fingerprint.**
+ A digest over the material as a set keyed by id types, titles,
+ summaries, matches, bodies, the truncation flag and nothing volatile:
+ not score, not heat, not the serving order, which pheromone reshuffles
+ on every use. A result that enters or leaves the set is a change of
+ reading; a reshuffle is not.
+- **A hit runs the forest and skips only the bill.** The sweep's
+ primitives really run, so a hit's trace is its own retrieval's and
+ the whisper of Part D now closes every hosted answer: heat on the
+ evidence, hit and miss alike (v0.33 whispered only on hits, telling
+ the Ranger that bought answers did not matter). The response says
+ which half is which retrieval fields fresh, model fields the record,
+ `cached: true` with the time the reply was bought.
+- **A reading that changed is a miss, exactly.** A `graft` on a node the
+ question reads invalidates it; a `plant` in a branch it never touches
+ invalidates nothing; a `tend` that changes rows but not prose changes no
+ reading. Heat that pushes a result out of the set or a new one in —
+ changes the reading and is honestly a miss; the worst case is a bought
+ run, never a stale answer.
+- **The walk stays v0.33.** A forager's path cannot be re-walked without
+ paying the model per hop, so walk entries keep HEAD in their key, are
+ served as received, and deposit heat through the trails store. **The
+ walk's key also carries the host's date (v0.79)**: the loop's prompt
+ states it (J.10.5 rule 3), so a hunt asked on two days is two hunts, and
+ an entry served across midnight would answer "today" with yesterday's
+ walk. On HEAD's terms and only the walk's — the sweep's prompt states no
+ date, and a date in its key would expire every stored answer nightly for
+ nothing.
+- **C.6c.2 stops refining index nodes.** Building the fingerprint exposed
+ a harvest bug: `sniff` resolves an index id to its subtree, so refining
+ an index result grepped the forest under it children's snippets
+ attributed to the index, chosen by heat rank, different on every read
+ (and `pick` then failed to open those foreign sections for content).
+ An index result now keeps the global sniff's within-body matches.
+- Criterion **F.37** rewritten for the reading check.
+
+**Changelog v0.33 → v0.34 the cap the operator sets.**
+
+C.6c capped `k` at five because the bundle is spent twice: once by the
+engine, in milliseconds, and once by whoever reads it prompt tokens,
+prefill, a context window that may not hold twenty thousand tokens of
+evidence. That second bill is the deployment's, not the dialect's: a
+Station bound to a wide-window model wastes nothing at eight bananas,
+and a thin client on a four-thousand-token window chokes on six. One
+compiled number cannot be right in both rooms, and until now it was
+compiled in.
+
+- **C.6c: the harvest cap moves to the environment.**
+ `MONKEYLLM_HARVEST_MAX_K` (an integer >= 1) sets it; unset means **5**,
+ exactly as before, so no deployment changes behaviour by upgrading. A
+ value that does not parse, or parses below 1, is refused (`E_SCHEMA`,
+ naming the variable) never silently corrected, per the project's
+ reject-early rule. The response budget is untouched and remains the
+ outer wall: a raised cap buys more items only until the budget
+ truncates, explicitly, as ever.
+- **J.10.7: the key holds the effective `k`.** The cap shapes the
+ sweep's answer, so the capped value is what names it. A cap raised
+ between restarts therefore misses cleanly instead of serving
+ five-banana answers under a ten-banana promise and two callers
+ asking past the cap stop minting distinct keys for one identical
+ answer. The walk's `k` (J.10.5) was never capped and keys as given.
+- New acceptance criterion **F.38**; F.37's miss list now says the
+ effective `k`.
+
+**Changelog v0.32 → v0.33 the answer already bought.**
+
+The cost of this product has two halves that differ by five orders of
+magnitude. Retrieval is a fraction of a millisecond J.10.6 exists because
+that fact is invisible from outside and the provider round trip behind an
+`answer` is seconds, and the only line on the bill. A deployment in front of
+real traffic does not receive an even spread of novel questions; it receives
+the same handful all day. Every repetition re-ran the model over the same
+harvest of the same forest under the same binding and the same scope, and
+paid full price for an answer the deployment had already bought. Nothing in
+the call was new. Only the bill was.
+
+v0.31 ruled that the host keeps no model output (J.5.9), and that ruling
+stands unrevised. A *run* is one operator's private working note about an
+evaluation kept where it was made, dead with the credential, shareable
+with nobody by construction. What v0.33 adds is a different object: an entry
+in a per-forest store, named by everything that shaped the call, served only
+to callers whose call is the same call, and invalidated by the forest itself
+moving. The host still keeps no history of what models said; it keeps the
+answer this deployment already paid for, under a key that states exactly
+what was paid for.
+
+- **J.10.7 The answer already given.** `answer` and `answer` alone is
+ fronted by a bounded per-forest store in `_derived/`. The key is a closed
+ list: the normalised question, the effective terms, `k`, the hops budget,
+ the resolved binding, the caller's scope, and the forest's HEAD. Anything
+ that could change the answer is part of the name of the answer.
+- **The forest's own clock is the invalidation.** Every write is already a
+ commit and HEAD is in the key, so every entry made before a write misses
+ after it there is no invalidation code to be wrong. A TTL is hygiene,
+ never correctness.
+- **Nothing empty and nothing broken is kept.** A retrieval that found
+ nothing, an errored or truncated response, a turn that wrote none of
+ them enter the store.
+- **A hit says so, and still heats the forest.** The response is labelled a
+ record, not a bill; heat lands on the entry's stored trail through the
+ trails store, never through a primitive. J.6.1's warming rule in mirror:
+ warming through `locate` would forge evidence of use, and serving from a
+ store without depositing would hide it.
+- **J.10.6 gains `cache`**, present when the store was consulted; on a hit
+ `model` is absent, because no provider ran. **J.4 records a hit as a
+ hit**, with the entry's digest, so a served answer still reconstructs.
+- **The near question is an opt-in with a disclosure.** Serving on
+ similarity instead of equality exists only where a Canopy index and an
+ embedder already do, is off by default, and names the stored question it
+ answered.
+- New acceptance criterion **F.37**.
+
+**Changelog v0.31 → v0.32 a batch is not a request.**
+
+Adopting a folder is minutes of work: a converter pass, a model round trip
+and a commit *per document*. The Station ran all of it inside the HTTP
+request that asked for it, on the one worker thread every forest shares —
+an acknowledged simplification, and this is the release that pays it off.
+The consequences arrived together, as they always do: the gateway timed the
+request out at its own limit and answered 524 while the work kept running,
+unwatchable; every console froze, because a `look` on an untouched forest
+was queued behind somebody else's ingest; and the operator, shown a dead
+spinner over a working batch, learned nothing a progress bar would not have
+told them. Three failures, three rules:
+
+- **Work that outlives a request must not answer as one.** The batch modes
+ of J.8 `adopt`, `sync`, `upload` now validate synchronously and answer
+ **202 with a job**: identity, progress, and, when it finishes, the same
+ unabridged `IngestReport` as before. `compose` is one document and a
+ review conversation, and stays in place. New section **J.9**.
+- **One forest's work must not delay another's.** Isolation between forests
+ is now normative, not an implementation aspiration: a call on one forest
+ MUST NOT wait on another forest's work (J.9). The SQLite thread-affinity
+ discipline was always per forest; the single lane never was.
+- **A batch must not starve the forest it is filling.** `adopt` and `sync`
+ become drainable step iterators in Part G one document per step, the
+ report as the final value so a host can let reads through between
+ steps and count progress without a second pipeline. New section
+ **G.10**; the recorded source root moves to *before* the first step,
+ which is what makes an interrupted batch completable by `sync` instead
+ of restartable from zero.
+
+Progress is watched, not streamed: a job is a host record, reading one
+touches no forest, and the console follows it by the address (`?job=`,
+J.5.8 discipline restoring a page never spends a call). A second batch on
+a busy forest is refused with the running job named, a restart forgets
+records but never work, and the MCP `ingest` tool waits by default because
+an agent's poll loop is context spent on plumbing.
+
+- New acceptance criterion **F.36**.
+
+**Changelog v0.30 → v0.31 judging a forest is a comparison, and the
+console kept nothing to compare.**
+
+Ask is where this product is judged. Somebody types a question, reads the
+answer, and then does what everybody does next: asks it again after an
+ingest, with the walk turned on, against a model that has since been
+rebound. Each of those destroyed the one before it. The console held exactly
+one result, in application state, and a reload held none.
+
+What was lost is not the prose. `answer` comes back with the evidence, the
+material the model was actually given, the walk it took, the host's three
+clocks and the token cost (J.10.4, J.10.5, J.10.6) the whole apparatus
+that turns an answer into something checkable rather than something to
+believe. The only way to keep any of it was the markdown download, which
+keeps the prose and drops all of it.
+
+So the console keeps the runs it made, and keeps them **in the browser**. A
+run is not a fact about the forest: the forest's own record of that call is
+already written the audit row of J.4 and the pheromone of Part D, both at
+the moment it ran. What a model said is not curated, not indexed, not
+versioned and not reproducible, so a host that stored it would be keeping
+model output where forest content lives; and a synced history would copy a
+grant's worth of node bodies onto a second machine to make a convenience
+work.
+
+- **J.5.9 The runs already made.** Question, parameters as sent and response
+ as received, kept by the browser, keyed by principal and forest, discarded
+ with the credential.
+- **A restored run is a record, not a call.** J.5.8's rule one level in: it
+ says when it was made and which model made it, and asking again is a
+ deliberate act that leaves a new run beside the old one.
+- **A run has no address.** J.5.8 made the console's places linkable; a run
+ exists only in the browser that made it, so a URL naming one would work
+ for its author and be broken for everyone else.
+- **The bound is stated, never silent** the truncation rule of C.6 applied
+ to a store instead of a response.
+- New acceptance criterion **F.35**.
+
+**Changelog v0.29 → v0.30 the console gets an address.**
+
+Every screen of Studio lived at `/`. Which forest was open, which console
+was showing and which node was selected were React state and nothing else,
+so the address bar said the same eight characters from sign-in to sign-out.
+Three consequences, each of them a thing an operator does daily:
+
+- **a reload lost the place.** F5 is not an exotic gesture it is what a
+ person does when a panel looks stale and it returned them to the first
+ forest of their list and the default console. For someone working in the
+ third forest that is a silent relocation into somebody else's data, and
+ the console said nothing about having moved them;
+- **nothing could be sent to anybody.** "Look at this node" was a sentence
+ containing directions rather than a link, in a product whose entire
+ subject is addressable knowledge;
+- **Back left the product.** The console had never written a history entry,
+ so the browser's back button went to whatever preceded the Station.
+
+The forest is the scope of every request on the page, and the operator
+picked it deliberately. It is the one piece of state a console must never
+choose again on the operator's behalf.
+
+- **J.5.8 The address bar.** `/f/{forest}/{console}`, with what the console
+ has selected in the query. The URL is the console's state, not a
+ decoration of it: moving writes a history entry, adjusting replaces one,
+ and rendering never writes at all.
+- **A forest that cannot be shown is named, not swapped.** Following a link
+ into a forest this principal has no grant on MUST say so. Redirecting to a
+ forest they *can* see is how a person comes to believe they are looking at
+ a forest they are not.
+- **Restoring a place restores a page, never a call.** The address carries
+ what is being looked at. It MUST NOT carry a model call, a write, or
+ anything else that spends money or changes the forest on arrival.
+- **The host answers the console's addresses.** A deep link is a GET of a
+ path the API does not own, so the Station serves the shell for it for
+ *document* requests only, so a missing asset stays a 404 instead of
+ becoming an HTML page with a JavaScript MIME type.
+- New acceptance criterion **F.34**.
+
+**Changelog v0.28 → v0.29 a client stopwatch is not a measurement of the
+engine.**
+
+The whole claim of this project is a number: navigation is cheap. A
+`locate` costs a fraction of a millisecond, and that is the difference
+between an agent that may look around and one that must be given everything
+up front. The console that exists to show a caller exactly what an agent
+sees the Playground was reporting that call at **29 ms**.
+
+Nothing was slow. The console had no other number to show. It timed the
+`fetch` with `performance.now()`, so what it displayed was TLS, the
+internet, HTTP, JSON and a React render, with the primitive somewhere inside
+it measured in process, that same `locate` is 0.226 ms of engine and
+0.58 ms of host. The console printed the transport and labelled it the
+call, in accent colour, as the headline of the panel.
+
+The engine has timed every primitive since Part D. J.10.4 already carries
+those numbers out to a caller but only for `answer` and `harvest`, on the
+reasoning that "a single primitive already reports its own latency to
+whoever invoked it". That is true of a library caller and false of an HTTP
+one: over the wire, `elapsed_ms` never leaves the process. Every REST client
+of this host has been in the same position as the Playground, with no way to
+tell a slow forest from a slow network.
+
+The fix is not to put a `trace` on every primitive. A response body is the
+agent's context window and it is budgeted in tokens (C.6); charging every
+`locate` for a diagnostic no agent reads would be paying for the console's
+instruments out of the model's pocket.
+
+- **J.10.6 The host's own clocks.** Every primitive response carries
+ `Server-Timing` `vine`, `host`, and `model` when a provider ran. It is a
+ header, so the body is byte-identical and the token budget is untouched;
+ it is the standard header, so a browser's network panel already draws it.
+- **The engine number is the headline, and transport is an aside.** A
+ console that shows latency MUST lead with the engine's own figure and MUST
+ NOT present a client-side round trip as the cost of a call. What is being
+ judged is retrieval; the rest of the span is the reader's own network and
+ host. It is still stated once, quietly, named as infrastructure because
+ a small number with no account of the gap is read as a claim.
+- **Never a second instrumentation.** `vine` is the sum of the tracer events
+ the call appended, the same slice J.10.4 already reports. `host` is what
+ is left of the host's own span after the engine and the provider: policy,
+ audit, serialisation. Three clocks that add up to the span, or the header
+ is wrong.
+- New acceptance criterion **F.32**.
+
+**And then the number was measured, which is the point of reporting it.**
+With the engine's own clock finally visible, the first call of a fresh
+process turned out to cost around ten milliseconds of host against a third
+of one from the second call on none of it the corpus, all of it SQLite
+waking up. Reporting a number honestly is what makes it worth improving.
+
+- **C.6.1 amended: derived storage is tuned for reads.** Every read
+ primitive deposits pheromone, so every read is also a commit; `_derived/`
+ databases open in WAL with `synchronous=NORMAL`. The durability given up
+ is durability the derived layer never had the files are the truth and
+ `reindex` is the repair and it buys roughly a fifth off the median
+ `locate` and a third off p95.
+- **C.6.1 amended: `warm()`.** Storage only, never through a primitive: a
+ server that warmed itself through `locate` would be forging the pheromone
+ the Ranger reads as evidence of where callers went.
+- **J.6.1 Boot opens the forests.** Default on, off for registries too large
+ to hold open, best effort so one locked forest cannot stop a Station.
+ Opening costs what it always cost; this only decides who waits for it, and
+ the answer should not be "whoever arrives first".
+- New acceptance criterion **F.33**.
+
+**Changelog v0.27 → v0.28 the first minute of a deployment, said out
+loud.**
+
+v0.25 gave a Station with nobody in it a way to acquire somebody. What it
+did not do was tell anybody. The first minute of every deployment happens in
+a terminal `docker compose up`, a stream of log lines and the product
+said nothing there about how to get in, while a convenience left over from
+before the owner existed quietly made sure that nothing worked.
+
+`station serve` minted an `admin` API key on any registry that held no key,
+and printed it. Three consequences, each of which alone is a locked door:
+
+- the key was granted `admin` **per forest**, and a fresh volume has no
+ forests, so it authenticated as a principal with no authority at all —
+ `admin: false`, and the first forest refused with `E_FORBIDDEN`. That is
+ the v0.25 deadlock exactly, re-entered through a door v0.25 did not look
+ at;
+- an API key **is a credential**, so minting one closed the J.2.4 setup
+ route before the first HTTP request arrived. The setup screen, the
+ documented first door, could never appear in the shipped image;
+- it was printed to a block-buffered standard output, so on the occasions it
+ would have mattered it did not reach `docker logs` at all.
+
+The correction is not a bigger banner. It is that **starting a server is not
+an act of administration**: a Station MUST NOT acquire a credential by being
+switched on, and what it says on first run must describe the door that is
+actually open.
+
+- **J.2.5 The first-run announcement.** On a Station nobody can yet sign in
+ to, the console output MUST say how to get in: the URL, and which of the
+ three states the deployment is in. It is keyed on the registry, not on a
+ flag file, so it appears exactly while it is true and stays quiet on every
+ later restart.
+- **Starting mints nothing.** The registry a Station starts on is exactly as
+ authoritative after boot as before it. J.2.4's window closes when a person
+ closes it, never as a side effect of a restart.
+- **The bootstrap key is break-glass, and it is asked for.** An operator with
+ no browser MUST still be able in, so `--bootstrap-key` mints the first key
+ explicitly, once, and **carrying the owner bit**, because a first
+ credential that cannot create the first forest is the deadlock with extra
+ steps. It consumes the same one-shot window as the setup screen, and the
+ announcement says so: two doors to the same first identity, never open at
+ the same time.
+- **The environment password is never printed.** Echoing a value the
+ operator already holds adds no way in and adds one copy in every log
+ aggregator downstream.
+- **J.6 amended: the announcement is unbuffered.** A first-run instruction
+ that arrives when a 8 KiB buffer happens to fill was not delivered. This is
+ a deployment detail and it is normative, because the feature is worth
+ nothing without it.
+- New acceptance criterion **F.31**.
+
+**Changelog v0.26 → v0.27 the console can shape the forest it serves.**
+
+A forest created through the console has exactly one branch: its master
+index. Nothing in the console could add a second. The only branch-maker in
+the whole product was `adopt`, which does not invent structure it mirrors
+a source directory tree so a forest that did not arrive as a folder tree
+could only ever be a flat pile at the root, and the operator's one shaping
+tool was to go and reorganise a folder on some other machine first, then
+ingest it. The Ingest console asked "where do these go?" and offered only
+the branches that a past adopt happened to create.
+
+Nothing in the engine was missing. `plant` already accepts `type: branch`,
+already refuses an id that does not live under its parent, already refuses
+a duplicate, already grafts the entry into the parent index and commits
+both files atomically, and `ScopedVine` already refuses a write outside the
+grant. The gap was entirely a console that never called it which is the
+best kind of gap, because closing it adds no second way to write.
+
+- **J.5.7 Shaping the forest.** The console creates a branch through
+ `plant` and through nothing else. The operator names it; the console
+ derives the id and never lets anyone type one, because ids are immutable
+ (C.7) and a typo would be permanent.
+- **The destination picker creates.** "Where do these go?" is exactly the
+ moment the missing branch is discovered, so the branch can be made from
+ there the same call, not a second one, and not a trip to another
+ console and back.
+- **The boundary is stated, not implied.** There is no move, no rename and
+ no delete: ids are immutable, a node's id encodes its branch, and no
+ primitive relocates one. The console can create structure and curate it.
+ It is not a file manager, and this is written down so nothing is designed
+ against one that does not exist.
+- New acceptance criterion **F.30**.
+
+**Changelog v0.25 → v0.26 ingest grows a perimeter: every source is
+named, vetted, and contained.**
+
+A forest created through the console and then refreshed from it ingested
+the Station's own installation tree. The chain had three links, each
+defensible alone: `sync` defaults to the source root a prior `adopt`
+recorded (G.3); a forest that has never adopted has no such root; and an
+absent root was read as the empty path, which every filesystem API resolves
+to *the working directory of the process*. So the one mode J.8 exempts from
+the `admin` requirement exempt precisely because its directory was vetted
+at adopt time became the one mode that walked a directory nobody had ever
+vetted, on behalf of a principal who was never asked for `admin`.
+
+Three more escapes of the same shape were open beside it. The walk had no
+notion of a forest, so a source placed above the registry would have
+adopted every neighbouring forest's passports as documents, across the
+tenant boundary. A targeted `sync` joined its caller's path onto the source
+root and checked containment with `relative_to`, which is lexical: `../../x`
+survived the join and came back out as a "relative" path, so the file was
+read and planted. And `content: reference` resolved `source_root/source_path`
+without checking the result stayed underneath it, which turned `pick` a
+*read* primitive into a reader for any file the host process could open.
+
+None of the four was a missing check inside a feature. They were the same
+absent idea, four times: **an ingest source is a boundary, and a boundary
+has to be stated somewhere.** This version states it.
+
+- **G.3 amended: an ingest source is always a named, contained directory.**
+ The empty source is a caller error, never a fallback to the working
+ directory. A source MUST NOT be, contain, or sit inside the forest, and
+ any directory that is itself a forest is pruned from every walk.
+- **G.8 amended: a targeted `sync` path is contained after resolution**,
+ not by string inspection `..` collapses and symlinks are followed
+ before the comparison, because the lexical check is the bug.
+- **G.7 amended: a `reference` body MUST resolve underneath the source
+ root.** `source_path` is ordinary frontmatter that anything able to
+ `plant` can set, so this is containment, not trust.
+- **J.8.2 Ingest roots, deny-by-default.** The host names the directories
+ it will read on a caller's behalf, and an unconfigured Station names
+ none: it accepts `upload` and `compose`, which carry their own bytes, and
+ refuses every host path. A control that must be switched *on* protects
+ only the deployments that already knew; a control that must be switched
+ *off* protects the rest. This is the one rule here that is a default
+ rather than a check, and it is the one that matters most, because the
+ operator who most needs it is the one who never reads this document.
+- **The registry root is never an ingest source**, listed or not: one
+ forest reading the volume that holds every forest is the tenant boundary
+ failing in the only direction that counts.
+- **J.6 amended**: a Station's working directory MUST NOT be its own
+ install tree defence in depth, so that the next path bug lands
+ somewhere empty.
+- **J.8 amended**: a console MUST NOT offer a refresh without naming the
+ directory it will re-read. A blind button is how this shipped.
+- New acceptance criterion **F.29**.
+
+**Changelog v0.24 → v0.25 the first boot: a deployment that has nobody
+yet must still be able to acquire somebody.**
+
+Every version so far assumed the registry already had an administrator. It
+does not on the day it is installed. A Station started on an empty volume
+grants the environment super-admin `admin` on every forest *in the registry*
+— of which there are none so it authenticates and governs nothing, and
+J.7 then refuses it the first forest because creating one requires `admin`
+on a forest that already exists. The two rules are individually sound and
+jointly a deadlock: the product cannot be reached through its own front
+door on the one occasion every deployment goes through.
+
+The fix is not a wider grant. It is recognising that **the authority to
+start a forest cannot itself be derived from a forest**, and giving that
+authority somewhere to live.
+
+- **J.2.4 First-run setup.** While the registry holds no credential, the
+ Station offers exactly one unauthenticated route that creates the
+ **owner** and it closes permanently, in the same transaction that
+ creates them. This is the one place a second authentication path could
+ hide, so its closing condition is normative and its race is specified,
+ not left to implementation.
+- **The owner is a property of the principal, not a sum of grants.** An
+ owner holds `admin` on every forest **present and future**, including on
+ none. Re-granting at boot was the alternative and it is wrong for the
+ same reason the deadlock exists: it derives authority from the very
+ thing the owner is needed to create.
+- **J.7 amended.** Creating a forest requires `admin` on an existing forest
+ **or** the owner bit. An unprivileged principal still never can.
+- **J.2.1 amended.** The environment super-admin is demoted to what it was
+ always described as: break-glass. It is no longer the documented way in,
+ and while it is configured the setup route does not exist one door at a
+ time, so the two can never race for the same first identity.
+- **J.5.6 The setup screen**, pre-identity like the Gate, and the optional
+ seeded forest that makes an empty console teach something. The seed is
+ never shipped as content: it is generated, outside the engine, by code
+ that only calls public primitives.
+- New acceptance criterion **F.28**.
+
+**Changelog v0.23 → v0.24 composing with review: the author sees the
+passport before the forest keeps it.**
+
+`compose` (v0.22) let a person post prose and have the Curator make a node
+of it. It planted first and reported afterwards, so the summary that becomes
+the scent every later hop navigates by, and the link proposals that become
+the Ranger's working set, were facts before anyone had read them. Undoing
+them meant editing a node that already existed.
+
+- **J.8.1 Two-phase compose.** `stage: true` runs the whole pipeline —
+ converter, curation, G.4.2.1 candidate proposals and stops at the
+ plant, returning the draft. A second call carrying `draft` accepts it.
+ Nothing is planted, grafted or committed by the staging call.
+- **Accepting re-runs the same pipeline, with the approval pinned.** The
+ approved passport enters as an `on_curate` hook, so the plant, the commit
+ and the content policy are the ones every adopted file gets. The model is
+ not asked twice: it would answer differently, and what shipped would not
+ be what was approved.
+- **The reviewer's edits are re-validated, not trusted.** A returned draft
+ is a client payload: summaries are re-clipped to the A.4 budget, tags
+ re-cleaned and capped, and every link re-checked against the closed-
+ candidate rules of G.4.2.1 `related-to` only, existing and in-scope
+ targets, never a branch, never self or parent, capped at 3, and pinned at
+ confidence 0.3 whoever kept it.
+- **Staging is not planting, and dry-run is a property of the Gardener.**
+ `Gardener(dry_run=True)` cannot write: no plant, no graft, no body cache,
+ no archived bytes, no config. The flag lives on the object rather than on
+ a call so no path can forget it.
+- New acceptance criterion **F.27**.
+
+**Changelog v0.22 → v0.23 the maintenance surface: the Ranger reports to
+somebody.**
+
+Part H gave the forest a Ranger and Part I gave it snapshots, and both have
+been reachable only from a shell. The operator who most needs to know a
+branch has grown too wide, or that a hundred link proposals are waiting, is
+the one running a hosted Station and they have a browser.
+
+- **J.13 Maintenance surface.** `GET /v1/admin/health` returns the Ranger's
+ H.3 report unchanged; snapshots can be created and listed over REST.
+ Neither is a primitive and neither invents a number: the report is what
+ `Ranger.health()` already computes.
+- **Health is an owner's view, and says so.** The report counts and names
+ things across the WHOLE forest lint errors, fat nodes, stale passports —
+ so it requires `admin` *and* an unrestricted scope. A scoped principal is
+ refused with the reason rather than handed a filtered half-report whose
+ numbers would quietly describe a forest they cannot see.
+- **Restore stays on the command line, deliberately.** Part I restores a
+ bundle into an *empty* destination; there is no in-place restore to offer,
+ and a console button that always answers "target is not empty" would be a
+ worse answer than no button. Disaster recovery is not a web workflow.
+- New acceptance criterion **F.26**.
+
+**Changelog v0.21 → v0.22 Forest Views: the map becomes visible.**
+
+Everything the Station serves has been legible to an agent and illegible to
+a person. `look` returns a digest, `scan` returns a page, and neither ever
+shows the shape of the thing being navigated. A forest is a graph with
+heat on it; a console that can only render lists is describing a map by
+reading out street names.
+
+- **J.11 Map projections.** Two read-only endpoints `GET /graph` and
+ `GET /trails` that project the Catalog (C.6.1) and the pheromone layer
+ (Part D/H.1) as whole-of-region payloads. They add no primitive and no
+ engine capability: everything they return is already reachable one node
+ at a time through `look`/`move`/`scan`, and they are subject to the same
+ J.3 filtering, including the recomputation of every derived count.
+- **J.5.4 Forest Views.** The Explore console gains modes over one
+ selection graph, tree, files and the file view renders each file as
+ what it is: markdown rendered by default with the stored source one click
+ away, a dataset payload as a browsable table, an HTML body as a page.
+ Presentation only: no request, response or permission changes.
+- **J.8's fourth mode, `compose`.** A person writes prose in the console
+ and it enters the forest through the ingest pipeline that already exists
+ same converters, same curation, same commits rather than through a
+ second, unaudited door.
+- **Editing is a write, not a save.** A console MAY offer rich editing of a
+ node, and MUST express the result as `graft`/`tend` operations. Writing
+ a node file directly is forbidden to every surface, the console included.
+- New acceptance criterion **F.25** (a map projection discloses nothing a
+ node-by-node walk would not).
+
+**Changelog v0.20 → v0.21 the Gauntlet: the vector layer moves from the
+entry to the hand:**
+
+Measurement (2026-08-08, bge-m3 on a local Ollama) established where the
+dense layer helps and where it hurts, and the two answers are opposite:
+
+| Corpus | BM25 R@1 | Hybrid R@1 |
+|---|---|---|
+| bench-forest, 18 v2 queries | 0.778 | **0.889** |
+| forest-fixture, 10 demo queries | **1.000** | **0.400** |
+
+RRF rewards *agreement*, not correctness. Fusing a fuzzy ranker into one
+that is already right can only pull the answer away from rank 1 on the
+fixture it dropped `block-loop` from 1st to 4th while the vector list
+surfaced the topically-adjacent `speculative-decoding`. So the published
+hybrid row does not survive scent-weighted BM25, and v0.19's own
+`{{TODO: hybrid re-run}}` is answered in the negative.
+
+The same rule points at where the layer *does* pay. Entry search already
+has a strong query-dependent ranker. **Navigation has none**: `look` orders
+edges by heat (past usefulness), `scan` by degree (connectivity), `move`
+not at all every one of them blind to what is being hunted right now.
+Adding a query-dependent signal there is not fusion; it is the first such
+signal, with nothing to dilute.
+
+- **Part K the Gauntlet.** The forager carries the query vector and the
+ *frontier* is ordered by proximity to it: which neighbours `look` shows
+ within its edge cap, which children `scan` returns under its budget,
+ which way `move` points. Cost is one query embedding per hunt, reused
+ across every hop; per hop it is a dot product over vectors already in the
+ Canopy no HTTP, **no tokens**.
+- **Strictly optional, and identical when absent.** No embedder, or no
+ index, or a stale index ⇒ every primitive behaves exactly as in v0.20.
+ Not degraded: identical. The Gauntlet MUST NOT become a dependency of
+ navigation.
+- **K.4 The mismatch guard**, a defect this work uncovered: the Canopy
+ records the model that built it and *nothing compared it*. An index built
+ with `bge-m3` queried by a `gemma4:12b` embedder reported `hybrid = True`
+ and silently compared vectors from two different spaces. Now a mismatch
+ disables the dense layer and says so.
+- New acceptance criterion **F.24** (absent/stale embedder is byte-identical
+ to v0.20; conditioning is visible in the response; mismatch disables and
+ reports).
+
+**Also in v0.21 J.10.1, the provider the deployment already declared:**
+
+A Station started with `MONKEYLLM_LLM_ENDPOINT` and its key has already
+been told everything the console's provider form would ask for. Asking
+again makes an operator copy a secret out of the place that governs it —
+the environment, which the deployment rotates and never backs up into a
+place that does not.
+
+- **J.10.1 Environment-declared providers.** They appear configured at
+ boot, marked `origin: "env"`. The key is resolved from the environment at
+ call time and **MUST NOT be written to the registry**. The console MUST
+ refuse to edit or remove one accepting would be undone by the next
+ restart. A row whose declaration is withdrawn becomes an ordinary console
+ row rather than being deleted with its bindings.
+
+**Changelog v0.19 → v0.20 one person, several forests, one decision:**
+
+v0.19 made the *person* the unit of administration but left the grant step
+naming a single forest, so a registry hosting six forests turned "give this
+service read access to everything" into six visits to the same form six
+requests, six chances to stop halfway, and a token whose real reach was
+never visible in one place. The person was one thought; their access was
+still shaped like one row of the grants table.
+
+- **J.2.3 `grant` and `revoke_access` take one forest or several.** `grant`
+ MAY carry `forests: [, …]` instead of `forest: `, and
+ `revoke_access` MAY carry a list. The scalar forms remain valid and mean a
+ one-element list, so every existing client keeps working.
+- **A list is not a relaxation.** Each named forest is authorised on its
+ own (`admin` on *that* forest), applied on its own, and refused on its
+ own a refusal names the forest and MUST NOT discard the forests the
+ caller was entitled to grant. This is J.2.3's partial-application rule
+ applied within a step rather than only between steps.
+- **Scope prefixes are forest-local.** `allow`/`deny` apply to every forest
+ named in the same grant, because a grant is one policy. Branch names are
+ not portable between forests, so the console offers the branch picker only
+ when exactly one forest is selected and grants the whole forest otherwise
+ (J.5.5) the API does not second-guess a caller that knows better.
+- **What did NOT change:** the escalation rule (J.2.2). A key still
+ authenticates a principal, so minting one still requires `admin` on
+ **every** forest that principal holds which is precisely why the grant
+ step has to be able to say "these forests" in one request that the
+ administrator of all of them can make.
+- Criterion **F.23** extended: a multi-forest grant lands on every forest
+ the caller administers, refuses the ones it does not by name, and the
+ resulting token reads in each granted forest.
+
+**Changelog v0.18 → v0.19 governance is organised around people:**
+
+The host grew three governance objects grants, passwords, API keys and
+the console grew one screen per object. That is the storage model wearing a
+navigation bar. Nobody administers a *grant*; they onboard a **person**, and
+onboarding is one thought: this is who they are, this is what they may see,
+here is how they sign in, here is a token for their script. Splitting that
+across three destinations made the operator hold the model in their head
+instead of the interface holding it for them.
+
+- **J.2.3 `POST /v1/admin/people`** one call applies any combination of
+ grant, revoke-access, password and token changes to one person, so the
+ console can offer onboarding as a single form. It is a **composite, not a
+ new authority**: each part re-checks the rule that already governed it,
+ and the parts apply in an order that makes a first-time grant usable
+ (grant first, so a brand-new principal becomes administrable and can then
+ receive a password and a key in the same request).
+- **`GET /v1/admin/people`** the person-shaped read the console needs:
+ identity, grants, password presence, tokens and last-seen in one object,
+ filtered by J.3.2. Assembling this client-side from three endpoints made
+ the console's shape depend on the registry's.
+- **J.5.5 The People console** replaces the separate Access and Tokens
+ screens: a list of people, a detail view per person carrying everything
+ about them, and a credential-shaped tab for the operator who wants to
+ audit tokens rather than people.
+- **What did NOT change:** every enforcement rule. `admin` on the forest to
+ grant, `administers_fully` to touch a credential, the environment account
+ refusing a stored password, per-forest filtering on read. A convenience
+ endpoint that relaxed any of them would be a new way in wearing the
+ clothes of a better form.
+- New acceptance criterion **F.23** (the composite performs each part under
+ its own rule and refuses the parts it may not do without abandoning the
+ parts it may; onboarding in one call yields a working sign-in and a
+ working token).
+
+**Changelog v0.17 → v0.18 administration stops being global:**
+
+Asking "should the console hide what a person cannot do?" turned into an
+audit of every host route, and the audit found the real defect. Every
+`/v1/admin/*` route correctly refuses a non-administrator but it treated
+`admin` **on any forest** as a licence to read **every** forest's
+governance data. An administrator of one forest could list every principal
+in the registry with their exact branch prefixes, and read the complete
+audit log of forests they cannot open. J.2.2 already closed this shape for
+API keys; the same reasoning was never carried to the rest.
+
+- **J.3.2 Administration is per forest.** Holding `admin` somewhere admits
+ a caller to a host route; it never entitles them to rows about forests
+ they do not administer. Every host route MUST filter its result to those
+ forests, and a route that cannot be filtered MUST require admin
+ everywhere instead.
+- **J.5.1 revised** a console the principal cannot use is now **omitted
+ from navigation** rather than shown with an explanation. Corporate
+ operators read a menu as a list of what they may do, and an entry that
+ only ever refuses teaches nothing that a support conversation would not
+ teach better.
+- **Hiding is presentation and MUST NOT be the control.** The view keeps
+ its own capability guard, and the API remains the authority: navigation
+ is a convenience over a decision the server already made and would make
+ again for a request the console never sent.
+- New acceptance criterion **F.22** (every host route refused without the
+ capability; per-forest filtering proven with a two-forest registry and a
+ partial administrator; navigation contains exactly the permitted
+ consoles).
+
+**Changelog v0.16 → v0.17 credentials get a front door and a lifecycle:**
+
+v0.16 made the console usable; it left the credential that opens it with no
+story at all. A key was minted by a CLI or hidden inside the grant form,
+never listed, never expiring, never revocable, with no record of last use —
+and the only way into the console was to paste one. That is the opposite of
+governance: the object that grants access was the one object the governance
+console could not govern.
+
+- **J.2.1 Two doors, one identity.** `POST /v1/auth/login` exchanges a
+ username and password for a **session token**, which is an ordinary API
+ key with a short lifetime. Machines keep pasting keys. Both arrive at the
+ same `authenticate()` and the same J.3 policy, so there is exactly one
+ authorization path no matter which door was used.
+- **The environment super-admin** `MONKEYLLM_STATION_ADMIN` and
+ `MONKEYLLM_STATION_PASSWORD` is verified against the environment and
+ never stored: it is break-glass, and hashing a value that already sits in
+ the environment protects nothing while giving a rotation two places to go
+ wrong.
+- **J.2.2 Token lifecycle** label, expiry, revocation, last use, and a
+ non-secret prefix so a token can be recognised in a list without being
+ disclosed. Expired and revoked keys MUST fail authentication, which is
+ where a lifecycle either exists or does not.
+- **The escalation rule that makes delegated token issuance safe:** minting
+ or revoking a key for a principal requires `admin` on **every** forest
+ that principal holds a grant on not merely on one of them.
+- **J.5.4 Tokens console**, and the explicit ruling that there is **no
+ second, super-admin panel**: one console over one API, with capabilities
+ deciding what appears. A second panel needs a second authentication path,
+ and a second authentication path is where the backdoor goes.
+- New acceptance criterion **F.21** (login, session expiry, revocation,
+ last-use, prefix-only listing, and the cross-forest escalation refusal).
+
+**Changelog v0.15 → v0.16 the console becomes usable by someone who has
+not read this document:**
+
+v0.14 and v0.15 gave the Station a front door and a choice of reader. Both
+were specified from the storage model outward, and the console inherited
+that: it asked an operator for capability sets and comma-separated branch
+prefixes, offered no way to start a forest or put anything into one, and
+spoke one language in one theme. A governed knowledge base that only its
+own author can operate is not a product. v0.16 specifies the console as a
+first-class contract rather than a rendering of the registry.
+
+- **J.5 rewritten** a normative information architecture (nine consoles
+ in three groups), the rule that **the console MUST address the operator
+ in the operator's vocabulary**, not the policy model's, and two
+ requirements the previous version left to taste: localisation
+ (English, Portuguese, Spanish) and both light and dark presentation.
+ The no-side-channel rule is unchanged and now explicitly covers strings:
+ a translation MUST NOT alter what a surface returns.
+- **J.7 Forest lifecycle** `POST /v1/admin/forests`, so a deployment can
+ reach its second forest without shell access to the volume. Creation is
+ A.5 `init_forest` and nothing else; the id is validated against path
+ escape before it is a path, and the creator is granted the forest so a
+ newly created forest is never orphaned.
+- **J.8 Ingest surface** the Gardener (Part G) reached over REST, with
+ `adopt`, `sync` and a **staged upload** for operators who have a browser
+ and no shell. Requires the `ingest` capability; the destination branch is
+ scope-checked, so ingest cannot be used to write where reads are denied.
+- **The naming reuse, stated plainly:** J.7 named "out of scope for Part
+ J" in v0.14 and became J.11 in v0.15. In v0.16 the free slot is reused
+ for the forest lifecycle. Cite J.11 for the exclusions.
+- New acceptance criterion **F.20** (console: every string localised in all
+ three languages, both themes, and the scoped principal's console shows a
+ scoped world; forest creation refuses path escape; ingest refuses to
+ write outside scope).
+
+**Changelog v0.14 → v0.15 J.10, per-forest inference (the forest picks its
+own model):**
+
+Part J gave forests a front door; v0.15 lets each one choose who reads it.
+A forest is not one workload: ingest wants a careful summariser whose output
+every later hop navigates by, while answering wants a fast reader that
+follows instructions. One global `MONKEYLLM_LLM_*` cannot express that, and
+it cannot express "this corpus stays on a local endpoint while that one uses
+a hosted model" either.
+
+- **J.10 Providers and role bindings** operators register any
+ OpenAI-compatible `/v1` (OpenRouter, LiteLLM, vLLM, local llama.cpp) in
+ the host registry, then bind a model per `(forest, role)` with
+ `role ∈ {ingest, answer, vision}`. Credentials are write-only across
+ every surface: the API accepts a key and reports only whether one is
+ set.
+- **J.10.3 Model-backed composites** `answer` (retrieval + the forest's
+ answering model, returning a grounded reply with its evidence) and
+ `curate` (re-summarise a node through the ingest model, under the A.4
+ scent rules). Both are host composites, not primitives: the engine gains
+ nothing.
+- **The invariant that makes this safe:** the retrieval half runs through
+ `ScopedVine` *before* the model is called, so a bound model only ever
+ sees material the principal could already read. Binding a model MUST NOT
+ become a way around J.3.
+- New acceptance criterion **F.19** (secrets never returned; bindings
+ refuse unknown providers/roles; the answering model receives only
+ in-scope material).
+
+**Changelog v0.13 → v0.14 Part J, the Station (the forest gets a front
+door):**
+
+Everything up to v0.13 assumes one operator who owns the filesystem.
+Corporate self-hosting needs the shape the database products converged on:
+an untouched engine wrapped by a host that adds identity, policy, audit and
+a friendly surface. Part J specifies that host and specifies it as a
+**privileged client**, not an extension: the engine gains nothing, loses
+nothing, and its test suite MUST pass unedited.
+
+- **J.1 The Station** one self-hostable service mounting a forest
+ registry (the `--root` resolution that already exists) and exposing
+ three surfaces REST, MCP, Studio over exactly one enforcement core.
+ No surface may reach an unscoped `Vine`.
+- **J.2 Identity** principals (users and service tokens), per-forest
+ roles, API keys now and OIDC later. Identity and policy live in the
+ **host registry**, never inside a forest: forests are content.
+- **J.3 Policy (`ScopedVine`)** deny-by-default grants over **branch
+ prefixes** plus a capability set, with one enforcement rule per
+ primitive. Two invariants make it trustworthy rather than merely
+ configured: scope filtering MUST precede budgeting (no truncation
+ oracle) and out-of-scope MUST be indistinguishable from absent (no
+ existence oracle) including through `move`'s edges, which would
+ otherwise leak a forbidden node's existence.
+- **J.4 Audit** writes stay git commits, now stamped with the acting
+ principal; reads extend the Part D telemetry with principal identity.
+- **J.5 Studio** the web console, itself a plain REST client with no
+ privileged side-channel.
+- New acceptance criterion **F.18** (the leak suite: one test per
+ primitive per surface, plus the two oracle invariants).
+
+**Changelog v0.12 → v0.13 branch rollup + Landmarks (the map grows a
+sense of place):**
+
+The branch hierarchy already occupies the position that graph-RAG systems
+pay dearly to discover (hierarchical communities); what it lacked was
+synthesized content at each level. Two additions, zero new primitives:
+
+- **G.4.4 Branch rollup** after adopt/sync curation, the Gardener MAY
+ synthesize branch (`_index.md`) frontmatter summaries bottom-up (deepest
+ branch first) from the children's entry lines. Scope is strictly
+ branches with `source: ingest` (hand-authored branch summaries are never
+ rewritten; an explicit `--all` override exists). A.4 summary rules apply
+ (validate-and-retry); LLM failure falls back to a deterministic composed
+ summary and never blocks (same posture as G.4.2). Writes go through the
+ C.8 `graft` path, so verbatim propagation into parent index entries and
+ `.md`-only commits are inherited, not reimplemented. Rollup cost is
+ O(branches), not O(nodes) the lazy end of the graph-RAG spectrum.
+- **A.5 entry-sync rule tightened** when a summary change propagates
+ into a `## Sub-branches` entry, the entry's trailing coverage suffix
+ (`. N bananas, M sub-branches.`) MUST be preserved (previously it was
+ silently dropped by the sync rewrite).
+- **A.5 `## Landmarks` implemented as a Ranger duty (H.7)** the master
+ `_index.md`'s Landmarks section (already normative since v0.5) is now
+ populated mechanically: top 10-20 highest-degree non-branch nodes from
+ the catalog's edges table, entry lines with summaries, idempotent
+ refresh through the audited `.md`-only path (`ranger(landmarks): …`).
+ Zero LLM involvement.
+- New acceptance criterion **F.17** (rollup scope/fallback/propagation +
+ Landmarks idempotence).
+
+**Changelog v0.11 → v0.12 Gardener v2: native DOCX + edge proposals (the
+forest starts weaving itself):**
+
+Two Gardener extensions, both strictly inside the edges-only surface (G.2):
+
+- **G.2.1 DOCX built-in converter** `.docx` joins the built-ins when
+ `python-docx` (MIT; lxml, BSD) is importable, mirroring the `openpyxl`
+ pattern. Single-pass `w:t` traversal in document order: body paragraphs
+ (style-mapped headings), tables (→ pipe tables), and text inside embedded
+ text boxes (`wps:txbx` / legacy `v:textbox`); fragmented runs merge
+ naturally by joining a paragraph's `w:t` descendants. Headers/footers are
+ EXCLUDED (page-number/letterhead boilerplate is scent noise). Technique
+ derived from the owner's pdf-replace project (MIT-clean reading side).
+ No `python-docx` → `.docx` files report `unsupported`, never a crash.
+- **G.4.2.1 Edge proposals** LLM curation MAY now propose `related-to`
+ links from the adopted node to EXISTING nodes, each carrying link-level
+ `confidence: 0.3` (the C.8 ladder's bottom rung). Candidates come from
+ the catalog (BM25 over curated metadata); the model can only pick from
+ the offered list a hallucinated target is structurally impossible.
+ This closes the loop with Part H: the Gardener proposes, usage heats,
+ the Ranger promotes (0.8) or prunes. Entity EXTRACTION (creating new
+ `entity` nodes) stays deferred: it needs a placement policy and a
+ `same-as` dedup story first.
+- New acceptance criterion **F.16** (DOCX fidelity + proposal guard rails).
+
+**Changelog v0.10 → v0.11 the map is not the territory (tiered storage,
+big sources, S3-ready):**
+
+A 2 TB source must not require 4 TB locally. The forest splits into three
+tiers SCENT (passports: summaries/outlines/links, ~0.1% of source size,
+always local, in git), FLESH (converted full text, ~1-5%, local, git or
+derived cache), BONE (raw binaries, 95%+, stay at the source / object
+storage, fetched rarely). New normative items:
+
+- **G.7 Content & archive policies** per-adoption `content:
+ inline | cached | reference` and `archive: never (default) | always`.
+ Non-inline bodies are resolved lazily by `pick`/`sniff`; the map
+ (locate/look/scan, heat, curation) never needed the body and is
+ unaffected. `archive: never` kills the redundant `_assets/` copy when
+ the source is durable.
+- **G.8 Targeted sync & triggers** `sync(path=...)` reprocesses a single
+ source file; an mtime+size fast-path avoids re-hashing unchanged trees.
+ Event sources (filesystem watchers, S3/Drive push notifications) are
+ EDGES that call targeted sync; **events trigger, the hash-diff
+ reconciler stays authoritative** (lost events are healed by the next
+ full sync).
+- **G.9 Payload fetchers** `payload`/`source_path` MAY carry a scheme
+ (`file://` implicit; `s3://` via optional MIT extra). Remote payloads
+ download on first use into `_derived/payloads/` (hash-validated cache).
+ Dataset `.db` files are **local-first by design** (SQLite cannot be
+ queried remotely; hot knowledge bases need sub-ms reads) object
+ storage holds them only as backup/cold tiers.
+- **H.6 Cache eviction** the Ranger evicts cold entries from
+ `_derived/payloads/` (LRU by last access; config `payload_cache_gb`).
+- **Part I Snapshots**: `vine snapshot create|restore` packages the
+ forest as a `git bundle` (full commit history travels along) +
+ compression, optionally uploaded to object storage; payload sidecar
+ optional. The Ranger MAY schedule snapshots (backup policy).
+- Informative (G.4 note): **progressive curation** adopt the skeleton
+ deterministically first (the engine answers immediately with weak
+ scent), then LLM-curate as a background queue prioritized by heat: the
+ pheromone tells the Gardener where to polish first. Querying an
+ UNMAPPED source per-question is the anti-pattern this project exists to
+ kill (O(corpus) per question vs O(corpus) once + O(hops) per question).
+- New acceptance criterion **F.15** (policies + targeted sync; fetcher/
+ snapshot coverage lands with their implementations).
+
+**Changelog v0.9 → v0.10 Part H: the Ranger (long-term maintenance the
+forest forgets, confirms and warns):**
+
+The pheromone layer only compounds if it can also FORGET: without
+evaporation every trail saturates at 1.0 and heat stops carrying signal;
+without pruning, agent proposals (confidence 0.3/0.5) accumulate as noise.
+New normative items:
+
+- **Part H (Ranger)** the maintenance daemon: heat evaporation with a
+ configurable half-life over `_derived/trails.db` (H.1); promotion and
+ pruning of uncertain links links born with link-level
+ `confidence < 1.0` are the ONLY Ranger-managed edges (H.2); a read-only
+ health report: `needs_split`, fat nodes, lint issues, stale passports,
+ low-confidence inventory (H.3); on-demand run + service loop (H.4).
+- Ranger is **trusted infrastructure** (like the Gardener): evaporation
+ touches only the derived layer (no commits `_derived/` is disposable);
+ promotion/pruning write through the audited `.md`-only path with commit
+ messages `ranger(promote): …` / `ranger(prune): …`.
+- The Ranger NEVER deletes nodes, never touches structural edges or any
+ link without a link-level confidence field, and never performs `same-as`
+ physical compaction (still human-approved, still out of scope).
+- New acceptance criterion **F.14** (synthetic-clock evaporation,
+ promotion/pruning safety, health report).
+
+**Changelog v0.8 → v0.9 Part G: the Gardener (brownfield ingest, the
+forest learns to grow itself):**
+
+The dominant real-world scenario is **adoption**: the engine is pointed at a
+directory tree already full of documents ("mata alta") and must curate all
+of it then notice when source files change. New normative items:
+
+- **Part G (Gardener)** the ingest pipeline: passport policy (G.1),
+ public converter contract with three discovery sources forest-config
+ command hooks, `monkeyllm.converters` entry points, built-ins (G.2);
+ `adopt` (mirror an existing tree: folders → branches, files → passports,
+ deterministic placement) and `sync` (hash-diff incremental update) (G.3);
+ curation stage with forest-level curation config and `on_curate` hooks —
+ the only LLM-dependent stage, always skippable (G.4); media via the same
+ converter contract: transcript/description is the body, the raw asset is
+ the payload (G.5).
+- **C.7.1 extension: initial `rows` at birth** `plant` of a dataset MAY
+ carry `rows` per table, inserted **parameterized** (never SQL text) before
+ `payload_hash` is computed. Bulk loads bypass neither the schema
+ validation nor A.3.1 and avoid `tend`'s keyword scanner false-positives
+ on arbitrary data.
+- **Extension surface is edges-only (normative)**: plugins exist for what
+ goes IN (converters, curation hooks). The primitives' semantics, budgets
+ and security guards are NOT extensible. UIs/automations (dashboards,
+ upload bots) are *clients* of the MCP server or the library they need
+ no plugin API.
+- New acceptance criterion **F.13** (adopt/sync end-to-end).
+
+**Changelog v0.7 → v0.8 dataset birth: declarative schema in `plant`
+(Phase 2, the living bank grows its own organs):**
+
+`tend` (C.10) writes rows into datasets that already exist; until now no
+primitive could *create* a dataset the `.db` was born only in offline
+generators. v0.8 closes the loop so an agent can collect data (web, PDFs,
+conversations), give it a structured home, and fill it all through the
+primitives. Normative items:
+
+- **C.7.1 Dataset planting** `plant` of a `type: dataset` node accepts an
+ optional **declarative `schema`** object (tables → columns → types). The
+ Vine generates the DDL itself (names regex-validated, types from an
+ allowlist), creates the SQLite payload, computes `payload_hash`, and
+ auto-generates the `## Query manual` body section from the schema. **No
+ raw DDL ever comes from the model** creation-time structure is data,
+ not SQL.
+- **`tend` is unchanged**: DDL stays forbidden there forever. The separation
+ is temporal creation (rare, structured, validated whole) vs operation
+ (frequent, single-statement DML). `ALTER TABLE` after birth is out of
+ scope (plant a new dataset and migrate, or wait for the Gardener).
+- A.3.1 holds with zero new machinery: the payload is created on the
+ filesystem, only the `.md` (with `payload` + `payload_hash`) is committed.
+- New acceptance criterion **F.12** (schema validation, payload creation,
+ auto manual, atomic rollback covered by tests).
+
+**Changelog v0.6 → v0.7 `tend`: dataset writes (Phase 2 entry, the living
+bank):**
+
+`query` stays read-only by design; agent writes to dataset payloads get
+their own primitive with a hard guard rail. New normative items:
+
+- **C.10 `tend(id, sql)`** the 10th primitive: single-statement
+ INSERT/UPDATE/DELETE on a `type:dataset` node's SQLite payload, with an
+ audit commit of the node's `.md` (`payload_hash` refresh) the binary
+ still never enters git (A.3.1 unchanged). Full contract in C.10.
+- **Lint: payload drift warning** `vine validate` MUST warn when a node's
+ `payload_hash` no longer matches the payload file's sha256 (completes the
+ A.3 drift-detection promise; `tend` keeps the hash fresh, out-of-band
+ edits become visible).
+- New acceptance criterion **F.11** (tend guard rails + audit, covered by
+ tests).
+
+**Changelog v0.5 → v0.6 shout trigger measures the real trail (Part D):**
+
+The shout never fired in practice: 39 successful hunts across the fixture and
+the bench forest produced zero shortcut suggestions. Root cause: the trigger
+reused `hops-to-banana`, which counts only `look`+`move` calls before the
+FIRST `pick`/`query` but agents traverse deep chains with **pick chains**
+(`locate → pick → pick → pick`), so the counter stays at 0–2 even on long
+winning trails. Normative changes:
+
+- New session metric **`trail_len`**: the number of traced read-primitive
+ calls (`locate`, `sniff`, `look`, `move`, `scan`, `pick`, `query`) made
+ strictly BEFORE the first harvest (`pick`/`query`) of a node listed in
+ `outcome.answer_nodes`. `null` when no answer node was harvested.
+- `close_session` MUST suggest shortcuts (`suggest_shortcuts`) when
+ `trail_len >= 4` (threshold unchanged). The shout edge itself is still the
+ orchestrator's decision (C.8 reinforce-before-create applies).
+- **`hops-to-banana` is unchanged** (look+move before the first harvest) —
+ it stays a Monkey Bench metric for longitudinal comparability; it is no
+ longer the shout trigger.
+
+**Changelog v0.4 → v0.5 canonical English vocabulary (normative):**
+
+The tool's vocabulary is English. Every contract token that was Portuguese
+is renamed; the Portuguese tokens are **removed** (clean break, pre-release —
+no alias layer). A forest MAY still declare extra types/rels of its own in
+`_meta/schema.md` (the dialect stays data-driven), but everything the Vine
+hardcodes, emits or parses now uses the English tokens below.
+
+| Kind | v0.4 (removed) | v0.5 (canonical) |
+|---|---|---|
+| node type | `galho` | `branch` |
+| node type | `nota` | `note` |
+| node type | `documento` | `document` |
+| node type | `entidade` | `entity` |
+| node type | `conceito` | `concept` |
+| node type | `evento` | `event` |
+| node type | `midia` | `media` |
+| node type | `dataset` | `dataset` (unchanged) |
+| rel | `parte-de` / `contem` | `part-of` / `contains` |
+| rel | `relacionado-com` | `related-to` |
+| rel | `mencionado-em` / `menciona` | `mentioned-in` / `mentions` |
+| rel | `autor` / `autor-de` | `author` / `author-of` |
+| rel | `comparado-com` | `compared-with` |
+| rel | `derivado-de` / `origem-de` | `derived-from` / `origin-of` |
+| rel | `same-as` | `same-as` (unchanged) |
+| rel | `atalho-descoberto` | `discovered-shortcut` |
+| rel | `sucede` / `precede` | `succeeds` / `precedes` |
+| `entity_kind` | `pessoa`, `organizacao`, `produto`, `lugar`, `outro` | `person`, `organization`, `product`, `place`, `other` |
+| `source` | `agente` | `agent` (`manual`, `ingest` unchanged) |
+| A.5 heading | `## Sub-galhos` | `## Sub-branches` |
+| A.5 heading | `## Bananas diretas` | `## Direct bananas` |
+| A.5 heading | `## Trilhas cruzadas` | `## Cross trails` |
+| A.5 heading | `## Landmarks` | `## Landmarks` (unchanged) |
+| `coverage` format | `"N bananas, M sub-galhos"` | `"N bananas, M sub-branches"` |
+| dataset body section | `## Manual de consulta` | `## Query manual` (source of C.2 `query_manual`) |
+| A.4 anti-patterns | "este documento descreve", "arquivo contendo" | "this document describes", "file containing" |
+| C.8 shout metadata | `discovered_by: agente` | `discovered_by: agent` |
+
+Test data remains Portuguese where it is content (fixture corpus prose,
+titles, summaries, ids, tags, demo/bench questions and prompts); only the
+structural tokens above change there.
+
+**Changelog v0.3 → v0.4:**
+
+- **C.0 Forest registry (multi-forest serving)**: one MCP server MAY host many
+ forests under a root directory (`vine serve --root DIR`). Every tool gains
+ an optional trailing `forest: string` parameter selecting the target forest;
+ a new `forests()` tool lists what the registry serves. Forests open lazily
+ on first touch (auto-index included). Single-forest mode (`--forest`) keeps
+ the previous behavior `forest` is optional there so v0.3 clients are
+ not broken.
+- New acceptance criterion F.10 (registry: selection, lazy open, path safety,
+ single-forest backward compatibility).
+
+**Changelog v0.2 → v0.3:**
+
+- New composite MCP tool **C.6c `harvest`**: zero-LLM, one-shot retrieval for
+ clients that bring their own model. Fuses `locate` + `sniff` (RRF), returns
+ ranked bananas with full body or matched sections plus exact snippets.
+ It is an orchestration over existing primitives the nine primitive
+ contracts are untouched.
+- Three integration modes documented (C.6c intro): direct navigation
+ (client LLM drives the primitives), harvest (one call, evidence back),
+ concierge (local SLM hunts and answers). Configuration picks the default;
+ the MCP client's LLM may choose per call.
+- New acceptance criterion F.9 (harvest quality + budget).
+
+**Changelog v0.1 → v0.2:**
+
+- New read primitive **C.6b `sniff`** (the sniffer): literal search over node **bodies**, returning node + section + snippet. Complements `locate` (which stays restricted to curated metadata C.1 contract intact) covering the case "exact term buried in the body, invisible to summary/tags".
+- **A.3.1 Binary payload policy**: binaries never enter the forest's Git Vine versions `.md` only (enforced at the commit layer); payloads are referenced by `payload` + `payload_hash` and excluded by the forest's `.gitignore`.
+- Acceptance criterion F.1 updated to include C.6b; new criteria F.7 (sniff quality) and F.8 (payloads outside Git).
+- Nothing else changes: every other contract is identical to v0.1 (which stays archived for history).
+
+---
+
+## Part A The Forest Dialect (`_meta/schema.md`)
+
+`schema.md` is a living file inside the forest that declares the valid types. The Vine MUST validate every write (`plant`/`graft`) against it. The agent MAY read it via `look("_meta/schema")` to learn the dialect in 1 hop.
+
+### A.1 Node types (`type`)
+
+| `type` | Description | Payload | Harvest verb |
+|---|---|---|---|
+| `branch` | Index file (`_index.md`) of a folder | | `look` |
+| `note` | Free-text knowledge (default banana) | | `pick` |
+| `document` | Converted document (PDF/DOCX origin) | original in `_assets/` | `pick` |
+| `dataset` | Tabular data | sibling SQLite (`.db`) | `query` |
+| `entity` | Person, organization, product, place (subtype in `entity_kind`) | | `pick` |
+| `concept` | Definition / technical term | | `pick` |
+| `event` | Dated fact (meeting, decision, release) | | `pick` |
+| `media` | Image/audio/video with description or transcript | original in `_assets/` | `pick` |
+
+Rules:
+- New types MUST be added to `schema.md` before first use; the Vine rejects an unknown `type` (`E_SCHEMA` error).
+- `entity` MUST have `entity_kind` ∈ {`person`, `organization`, `product`, `place`, `other`}.
+
+### A.2 Edge types (`rel`)
+
+Edges are directed, typed, and declared in the source node's frontmatter (`links:`). The derived layer materializes the inverses automatically.
+
+| `rel` | Inverse (derived) | Semantics |
+|---|---|---|
+| `part-of` | `contains` | Logical hierarchy (not the physical folder hierarchy) |
+| `related-to` | `related-to` | Generic association (symmetric) |
+| `mentioned-in` | `mentions` | Entity cited in a document |
+| `author` | `author-of` | Authorship |
+| `compared-with` | `compared-with` | Technical contrast (symmetric) |
+| `derived-from` | `origin-of` | Provenance (note derived from document, dataset from export, etc.) |
+| `same-as` | `same-as` | **Soft merge** of duplicate entities (symmetric) |
+| `discovered-shortcut` | | The monkey's shout (created by `graft`, see Part C.8) |
+| `succeeds` | `precedes` | Temporal order between events/versions |
+| `supersedes` | `superseded-by` | Replacement (v0.58): the successor makes the predecessor history — the sweep suppresses the target by default (C.6c.4). `succeeds` orders moments without judging them; `supersedes` judges. |
+
+Rules:
+- A `rel` outside this table → `E_SCHEMA` error (the table grows by editing `schema.md`, never ad-hoc).
+- **The refusal names the set that WOULD be accepted (v0.61).** The table
+ above is the engine's default; the forest's own `_meta/schema.md` is the
+ authority at runtime, so a rel the engine ships may legitimately be
+ absent from a given forest a forest created before `supersedes` existed
+ declares nine rels and refuses the tenth, which is this rule working as
+ designed. What was not working is the refusal: `unknown rel
+ 'supersedes'`, with no `hint`, in the one case where listing the
+ forest's declared rels answers the question completely and costs a
+ sorted join. C.12's envelope requires an actionable hint on every
+ refusal; `E_SCHEMA` for an undeclared `type` or `rel` MUST therefore name
+ the forest's declared set (clipped, with the count, if it is long) and
+ say where it is declared. Applies to `plant`, `graft` and every other
+ path that validates against the dialect a caller must never have to
+ guess a vocabulary the forest can simply state.
+- `same-as` MUST NOT delete nodes; physical merging is the Ranger's compaction alone (out of Phase 0 scope).
+- Maximum of 50 `links` per node; above that the node is a candidate to become a branch (signal for the Ranger).
+
+### A.3 Normative frontmatter
+
+Fields required on **every** node:
+
+```yaml
+id: string # stable slug, unique in the forest, = relative path without .md
+type: string # one of the A.1 types
+title: string # human title (mutable; id never changes)
+summary: string # 1-3 sentences, <= 60 tokens. THE SCENT. See A.4.
+created: date # ISO 8601
+updated: date # ISO 8601, refreshed on every graft
+```
+
+Optional fields:
+
+```yaml
+tags: [string] # discriminating tokens in the document's own
+ # language; diacritics KEPT (G.4.2, v0.75)
+links: [{rel, target}] # typed edges (A.2)
+confidence: float # 0.0-1.0; default 1.0; <1.0 = unconfirmed knowledge
+source: enum # manual | ingest | agent
+payload: string # sibling file name (datasets/media)
+payload_type: enum # sqlite | pdf | docx | image | audio
+payload_hash: string # sha256 of the payload (drift detection)
+entity_kind: enum # only for type: entity
+aliases: [string] # alternate names (used by lexical locate)
+origin: string # where this document came from (v0.57): one URI
+ # (path, URL, commit ref) — free-form, <= 2048
+ # chars, no whitespace or control characters
+lang: string # BCP-47 language tag (v0.75): pt, pt-BR, zh-Hans.
+ # Absent means nobody has said — never a guess.
+```
+
+Rules:
+- `id` is immutable. Renaming = creating a new node + `same-as` + tombstone (out of Phase 0 scope; renaming is forbidden in Phase 0).
+- The parser MUST reject invalid frontmatter with `E_FRONTMATTER` and the field's path.
+- `origin` (v0.57) is provenance toward the world outside the forest —
+ the complement of `derived-from`, which is node↔node. It is mutable
+ (`set_frontmatter`), returned by `look` whenever present, filterable in
+ `scan`. The engine MUST NOT dereference it: it is an address a person
+ or a reconciliation job follows, never a fetch instruction. One token:
+ whitespace or control characters are `E_SCHEMA` (the same rule as J.8's
+ `source_url`, for the same reason — an `origin` with a newline is prose
+ wearing a field).
+
+#### A.3.2 `lang` the document's language (v0.75)
+
+The Curator has always been told to write "in the same language as the
+content", so every ingest decides a language, spends it on one summary and
+throws it away. Nothing in the forest records the answer, which means a
+mixed-language forest cannot be listed, counted or curated by language,
+and every later call has to decide again from nothing.
+
+Normative:
+
+1. **Shape.** One BCP-47 language tag: a 2-3 letter primary subtag,
+ optionally a script and/or region subtag (`pt`, `pt-BR`, `en`,
+ `zh-Hans`). At most 35 characters, no whitespace. Anything else is
+ `E_SCHEMA` naming the field — never coerced and never truncated into
+ something that looks valid.
+2. **Optional, mutable, and ABSENT is a real state.** `lang` is not
+ required, is settable by `plant`, is mutable by `graft` (like `origin`,
+ and `None` clears it), and its absence means *nobody has said* — never
+ "English", and never a value the engine picked to avoid a null.
+3. **Where a value may come from, in precedence order**: the caller's own
+ frontmatter; the converter, when the SOURCE FORMAT declares it (a
+ `.docx` run language, an HTML `lang=`); the forest's ingest
+ configuration default. **A model is not one of the sources.** The
+ Curator infers a language in order to write a summary and that
+ inference is fine for a summary — writing it into the passport would
+ store a guess in the shape of a fact, which is the rule that already
+ keeps ingest from deriving `origin` (G.2.7).
+4. **The Curator is told, when the forest knows.** Where `lang` is
+ present, the curation prompt MUST state it instead of asking the model
+ to infer it; where it is absent, the existing instruction stands
+ unchanged. A forest that has never set a language behaves exactly as it
+ did before v0.75, to the byte.
+5. **A filter, never a boost.** `lang` is a facet like `created`, not
+ scent: it is a catalog column and MUST NOT enter the FTS row. `scan`
+ filters on it; `locate`, `sniff` and `harvest` take an optional `lang`
+ under C.13's discipline — the predicate is a bare comparison on the
+ indexed column, it is applied where CANDIDATES are chosen so `k` is
+ still met inside the filter, and a value the engine cannot read is
+ `E_SCHEMA` rather than a filter silently dropped. Ranking is untouched:
+ a node's language MUST NOT change its score.
+6. **`coverage` counts languages per root and in the totals** (C.17
+ rule 7, metadata only, no body opened), with the nodes carrying no
+ `lang` counted as their own group. This is the number that makes a
+ mixed forest visible; without it, a rule that only misbehaves outside
+ English — as G.4.2's ASCII tag filter did for two years — has nothing
+ it could ever have shown up in.
+
+**F.164 (acceptance).** `lang` is a fact or it is absent. A node planted
+with `lang: pt-BR` returns it from `look`, is selected by a `lang` filter
+on `scan`/`locate`/`sniff`/`harvest`, and is excluded from a filter naming
+another language; `graft` changes it and `null` clears it. A malformed
+value (`portuguese`, `pt BR`, 40 characters) is `E_SCHEMA` naming the
+field — never coerced. A node whose frontmatter never mentioned a
+language reads back with no `lang` after an ingest, a curation and a
+`sync`, proving no stage guessed one. `coverage` counts the languages per
+root and groups the nodes carrying none. A forest that sets no language
+anywhere behaves byte-identically to v0.74 on every one of these calls.
+
+#### A.3.1 Binary payload policy (v0.2)
+
+Binaries **never enter the forest's Git**. Normative:
+
+1. The Vine MUST NOT version anything beyond `.md`: `plant`/`graft` stage only markdown files (hard guard at the commit layer, not convention).
+2. The forest's `.gitignore` MUST exclude binary payloads (`*.db`, `*.sqlite`, `_assets/`), plus `_derived/` and `.vine.lock`.
+3. The payload lives on the filesystem next to the node (or in external storage, in future phases) and the **node** versions only the reference: `payload` (name) + `payload_hash` (sha256). Binary drift is detected by hash, not diff.
+4. Rationale: Git delta-compresses text, not binaries frequently updated payloads would blow up the repository. The versioned knowledge is the distilled layer (markdown); heavy data is referenced, not embedded.
+
+### A.4 The `summary` specification (the most critical component)
+
+The `summary` MUST let an SLM decide "does this node matter to me?" without opening the body. Normative format:
+
+1. **Sentence 1:** what it is (category + subject).
+2. **Sentence 2:** the key content (concrete numbers, names, time scope).
+3. **Sentence 3 (optional):** what is NOT here / where the complement lives.
+
+- Limit: 60 tokens (validated by the Vine at `plant`).
+- FORBIDDEN: "This document describes...", "File containing..." (anti-patterns that spend tokens without scent).
+- Good: `"Sales by region and SKU, Jan-Mar 2026, 14,302 rows with margin and channel. Does not include returns (see sales/returns-q1)."`
+
+### A.5 The `_index.md` specification (branch)
+
+Required structure, in this order:
+
+```markdown
+---
+id: /_index
+type: branch
+coverage: "N bananas, M sub-branches"
+updated:
+---
+
+#
+
+> <1-2 sentences: what lives here + where to go if not here>
+
+## Sub-branches
+- [[]] . .
+
+## Direct bananas
+- [[]]
+
+## Cross trails
+- → [[]]
+```
+
+Rules:
+- Entries replicate the child nodes' `summary` VERBATIM (the Gardener/Vine keeps sync; humans do not hand-edit these lines).
+- Sync rewrites of a `## Sub-branches` entry MUST preserve the trailing coverage suffix (`. N bananas, M sub-branches.`) v0.13.
+- A branch's frontmatter `summary` MAY be synthesized bottom-up by the Gardener from the children's entries (G.4.4) when the branch was born from ingest; hand-authored branch summaries are never rewritten.
+- A branch with > 150 entries or > 3,000 tokens → `needs_split` flag for the Ranger.
+- The master branch (`/_index.md`) MUST additionally contain a `## Landmarks` section (10-20 highest-degree nodes, with summary). The Ranger keeps it fresh mechanically (H.7, v0.13): top non-branch nodes by degree over the typed-edge table, idempotent, audited `.md`-only commit.
+
+---
+
+## Part B Identity, Trail and Addressing
+
+- **Canonical ID:** path relative to the root, without extension. E.g.: `projects/mixerllm/architecture`.
+- **Trail:** list of IDs from the root to the node. E.g.: `["_index", "projects/_index", "projects/mixerllm/_index", "projects/mixerllm/architecture"]`.
+- Wikilinks in the body use `[[id]]` or `[[id|text]]`. The parser resolves `[[...]]` only against canonical IDs (no fuzzy match ambiguity is a Ranger lint error, not runtime guessing).
+
+---
+
+## Part C Primitive Contracts (Vine server, MCP)
+
+Transport: MCP (stdio for dev; HTTP/SSE on Docker). All responses in JSON. Errors follow `{error: {code, message, hint}}` with codes `E_NOT_FOUND`, `E_SCHEMA`, `E_FRONTMATTER`, `E_READONLY`, `E_QUERY_FORBIDDEN`, `E_QUERY_INVALID` (v0.47, C.5.2), `E_TIMEOUT`, `E_LOCKED`, `E_ANCHORED` (v0.56, C.14), `E_MOVED` (v0.58, C.15 — HTTP 404, `data.moved_to` when the new address is in the reader's scope).
+
+### C.0 Forest registry multi-forest serving (v0.4)
+
+The product is filesystem-native: a folder is a forest, its `_index.md` is
+the door. One server therefore serves N forests; the request picks one.
+
+Server modes:
+
+- **Single-forest** (`vine serve --forest DIR`): the v0.3 behavior. The
+ `forest` parameter is optional everywhere; when present it MUST match the
+ served forest's name (else `E_NOT_FOUND`).
+- **Registry** (`vine serve --root DIR`): every subdirectory of `DIR`
+ containing an `_index.md` is a servable forest, identified by its path
+ relative to the root (nested ids like `clients/acme` are allowed). The
+ `forests()` tool lists direct children; `forest` is REQUIRED on every other
+ tool (`E_SCHEMA` with the available ids as hint when missing).
+
+Rules (normative):
+
+1. **Lazy open + auto-index**: a forest is opened on first touch; an empty
+ catalog triggers a full reindex (Vine's standard first-touch behavior).
+ Opened forests stay open for the server's lifetime; each has its own
+ catalog, trails, tracer session and (when writable) writer lock.
+2. **Path safety**: the resolved forest path MUST stay inside the root —
+ `..`, absolute paths or symlink escapes are `E_NOT_FOUND`. A directory
+ without `_index.md` is not a forest (`E_NOT_FOUND`).
+3. **Isolation**: pheromone, traces and indexes never leak across forests
+ (they live in each forest's own `_derived/`).
+4. `forests()` → `{"forests": [{"id", "active"}], "mode": "registry"|"single"}`
+ where `active` means already opened in this server.
+
+```json
+{"tool": "locate", "args": {"query": "...", "forest": "clients/acme"}}
+```
+
+Cross-cutting principle: **every response MUST fit the declared token budget**. The Vine truncates with an explicit `"truncated": true` marker never silently.
+
+### C.1 `locate(query: string, k: int = 5, scope: "all"|"branches"|"notes" = "all", type_filter?: string, include?: [string]) → LocateResult`
+
+The **helicopter**: a location engine that drops the monkey in the region closest to the target it never starts from the trunk. RRF fusion of vector search (over summaries) + BM25 (over title, aliases, tags, summary). In Phase 0, MAY be BM25-only (SQLite FTS5); the interface does not change once vectors land.
+
+The index covers **two levels**: bananas (leaves) and branches (regions every branch has its own summary, hence indexable). A branch result = **landing zone**: the monkey lands in the right region and navigates 1-2 hops with local context, instead of dropping onto a possibly wrong leaf. `scope: "branches"` is useful for broad questions ("what do we know about sales?"); `scope: "notes"` for pointed ones.
+
+```json
+{
+ "results": [
+ {
+ "id": "sales/_index",
+ "kind": "branch",
+ "type": "branch",
+ "title": "Sales",
+ "summary": "...",
+ "trail": ["_index"],
+ "coverage": {"notes": 23, "branches": 4},
+ "score": 0.91,
+ "heat": 0.40
+ },
+ {
+ "id": "projects/mixerllm/architecture",
+ "kind": "note",
+ "type": "document",
+ "title": "MixerLLM Architecture",
+ "summary": "...",
+ "trail": ["_index", "projects/_index", "projects/mixerllm/_index"],
+ "score": 0.82,
+ "heat": 0.31
+ }
+ ],
+ "truncated": false
+}
+```
+
+Budget: <= 800 tokens. Ordering: `score_final = rrf_score x (1 + alpha*heat)`, alpha default 0.3 (configurable; alpha=0 turns pheromone off).
+
+**`scope` is an enum (v0.54, C.12 rule 7):** a value outside
+`all | branches | notes` is `E_SCHEMA` naming the parameter, the value
+and the accepted set — never treated as `"all"`. A parameter that silently
+falls back to a default turns a typo into a result the caller believes was
+filtered.
+
+**The metaphor stays in the prose (v0.56).** The wire's leaf token is
+`notes` for the scope and `"note"` for every emitted `kind` field — the
+consumer team's first call of a session failed on vocabulary the docs
+taught and the wire refused, and then the wire answered `"kind": "banana"`
+back. Charm aimed at a person, in a field only machines read, is friction
+in both directions. `scope: "bananas"` remains accepted as a **deprecated
+alias** for one minor version (same filter, no warning field — the hint
+lives in the docs); the catalog MAY keep any internal spelling it likes,
+because storage is not the wire. Prose, guides and the index-body headings
+(`## Direct bananas`, A.5) keep the metaphor — they address people, and
+A.5 headings are forest format, not API payload.
+
+**`coverage` is counts (v0.54):** a branch result carries
+`coverage: {"notes": n, "branches": m}` — machine fields carry numbers.
+The prose rendering ("23 bananas, 4 sub-branches") remains what the index
+*bodies* say (A.5); it does not cross into API payloads.
+
+#### C.1.1 What the entry list says about itself (v0.52)
+
+`locate` searches curated metadata and nothing else the split with
+`sniff` is normative (C.6b) and stays exactly as it is. But that split is a
+decision the caller cannot see, and its consequence is measurable: a term
+present eight times in a body and absent from the summary returns
+`{"results": [], "truncated": false}`, which is byte-identical to the answer
+for a subject the forest has never heard of. An agent following the
+documented order (`locate` → `look` → `pick`) reads that as "the forest does
+not know" and answers from its own parameters, with the forest one call
+away. The primitive was right and the caller was wrong for a reason the
+primitive could have removed.
+
+Three additions. All three are facts the catalog row already holds, so none
+of them opens a file or costs a search:
+
+1. **`body_tokens` on every result.** The same number `look` reports under
+ `stats`, delivered at the moment the agent is choosing what to open
+ which is the only moment it changes a decision. Chosen blind, the agent
+ discovers the size of what it opened after paying for it, and under a
+ tight budget takes the conservative wrong option: the small irrelevant
+ node instead of the large correct one.
+2. **`include: ["outline"]`.** Optional; adds each result's section headers
+ from the same row. `look` remains the digest and nothing moves out of it;
+ what disappears is the hop whose only purpose was learning which section
+ to `pick`. The 800-token budget is unchanged and truncation stays
+ explicit, so asking for outlines costs results at the tail never
+ silence, and never a bigger response than the budget allows.
+3. **`searched` and a `hint`, when and only when the list is empty.** An
+ empty result carries `searched` (how many nodes' scent the search ran
+ over) and a `hint` naming the search this primitive does **not** perform.
+ Computed only on the empty path, because a caller holding results has
+ already been told what it needed: the count answers "is there anything
+ here at all", and a non-empty list answers it better.
+
+`searched` is bounded by what the principal may see. Under a restricted
+policy (J.3) it counts the nodes in scope the number they could reach by
+walking never the forest's size, which would make an entry search a
+size oracle for the region they were not granted.
+
+A `hint` MUST NOT name a node, a term or anything from the forest's
+content: it says which primitive searches bodies, and stops there.
+
+**F.56 (acceptance).** Every `locate` result carries `body_tokens` equal to
+what `look` reports for the same node under `stats`. `include: ["outline"]`
+adds the section list without the response exceeding 800 tokens, and drops
+results at the tail with `truncated: true` when it would. A query matching
+nothing returns `searched` and a `hint` naming `sniff`; a query matching
+something returns neither. Under a scoped policy, `searched` never exceeds
+the number of nodes in scope. A term present in a body and absent from every
+summary still returns no results the split with `sniff` is unchanged, and
+the hint is what tells the caller so. Covered by tests.
+
+#### C.1.2 The query is derived before it is searched (v0.70)
+
+`locate` MUST derive search terms from `query` before building the FTS
+match, by the same rule `harvest` uses (C.6c): drop the stopword set, keep
+code-shaped tokens whatever their length and order them first, apply the
+term cap. The raw sentence MUST NOT be the search.
+
+**Rule 1 — the derivation is `harvest`'s, not a second one.** Two
+derivations from one intent agree only where somebody compared them, and the
+sweep already calls `locate`; a `locate` that read the question differently
+from the `sniff` beside it would make one call contradict its own other
+half.
+
+**Rule 2 — an empty derivation falls back to the whole query.** A question
+made entirely of grammar, and equally a single short lowercase token
+("api", "sql", "ui"), derives to nothing. `locate` MUST then search the raw
+tokens exactly as it did before this version. A search that answers nothing
+because the *filter* consumed the question is indistinguishable, to the
+caller, from a forest that does not hold the subject — C.1.1's failure,
+manufactured. This rule is what keeps the change from creating the very
+silence C.1.1 was written against.
+
+**Rule 3 — nothing else moves.** Scope, window, type filter, budget,
+`searched`, the `sniff` hint, the ranking, the hybrid fusion and every
+response field are untouched. This section changes which terms are searched
+and nothing about what happens to what is found.
+
+**A stated cost.** The floor that removes grammar also removes lowercase
+tokens shorter than four characters that are not code-shaped, so a query
+like "erro sql no worker" searches `erro` and `worker` and drops the term
+that discriminates. Measured on the corpus above the trade is positive
+overall and this case is real: it is named here rather than left to be
+rediscovered. Lowering the floor to three changed nothing on the labelled
+set (the set contains no such question, which is the honest reason to leave
+the floor alone rather than tune it against no evidence). A future set with
+a short-technical-token class is what would settle it.
+
+**F.147 (acceptance).** On a labelled set with headroom, `locate` scores no
+worse than the raw-sentence behaviour it replaces, per class as well as in
+aggregate. The comparison MUST be run on a set whose baseline is under 1.000
+— a saturated set cannot refuse this change and MUST NOT be cited as having
+approved it.
+
+**F.148 (acceptance).** `locate("api")`, `locate("sql")` and a query made
+only of stopwords return exactly what they returned before v0.70, byte for
+byte. A query whose derivation is non-empty searches the derived terms.
+Covered by tests.
+
+### C.2 `look(id: string, fields?: [string]) → Digest`
+
+The central operation. Hard budget: **<= 500 tokens**.
+
+`fields` (optional): list of desired fields (e.g. `["summary", "edges_out"]`). When present, the response contains ONLY those fields (+ `id`, always). Typical use: a monkey in scan mode asking only for `summary` of several nodes cost drops from ~400 to ~70 tokens per look.
+
+Response for a **banana** (`note`/`document`/`concept`/`entity`/`event`):
+
+```json
+{
+ "id": "projects/mixerllm/architecture",
+ "type": "document",
+ "title": "MixerLLM Architecture",
+ "summary": "...",
+ "tags": ["inference", "slm"],
+ "confidence": 1.0,
+ "created": "2026-05-02",
+ "updated": "2026-06-10",
+ "source": "ingest",
+ "outline": ["Overview", "Mixer-lang", "Block-loop", "Benchmarks"],
+ "edges_out": [
+ {"rel": "part-of", "target": "projects/mixerllm/_index", "target_summary": "..."},
+ {"rel": "compared-with", "target": "concepts/speculative-decoding", "target_summary": "..."}
+ ],
+ "edges_in": [
+ {"rel": "mentions", "source": "people/jimmy-wesley"}
+ ],
+ "stats": {"body_tokens": 2840, "degree": 7, "heat": 0.45}
+}
+```
+
+Response for a **branch**: replaces `outline` with `children` (sub-branches and direct bananas, each with `id` + `summary`), `cross_trails`, and `coverage` as `{"notes": n, "branches": m}` counts (v0.54 — same rule as C.1).
+
+Response for a **dataset**: includes `query_manual` (tables, key columns, 2-3 example_queries), `sample_rows` (<= 3 rows) and `notes` (C.2.1).
+
+Rules:
+- `edges_out`/`edges_in` capped at 12 each, ordered by heat desc; surplus indicated in `stats.degree`.
+- `target_summary` MUST come truncated to 25 tokens (it's a neighbor's scent, not a full digest).
+- `body_tokens` lets the agent estimate a `pick`'s cost before making it.
+- **The passport says who and when (v0.56):** `created` and `source`
+ are returned always, `aliases` whenever the node carries any, and
+ `origin` whenever the node carries one (v0.57). All three
+ were in every passport and in the indexed catalog since their birth;
+ `look` just never said them, so the consumer team probed for provenance
+ and concluded the product had none — and could not audit why an alias
+ search hit. A forest shared between people and agents owes its reader
+ "who asserted this, and when" for the same reason answers cite node
+ ids. (Time of day and the writing principal are deliberately NOT here
+ yet: frontmatter dates are day-precision, and the engine does not know
+ the principal — that design lands with per-node history, next
+ version.)
+- **The budget clips in declared order, and it says so (v0.57).** Found
+ in real use: a node with a 28-item outline answered `edges_out: []`,
+ `edges_in: []` beside `stats.degree: 2` — the budget shrink emptied the
+ edge lists (small, structural, irreplaceable in this response) while
+ the outline (large, re-derivable via `pick`'s first page) stayed. The
+ caller concluded the node was isolated and never called `move`: the
+ graph, lost in silence, in the primitive the skill calls "where I read
+ the passport". Every other cut in the product announces itself
+ (`truncated`, `dropped`, `searched`); this one now does too. When the
+ digest exceeds `BUDGET_LOOK`, fields are clipped in this order —
+ `outline` first (big, and re-derivable through `pick`'s first page;
+ whole items from the tail), then `children`, then
+ `edges_in`/`edges_out`, and a dataset's `sample_rows` only as the last
+ resort (its digest exists to feed `query`) — and **every field the
+ budget touched is named in `truncated_fields`** beside the existing
+ `truncated: true`. A caller who sees `edges_out: []` WITHOUT
+ `"edges_out" in truncated_fields` may finally trust the emptiness;
+ `stats.degree` remains the arithmetic truth either way. `fields=`
+ remains the escape: a digest asked to carry less rarely clips at all.
+
+#### C.2.1 `## Notes` what a person teaches the agent (v0.46)
+
+A dataset is the one node type whose contents no text primitive can see:
+`locate` reads curated metadata, `sniff` reads bodies, and the facts live
+in a `.db`. G.2.3's map fixed *structure* the tables, the columns, three
+rows. What it cannot supply is **meaning**: that `total_invoice` is in USD
+while `exchange_value` is in BRL, that `status` uses one-letter codes,
+that direct imports are the rows where `lessee` is null, or which join is
+the one that answers the question people actually ask. Nor can it supply
+*shape*: that `total_invoice` is TEXT holding `USD 54.607,56`, so
+`SUM(total_invoice)` is `0.0` and looks like an answer.
+
+That knowledge exists only in somebody's head, and an agent writing SQL
+without it writes SQL that runs and answers wrongly the worst failure
+this system can produce, because it is indistinguishable from success.
+
+A dataset passport MAY therefore carry a **`## Notes`** section.
+
+Normative:
+
+1. **It is the operator's, and nothing else writes it.** The Gardener
+ rewrites the two generated sections and only those (G.2.3 rule 4), so
+ `## Notes` survives every `sync`, every re-adoption and every payload
+ replacement. Curation MUST NOT write it either: a model's guess about
+ what a column means is exactly what this section exists to correct.
+2. **`look` MUST return it**, as `notes`, for `type: dataset`. This is the
+ whole point and not a convenience: the path an agent takes to a dataset
+ is `look` then `query` a note it can only reach through `pick` is a
+ note it will not read, and a teaching surface nobody reads is worse
+ than none, because somebody maintains it. **And for `type: media`
+ (v0.78)**: the path to a picture is `look` then `view`, the same shape
+ with the same gap, and the uploader's note about what the picture shows
+ (J.8.4) is what makes the view worth spending.
+3. **Bounded, and truncated out loud.** `notes` carries its own budget
+ (200 tokens) inside `look`'s 500, clipped with the C.6 rule a
+ `truncated` marker, never a silent cut. It is clipped **before** the
+ digest's overall budget check, and it outranks `sample_rows` when the
+ digest still has to shed: three generated rows are cheaper to lose
+ than a sentence a person wrote on purpose.
+4. **Written through `graft` like every other body edit** (C.8:
+ `replace_section`, or `append_section` the first time). There is no
+ second write path, no side file and no store it is part of the node,
+ so it is versioned, attributed and committed exactly as its summary is.
+5. **Datasets and media only (media as of v0.78).** On every other node
+ type the body itself is reachable through `pick` and searchable through
+ `sniff`, so a section promoted into `look` would spend the tightest
+ budget in the system on something already available. A `## Notes`
+ heading on a note is an ordinary section and stays one.
+6. **The notes travel with the dataset, on every path (v0.47).** Stated
+ first for `harvest` (v0.46) and now as the general rule it always was:
+ **any material a host assembles for a model MUST carry the `notes` of
+ every dataset — and (v0.78) every media node — present in that
+ material.** Not only `look`, which is one
+ primitive the model may or may not call.
+
+ The failure this fixes was observed. The sweep is `locate` + `sniff` +
+ matched sections and never looks at anything, so v0.46 attached the
+ notes to its dataset items. The walk (J.10.5) enters through `locate`,
+ whose result is curated metadata and carries no body and a model that
+ goes from that entry straight to `query`, which is the natural move on a
+ dataset, never calls `look` and never sees a word the operator wrote. In
+ a measured run the mode with *more* freedom was the mode with *less*
+ information, which is the opposite of the intent, and the operator's
+ reasonable reading was that the agent had ignored them.
+
+ The attachment is **unconditional**: whether the section happens to
+ share vocabulary with today's question is not a reason to withhold a
+ person's instructions about how to read the data. A teaching that
+ depends on the agent choosing to be taught is not a teaching surface,
+ and a third answer path added later inherits this rule rather than
+ rediscovering it.
+
+*Informative:* this is the human half of the same idea the map is the
+machine half of. The map says what is there; the notes say what it means.
+
+#### C.2.2 A missing payload is a fact about the payload (v0.61)
+
+`look` builds a dataset's `query_manual` and `sample_rows` by opening the
+`.db`, and an absent file raised `E_NOT_FOUND` for the **whole digest**.
+Everything else in that digest title, summary, tags, `created`,
+`origin`, the edges, `## Notes` comes from the passport and the catalog
+and never depended on the payload. So a node that `scan` lists and
+`locate` ranks was unreadable through the one primitive whose job is to
+read passports, and the reader was told the *node* was not found. That is
+the most confusing pair of answers the surface can give about one id, and
+it was observed in the field on both datasets of a live forest.
+
+1. **The digest degrades; it does not vanish.** When a `type: dataset`
+ node's local payload is absent, `look` returns the passport as always,
+ OMITS `query_manual` and `sample_rows`, and carries
+ **`payload_missing: true`**. `notes` is unaffected: it is read from the
+ body, and what a person wrote about the data outlives the file.
+2. **It is stated, never inferred.** A caller MUST be able to tell "this
+ dataset has no sample rows to show" from "this dataset's file is gone".
+ The flag is the difference, and its absence means the payload was
+ there.
+3. **Every other primitive keeps refusing.** `query` and `tend` name the
+ missing payload with `E_NOT_FOUND` exactly as before: they cannot do
+ their work without the file, and inventing an empty result set would be
+ the silent-wrong-answer failure this document exists to prevent.
+ Degrading is for the digest, whose content is the passport.
+4. **A remote payload is not this case.** G.9's fetch-on-first-use has its
+ own failures and its own errors; `payload_missing` is about a LOCAL
+ file the passport names and the filesystem does not have.
+5. **The condition is countable in one call** `coverage` reports it per
+ root (C.17 rule 11), so an operator learns the size of the damage
+ without walking the forest one `E_NOT_FOUND` at a time.
+
+6. **The other node whose worth is a file (v0.77).** A `type: media`
+ node — and any non-dataset node whose passport names a `payload` —
+ carries the same flag under the same condition: **`payload_missing:
+ true`** when the passport names no payload or names a local file the
+ volume does not hold. When the bytes are there, the digest carries
+ `payload_type` (the passport's word: `image`, `audio`, …) and
+ `payload_bytes` (the file's size). A remote payload (rule 4) carries
+ `payload_type` alone. A stat, never an open. This is deliberately the
+ one place the question "will `view` serve this?" can be answered
+ BEFORE the call: `view` answers a payload-less node with the
+ missing-node envelope on purpose (C.6d rule 1), so the digest is where
+ the fact lives.
+
+### C.3 `move(id: string, rel?: string, direction: "out"|"in"|"both" = "out") → [Neighbor]`
+
+```json
+{
+ "neighbors": [
+ {"id": "...", "rel": "compared-with", "direction": "out", "type": "concept", "summary": "...", "heat": 0.1}
+ ],
+ "truncated": false
+}
+```
+
+Without `rel`: all neighbors. Budget: <= 600 tokens. `move(id, "children")` is sugar for a branch's physical children.
+
+**`direction` is an enum (v0.54, C.12 rule 7):** a value outside
+`out | in | both` is `E_SCHEMA` naming the parameter, the value and the
+accepted set. It MUST NOT answer an empty neighbour list: on a node with
+`degree > 0`, `{"neighbors": []}` for a mistyped direction is
+byte-identical to an isolated node, and the observed mistake —
+`direction="all"`, borrowed from `locate`'s `scope` vocabulary — is one
+every integrator makes once. The refusal's hint names `both` as this
+primitive's word for every direction. The MCP tool schema declares the
+same enum, so the mistake dies at validation on that surface too.
+
+### C.4 `pick(id: string, section?: string | [string], after?: string) → Content`
+
+```json
+{
+ "id": "...",
+ "title": "...",
+ "section": "Mixer-lang",
+ "body": "",
+ "body_tokens": 612,
+ "truncated": false
+}
+```
+
+- `section` matches against the `outline`'s headers (case-insensitive, exact match first, then prefix).
+- A single `section` string returns the shape above, **to the byte** what it returned before v0.56.
+
+#### C.4.1 A document is read back whole, in pages (v0.56)
+
+`pick` on a body over 4,000 tokens used to return an empty body, the
+outline and a hint. The ceiling was right — it exists so one call cannot
+flood a walk — but the dead end was not: the consumer team measured their
+own reports at 4,855 tokens and found that rereading a document they had
+just planted cost 28 `section=` calls. While that is true, the local
+`.md` copy is cheaper than the forest, and it survives. The body of one
+large node is the same problem as one large forest, and it gets the same
+answer the C.6.2 enumeration cursor got: pages, an `after` cursor, and
+totals.
+
+Normative:
+
+1. **The page unit is the paragraph block.** The body is split at blank-
+ line boundaries into contiguous segments; every page is a
+ concatenation of whole segments and is a **byte-exact substring** of
+ the body. Pages fetched in order and concatenated reproduce the body
+ byte-identically — that is F.80, and it is the property that lets an
+ agent trust the reassembly.
+2. **The cursor is `scan`'s idiom.** An over-budget read (no `section`)
+ returns the FIRST page within the 4,000-token budget plus
+ `truncated: true`, `next` (opaque cursor naming the last block
+ delivered), `returned` and `total` (block counts) and a hint teaching
+ `after=`. Passing `after` resumes; an unknown or malformed cursor is
+ `E_SCHEMA` naming it. A body within budget and no `after` returns the
+ pre-v0.56 shape unchanged.
+3. **Progress is guaranteed, and so is the flag.** A single block wider
+ than the whole budget arrives alone, hard-cut, with `cut: true` beside
+ `truncated` — and `next` still advances past it. Without the advance
+ the cursor parks forever on the block; without the flag the cut is
+ silent, which C.6's rule forbids.
+4. **`section` accepts a list** (≤ 10 names, one call, one 4,000-token
+ budget — C.11's rule, never per-item times count). The result carries
+ `sections: [{section, header, body, body_tokens}]` in request order —
+ `section` echoes the name as asked, `header` (v0.57) the header line
+ that actually matched, because matching is by prefix (C.4) and a
+ result identified only by order is a result the caller re-derives —
+ plus
+ `missing` (names no header matched — with the outline in the hint) and
+ `dropped` (names whose content did not fit; whole sections drop from
+ the tail and are named). Every requested name lands in exactly one of
+ the three. A one-element list returns a list; a bare string returns
+ the old single shape, to the byte.
+5. **`after` and `section` never combine** — two addressing schemes in
+ one call is `E_SCHEMA`. `after` pages the whole body; `section`
+ addresses pieces of it by name.
+
+### C.5 `query(id: string, sql: string) → Rows`
+
+- Preconditions: node `type: dataset`, `payload_type: sqlite`.
+- Validation: a single statement only; MUST start with `SELECT` or `WITH`; forbidden: `ATTACH`, `PRAGMA` in both its spellings the keyword and the `pragma_*` table-valued functions (v0.50) and `INSERT/UPDATE/DELETE/DROP/ALTER` → `E_QUERY_FORBIDDEN`. Connection opened read-only (`mode=ro`).
+- Forced `LIMIT`: if absent, injects `LIMIT 200`. 2s timeout → `E_TIMEOUT`.
+- **A name that is not there MUST say what is (v0.46).** `no such table`
+ and `no such column` are how generated SQL usually fails an agent that
+ has not `look`ed yet guesses the table from the node's id, which is a
+ reasonable guess and normally wrong. The error's `hint` therefore
+ carries the dataset's actual table names (or, for a column, the columns
+ of its tables). Without it the caller spends a whole extra hop, and a
+ model call, discovering something the failing call already had open. The
+ lookup is best effort: it runs while an error is being raised and MUST
+ NOT be able to replace it.
+
+```json
+{
+ "columns": ["region", "total"],
+ "rows": [["Southeast", 1250000.0], ["South", 740000.0]],
+ "row_count": 5,
+ "limited": false,
+ "elapsed_ms": 3
+}
+```
+
+Columnar format (`columns` + `rows` as arrays) not objects repeating the keys; saves ~40% of the tokens.
+
+##### C.5.1 The result is budgeted, and `columns` is not (normative, v0.47)
+
+`query` was the only read primitive with no token bound. It had a row cap
+— the injected `LIMIT 200` and a row cap is not a token cap: width is
+unbounded. Measured on a 141-column ERP export, `SELECT *` returned
+**86,929 tokens for 15 rows** and **429,397 for all 129**. Fed to a model
+that re-sends its history each turn, one such call was the whole cost of a
+walk.
+
+1. **`BUDGET_QUERY` = 2000 tokens**, applied to the response the same way
+ every other budget is applied (A.4): whole items are dropped from the
+ tail and `truncated: true` is set. Never a sliced row, never a silent
+ cut. It sits below `pick`'s 4000 deliberately a body is read once,
+ while a result enters a loop that carries it forward.
+2. **`columns` is never dropped.** It is the smallest useful thing in the
+ response and the only part that tells the caller how to ask again. A
+ result whose every row was dropped therefore still answers: *these are
+ the columns your statement produces, and none of the rows fit*. That is
+ a map back, and it costs a few hundred tokens against the tens of
+ thousands refused.
+3. **`row_count` is what came back**, and `truncated` is what says more
+ existed. The two flags are not interchangeable and both may be true:
+ `limited` means the injected `LIMIT 200` was reached and the *query*
+ matched more; `truncated` means the token budget dropped rows the query
+ *returned*. A caller that cannot tell them apart cannot tell "narrow
+ your filter" from "narrow your projection".
+4. **The hint names the way out.** When rows were dropped, the hint MUST
+ state the column count and say to name the columns needed. When *every*
+ row was dropped it is the entire useful payload, so it MUST also give
+ the per-row cost. Aggregates are unaffected by construction `SELECT
+ SUM(x)` is one short row which is the point: the bound bites exactly
+ the statements that should have been narrower, and the caller finds
+ that out in one hop instead of in a context window.
+5. **Nothing here is a refusal.** The statement ran; the payload did not
+ fit. `truncated` is the same contract as everywhere else in this spec,
+ and a caller MUST NOT read it as absence the C.6/C.6b rule that
+ `truncated: true` never means "does not exist" applies unchanged.
+
+##### C.5.2 Invalid is not forbidden (normative, v0.47)
+
+Every SQLite failure surfaced as `E_QUERY_FORBIDDEN`, the code the guard
+raises for attempting a write. So `no such table: report_2026` a
+caller's typo, on a dataset they are allowed to read was reported with
+the vocabulary of a policy denial, in the response, in the console and in
+the audit trail. An operator reading a walk saw two locked doors where the
+engine had in fact answered both questions.
+
+`E_QUERY_INVALID` is therefore its own code, for statements that pass the
+guard and fail in SQLite: unknown table or column, syntax error, wrong
+argument count. `E_QUERY_FORBIDDEN` keeps exactly what the guard decides —
+not a dataset, not a single statement, wrong leading keyword, forbidden
+keyword, `UPDATE`/`DELETE` without `WHERE` (C.10), a remote payload under
+`tend` (G.9). The split applies to `query` and `tend` alike; one of the two
+kept honest would be worse than neither, because then the code would mean
+different things per primitive.
+
+Over HTTP (J.4) `E_QUERY_INVALID` is **400** and `E_QUERY_FORBIDDEN` stays
+**403**. The distinction is the ordinary one and it is worth having: 403
+says the principal may not, 400 says the request was wrong. A client
+retrying on 403 is confused; a client retrying on 400 with a corrected
+statement is doing the right thing, and until now it could not tell which
+it was holding.
+
+The C.5 name hint is unchanged and is now carried by `E_QUERY_INVALID`,
+where it always belonged.
+
+**The one case where the two halves meet (v0.50).** A table allow-list is
+enforced by SQLite (C.5.3), so its refusal is *noticed* by SQLite and
+*decided* by the grant. It keeps `E_QUERY_FORBIDDEN` and **403**: the
+principal may not, which is precisely what 403 says, and a client that
+corrects the statement will be refused again. The message MUST state that
+the statement reaches outside the allow-list and MUST NOT name the table
+it stopped at otherwise the refusal answers "does this table exist?"
+for everything the scope withholds, one guess at a time.
+
+##### C.5.3 The table allow-list is decided by the database (normative, v0.50)
+
+J.3 lets a grant narrow a dataset to some of its tables, and requires that
+narrowing to be checked "against the parsed statement". This section says
+what does the parsing: **SQLite itself**, through its authorizer, consulted
+while the statement is prepared.
+
+The rule and its reason are the same sentence. Deciding what a statement
+touches by reading its text means keeping a second parser, and two parsers
+agree only where somebody thought to compare them; for SQL that comparison
+has no natural end, and being wrong about it is silent. The authorizer is
+asked once per table and column the statement **actually** touches, so a
+subquery, a CTE, a view and a table-valued function are the same question,
+answered by the component that resolves them.
+
+Normative consequences:
+
+1. When a grant carries a table allow-list for a dataset, `query` MUST
+ enforce it through the authorizer. An implementation MAY also pre-read
+ the statement to produce a friendlier message naming the offending
+ table it MUST NOT rely on that reading as the control.
+2. `SQLITE_READ` on a table outside the list is a refusal, as are the
+ write actions. Both matter, and on the write path (C.10) the read
+ action is the one that matters most: a statement may write only where
+ it is permitted and still take its value from a table it may not read.
+ A scope that governs the destination and ignores the source is not a
+ scope.
+3. Under an allow-list, the schema is not readable either. SQLite's own
+ internal tables and the `pragma_*` functions describe every table there
+ is, which is the map to exactly what the grant withholds.
+4. The C.5 name hint MUST be filtered by the allow-list, and MUST be
+ omitted entirely when nothing permitted remains. A misspelling is not a
+ reason to answer with an inventory of what is being withheld.
+5. The allow-list travels as a **host-supplied** argument. The engine
+ exposes it keyword-only and the scoped surface supplies it from the
+ grant; it MUST NOT be reachable from the wire, because a narrowing a
+ caller can set is a narrowing a caller can omit. (Same construction as
+ G.2.5's adoption flag.)
+
+Nothing here changes what a permitted statement returns, or its budget
+(C.5.1), or its timeout. An ungoverned principal one whose grant carries
+no table list is unaffected: no authorizer is installed for them.
+
+### C.6 `scan(parent_id: string, filter?: Filter, fields?: [string], recursive: bool = false, limit: int = 50, after?: string) → [PartialNode]`
+
+**Metadata** query over a branch's children, without opening any file. Served by the **Catalog** (see C.6.1).
+
+`Filter` supports equality and comparison over frontmatter fields:
+
+```json
+{
+ "parent_id": "projects/_index",
+ "filter": {"type": "dataset", "updated_after": "2026-03-01", "tags_any": ["sales"]},
+ "fields": ["id", "summary", "payload_type"],
+ "recursive": true
+}
+```
+
+Response: list of partial nodes (only the requested `fields`), ordered by `heat` desc. Budget: <= 800 tokens, with explicit `truncated`. Default `fields`: `id`, `type`, `summary`, `body_tokens` (v0.54 — the cost of opening a node is known wherever a node is offered, C.1.1's rule).
+
+Canonical use case: "I only want the sales datasets updated this quarter" → 1 call, ~3ms, ~200 tokens instead of descending the hierarchy opening indexes.
+
+**`fields` is a stated set (v0.54, C.12 rule 7).** The accepted names are
+the catalog's caller-facing columns — `id`, `kind`, `type`, `title`,
+`summary`, `tags`, `aliases`, `created`, `updated`, `confidence`, `source`,
+`entity_kind`, `payload_type`, `parent`, `trail`, `coverage`,
+`body_tokens`, `outline`, `heat` — and an unknown name is `E_SCHEMA` naming
+it and the set. Before this, an unknown field was silently omitted from
+every item, which reads exactly like "that field is empty on every node".
+`payload`, `payload_hash` and the catalog's internal columns are not in the
+set: a payload location is J.14's business, not a listing's.
+
+**Provenance is a filter (v0.56).** `filter` matches any caller-facing
+catalog column by equality (plus the comparative keys above), and that
+includes `source` — so "what did the agents write here?" is
+`scan(_index, filter={"source": "agent"}, recursive=true)`, one
+enumeration. This worked mechanically before v0.56; it is now stated,
+taught by the skill, and covered by F.82, because a capability nobody can
+discover is a capability the product does not have (the C.1.1 lesson,
+applied to itself).
+
+**Where a node came from is a filter too (v0.59).** `origin` (A.3) is a
+URI, and two different questions are asked of it: *which node is this
+exact file?* and *what came out of that directory?* The first is the
+equality match every catalog column already has. The second is
+`origin_prefix`, matching when the node's `origin` starts with the given
+string — the reconciliation filter the consumer team asked for, and the
+one that makes a partial ingest visible
+(`scan(_index, filter={"origin_prefix": "file:///srv/dump/tasks/"},
+recursive=true)`). Both keys stay: collapsing them into one prefix match
+would delete the ability to ask about a single file, which is the finer
+of the two questions. A node with no `origin` matches neither. The prefix
+a caller passes is the one `coverage` publishes per root (C.17 rule 4) —
+there is no arithmetic between finding a source and listing it.
+
+**`kind` speaks the wire's spelling (v0.56, C.1's rule).** Every emitted
+`kind` is `note` or `branch`, and a `kind` filter takes those same
+values — the filter MUST match what the field emits, whatever the catalog
+stores internally. A filter that only matches the storage spelling would
+make `filter={"kind": "note"}` silently empty, which is C.12's forbidden
+lie.
+
+**A system node says so (v0.54).** An item whose id lives under `_meta/`
+carries `system: true`. `_meta/schema.md` is a child of no branch, so it
+appears in a recursive `scan` and in no `look` — two tools answering "what
+is here" with different counts and no explanation. The marker is the
+explanation; nothing is hidden, because a dialect an agent may read is not
+a secret, only not content.
+
+#### C.6.2 The forest is enumerable (v0.54)
+
+`truncated: true` used to be a dead end: `limit` could only shrink the
+page, the budget cut the rest, and nothing said what was dropped, how much
+existed, or how to get it. On a real corpus (1,877 nodes) that made `scan`
+constitutionally unable to answer "did the ingest complete?", "what is in
+this forest?" — the questions an inventory exists for. `pick` batches
+already solved this shape the right way (C.11 names what it drops); `scan`
+now says what it left out and how to continue:
+
+1. **`total` and `returned`, on every response.** `total` is what the
+ requested scope holds — parent + `recursive` + `filter` + window,
+ counted before any cut; `returned` is what this response carries after
+ all of them. The two numbers are the size of what was NOT received,
+ which is the fact a caller cannot otherwise learn.
+2. **`after` — an id cursor.** When present, results come in id order,
+ strictly after the cursor (`after: ""` starts at the beginning), and a
+ page that left something behind carries `next`: the last id returned,
+ which is exactly what the next call's `after` takes. Enumeration is
+ complete and duplicate-free over a stable forest; nodes planted behind
+ the cursor while a walk is in flight are the next walk's to find.
+3. **One order per mode.** `after` selects id order; without it the heat
+ order of every previous version is byte-identical. `after` beside
+ `toward`/`gauntlet` is `E_SCHEMA`: an enumeration has one order, and a
+ ranked page cannot be resumed.
+4. **The budget is part of the contract, stated where the caller reads:**
+ <= 800 tokens, <= 50 items per page, whichever cuts first — and neither
+ cut is silent anymore, because `total`/`returned`/`next` survive both.
+5. **Under a policy, the counts are the principal's own (J.3).** The
+ scope's predicate is applied where candidates are chosen — same
+ construction as C.13.3 and `searched` — so `total` counts nodes in
+ scope, never the forest (a finer size oracle than `locate` could ever
+ be), and the cursor walks the principal's nodes without skipping what
+ a post-hoc trim would have dropped.
+
+#### C.6.1 The Catalog (`_derived/catalog.db`)
+
+SQLite in the derived layer with one row per forest node: every frontmatter field + trail + degree + heat. Rebuildable from scratch by a full scan (`vine reindex`); updated incrementally on every `plant`/`graft`. It's what serves `scan()` and `locate`'s lexical side (FTS5 over title/aliases/tags/summary in the same base). **Not the source of truth** if it diverges from the files, the files win and the catalog rebuilds.
+
+**The body hash (normative, v0.40).** Each row also carries
+`body_hash`: the digest of the node's body **as `sniff` would scan it**
+(the raw markdown body, frontmatter excluded), written on every upsert
+and rebuilt by `reindex`. It exists so a memoized scan (C.6b.1) can
+decide validity by content rather than by clock, and it is the same
+disposable-layer bargain as the rest of this file: a divergence is
+repaired by `reindex`, never by trusting the catalog over the files. A
+row whose `body_hash` is absent (a catalog written before this version)
+MUST behave as a cache miss, never as a match an empty hash that
+compared equal would serve stale snippets forever.
+
+**Derived storage is tuned for reads, not for durability (normative,
+v0.29).** Every read primitive deposits pheromone (Part D, E.2), so every
+read is also a commit in SQLite's default rollback mode, a journal
+created, fsynced and deleted per call. Databases under `_derived/`
+(`catalog.db`, `trails.db`) MUST therefore open in WAL with
+`synchronous=NORMAL`.
+
+The durability this trades away is durability the derived layer does not
+have to begin with: the `.md` files are the source of truth, `_derived/` is
+disposable by definition, and the repair for any inconsistency is already
+`reindex`. A crash costs the tail the last few heat deposits, which
+evaporate on a schedule anyway (H.1) and never the corpus. WAL remains
+crash-safe; what is lost is recency, and recency is exactly what heat is
+allowed to lose.
+
+Best effort, and that is normative too: a filesystem that cannot support
+WAL (a network mount with no shared memory) MUST keep the mode it had and
+keep working. A forest that refused to open because it could not be made
+faster would have traded the whole feature for part of one.
+
+**Warming (normative, v0.29).** A Vine MUST expose `warm()`: fault in the
+pages a search will want, through the storage layer only. It MUST NOT go
+through a primitive that would append a trace event and deposit heat, and
+a server that warmed itself through `locate` would be forging the pheromone
+the Ranger later reads as evidence of where callers went. It MUST NOT read
+bodies; that is the whole corpus off disk, which is a different trade and
+not this one. Opening a forest warms it.
+
+### C.6b `sniff(terms: string | [string], scope?: string, k: int = 5, type_filter?: string) → SniffResult`
+
+The **sniffer**: **literal** search over nodes' markdown bodies, returning node + section + occurrence snippet. It complements `locate`: the helicopter flies over curated metadata (summary/tags/title); the sniffer goes down to ground level and follows the trail of an exact term error code, proper name, invoice number, identifier that nobody bothered (or was obligated) to lift into the summary. The contract split is normative: **`locate` MUST NOT index bodies; `sniff` MUST NOT query curated metadata** (except to display the result).
+
+Parameters:
+
+- `terms`: 1 to 8 **literal** terms (a single string is promoted to a 1-item list). Substring matching, case- and diacritic-insensitive (NFD, combining marks stripped). A term with a space = exact phrase. A normalized term with < 2 characters → `E_SCHEMA`. **Regex is NOT accepted** (Phase 0): SLMs write fragile regex, and arbitrary regex opens unpredictable cost; literal terms give 95% of the value with a simple contract.
+- `scope` (optional): id of **any node**. A branch (`sales/_index` or `sales`) restricts the search to the matching physical subtree; a banana restricts it to that single node's body (grep-within-node the natural chaining after a `locate`/`look` that already found the target). Without `scope`, the whole forest. Nonexistent node → `E_NOT_FOUND` — `scope not found: ` (v0.77), naming the string the caller sent and never the `_index` the engine derived from it, with a hint saying what a scope is. The bare `_meta` is `E_SCHEMA` naming the dialect: it is not a branch and holds no content to grep (the dialect is read with `pick("_meta/schema")`).
+- `k`: max nodes in the result (default 5, cap 20).
+- `type_filter`: as in `locate`.
+
+Search semantics:
+
+- Scans **only the body** of `.md` files (frontmatter excluded; `_derived`, `_assets` and binary payloads ignored).
+- A node matches when **at least one** term occurs in the body; nodes matching **more distinct terms** rank first (AND-preferred, OR-tolerant).
+- `match` = the occurrence's line, attributed to the section (H2/H3 header) containing it. Max of **3 matches per node** in the response (`match_count` reports the total; surplus flagged by `truncated_matches: true`).
+- `snippet` = a window of the line centered on the first occurrence, truncated to ~25 tokens.
+
+Ordering (v0.52): `score = strength x density x (1 + alpha*heat)`, where
+`strength = matched_terms/requested_terms` (unchanged) and
+`density = 1 + beta*log2(match_count)`, beta default 0.15 so ten
+occurrences of a term outweigh a maximal pheromone bonus, and a single
+occurrence changes nothing. `match_count` MUST NOT be left as the tie-break
+alone: across literal hits `strength` is frequently constant, which leaves
+`heat` as the only term that separates them and makes the ranking of a body
+search the ranking of the traversal that came before it. Measured on a
+served forest, `sniff(["421", "Host"])` put a branch index holding **one**
+occurrence above the note holding **ten**.
+
+**A pointer never outranks what it points at (v0.52).** An index node
+(`_index`, `/_index`) carries the summary of every child, so it
+matches nearly any term somebody asks about, and it accumulates heat by
+being the way through to everything under it. A term found inside it is
+evidence about a child. An index node therefore ranks **below every
+non-index node in the same result set**, whatever its score. It is not
+removed a match in an index is still a way in and its `score` is
+reported unchanged, because a score adjusted to force an order is a number
+that lies, while an order stated in the contract is one the caller can
+read. This is the same judgement C.6c.2 already made when it refused to
+refine an index node's matches.
+
+**The demotion is visible (v0.54).** A demoted hit carries
+`demoted: true` beside its unadjusted `score`. An order stated only in the
+contract is invisible on the wire: the natural move for any client that
+fuses result sets is to re-sort by `score`, and doing so silently undid
+the rule above — the response gave it no way to know a rule existed. The
+marker says "this item's position is deliberate"; the score keeps telling
+the truth.
+
+**`body_tokens` on every result (v0.54).** Same field, same source and
+same reason as C.1.1 rule 1: the row is already in hand, and the caller
+is choosing what to open.
+
+```json
+{
+ "results": [
+ {
+ "id": "sales/exchange-policy",
+ "type": "note",
+ "title": "Exchange policy",
+ "trail": ["_index", "sales/_index"],
+ "score": 0.95,
+ "heat": 0.31,
+ "body_tokens": 612,
+ "match_count": 4,
+ "truncated_matches": true,
+ "matches": [
+ {"section": "Deadlines", "line": 23, "snippet": "…return with invoice NF-4412 within 30 days…"}
+ ]
+ }
+ ],
+ "scanned_nodes": 82,
+ "truncated": false
+}
+```
+
+Budget: <= 800 tokens, explicit truncation (`truncated: true`) dropping nodes off the end of the list.
+
+Canonical use (the monkey's decision, taught in the orchestrator's system prompt):
+
+1. Question contains an exact/rare term → `sniff` directly: lands in the right section and harvests with `pick(id, section)` cuts hops-to-banana.
+2. Conceptual question → `locate` (unchanged).
+3. Chained: `locate` finds the region, `sniff(terms, scope=branch)` hunts the snippet within it.
+
+Phase 0 implementation: direct file scan on every call (grep-like, no new index) always fresh by construction, no extra derived state. MAY gain an index (body FTS5 in a separate table) in a future phase **with no interface change**, as long as the contract split with `locate` holds.
+
+#### C.6b.1 The memoized scan (`_derived`, v0.40)
+
+`_sniff_body(body, term)` is a **pure function**: the same body and the
+same folded term yield the same lines, always. A Vine therefore MAY
+memoize it in the derived layer. The permission is narrow and the
+following are normative.
+
+- **Identical output.** A memoized `sniff` MUST return exactly what the
+ direct scan returns same nodes, same sections, same line numbers,
+ same snippets, same `match_count` and `truncated_matches`, same
+ ordering. This is memoization of the scan, **not** its replacement by
+ an index: no tokenization, no stemming, no analyzer. C.6b's literal
+ substring semantics and the `locate`/`sniff` split are untouched, and
+ any divergence is a defect, not a tuning choice.
+- **Validity is the body hash** (C.6.1). An entry is usable while the
+ node's current `body_hash` equals the hash recorded with the entry.
+ Absent hash on either side = miss. Timestamps MUST NOT be used for
+ this: `mtime` granularity is coarse and platform-dependent and clocks
+ move backwards.
+- **Non-matches are recorded.** An entry MUST distinguish "this node was
+ scanned for this term and matched nothing" from "this node was never
+ scanned for this term". Without the negative, every non-matching node —
+ nearly the whole forest is rescanned on every call.
+- **Line granularity.** The scan emits one match per *line*, whose
+ snippet is centred on the leftmost position among the terms that hit
+ that line. A per-term memo MUST therefore record, per matching line,
+ enough to rebuild that combined result (the line's number, its section,
+ the term's position in it, and the line's text) storing the rendered
+ snippet alone is wrong, because a second term in the same line moves
+ the window. Entries MUST record the **complete** line list: the
+ 3-matches-per-node cap of C.6b is applied when answering, and
+ `match_count`/`truncated_matches` are computed before it.
+- **Scope-independent.** An entry is a fact about one node and one term,
+ so it is reusable by any later call whatever its `scope`, `k` or
+ `type_filter`, and a scoped scan populates entries a global scan can
+ later use.
+- **Ranking is never memoized.** `heat`, `score` and ordering are
+ query-time state (Part D) and MUST be recomputed on every call. An
+ entry that froze the ranking would make the pheromone unobservable
+ through the primitive that reads it most.
+- **A remembered non-match is a count, not a row (v0.59).** The memo
+ records non-matches precisely so the ~95% of a forest that holds none of
+ the terms costs nothing but a lookup — and the first implementation then
+ carried every one of those rows out of the database and deserialized it,
+ to discover the list was empty. The negative MUST still be recorded (the
+ rule above is unchanged; without it every non-matching node is rescanned
+ forever), but a read MUST NOT be proportional to it. What a call needs
+ from the memo is the matching lines and the set of nodes the memo does
+ **not** cover — the second being the cheap half to ask for, and empty on
+ a warm forest. Everything else in scope is covered and matched nothing,
+ which is one count. Concretely: the rows fetched, the records
+ deserialized and the catalog rows loaded are all proportional to the
+ MATCHES, while `scanned_nodes` keeps counting every node the scan
+ covered, because the honesty of that number is the reason it exists.
+ A node covered for a term and absent from its matches IS the empty
+ record, inferred rather than transferred; recombining empty line lists
+ produces the empty match list, which is the result the scan skips.
+- **Heat for the candidates is one statement (v0.59).** Ranking is never
+ memoized — the rule above stands and is not weakened — but recomputing
+ it MUST NOT mean one SQLite round trip per node. A `sniff` whose terms
+ are common words matched most of a 1,911-node forest and asked for heat
+ **1,945 times**, which is the same mistake C.13.3 named for counting:
+ the work belongs in one query, not in a Python loop around one.
+- **A snippet is rendered for a result that is answered (v0.59).** The
+ windowing rule of C.6b (centred on the leftmost hit in the line) is
+ unchanged, and `match_count`/`truncated_matches` are still computed
+ over the **complete** line list before any cap. What changes is when the
+ window is computed: a call that returns at most `k` nodes of at most 3
+ matches each MUST NOT render thousands of snippet windows it will
+ discard. Rendering happens after the ranking and the cut.
+- **What stays proportional to the matches, and what cannot (v0.59).** With
+ the three rules above, a warm `sniff` for a term that lives in a handful
+ of bodies touches a handful of bodies' worth of work, whatever the size
+ of the forest — measured on a 1,911-node corpus, 10.9 ms to **1.5 ms**.
+ A term that genuinely appears in most of the corpus is a different
+ question: every matching node must be counted and ranked, so that call is
+ proportional to the matches and there is no honest way around it. This
+ document states the distinction rather than promising a single number,
+ because a reader who benchmarks the second case and finds it unchanged
+ should know that is the contract and not a regression.
+- **The fold is a table, not a loop (v0.62).** C.6b matches on a folded
+ form lowercased, diacritics stripped to the base character of the NFD
+ decomposition, length preserved so a position maps back to the original
+ line. That is a per-character mapping, and a per-character mapping
+ implemented as a Python loop over the text costs more than reading the
+ text from disk: measured, **387 ms to fold 11.9 MB against 31 ms to read
+ it**. The mapping MUST be applied in one pass by the runtime
+ (`str.translate` against a precomputed table; ASCII text folds by
+ `str.lower()`, which for ASCII *is* the definition). Two constraints
+ make this a memoization and not a change of meaning. The table MUST
+ cover every code point the fold can change **this is not the BMP**:
+ cased scripts live in the SMP (Deseret, Adlam, Osage, Vithkuqi, Warang
+ Citi, Medefaidrin) and CJK Compatibility Ideographs decompose as high as
+ U+2FA1D, so a BMP-sized table silently stops folding six living scripts.
+ And the limit MUST be pinned by a check against the Unicode version in
+ use, so a later version that raises it fails loudly rather than narrowing
+ the match in silence. The table MAY be built on first use it is state
+ no write primitive needs.
+- **A frontmatter marker is read in the frontmatter (v0.62).** G.7's
+ `content: cached|reference` says where a node's FLESH lives. It is
+ frontmatter by definition, and looking for it in the whole file was a
+ scan of the corpus to answer a question about its first few lines: **71
+ ms** of the 629 above, and 4 ms when asked of the frontmatter alone. The
+ scan MUST find the boundary between frontmatter and body once and ask
+ each half only what that half can answer. The old reading also treated a
+ body that merely *quotes* the marker as a node whose body lives
+ elsewhere; that was harmless (an inline node resolves to its own body)
+ and it is gone.
+- **What a cold scan costs, and what it does not (v0.62).** A `sniff` for a
+ term no entry covers MUST scan every body in scope there is no honest
+ alternative inside a literal contract, and the memo cannot pre-compute an
+ answer to a question nobody has asked. What this document now records is
+ what that costs when the implementation is not wasteful, so a later
+ proposal is measured against it rather than against an artifact.
+ On 1,902 nodes / 11.9 MB, CPU time, medians of five interleaved runs:
+
+ | cold `sniff` | before v0.62 | v0.62 |
+ |---|---|---|
+ | term matches nothing, ASCII bodies | 629 ms | **133 ms** |
+ | term matches nothing, accented bodies | 612 ms | **258 ms** |
+ | term matches, ASCII bodies | 726 ms | **244 ms** |
+ | term matches, accented bodies | 731 ms | **379 ms** |
+ | sweep (`harvest`), cold | 933 ms | **448 ms** |
+
+ Accented bodies cost more and MUST be expected to: they cannot take the
+ ASCII path, so every character passes through the table. A warm `sniff`
+ is unchanged by all of this it never folds and the v0.59 rules above
+ continue to govern it.
+- **These are cost rules, and the first rule governs them (v0.59).** Every
+ one of the three above is required to leave the answer byte-identical to
+ the direct scan — same nodes, same lines, same snippets, same counts,
+ same order. A cost rule that changed a result would not be a cost rule;
+ it would be a different search wearing the same name. Measured motive:
+ before them, a warm `sniff` on that forest cost 94 ms of a 103 ms sweep
+ and throughput **fell** as concurrency rose, because the work was
+ CPU-bound Python and the reader pool of J.6.2 cannot multiply what the
+ interpreter serializes. The pool fixed blocking; only this fixes cost.
+- **Only bodies the hash actually covers.** `body_hash` digests the
+ node's own `.md` body, so the memo is confined to `content: inline`
+ nodes. A `reference` body resolves to a file outside the forest that
+ changes with no write the catalog observes; a `cached` body lives in
+ `_derived/bodies` and can change or go missing, which the direct
+ scan reports by skipping the node while the `.md` body, a stub,
+ stays byte-identical. Both MUST record an absent `body_hash` and keep
+ the direct scan. A later version MAY extend the memo to them by
+ hashing the *resolved* body instead; until then the narrower rule is
+ the honest one.
+- **Disposable, and bounded.** The memo lives under `_derived/`, is
+ rebuilt by use (never by `reindex`, which only has to invalidate it),
+ and MUST be droppable at any moment with no effect other than latency.
+ A deployment MAY evict it least-recently-used by term is the
+ precedent (H.6) and eviction MUST NOT change any answer.
+
+**F.58 (acceptance).** A `sniff` where an index node and a content node
+match the same terms ranks the content node first, whatever their `heat`,
+and reports both scores unadjusted. Between two content nodes with equal
+term coverage, the one with more occurrences ranks higher; with equal
+occurrences, heat still decides. Covered by tests.
+
+#### C.6b.2 Containment is decided where the id arrives (v0.69)
+
+`Forest.path_for` maps an id to a path and rejects ids that escape the
+forest. That rejection is normative and unchanged. What this section fixes
+is **where the expensive form of it runs**.
+
+**Rule 1 — every boundary that accepts an id from outside the engine MUST
+resolve.** The wire (REST and MCP), `ScopedVine`, and any host-supplied path
+(`MONKEYLLM_INGEST_ROOTS`, upload staging, snapshot import) MUST decide
+containment with symlinks resolved. At those points the id is untrusted
+input and a symlink planted inside the forest is a real escape.
+
+**Rule 2 — an id the engine read from its own catalog is not such a
+boundary.** The catalog holds ids for nodes the engine planted and whose
+paths the engine wrote. A read driven by the catalog — the `sniff` scan is
+the one that matters, at one call per node in scope — MAY decide containment
+textually: normalize the joined path (which resolves `..` and therefore
+still refuses traversal) and require the forest root as a prefix.
+
+**Rule 3 — a write always resolves.** `plant`, `graft`, `prune`,
+`transplant` and `tend` take their ids from a caller, so they are rule 1's
+case wherever they appear, including when a batch (C.7.4) rehearses them.
+
+**Rule 4 — the boundary set is written down, and tested per surface.** The
+failure mode of rule 2 is silent: a boundary left out does not raise, does
+not log and does not slow down; an id simply passes. An implementation
+therefore MUST carry a test that exercises traversal (`../`), encoded
+traversal, and a **real symlink** planted inside a test forest against
+**each** surface named in rule 1, and every one MUST refuse with the
+byte-identical `E_NOT_FOUND` J.3 requires. A surface not in that test is not
+covered by this section.
+
+**Rule 5 — the relaxation is unreachable from the wire.** The textual form
+MUST NOT be selectable by an argument any caller can send. It is the
+engine's own internal path, chosen where the id's provenance is known — the
+same construction G.2.5 uses for `adopted=` and C.5.3 for the table
+allow-list: keyword-only, host-supplied, absent from the dispatch table.
+
+This is a cost rule in C.6b.1's sense — no wire shape moves and no answer
+changes — with one exception that is why it appears here rather than in a
+changelog line alone: it redistributes a **security** check, and a security
+check moved without being written down is a security check deleted.
+
+**F.145 (acceptance).** Traversal, encoded traversal and a real symlink
+planted inside a test forest are each refused with byte-identical
+`E_NOT_FOUND`, on every surface of rule 1: engine, `ScopedVine`, REST and
+MCP. Covered by tests.
+
+**F.146 (acceptance).** A cold `sniff` over a forest of ~1,900 nodes spends
+no `realpath` per node: the scan's containment is textual, and the primitive
+returns the same nodes, sections, snippets and order it returned before this
+version. The memo (C.6b.1) is untouched — a warm `sniff` is byte-identical.
+Covered by tests.
+
+### C.6c `harvest(query: string, terms?: [string], k: int = 3) → HarvestResult`
+
+**Composite tool, not a primitive**: a deterministic, zero-LLM orchestration
+over C.1 `locate`, C.6b `sniff` and C.4 `pick`. It exists for the
+bring-your-own-model integration: the caller's LLM (MCP client) gets ranked
+evidence in one call and decides the next steps itself.
+
+The three integration modes (informative):
+
+1. **Direct navigation** the client's LLM drives the primitives itself.
+ Best when reasoning must happen *during* navigation. Token cost is bounded
+ by the per-primitive budgets; the real cost is round-trips.
+2. **Harvest (this tool)** one call, evidence back, zero tokens spent on
+ the server side. Best default for capable client models.
+3. **Concierge** a local SLM hunts and returns a synthesized answer
+ (orchestrator-side, e.g. `examples/demo/run_demo.py`); for thin clients.
+
+Parameters:
+
+- `query`: free text; feeds `locate` as-is.
+- `terms` (optional): exact literal terms for `sniff`. When absent, terms are
+ derived from the query: words >= 4 characters, stopwords removed, max 8 —
+ **plus, since v0.52, any token that looks like code whatever its length**.
+ A token qualifies as code-shaped when it contains a digit, is written in
+ capitals, or carries `-`, `_`, `.` or `/`. The four-character floor was
+ discarding exactly the tokens a technical corpus is searched by (`RAG`,
+ `MCP`, `JWT`, `SSO`, `421`, `p95`), so "how do I fix the 421 from MCP"
+ reached `sniff` as `["corrigir"]` — a literal search for the question's
+ verb, against a forest where the answer was sitting under both discarded
+ tokens. The floor stays for ordinary words: a short common word is
+ grammar, and every junk term lowers the `strength` of a real hit (C.6b),
+ so widening the filter costs precision on every other query. Code-shaped
+ tokens are ordered **first**, so the 8-term cap drops grammar before it
+ drops signal.
+- `k`: max bananas returned (default 3). The cap is the deployment's,
+ not the caller's: `MONKEYLLM_HARVEST_MAX_K`, an integer >= 1 read from
+ the environment, default **5** when unset. A value that does not parse
+ as an integer, or parses below 1, is refused with `E_SCHEMA` naming
+ the variable never silently corrected. The cap bounds the item
+ count only; the response budget (below) is unchanged and remains the
+ outer wall, with truncation explicit as ever.
+
+Semantics (normative):
+
+1. Candidates = RRF fusion of `locate(query, k*2)` and `sniff(terms, k*2)`
+ rankings (same RRF as C.1's hybrid mode).
+2. Match refinement by **term scarcity**: per-term `sniff` scoped to each
+ selected node, rarest term first a rare exact term ("1045") MUST NOT be
+ drowned by common co-occurring terms under the per-node match cap.
+ **Never for an index node (v0.35)**: `sniff` resolves an index id to its
+ subtree, so "refining" one grepped the forest under it children's
+ snippets attributed to the index, chosen by heat rank and therefore
+ different on every read. An index result keeps the global sniff's
+ matches, which are found inside its own body; refinement MUST NOT cross
+ the node it refines.
+3. Content policy per node: full body when <= 1200 tokens; otherwise the
+ matched sections (max 2) via `pick(section)`; otherwise outline + hint.
+4. Response items carry: `id`, `title`, `type`, `trail`, `summary`, `score`,
+ `found_by` (locate/sniff), `matches` (section, line, snippet),
+ `body_tokens` (v0.54 — the whole body's size, C.1.1's rule, so a caller
+ deciding to `pick` past the excerpt knows the price) and `content`. The
+ caller can always continue with the primitives using `id`.
+5. **An empty sweep says what it swept (v0.52).** When both legs come back
+ with nothing, the response carries `searched` and a `hint` on the same
+ terms as C.1.1 — the sweep is the one call a caller makes *instead of*
+ navigating, so an empty one with no coverage is the same silence C.1.1
+ describes, arriving where there is no next primitive to try. The hint
+ points at the two moves that remain: narrowing the question, and passing
+ `terms` explicitly. The derived list is already in the response, and
+ when the sweep found nothing it is the first thing worth doubting.
+
+Budget: <= 4000 tokens total, explicit `truncated: true` dropping whole tail
+results first (never silently slicing a body).
+
+```json
+{
+ "query": "...", "terms": ["..."],
+ "results": [
+ {
+ "id": "projetos/mixerllm/log-experimentos",
+ "title": "...", "type": "note",
+ "trail": ["_index", "projetos/_index", "projetos/mixerllm/_index"],
+ "summary": "...", "score": 0.0328, "found_by": ["locate", "sniff"],
+ "body_tokens": 1832,
+ "matches": [{"section": "Experimento 45", "line": 141, "snippet": "…"}],
+ "content": [{"section": "Experimento 45", "body": "…", "body_tokens": 146}]
+ }
+ ],
+ "truncated": false
+}
+```
+
+**F.59 (acceptance).** `harvest("how do I fix the 421 from MCP")` derives
+terms including `421` and `MCP`. `RAG`, `JWT`, `p95` and `x-api-key` survive
+derivation; `fix`, `the` and `como` do not. When the cap is reached,
+code-shaped tokens are the ones kept. A sweep that finds nothing carries
+`searched` and a `hint`; a sweep that finds something carries neither.
+Covered by tests.
+
+#### C.6c.3 The sweep knows what time it is (v0.57)
+
+A knowledge base accumulates versions of the truth — that is what it is
+*for* — and the consumer team proved the failure mode the day they used
+it: asked "what is still open?", the hosted answer read a two-rounds-old
+report and the current one as the same present, reporting as open what
+the newer document says was fixed. The `succeeds` edges stating the order
+sat in the graph, declared with the very rel `_meta/schema` defines for
+temporal order, and nothing read them. A memory that weighs August and
+October as equal witnesses gets less reliable the more it remembers.
+
+Three rules, none of which spends a model call:
+
+1. **Every item states its time.** Sweep items carry `created` and
+ `updated`, read off the catalog row the selection already loaded —
+ never a file open. What the model does with them is the host's
+ business (J.10 teaches it); the contract's business is that the
+ material is dated.
+2. **Equal relevance prefers the newer.** The RRF fusion breaks score
+ ties toward the more recently `updated` node (then `created`, then id
+ for determinism). A tie-break, never a boost: recency MUST NOT outrank
+ relevance — a five-year-old architecture note that matches the
+ question still beats yesterday's standup that does not.
+3. **A succession inside the result set is annotated.** When one selected
+ item `succeeds` another (directly, in the catalog's edges), the newer
+ carries `supersedes: [ids]` and the older `superseded_by: [ids]`.
+ Annotated, never suppressed: the older node stays in the material —
+ history is evidence too — but the model is no longer the only one who
+ could have discovered the order, because it never did. (A `supersedes`
+ rel that suppresses its predecessor from retrieval by default is the
+ fullest form of this fix and is deferred to the history design —
+ suppression without a way to see what was suppressed is how a forest
+ starts lying in the other direction.)
+
+The dates and the annotations enter the J.10.7 reading fingerprint:
+material re-dated or re-ordered is material re-read, and a stored answer
+built before the succession was declared MUST NOT be served after it.
+
+#### C.6c.4 A replacement suppresses what it replaced (v0.58)
+
+C.6c.3 annotated and deliberately did not suppress: suppression without
+a way to see what was suppressed is how a forest starts lying in the
+other direction. With `history` (C.16) and the graph both able to show
+the past, the fullest form the consumer team asked for lands, scoped to
+the one place it belongs:
+
+1. **The rel is `supersedes` (A.2), and it is a judgement.** `succeeds`
+ says "B came after A" — both remain the truth of their moments; a
+ round-4 report does not falsify round 3. `supersedes` says "B is what
+ A used to be" — the policy that replaced a policy, the spec that
+ absorbed a draft. Writers choose which claim they are making.
+2. **The sweep excludes the superseded and refills the seat.** After
+ selection, a candidate that a LIVE node `supersedes` leaves the
+ result set and the next-ranked candidate takes its place — `k` is
+ still met. The successor competes on its own relevance and is never
+ smuggled in: a replacement that does not match the question is not
+ evidence for it.
+3. **Nothing is hidden silently.** The response carries
+ `superseded_excluded: [{id, by}]` — the reader is told what was set
+ aside and by what, in the same breath. An empty sweep whose only
+ matches were superseded still says so, which is the difference
+ between "nothing matches" and "what matched has been replaced".
+4. **`include_superseded: true` restores the history view** — the
+ excluded items return, carrying their C.6c.3 annotations. Off by
+ default; the flag enters the J.10.7 key (it changes the reading).
+5. **Navigation never suppresses.** `locate`, `sniff`, `move`, `scan`
+ and the map projections show the forest as it is — suppression is a
+ rule about material assembled for ANSWERING, not about the map. An
+ agent walking the graph sees the superseded node, its edges, and its
+ history.
+6. Scope is honest: the suppression edge is read from the catalog under
+ the caller's own view — a successor the caller cannot see cannot
+ suppress what they can (the same periscope rule as C.15's waymark).
+
+### C.6d `view(id: string) → media content` (v0.48)
+
+**MCP tool, not a REST primitive**: the image payload behind a `media`
+node, handed to the *caller's* model as MCP image content. G.5 stated
+the split text to find, binary to consume and named this tool as a
+possible future; this section makes it normative. `harvest` finds the
+screenshot by its describer prose; `view` is how a multimodal client
+then reads the pixels themselves: a tutorial screenshot before acting
+on it, a UI mock a person clipped as feedback, a whiteboard photo whose
+arrows the describer could only gesture at.
+
+Semantics (normative):
+
+1. Resolution follows J.14 exactly: the node, its `payload` field
+ resolved relative to the node's directory, contained in the forest
+ root after resolution. Absent node, node without a `payload`, and a
+ `payload` whose file is missing all answer the **same** `E_NOT_FOUND`
+ envelope as a missing node and on a host, an out-of-scope node is
+ byte-identical to all of them (J.3's no-existence-oracle invariant).
+2. A remote payload URI (G.9) is refused `E_SCHEMA` naming the scheme,
+ as on J.14: fetching inside a read hides a network dependency;
+ `vine prefetch` is how a remote region is warmed.
+3. **Images only.** A payload whose type is not `image/*` is refused
+ `E_SCHEMA` naming the type: a dataset is read with `query`, audio
+ waits for a transcriber role (G.5.1), and raw bytes of arbitrary
+ kind remain the human surface's affair (J.14).
+4. **Bounded at 6 MiB** the same number as the G.5.1 describer's own
+ refusal, because it is the same question ("too big to hand a
+ model?") and one project answers it once. Over the bound is
+ `E_SCHEMA` naming the size; the J.14 route serves bytes of any size
+ to people.
+5. The result is two content blocks: a JSON header `id`,
+ `media_type`, `size`, `payload_hash` and the image content itself.
+ The token budgets of Part C do not apply to the image block: the
+ bytes land in the caller's context by the caller's explicit choice,
+ and the byte bound above is their ceiling.
+6. Traced like any read (Part D) and it deposits pheromone: a model
+ that chose to open an image is the strongest evidence of usefulness
+ this node will ever receive.
+7. On a host: requires the `read` capability, audited like a read with
+ the byte size (never the bytes), and **not** offered to the J.10.5
+ walk its whitelist is unchanged, because the forest's own `answer`
+ binding is not presumed multimodal. A vision-capable walk is a
+ possible future, not this section.
+8. On the REST surface this tool does not exist: `GET .../payload/{node}`
+ (J.14) is REST's byte route, and a JSON twin of it would only
+ disclose server paths.
+
+**F.50 (acceptance).** `view` on a media node returns image content
+whose bytes equal the payload file and a header whose `payload_hash`
+matches the passport. On a dataset it answers `E_SCHEMA` naming the
+type; over 6 MiB, `E_SCHEMA` naming the size; on a remote URI,
+`E_SCHEMA` naming the scheme. A scoped principal viewing an
+out-of-scope media node receives the same envelope as for a missing
+one, and a node without a payload the same. All covered by tests.
+
+### C.7 `plant(node: NodeSpec) → PlantResult`
+
+`NodeSpec` = full frontmatter + `body` + `parent` (destination branch id).
+
+Atomic operation (in this order; failure at any step = full rollback):
+1. Validates frontmatter against the schema (A.3) and `summary` (A.4);
+2. Checks `id` uniqueness;
+3. Writes the file;
+4. Inserts the entry into the parent `_index.md`'s `## Direct bananas` (or `## Sub-branches`);
+5. `git commit` with the standardized message `plant(): [source=]`;
+6. Marks the node stale in the derived layer (lazy re-embedding).
+
+Returns: `{id, commit, trail}`.
+
+#### C.7.1 Dataset planting declarative schema (v0.8)
+
+A `NodeSpec` with `type: dataset` MAY carry a `schema` object describing the
+payload to be **born** with the node:
+
+```json
+{
+ "type": "dataset",
+ "id": "clients/prospecting-2026",
+ "parent": "clients/_index",
+ "title": "Client prospecting 2026",
+ "summary": "...",
+ "schema": {
+ "clients": {
+ "columns": {"name": "TEXT", "site": "TEXT", "segment": "TEXT",
+ "collected_at": "TEXT"},
+ "primary_key": ["name"]
+ }
+ }
+}
+```
+
+Rules (normative):
+
+1. **The model never writes DDL.** The schema is data; the Vine generates
+ the `CREATE TABLE` statements itself. Validation, all `E_SCHEMA` on
+ failure:
+ - table and column names MUST match `^[a-z_][a-z0-9_]*$` (≤ 64 chars);
+ - column types MUST be one of `TEXT`, `INTEGER`, `REAL`, `BLOB`;
+ - `primary_key` (optional, per table) MUST reference declared columns;
+ - limits: ≤ 10 tables per dataset, ≤ 50 columns per table, ≥ 1 of each.
+2. `schema` on a non-dataset `type` → `E_SCHEMA`. A dataset planted
+ WITHOUT `schema` keeps the v0.7 behavior (reference to a payload that
+ already exists on the filesystem).
+3. **Payload birth**: `payload` defaults to `.db`,
+ `payload_type` to `sqlite` (explicit values are honored; `payload` MUST
+ be a bare filename ending in `.db`). The target file MUST NOT already
+ exist (`E_SCHEMA` never silently overwrite a payload). The Vine
+ creates the SQLite file, applies the generated DDL, and computes
+ `payload_hash` (sha256) into the frontmatter.
+4. **Auto manual**: when the body lacks a `## Query manual` section, the
+ Vine appends one generated from the schema each table with its column
+ list, plus example queries (`` `SELECT * FROM LIMIT 5` ``,
+ `` `SELECT COUNT(*) FROM ` ``) so C.2 `look`'s `query_manual`
+ contract works from birth. **When the node is born with `rows` (rule 7)
+ the auto manual is followed by the `## Sample rows` map of G.2.3**,
+ taken from those rows: the body is the only place a value inside the
+ payload is ever visible to `sniff`, and a dataset born full and mapped
+ empty is findable by nothing it contains. A caller-provided manual is
+ kept verbatim, and suppresses both sections an author who wrote the
+ manual owns the body.
+5. **Atomicity**: the C.7 rollback covers the payload any failure after
+ the `.db` is created MUST remove it along with the `.md`. A.3.1 intact:
+ the commit carries only markdown; one dataset node = one `.db` = one
+ database (several tables = several keys in `schema`; there is no
+ separate "create database" concept).
+6. After birth, rows enter exclusively via `tend` (C.10) multi-row
+ `INSERT INTO t VALUES (...), (...)` is a single statement and therefore
+ already legal there. Schema evolution (`ALTER`) is NOT available to
+ agents in v0.8.
+7. **Initial rows (v0.9)**: the `NodeSpec` MAY carry `rows`, a mapping
+ `table → list of rows` loaded at birth, after the DDL and BEFORE
+ `payload_hash` is computed. Normative: rows are inserted **parameterized**
+ (`executemany` with placeholders row values are data, never SQL text,
+ so no keyword scanning applies and injection is impossible by
+ construction); every `rows` table MUST exist in `schema` and every row
+ MUST have exactly the table's column count (`E_SCHEMA` otherwise); the
+ atomic rollback of C.7 covers loaded rows (the payload is removed whole).
+ This is the bulk-load path for the Gardener (G.3) and collector agents;
+ incremental writes after birth remain `tend`-only.
+
+Canonical uses (informative): an agent collecting external data plants the
+dataset then fills it with `tend`, for later harvest by `query`/humans; an
+agent finding a large markdown table in a `document` plants a dataset twin,
+loads the rows, and `graft`s a `related-to` link from the document prose
+stays as source, the data becomes filterable SQL.
+
+#### C.7.2 A write you can repeat (v0.52)
+
+`plant` refuses a duplicate `id` with `E_SCHEMA`, and that MUST remain the
+default: a node silently overwritten because somebody reused an id is the
+one failure a knowledge base does not recover from, and it is invisible
+afterwards. The cost of that correctness is that **a write cannot be
+retried**. An agent whose `plant` timed out does not know whether the node
+exists; retrying may fail for a reason that reads like its own mistake, and
+not retrying may drop the fact it was asked to keep. Unsupervised writing
+needs one of the two to be safe.
+
+`plant(node, if_absent: true)` makes the call idempotent **by id**:
+
+1. Id free: the node is planted exactly as it is planted today validation,
+ index entry, commit, all of C.7 unchanged and the result carries
+ `created: true`.
+2. Id taken: **nothing is written, nothing is committed**, and the result is
+ `{id, created: false, trail}` describing the node that is already there.
+3. The submitted content is NOT compared to the existing node, NOT merged
+ and NOT applied. `if_absent` says "make sure this exists"; changing what
+ exists is `graft`'s job, and a flag that quietly edited would be the
+ silent overwrite this contract refuses.
+4. Scope is unchanged. The destination parent is resolved and gated exactly
+ as ever, so the flag cannot be used to probe ids under a branch the
+ principal may not write to; an out-of-scope destination is refused before
+ the id is ever looked at.
+5. `created` is present on **every** `plant` result, with or without the
+ flag it is `true` on the path that plants, so a client never has to
+ infer the outcome from the absence of a field.
+
+**F.61 (acceptance).** `plant(node)` on a free id returns `created: true`
+and a commit; repeated, it returns `E_SCHEMA` and writes nothing. The same
+call with `if_absent: true` returns `created: false`, no commit, the
+existing node's trail, and leaves the existing node byte-identical even when
+the submitted body differs. Two `if_absent` plants of the same id leave one
+node and one commit in the forest's history. Covered by tests.
+
+#### C.7.3 A write rehearsed (v0.57)
+
+The 60-token summary ceiling is a good rule with an expensive messenger:
+the refusal arrives *after* the whole node crossed the network — the
+consumer team lost two of eight plants to summaries of 61 and 62 tokens,
+each costing a 33 KB round trip to learn one number. The error message
+was excellent and late.
+
+`plant(node, dry_run=true)` runs the rehearsal:
+
+1. **Every validation the real plant runs, in the real order** — id and
+ parent-chain rules, declared types and rels against `_meta/schema`,
+ the summary ceiling, alias bounds, link targets' existence wherever
+ the real plant checks it, dataset `schema` validation (C.7.1). Not a
+ parallel checker: the same code path up to the first write, because
+ two validators agree only where somebody compared them (the C.5.3
+ lesson).
+2. **Nothing is written, nothing is committed, nothing is indexed.** No
+ file, no catalog row, no pheromone, no `_derived` touch. A dry run
+ repeated forever leaves the forest byte-identical.
+3. The success answer is `{id, valid: true, dry_run: true}` — and
+ `created` is absent, because nothing was. A failure is the exact
+ envelope the real call would have raised.
+4. `dry_run` composes with `if_absent` (the rehearsal then also reports
+ `created: false` for a taken id instead of `E_SCHEMA`, mirroring
+ C.7.2's answer shape).
+5. Scope and capability are checked as on the real path: the rehearsal
+ requires `write` and gates the destination — a dry run that a
+ read-only principal could call would be an oracle for what a write
+ *would* say.
+6. **A failing rehearsal names every problem it can determine (v0.59).**
+ The point of this primitive is to replace trial-and-error with one
+ call, and validating in a chain — first problem, stop — left it
+ trial-and-error that merely costs less: the consumer team's node had a
+ 61-token summary *and* a non-existent parent, and learned about them in
+ two round trips. A dry run MUST run every check whose preconditions
+ hold and report the lot. Three constraints keep it honest:
+ - **The envelope is unchanged to the byte.** `code`, `message` and
+ `hint` remain those of the *first* problem in the real path's own
+ order, so a client that reads the code, and every test written
+ against v0.57, sees exactly what it saw before. The complete list
+ rides in the envelope's `data` as `errors: [{id, code, message,
+ hint}]` (C.12's `data` carrier, the shape C.14 already uses for
+ `anchors`), the first entry being the one the envelope repeats.
+ - **A check whose precondition failed is skipped, never guessed.** If
+ the parent does not exist, the rehearsal does not invent what its
+ kind would have been; the reported list is what could be
+ *determined*, and a second rehearsal after the fixes is a legitimate
+ step, not a failure of this rule.
+ - **A batch rehearses every node (C.7.4).** `dry_run` over a list runs
+ all of them and collects across all of them, each error carrying its
+ node's `id` and its `index` in the list — twenty nodes with twenty
+ problems is one correction, which is the whole reason the list form
+ exists. The real `plant` is unaffected: it refuses at the first
+ problem, because writes wait behind it and there is nothing to gain
+ from computing more.
+
+#### C.7.4 A batch is one plant (v0.58)
+
+The consumer team planted eight documents in eight calls; two failed on
+their summaries and left a half-built graph — nodes present, links
+aimed at absences — that only a second corrective pass healed. Their
+sentence: the same batch principle `look`, `pick` and `scan` already
+have on the read side, owed to the write side. And the load report adds
+the other half of the motive: one plant is one writer-lane occupation
+and one git commit, so one hundred nodes were one hundred commits.
+
+`plant` accepts a **list** in `node` (≤ 20 — `MAX_BATCH_PLANT`):
+
+1. **Everything is validated before anything is written.** Every node
+ runs C.7.3's rehearsal, in list order, against the forest PLUS the
+ batch's own earlier nodes — so a branch and its children may share
+ one batch. The first failure refuses the whole call with the exact
+ envelope the failing node would have raised alone, prefixed with its
+ id; nothing is written, nothing is committed. All-or-nothing, stated:
+ a partial batch is the half-built graph this rule exists to end.
+2. **The batch lands in ONE commit** (`plant(batch): N nodes`), every
+ file and every parent-index refresh inside it. One commit is one
+ lane occupation and one git ceremony — the write ceiling, divided by
+ the batch size.
+3. **The answer accounts for every id sent** (C.11's idiom):
+ `{created: [ids], existing: [ids], commit, count}` — `existing` is
+ `if_absent`'s per-node answer (an id already taken skips, writes
+ nothing, fails nothing); without `if_absent` a taken id fails the
+ batch as it fails a single plant.
+4. **`dry_run` composes:** the whole batch rehearses, nothing lands,
+ and the answer is `{valid: true, count, dry_run: true}` — or the
+ first failure, exactly as rule 1.
+5. A duplicate id INSIDE the batch is `E_SCHEMA` naming it — two nodes
+ cannot claim one address even transiently.
+6. A single dict in `node` keeps the v0.57 shapes to the byte; a
+ one-element list answers the batch shape. Datasets (`schema`) are
+ refused in batches for now — a payload birth mid-batch has no
+ rollback story yet, and refusing is honest where restoring is not.
+
+#### C.7.5 A media passport names bytes the forest holds (v0.77)
+
+`plant` carries frontmatter and a body and nothing else; a `media`
+node's worth is its bytes. Those two facts were never stated together,
+and an agent that planted a media node "with" an image — an `origin`
+naming a file store the engine cannot see — was told `created: true`
+and learned the truth from `view`'s `E_NOT_FOUND`, which is anonymous by
+C.6d rule 1 and could not say what had gone wrong.
+
+1. A `type: media` node planted through `plant` **MUST carry
+ `payload`**. A missing one is `E_SCHEMA` (`a media node needs a
+ payload`) with a hint naming the path bytes actually take:
+ `ingest(mode: "upload", files: [{name, b64}])`, which plants the
+ media node, keeps the bytes under `_assets/` and writes the
+ description (G.5.1).
+2. A local `payload` MUST resolve inside the forest root and MUST exist
+ on the volume, both decided with a stat before the first write:
+ `payload escapes the forest` and `payload not found: ` are
+ `E_SCHEMA` at the plant, not `E_NOT_FOUND` three calls later. A
+ remote URI (G.9) is accepted as written.
+3. It rehearses (`dry_run`, C.7.3) and it refuses a batch (C.7.4) like
+ any other problem of a node.
+4. **The Gardener is exempt** (`adopted=`, G.2.5's construction): under
+ `archive: never` an adopted image is referenced, not copied, and its
+ passport names no payload by G.7's design — that node is a real state
+ of the tiered store, and `look` says `payload_missing` for it
+ (C.2.2 rule 6).
+5. Reads are untouched: a pre-v0.77 media passport without bytes still
+ opens, still grafts and still prunes.
+
+### C.8 `graft(id: string, patch: GraftPatch) → GraftResult`
+
+`GraftPatch` supports four operations (combinable):
+- `set_frontmatter: {field: value}` mutable fields only (`title`, `summary`, `tags`, `confidence`, and `aliases` as of v0.54 — a list of at most 16 non-empty strings of at most 80 chars each, anything else `E_SCHEMA`); `id`, `type`, `created` are immutable (`E_READONLY`);
+- `add_links: [{rel, target}]` / `remove_links: [...]`;
+- `append_section: {header, body}` or `replace_section: {header, body}`;
+- `replace_body: string` (v0.43) the entire body at once, the note as
+ the unit of edit. The empty string is a valid body. NOT combinable
+ with the section operations (`E_SCHEMA`: one patch, one truth about
+ the body), and refused on index nodes (`E_SCHEMA`: an index's body is
+ the indexer's render, and a hand-written one would stop parsing as a
+ map). The serialized node MUST re-parse before the commit happens.
+
+Special rules:
+- A `summary` change propagates to every `_index.md` that replicates it (same transaction).
+- **A write that outdates the scent says so (v0.54).** A `graft` whose
+ patch changed the body (`replace_body`, `replace_section`,
+ `append_section`) without carrying a `summary` in the same patch returns
+ `summary_stale: true`. `locate` indexes title, aliases, tags and summary
+ and nothing else (C.6b split), so every body edit that leaves the summary
+ behind ages the exact layer navigation trusts — and v0.52's empty-read
+ hint *taught* callers to trust it. The flag is the caller's chance to
+ repair in the same turn, and the repair is one call:
+ `graft(id, {set_frontmatter: {summary: …}})` — which was always legal and
+ is now stated. The flag is absent (never `false`) on every other patch;
+ it is a signal, not a judgement of whether the summary still fits, which
+ only a reader of both can make.
+- **An unknown patch key is refused, never absorbed (v0.56).** A key of
+ `patch` that names no operation above is `E_SCHEMA`, naming the key and
+ listing the operations that exist. Before this, an unknown key was
+ silently discarded: alone it read as an empty patch, but **beside a
+ legal operation the call answered `200` and did less than it was asked**
+ — `graft(id, {"regenerate_summary": true, "append_section": …})`
+ appended the section, dropped the flag, and the caller walked away
+ believing the summary was regenerated. A patch key is a claim about
+ what the write does; the write must refuse claims it does not
+ understand, which is the same discipline v0.54's rule 8 (C.12) applied
+ to unknown REST parameters. The refusal happens before anything is
+ written — a patch half-understood MUST NOT be half-applied.
+- **Reinforce-before-create policy (shortcuts):** at the end of a successful hunt, the decision cascade is: (1) if a shortcut already covers the entry→banana connection on the trail, do NOT create one just increment the existing one's `heat` and `confidence` (fortification, no commit); (2) if none exists and the trail was >= 4 hops, `graft` a new `discovered-shortcut` with `confidence: 0.5` and `discovered_by: agent`; (3) new lateral connections the agent notices (`related-to` between the banana and semantic neighbors) enter as a **proposal** with `confidence: 0.3`, subject to confirmation or pruning by the Ranger. The Vine MUST implement step 1's check inside `graft` itself (shortcut idempotence): grafting a duplicate link automatically becomes fortification, never an error or a duplicate.
+- Commit: `graft(): `.
+
+### C.10 `tend(id: string, sql: string) → TendResult` (v0.7 Phase 2)
+
+The dataset-write primitive: the forest stops being a smart reader and
+becomes memory that learns. `query` (C.5) remains read-only forever; `tend`
+is the only sanctioned write path into a dataset payload.
+
+Preconditions:
+
+- Writable Vine (read-only server → `E_READONLY`).
+- Node is `type: dataset` with `payload_type: sqlite` and an existing
+ payload file anything else → `E_QUERY_FORBIDDEN` / `E_NOT_FOUND`.
+
+Statement rules (normative, mirror of C.5's paranoia):
+
+- Exactly ONE statement, and it MUST start with `INSERT`, `UPDATE` or
+ `DELETE`. Reads belong to `query`; schema changes (CREATE/ALTER/DROP)
+ belong to the Gardener all rejected with `E_QUERY_FORBIDDEN`.
+- Forbidden anywhere in the statement: `ATTACH`, `DETACH`, `PRAGMA` in
+ both spellings, the keyword and the `pragma_*` functions (v0.50)
+ `DROP`, `ALTER`, `CREATE`, `VACUUM`, `REINDEX`, `BEGIN`, `COMMIT`,
+ `TRANSACTION` → `E_QUERY_FORBIDDEN`.
+- `UPDATE`/`DELETE` MUST carry a `WHERE` clause (mass-wipe guard): target
+ rows explicitly; full rewrites are the Gardener's job.
+- **The table allow-list applies here too, reads included (v0.50).** When
+ the grant narrows the dataset (J.3), `tend` enforces it through the same
+ authorizer as `query` (C.5.3), refusing the write actions *and*
+ `SQLITE_READ` outside the list. Without the read action the scope would
+ hold only for the destination, and writing is a way of reading.
+- Timeout 2s → `E_TIMEOUT`. SQL errors roll the transaction back and
+ surface as `E_QUERY_INVALID` (v0.47, C.5.2 the guard above decides
+ what is *forbidden*; SQLite decides what is *invalid*, and a `tend`
+ naming a column that does not exist is the second, on a dataset the
+ principal is allowed to write). The payload is untouched either way.
+
+Audit trail (A.3.1 compliant):
+
+1. The write commits in the payload SQLite.
+2. The Vine refreshes the node's frontmatter: `payload_hash` = sha256 of
+ the payload file, `updated` = today.
+3. `git commit` of ONLY the `.md`, message `tend(): row(s)`.
+ The what/when history lives in the markdown commit stream; the binary
+ never enters git.
+4. If step 2-3 fails after step 1 committed, the `.md` is restored and the
+ error surfaces the resulting hash drift is exactly what
+ `vine validate` now warns about (self-healing: the next successful
+ `tend` refreshes the hash).
+
+Response:
+
+```json
+{"id": "vendas/pedidos-2026", "rows_affected": 1,
+ "payload_hash": "", "commit": "", "elapsed_ms": 4.2}
+```
+
+### C.9 Concurrency and consistency (Phase 0)
+
+- **One writer, N readers:** `plant`/`graft` go through a single queue (global mutex in the Vine). Reads never block.
+- Readers MAY see state up to 1 write behind (eventual consistency of seconds) acceptable by design.
+- The `.vine.lock` file at the root prevents two writer Vines on the same forest (`E_LOCKED`).
+
+**The lock is possession, not existence (v0.55).** Until v0.54 the lock
+was the file: created `O_EXCL`, holding nothing but a pid nobody ever read
+back. A process that exits without deleting it — a kill, an OOM, a
+container upgrade, which is how server processes actually end — left the
+forest refusing every writable open forever, and since a host serves reads
+through its one writable Vine, an orphan file was a total outage repaired
+only by shell access. Measured in the field: one upgrade, two forests,
+every primitive dead, health green.
+
+Four rules replace that:
+
+1. **Possession is the kernel's.** Acquiring the lock is taking the OS's
+ advisory lock (`flock`-class) on the open lock file, which the kernel
+ releases when the holding process exits, however it exits. The file's
+ existence decides nothing.
+2. **The file is the holder's card.** On acquire it is rewritten with
+ `{pid, host, since}` — diagnostics for humans and for the refusal,
+ never the control. A card left by a dead process is an orphan: the
+ next open takes the kernel lock over it, rewrites the card, and
+ serves. Silently, because recovering from a crash is not an event the
+ caller needs to act on.
+3. **`E_LOCKED` names the holder.** A refusal quotes the card — pid,
+ host, since — so "who has it" stops being a filesystem investigation.
+ The hint says the lock releases itself when its holder exits, instead
+ of prescribing a manual `rm` to callers who have an API and no shell.
+4. **A filesystem that cannot hold the lock says so by behaving as
+ before.** Where the kernel lock is unsupported (some network mounts),
+ acquisition falls back to v0.54's existence semantics — refusing on a
+ present file — because guessing liveness without the kernel is how
+ two writers happen. The J.13.5 release endpoint is the operator's
+ path there.
+
+Reclaim races are closed by identity, not by luck: after taking the
+kernel lock the holder verifies the path still names the inode it locked
+(a release unlinks while holding, so a waiter that acquired on the
+now-unlinked inode retries against the fresh file). Bounded retries; a
+forest that stays contended answers `E_LOCKED` honestly.
+
+**N readers are real now (v0.57).** The lock is possession of the
+*write*: a read-only Vine (`writable=False`) takes no lock and MAY be
+opened in any number beside the writer, in the same process or another.
+What makes that safe is WAL — a reader sees every write whose transaction
+committed before its own began, which is exactly the "up to 1 write
+behind" this section has promised since Phase 0. What makes it *practical*
+is one more pragma: every read deposits pheromone, so N readers are N
+occasional writers to the trails store, and SQLite's default answer to a
+busy writer is an immediate "database is locked". The derived-layer
+tuning (`tune_derived`) therefore sets `busy_timeout` alongside WAL: a
+contended deposit waits its milliseconds instead of erroring. The host
+layer's use of this property is J.6.2.
+
+---
+
+### C.11 A batch is one call (v0.52)
+
+`look` and `pick` accept a **list** of ids where they accept one. The
+minimum path to read a passage is three calls (`locate` → `look` → `pick`),
+so reading five nodes cost eleven round trips, eleven tool results in the
+caller's context window, and — measured against a served Station — about
+six seconds of network for a forest of four nodes. Context is the resource
+this product promises to save, and the shape of the surface was spending it
+on plumbing. An agent that tried the obvious grouping, `pick(id=["a","b"])`,
+got `500 Internal Server Error` (C.12).
+
+The saving is round trips. It is **not** tokens, and the contract says so:
+
+1. **One call, one budget.** A batch is sized by a single budget, not by the
+ per-item budget times the number of items: `look` ≤ 2000 tokens for the
+ whole response (BUDGET_LOOK stays 500 for a single digest and for each
+ digest inside a batch), `pick` ≤ 4000 — the same wall a single body
+ already meets. An agent that asks for five large bodies gets what fits
+ and is told the rest was dropped; nothing about a batch may deliver more
+ material into one turn than the primitive would deliver alone.
+2. **Whole items drop, from the tail, in the caller's order.** Results are
+ returned in the order the ids were given ranking a list somebody
+ already chose would make which item is dropped unpredictable. Dropped
+ items are **named** in `dropped: [id, …]` with `truncated: true`; a body
+ is never sliced to make room.
+3. **Every id comes back accounted for.** Each id in the request appears
+ exactly once in the response: in `nodes`, in `missing`, or in `dropped`.
+ A batch of ten with one bad id MUST NOT fail as a whole — the other nine
+ were valid questions and re-asking them is exactly the round trip this
+ removes.
+4. **`missing` keeps J.3's rule.** An id that does not exist and an id the
+ principal may not see are both `missing`, byte-identical, exactly as
+ `E_NOT_FOUND` is for a single read. A batch MUST NOT become the surface
+ that distinguishes them.
+5. **The shape follows the request, not the result.** A list in returns
+ `{nodes, missing, dropped, truncated}`; a string in returns the single
+ digest or content object of C.2/C.4, unchanged to the byte. A one-element
+ list is still a list: a client that built its request as a list must not
+ have to branch on how many ids it happened to hold.
+6. **Bounded.** `look` accepts at most 10 ids, `pick` at most 5; more is
+ `E_SCHEMA` naming the cap. An empty list is `E_SCHEMA` too — it is not
+ a request for nothing, it is a caller with a bug.
+7. Duplicates are collapsed, keeping first position. The same node read
+ twice in one call is a mistake with no meaning to preserve.
+
+Each item is built by the primitive itself: same fields, same per-node
+budget, same `fields`/`section` argument applied to every id in the batch.
+A batch is a transport shape, never a second semantics.
+
+**F.57 (acceptance).** `look` and `pick` with a list of ids answer
+`{nodes, missing, dropped, truncated}` in the caller's order; with a string
+they answer exactly the single-node shape they answered before. A batch
+containing one absent id and one out-of-scope id reports both in `missing`,
+byte-identically, and still returns the valid ones. A batch whose bodies
+exceed the budget drops whole items from the tail, names them in `dropped`,
+and never returns a sliced body. Ids over the cap, and an empty list, are
+`E_SCHEMA`. For every accepted batch, the union of `nodes`, `missing` and
+`dropped` is exactly the set of ids requested. Covered by tests.
+
+### C.12 Every exit is an envelope (v0.52)
+
+Part C says a failure is `{error: {code, message, hint}}`, and the project's
+error text is one of the things a reader of this surface praises — because
+the consumer is a model, and an error that teaches is the difference between
+recovering in the next turn and giving up. The hole was never in the codes
+it defines; it was in the paths that reach none of them. Seven malformed
+calls against a served Station produced five different behaviours:
+
+| call | was |
+|---|---|
+| `pick(id=["a","b"])`, `look(id=["a"])`, `locate(query=["a"])` | `500`, body `Internal Server Error`, no envelope |
+| `locate(query="x", k="three")` | `400 E_SCHEMA: "'>' not supported between instances of 'int' and 'str'"` |
+| `look(id=null)` | `404 E_NOT_FOUND: node not found: None` |
+| `sniff(terms=[123])` | `200 {"results": []}` |
+
+The last two are worse than the crashes, because they answer. A `null` id
+was coerced into the string `"None"` and looked up; an integer term was
+accepted and matched nothing, which is indistinguishable from "nothing in
+this forest matches". A `500` with no code cannot even be classified: it
+tells a model nothing about which of three opposite reactions is right
+alert the operator, stop, or just fix the argument.
+
+Normative:
+
+1. **One signature table.** Every primitive's parameters name, accepted
+ types, default, whether required are declared **once, in the engine**,
+ and every surface that receives arguments from the wire enforces that
+ declaration either directly, before the primitive is reached, or
+ through a transport that already refuses on a schema of its own (MCP
+ validates a tool call against the tool's input schema). Where the second
+ is relied on, the two MUST be checked against each other mechanically:
+ two descriptions of one contract agree only where somebody compared them
+ (C.5.3's rule, applied to arguments rather than tables). A parameter an
+ MCP tool accepts and the table does not know is a defect in whichever is
+ wrong, and the comparison is what says so.
+2. **Argument shape is `E_SCHEMA`/400**, and the message names the
+ parameter, what arrived, and what was expected. A Python exception's text
+ is not a message: `'>' not supported between instances of 'int' and
+ 'str'` is a stack trace wearing an envelope, and it names neither the
+ parameter nor the type.
+3. **`null` is not a value.** A parameter given `null` is a parameter not
+ given: refused as missing when it is required, defaulted when it is
+ optional. Never coerced to a string and looked up.
+4. **A list where a scalar belongs is refused as a list** unless the
+ primitive takes one (C.11) never iterated, never joined, never
+ silently taking the first element.
+5. **The last resort is still an envelope.** Any exception no rule above
+ caught is answered `E_INTERNAL`/500 in the envelope shape, naming the
+ primitive and the exception's type and nothing more: no traceback, no
+ file path, no SQL. An unhandled path is a defect in the host; served as
+ a bare 500 it becomes the caller's defect too, because the caller cannot
+ tell it apart from its own bad argument. This applies to **every** route
+ the host serves, not only the primitives.
+6. **A missing parameter is not a denial.** A route that requires
+ `?forest=` and did not get one answers `E_SCHEMA`/400 naming it. Measured:
+ `GET /v1/admin/health` with no `forest` told a key holding `admin` on
+ every forest it lacked `admin` on that forest sending an operator to
+ audit grants over a mistake that was in the URL. What was asked for is
+ resolved before who may have it, for every parameter whose absence is
+ not itself a secret; an id that may not exist keeps its existing
+ treatment (J.3), because "which forest" is a question about the request
+ and "may I" is a question about the principal.
+
+**The table covers the composites too (v0.67).** `harvest` and the host's
+`answer` are declared in the same one table as the primitives, for rule 1's
+reason and not by analogy: they receive arguments from the wire, so a
+parameter one surface accepts and the table does not know is the same defect
+there as anywhere. `answer`'s optional `terms` (J.10.3) is therefore a row
+in that table — not a key the route happens to read — which is what makes it
+enforceable identically on REST and MCP and comparable by the test rule 1
+requires.
+
+**F.60 (acceptance).** Each of the seven malformed calls tabled above is
+answered with the envelope: `E_SCHEMA`/400 naming the parameter and the
+types for the wrong-shape ones, `E_SCHEMA` for a `null` required parameter
+and for `sniff(terms=[123])`. No route answers a bare `500`: an exception
+raised inside a primitive arrives as `E_INTERNAL`/500 in the envelope shape,
+naming the primitive and the exception type and carrying no traceback.
+`GET /v1/admin/health` with no `forest` answers `E_SCHEMA`/400 to a key that
+holds `admin`, and `E_FORBIDDEN`/403 to one that does not hold it on the
+forest it named. A test compares the MCP tool schemas against the engine's
+signature table and fails on any divergence. Covered by tests.
+
+### C.13 Where the material sits in time (v0.52)
+
+"Last week I wrote something about this" is how a person addresses their own
+knowledge, and the forest already knows: every passport carries `created`
+and `updated` (A.3), the Gardener stamps `created` when a document enters,
+git versions both, and `reindex` rebuilds them identically. What was missing
+was any way to **use** them. An agent asked about last week had to sweep the
+whole forest and hope the ranking floated something recent — on a forest of
+four nodes that is invisible, and on a forest of forty thousand it is the
+difference between a search and a scan.
+
+A window is also the cheapest filter this system has: it is decided from the
+catalog row, before a single body is opened. A `sniff` bounded to seven days
+opens the files of those seven days and no others.
+
+Two additions, and the second is what makes the first safe.
+
+#### C.13.1 Windowed reads
+
+`locate`, `sniff`, `scan`, `harvest` and `answer` (J.10) take three
+optional parameters:
+
+- `since`, `until` — inclusive bounds, accepted as `YYYY`, `YYYY-MM` or
+ `YYYY-MM-DD`. A partial bound expands to its own period: `since:
+ "2026-08"` is 2026-08-01, `until: "2026-08"` is 2026-08-31, `until:
+ "2026"` is 2026-12-31. Either may be given alone.
+- `date_field` — `"created"` (default) or `"updated"`.
+
+Normative:
+
+1. **Optional, and absent means unchanged.** A call without them behaves
+ byte-for-byte as it did before this version: same candidates, same
+ ranking, same budget, same fields. Nothing about a window is ever a
+ default, because a default window is a forest that quietly shrank.
+2. **A window narrows the candidates, never the ranking.** It is a
+ metadata filter (C.6b's split is untouched: `locate` still reads
+ curated metadata, `sniff` still reads bodies); `score` and `heat` mean
+ exactly what they meant.
+3. **Applied where candidates are chosen, not after the cut.** Filtering a
+ ranked top-`k` after the fact returns fewer than `k` results while the
+ forest holds more that match — the caller then reads scarcity that the
+ implementation invented. The predicate belongs in the query that selects
+ candidates.
+4. **An unparseable bound is `E_SCHEMA`**, naming the accepted forms. It
+ MUST NOT be ignored: a filter silently dropped is a lie about what was
+ searched, and it is told to a caller who will believe the result covers
+ only their window. Same for an unknown `date_field`, and for `since`
+ later than `until`.
+5. **Undated nodes never match a window**, and the response says how many
+ were excluded that way (`undated_excluded`, present only when there are
+ any — a forest with complete passports should not spend a line of its
+ budget on a zero). A node whose passport carries no usable date is not
+ "recent" and is not "old"; dropping it silently is how a windowed search
+ loses material nobody suspects. Under a policy the number counts only
+ nodes in scope, like every other count a scoped caller reads (J.3).
+6. **The response echoes the window it applied**, normalized —
+ `window: {since, until, date_field}`. The caller sent `2026-08`; what it
+ gets back is the two dates the search actually used, which are also the
+ two it can reuse.
+7. **A bounded hunt is bounded at every hop.** When `answer` walks (J.10.5)
+ rather than sweeping, the window is forced onto every searching call the
+ model makes, not only onto the entry search, and the prompt states the
+ bound. A model that forgot its window on the second hop would produce an
+ answer labelled with a period it left — which is worse than no window,
+ because the label is what a reader trusts. The bound REPLACES any
+ window the model authors on those calls and on `calendar` (v0.79), and
+ the prompt says so; absent a caller's window the model MAY author one
+ per call, and the hop record reports it (J.10.5).
+8. **A window names the answer it produced.** Unlike `min_evidence`
+ (J.10.10), a window on `answer` MUST enter the J.10.7 cache key: it
+ changes which nodes the retrieval could reach at all, so the same
+ question bounded to June and to July are two questions, and one entry
+ serving both would answer one with the other. It enters only when set,
+ so a call without it keys exactly as it did before this version.
+9. **The derived layer is not a clock.** `date_field` names a passport
+ field and nothing else. "When it was indexed" MUST NOT become a third
+ option: `_derived/` is disposable and rebuilt on demand (`reindex`), so
+ a window over an indexing timestamp would mean one thing today and a
+ different thing after a rebuild, with no way for a caller to know which.
+ The date a document entered the forest is already `created`, written by
+ whoever planted or ingested it, versioned in git with the node.
+
+#### C.13.2 The empty window explains itself
+
+The risk of any filter is that it turns "nothing here" into "nothing
+anywhere", and a window is the easiest filter in this system to get wrong:
+a caller guessing at last week's dates, a forest whose material sits in a
+different month, a `created` that never got written. C.1.1's rule therefore
+applies with one addition — **when a windowed read comes back empty it says
+whether the window was the reason.**
+
+An empty windowed read carries, beside C.1.1's `searched` and `hint`:
+
+- `matched_window` — how many nodes fall inside the window at all,
+ independent of the question. `0` means the window is the reason and the
+ question was never tested; a number above zero means the window held
+ material and the question did not match any of it. Those are different
+ mistakes with different repairs, and a caller cannot guess which it made.
+- a `hint` naming the forest's actual date range and the nearest periods
+ that DO hold material, so the next call is a correction rather than
+ another guess.
+
+Both are computed only on the empty path, for C.1.1's reason: a caller
+holding results has already been told what it needed.
+
+#### C.13.3 `calendar(scope?, date_field="created", granularity="month", since?, until?, limit=24) → Calendar`
+
+The map that makes a window a choice instead of a guess. It answers, from
+the catalog alone and without opening a single body: **what periods hold
+anything, and how much.**
+
+```json
+{
+ "date_field": "created",
+ "granularity": "month",
+ "range": {"first": "2026-01-03", "last": "2026-08-19", "nodes": 82},
+ "buckets": [
+ {"period": "2026-08", "since": "2026-08-01", "until": "2026-08-31", "nodes": 12},
+ {"period": "2026-06", "since": "2026-06-01", "until": "2026-06-30", "nodes": 31}
+ ],
+ "undated": 3,
+ "truncated": false
+}
+```
+
+- `granularity`: `day` | `week` | `month` | `year`. A week is ISO-8601
+ (Monday-first, labelled `2026-W34`).
+- **Most recent first.** The question that brings a caller here is almost
+ always about the recent end, and `limit` cuts the far end.
+- **Empty periods are omitted.** A three-year gap costs nothing to report
+ and the buckets that exist are exactly the answer to "which weeks have
+ anything".
+- **A bucket's `since`/`until` are the strings the reads take.** The map
+ hands back the query: there is no arithmetic for the caller to get wrong
+ between finding a period and searching it.
+- `range` is the real first and last date in scope, so a caller that wants
+ a window nobody listed can still see the edges of what exists.
+- `undated` is reported for C.13.1 rule 5's reason.
+- **A walk may call it (J.10.5, v0.79).** It is on the loop's whitelist for
+ the reason `coverage` is: metadata only, counts by the policy, and the
+ one read that turns "today" into a window instead of a guess.
+- Budget ≤ 800 tokens; over it, buckets drop from the far end with
+ `truncated: true`. `limit` is capped at 120.
+- `scope` restricts to a branch's subtree, exactly as `sniff`'s does.
+- Under a policy (J.3) every count covers **only nodes in scope**. A global
+ count here would be a size oracle, and a finer one than `locate` could
+ ever be: it would describe the shape of a region the principal was never
+ granted, period by period.
+
+Two implementation rules, normative because both are ways the obvious code
+gives up the performance this section exists for:
+
+- **The window is a bare comparison on the column.** `created >= ?` uses
+ the catalog's index; `substr(created, 1, 10) >= ?` computes the same
+ answer and cannot, which on a forest large enough to want windows is the
+ whole difference. The upper bound is therefore held exclusive, one day
+ past the caller's inclusive `until`, so a column carrying a time still
+ falls inside its own last day. The catalog indexes both date columns.
+- **The aggregation belongs to SQLite.** `calendar` MUST group in the
+ database and return one row per period, not one row per node folded in
+ the host: the counting is the part that scales with the forest, and it is
+ the part a database does in C. A scoped `calendar` (J.3) pushes the
+ policy's own prefixes into the same `WHERE`, so scoping stays a predicate
+ rather than a walk. Where the same fold exists in the host as well, the
+ two MUST be compared mechanically over a real corpus — C.5.3's rule
+ again: two spellings of one decision agree only where somebody checked.
+
+**F.64 (acceptance).** A `locate`, `sniff`, `scan`, `harvest` or `answer`
+carrying `since`/`until` returns only nodes whose `date_field` falls inside the
+inclusive window, with `k` still met when the window holds enough matches,
+and echoes the normalized window. `since: "2026-08"` and `since:
+"2026-08-01"` produce identical results. A malformed bound, an unknown
+`date_field`, and `since` after `until` are each `E_SCHEMA`. A node with no
+date is absent from every windowed result and counted in
+`undated_excluded`. An empty windowed read carries `matched_window` and a
+hint naming the nearest populated periods; the same read without a window
+carries neither. `calendar` returns buckets most recent first, omits empty
+periods, and each bucket's `since`/`until`, fed back into `locate`, return
+that bucket's nodes. Two `answer` calls differing only in their window are
+two store entries. Under a scoped policy no count — and no number quoted in
+a hint — exceeds the nodes in scope. Covered by tests.
+
+### C.14 `prune(id: string, force: bool = false) → PruneResult` (v0.56)
+
+The write you can take back. Until v0.56 every `plant` was irreversible —
+no primitive removed a node — and the consumer team measured the
+consequence in their own behavior: three probe nodes left as permanent
+garbage in a production forest, tagged `delete-me` because a tag was the
+most deletion the product offered, and the stated conclusion that *an
+agent that cannot undo should not write alone*. The rational strategy
+under that constraint is to write on the local disk, where `rm` exists,
+and promote only finished work — which is exactly the habit this product
+exists to end. A removal primitive is therefore not a convenience; it is
+what makes unsupervised writing rational.
+
+```json
+{"id": "probes/size-probe-report", "pruned": true,
+ "backlinks_removed": 0, "payload_moved": null, "commit": ""}
+```
+
+Normative:
+
+1. **What it removes.** The node's `.md` leaves the working tree through
+ git (the deletion is staged and committed as `prune()` — history
+ keeps every byte, which is what makes this *soft*: recovery is an
+ operator act, `git revert` or a Part I snapshot, never a primitive).
+ The parent index loses the child's entry and its coverage counts are
+ refreshed, in the same commit (the reverse of C.7's planting, using
+ the same indexer). The catalog row is deleted synchronously, so no
+ read offers the node again. Derived remnants — heat, sniff memos,
+ embeddings — become unreachable the moment the row is gone and are
+ the derived layer's to evaporate; a `reindex` owes them nothing.
+2. **A local payload moves to the graveyard, never dies with the call.**
+ A payload file inside the forest (a dataset's `.db`, an `_assets/`
+ media file) is MOVED to `_derived/graveyard//` — binaries
+ are not in git (A.3.1), so unlink would be the one truly
+ irreversible byte of a primitive that promises to be reversible.
+ The graveyard is `_derived/`: disposable, never a source of truth,
+ and the operator's to empty. A remote payload (G.9) is left where it
+ lives — the forest never owned it.
+
+ **And so does a source staged inside the forest (v0.61).** When the
+ node's `source_path` resolves under the forest's OWN `_derived/` —
+ the upload staging area, disposable by the same construction — that
+ file moves to `_derived/graveyard//source/` and the result
+ says `staged_moved`. Leaving it behind is what let a later pass over
+ the staging area read the pruned document as *new* and plant it
+ again. A source tree on the host is never touched, by this or any
+ other primitive: deleting somebody's file is not a thing a removal
+ inside a forest may do (G.3), and the distinction is the containment
+ test, not a flag.
+3. **What points at the node refuses the removal (`E_ANCHORED`, HTTP
+ 409).** A node with `edges_in` is refused, and the refusal carries
+ `anchors: [{source, rel}, …]` (capped at 20, `anchor_count` exact) —
+ the caller asked to remove something other nodes cite, and the list
+ is what it needs to decide. `force: true` overrides: the same commit
+ that removes the node also edits every pointing node's frontmatter,
+ dropping exactly the links whose target died — a forest MUST NOT be
+ left pointing at a hole it created on purpose. `backlinks_removed`
+ counts them.
+4. **A branch with children is never prunable — `force` included.** The
+ refusal is `E_ANCHORED` naming the child count. Prune the children
+ first: recursive deletion is a loop the CALLER writes, one audited,
+ one-node decision at a time, not a flag that can erase a subtree in
+ one call. The forest root's `_index` is never prunable (it has no
+ parent to account for it).
+5. **A pruned id is free.** `plant` of the same id afterwards is legal
+ and creates a NEW node — ids are immutable *while they exist*
+ (C.7.2's duplicate refusal protects living nodes, not ghosts). The
+ forest's git history distinguishes the generations.
+6. **Scope and capability (J.3).** `ScopedVine.prune` gates the id
+ exactly as every read does — out of scope answers `E_NOT_FOUND`
+ byte-identical to absent — and rides the same write capability as
+ `plant`/`graft`: J.2.6's mask ceiling is a closed set, and a fourth
+ token would invalidate every issued key. Under `force`, a pointing
+ node OUTSIDE the caller's scope is not silently edited: the whole
+ call refuses (`E_ANCHORED`, the out-of-scope anchors reported only
+ as a count — J.3 forbids naming them), because a write the caller
+ cannot see is a write it cannot have authorized.
+7. **Audited and emitted.** The audit row and the J.16 `prune` webhook
+ event carry the id, the type, `backlinks_removed` and the commit —
+ identity, never content, like every event.
+8. **Answer-cache honesty is free.** A cached `answer` whose material
+ cited the pruned node re-runs its sweep on every ask (J.10.7); the
+ reading fingerprint no longer matches, so the stored reply is
+ replaced, never served stale. Stated, not new machinery.
+
+### C.15 `transplant(id: string, new_id: string) → TransplantResult` (v0.58)
+
+An id is a path, so misplacement was permanent: the consumer team put a
+document under the wrong branch and the only remedy was rebuilding it by
+hand — plant a copy, restring every link, prune the original, lose the
+history. Every deferral of this feature named the same fear: a move
+rewrites every edge, trail and cache key that names the node. The answer
+is not to make the id mutable — it is to make the move ONE audited act
+that leaves a waymark.
+
+1. **One leaf node, whole, in one commit.** The passport is rewritten
+ under `new_id` (frontmatter `id` updated, everything else preserved —
+ `created` included) and the old file removed in the same commit; the
+ change is small enough that git's rename detection keeps
+ `history --follow` unbroken. A local payload moves beside the new
+ passport. Branches refuse (`E_SCHEMA`): move the leaves, one audited
+ decision at a time — C.14's own rule against subtree operations in
+ one call. Root and `_meta/*` never move; `new_id` obeys every rule a
+ plant obeys (parent chain exists, id free, `expected_parent`).
+2. **Every backlink is rewritten, or the call refuses.** The nodes that
+ point at `id` (catalog `edges_in`) are edited to point at `new_id`,
+ inside the same commit — and exactly as C.14 rule 6: an anchor
+ outside the caller's scope refuses the whole call with a count,
+ because a write the caller cannot see is a write it cannot have
+ authorized. There is no `force` here: a move that stripped links
+ instead of following them would be a prune wearing a move's name.
+3. **The old address is a waymark.** `new_id`'s passport records
+ `moved_from: [old ids]` (appended across chained moves) and the old
+ id joins `aliases` (union, 16-cap, overflow counted) — so `locate`
+ finds the old name forever. The redirect map is DERIVED from
+ `moved_from` at indexing: the files are the truth, and a reindex
+ rebuilds it (nothing lives only in `_derived`).
+4. **A read of the old id answers `E_MOVED`, naming `moved_to`** — a
+ read says what it did not do, and "it is not here" is half the
+ truth when the other half is known. **Every read by id (v0.61):**
+ `look`, `pick`, `move`, `history`, `view` and `query` alike. Stated
+ generally in v0.58 and implemented in five of the six `pick` read
+ the file directly and answered a bare `E_NOT_FOUND`, which is the
+ call an agent holding a written-down id actually makes, so the
+ waymark was missing from exactly the path it was built for. A
+ redirect honoured by half a surface teaches callers not to trust it.
+ HTTP 404 (it is not at this
+ address); the envelope's `data` carries `moved_to`. Under a policy,
+ the new address is disclosed ONLY when it lies in the reader's own
+ scope; otherwise the answer is the byte-identical `E_NOT_FOUND` of a
+ node that never existed — a waymark must not be a periscope into a
+ region nobody granted.
+5. **Heat follows the node, best effort.** The trails store re-keys the
+ old id's heat to the new one; it is `_derived`, evaporation heals
+ whatever a crash loses, and the pheromone was earned by the content,
+ which did not change.
+6. **Scope and capability:** `write`, gated on BOTH addresses — reading
+ rule on the source (out of scope answers `E_NOT_FOUND` as absent),
+ writing rule on the destination (`E_FORBIDDEN` naming the grant,
+ C.7's own asymmetry). Audited with both ids; J.16 emits
+ `node.transplanted` (identity only).
+7. **The stores stay honest for free:** the sweep's reading fingerprint
+ keys material by id, so a moved node misses cleanly and the fresh
+ run reads the new address; the walk's HEAD key invalidates on the
+ commit. Stated, not new machinery.
+8. `graft`'s `set_parent` remains a refused unknown key: an address is
+ not a field, and an edit that relocates is this primitive, audited
+ as itself.
+
+### C.16 `history(id: string, limit: int = 20) → History` (v0.58)
+
+Every write has been a commit since C.7, and since v0.57 the acting
+principal rides the commit itself — yet nothing on the surface could
+read any of it back. The consumer team accumulated ten commits in one
+session and put it plainly: git answers "what changed and who changed
+it" better than the forest does. This primitive is that answer, on the
+forest's own surface.
+
+1. **The node's commits, newest first, through renames.** Backed by the
+ repo's own log with rename-following, so a transplanted document's
+ past does not begin at its move. `limit` ≤ 50, default 20; the
+ response says `total` is unknowable cheaply and instead carries
+ `truncated` + the oldest sha served — resuming deeper is Part I's
+ territory (the bundle has everything).
+2. **Each entry carries:** `commit` (sha), `at` (ISO 8601 **with time
+ of day** — the intraday answer day-precision frontmatter could
+ never give; D-01b closes here), `action` (the commit subject's own
+ prefix: `plant`, `graft`, `tend`, `prune`, `transplant`,
+ `gardener(...)`, `ranger(...)`, `init`, or the subject verbatim when
+ it matches no convention), `message` (the subject line), and `by` —
+ the attribution trailer's value when the commit carries one
+ (`station-principal:`, the trailer J.4 defines; absent on engine-only
+ writes, honestly).
+3. **Read semantics throughout:** `read` capability, scope-gated like
+ `look` (out of scope answers `E_NOT_FOUND` byte-identical to
+ absent), token-budgeted (`BUDGET_HISTORY` = 800, whole entries drop
+ from the tail with `truncated: true`), traced and heat-depositing
+ like any read. A pruned id has no history to ask for (`E_NOT_FOUND`
+ — recovery is Part I's, an operator act); a moved id answers
+ `E_MOVED` like every read of a waymark (C.15 rule 4).
+4. **Listing, never time travel.** `history` says what happened; it
+ does not serve old bodies. Reading a document *at* a commit is
+ deferred with the restore design (Part I) — a listing that also
+ time-travelled would be two primitives wearing one name.
+
+### C.17 `coverage(scope?: string, date_field?: string) → Coverage` (v0.59)
+
+Every read in Part C says what it did not do. `locate` names the count it
+searched; `look` names the field it clipped; `scan` returns `total` beside
+`returned`; the sweep counts what it suppressed. None of that reaches the
+question that comes *before* any of them: **what is in this forest at
+all?**
+
+A consumer agent asked a faithful question, received a faithful answer
+built from a real document, and the answer was wrong about the subject —
+because the branch holding the material had never been ingested. Nothing
+on the surface could have told it so. A partial corpus produces an answer
+in the exact shape of a complete one: cited, sourced, traced. `coverage`
+is the read that makes the shape of the corpus askable.
+
+1. **Metadata only, grouped in SQLite.** No file is opened, no body is
+ read, and no count is computed one node at a time — every aggregate is
+ one statement over the catalog, so a forest of forty thousand nodes
+ answers in the same shape as one of forty (C.13.3's rule, for the same
+ reason).
+2. **A root is where the caller starts.** Unrestricted, the roots are the
+ branch children of the forest's root `_index`. Under a policy they are
+ the principal's own granted subtrees — the same list `forests()`
+ publishes, so the two surfaces cannot disagree about where a session
+ begins.
+3. **Each root carries:** `id`, `title`, `nodes` (everything under its
+ prefix, the root index included), `branches`, `first`/`last` (the
+ oldest and newest date under it, in the requested `date_field` —
+ `created` by default, `updated` the other, and **never** an "indexed"
+ date, C.13's rule), `origin` when the material under it declares one,
+ and `without_origin` — how many of its nodes carry none.
+4. **`origin` is reported as the prefix `scan` takes.** The value is the
+ longest common prefix of the origins under that root, ending at a
+ URI path boundary, and it is exactly the string
+ `scan(filter: {origin_prefix: …})` accepts (C.6). A caller must never
+ have to construct it: the map names the strings the reads take, which
+ is the contract C.13.3 gives windows.
+5. **A partial origin is stated, never rounded.** `without_origin` exists
+ because a root where nine nodes in ten know their source and one does
+ not is not a root with an origin. A forest ingested before G.2.7 has
+ no origins at all, and `coverage` must say that rather than omit the
+ field and read as "no source".
+6. **Totals beside the roots:** `total` (nodes in scope), `types`
+ (`{type: count}`), `sources` (`{source: count}` over the `source`
+ enum), `undated` (nodes with no date in the requested field, C.13.2's
+ count), `system` (the dialect's own `_meta/` files, which are the child
+ of no branch and therefore under no root), and `date_field`. A node
+ with no value in a grouped column falls in no bucket — a column with no
+ value is not a category — so a grouping need not sum to `total`, while
+ the roots plus the listing's own index plus `system` always do.
+7. **Under a policy every number is the policy's own.** Roots are
+ `Policy.roots`; every aggregate is filtered by the policy's own
+ prefixes as SQL (`Policy.sql_scope`), keyword-only and host-supplied,
+ unreachable from the wire (G.2.5's construction). A global count here
+ would describe the size and shape of a region nobody granted — a finer
+ oracle than `locate`'s `searched`, which is already scoped for this
+ reason.
+8. **It says what is there; it never guesses what is not.** No `missing`
+ list, no comparison against a source tree, no inference about what an
+ operator meant to ingest. The forest can only testify about itself.
+ What changes is that a caller reads the whole shape in one cheap call
+ and draws the conclusion — "there is no `docs` root here, so ask
+ somewhere else" — which previously required having the source tree
+ mounted on disk.
+9. **Read semantics throughout:** `read` capability, traced like every
+ read, budgeted (`BUDGET_COVERAGE` = 800). Roots drop from the tail
+ with `truncated: true`; **the totals never drop** — they are the
+ answer's spine, and a coverage report whose total was clipped would be
+ the failure it exists to prevent. It deposits no pheromone: it chose
+ no node.
+10. **`scope`** narrows the whole answer to one subtree — the roots
+ become that branch's own children, and every total counts inside it.
+ The same string `scan` and `sniff` take.
+11. **A payload the forest names and does not have is counted (v0.61).**
+ Each root carries `payload_missing` how many of its nodes declare a
+ LOCAL payload that is not on disk and the totals carry the same
+ number for the scope. It is the one integrity fact this primitive can
+ establish without opening a file: the passport says the path, and the
+ filesystem answers. A forest that announces `dataset: 2` while
+ neither serves a query is announcing capacity it does not have, which
+ is precisely the class of silence C.17 was created to end; the
+ difference is that here the forest CAN testify, so it must. Remote
+ payloads (G.9) are not counted: their absence is a fetch away and is
+ not a fact the catalog holds. The count is filtered by the policy
+ like every other number here (rule 7).
+
+The field named `coverage` on a branch node (A.4) answers the same
+question one level down — what this branch holds. The primitive is that
+field for the forest.
+
+## Part D Telemetry (feeds the pheromone and the Monkey Bench)
+
+Every navigation session generates a trace in `_derived/traces/.jsonl`, one event per primitive call: `{ts, session, primitive, id, tokens_in, tokens_out, elapsed_ms}`.
+
+When the call obtained a query vector through K.2/K.6, the event also
+carries `embed_ms` — the milliseconds of that embed, memo hits included
+(a hit's near-zero is the memo working, and only this figure can say so).
+`elapsed_ms` remains the whole span, embed included; an event for a call
+that ran no embed carries no such key and is byte-identical to v0.67. A
+pre-v0.68 event's absent `embed_ms` reads as "no embed ran", never as an
+unknown share (v0.68).
+
+At the end, the orchestrator MUST close the session with `outcome: {success: bool, answer_nodes: [ids]}`. This closing is what:
+1. Increments `heat` along the whole winning trail (whisper);
+2. Evaluates the shout (v0.6): when the session metric `trail_len` read
+ calls made before the first harvest of an answer node is `>= 4`, the
+ answer nodes come back in `suggest_shortcuts`, and the orchestrator MAY
+ `graft` a `discovered-shortcut` from the hunt's entry node (C.8 applies);
+3. Feeds the Monkey Bench metrics: **hops-to-banana** = number of `look`+`move` calls before the answer's first `pick`/`query`; **tokens-to-banana** = sum of session tokens_out; **banana precision** = correct answer_nodes / harvested answer_nodes; **trail_len** (v0.6) = read calls before the first harvest of an answer_node.
+
+---
+
+## Part E The Troop (Parallel Swarm Navigation)
+
+N monkeys (navigator SLM instances) hunt the same banana in parallel, coordinated by **intra-session stigmergy**: they never exchange messages they smell each other's trails. The Vine is already N-readers by design (C.9); the Troop is an **orchestrator**-side component (the MCP client side), not the bank.
+
+### E.1 Hunt protocol
+
+1. **Frontier partition:** `locate(query, k=N)` → each monkey gets a distinct entry point (top-N results). Without partitioning, everyone explores the same trail and the parallelism is wasted.
+2. **Session pheromone:** each monkey, upon judging a node promising (the SLM's own call: "relevant to the question? yes/no"), deposits `session_heat` in the hunt's scope (`_derived/trails.db`, session namespace). `locate`/`look`/`scan` inside the session apply `score x (1 + beta*session_heat)` monkeys gravitate toward regions where others found signal.
+3. **Shared visited set:** `look`/`scan` digests already made in the session land in a shared cache; a monkey that would touch an already-visited node gets the cached digest instead (zero cost), and the orchestrator redirects it to unexplored frontier.
+4. **Stop:** the hunt ends when (a) a monkey harvests a banana with high confidence (self-assessment above threshold), (b) the troop's hop budget runs out, or (c) the frontier empties. A **judge** (may be the main model itself) aggregates the harvests and synthesizes the answer.
+5. **Post-session:** only the winning trail(s) convert `session_heat` into persistent `heat` (Part D). Losing trails evaporate with the session the swarm does not pollute long-term pheromone.
+
+### E.2 Implementation notes
+
+- **Concurrency:** asyncio in the orchestrator; the monkeys spend ~95% of their time waiting on inference. On the 3090, serving the N monkeys through the same inference server with *continuous batching* (vLLM/llama.cpp parallel slots) makes N=3-5 cost nearly the same wall-clock as N=1.
+- **Sizing:** N=3 is the default; above N~5 returns diminish (frontiers overlap in small forests). N is a Monkey Bench parameter, not a constant.
+- **New metric:** *troop speedup* = wall-clock hops (parallel rounds) vs the solo monkey's total hops, and total token cost (the troop spends more tokens in aggregate the speed x cost trade-off MUST be measured, not assumed).
+- **Phase:** Troop is Phase 1.5 requires the full Vine + telemetry (Part D) working. Nothing in Phase 0 changes, except ensuring `trails.db` supports session namespacing (already anticipated in the trace schema).
+
+## Part F Phase 0 Acceptance Criteria
+
+Deliverable: Vine (MCP, Python) + a manual test forest (~100 nodes, 10 branches, >=1 SQLite dataset) + test suite.
+
+1. All C.1-C.6b primitives functional with the exact contracts above (locate may be BM25-only), including `fields` in `look` and the Catalog serving `scan`.
+2. `plant`/`graft` atomic with a Git commit and index update, verified by test.
+3. Token budgets respected (tests with giant synthetic nodes verifying explicit truncation).
+4. `query` rejects all write SQL (injection suite: `;DROP`, `ATTACH`, multi-statement, PRAGMA).
+5. Demo: a local SLM (Qwen 7-14B Q4), given only the MCP tools and the master branch, answers 10 multi-hop questions about the test forest, with recorded traces and computed metrics.
+6. Latency: p95 of `look`/`move`/`pick` < 10ms, `query` < 50ms, `locate` < 100ms, `sniff` < 100ms (local forest, NVMe).
+7. `sniff`: finds a fact present ONLY in the body (invisible to `locate`), attributes the correct section, respects `scope`, normalizes case/diacritics, and rejects empty terms (`E_SCHEMA`) all covered by test.
+8. Payloads outside Git (A.3.1): the Vine's commit ignores non-`.md` files even if requested, and the test forest's `git ls-files` contains no binary both verified by test.
+9. `harvest` (C.6c): buried fact returns the right matched section under term-scarcity refinement; small bodies come whole; `k` and the 4000-token budget are honored with explicit truncation all covered by tests.
+10. Forest registry (C.0): per-request forest selection works across two forests with isolated results; lazy first-touch open auto-indexes; path escape and non-forest directories are rejected; single-forest mode serves v0.3 clients unchanged all covered by tests.
+11. `tend` (C.10): accepts only single-statement INSERT/UPDATE/DELETE (its own
+ injection suite: DDL, ATTACH/PRAGMA, multi-statement, WHERE-less
+ UPDATE/DELETE all rejected); refreshes `payload_hash` and commits only the
+ `.md`; read-only Vine rejected; failed SQL leaves the payload untouched;
+ `vine validate` warns on payload hash drift all covered by tests.
+12. Dataset planting (C.7.1): declarative schema births a queryable payload
+ (`look` shows the auto query manual, `query`/`tend` work immediately);
+ name/type/limit validation rejects bad schemas (`E_SCHEMA`), including
+ injection attempts via table/column names; existing payload is never
+ overwritten; rollback removes the newborn `.db`; the commit carries only
+ the `.md` all covered by tests.
+13. Gardener (Part G): `adopt` of a mixed source tree (markdown, text,
+ tabular) produces a forest that lints with zero errors folders
+ mirrored as branches, passports carrying `source_path` + `source_hash`,
+ non-text originals archived under `_assets/`, datasets born with rows
+ loaded, no binary in the forest git; `sync` classifies new / changed /
+ deleted sources by hash-diff with no false positives, updates changed
+ passports through the audited write path, and never deletes; converter
+ discovery honors the config-hook > entry-point > built-in order; an
+ external command hook converts a file end-to-end; an `on_curate` hook
+ can enrich a draft and a crashing hook does not abort the ingest all
+ covered by tests.
+14. Ranger (Part H): under a synthetic clock, one half-life halves heat and
+ dust rows vanish; stale session scopes are cleared; promotion raises a
+ well-used proposal's link confidence with an audited commit; pruning
+ removes only cold, low-confidence links links with confidence 1.0 or
+ without a link-level confidence are NEVER touched; the health report
+ flags an oversized branch (`needs_split`), an over-linked node and a
+ stale passport; repeated runs are idempotent all covered by tests.
+15. Tiered storage (G.7/G.8): a `cached` adoption keeps node `.md`s body-
+ free with the flesh in `_derived/bodies/` and OUT of git, while `pick`
+ and `sniff` resolve it transparently; a `reference` adoption reads the
+ source live; an unresolvable body fails with `E_NOT_FOUND` + hint
+ while `locate`/`look` keep working (degraded map); `archive: never`
+ creates no `_assets/` copies; `sync(path=...)` reconciles exactly one
+ file; the mtime+size fast-path skips hashing unchanged files all
+ covered by tests. (Fetcher cache, H.6 eviction and Part I snapshots
+ are covered by tests as their implementations land.)
+16. Gardener v2 (G.2.1 + G.4.2.1): the DOCX built-in extracts headings,
+ plain paragraphs, pipe tables, fragmented runs (joined whole) and
+ text-box text from a real `.docx`, excludes headers/footers, and a
+ missing `python-docx` yields `unsupported` (never a crash) with a
+ command hook still able to claim `.docx`; edge proposals accept only
+ catalog-offered targets at link-level `confidence: 0.3` with `rel:
+ related-to` (hallucinated ids, self-links, duplicates and over-cap
+ picks are dropped; branches are never candidates), the planted node
+ carries the proposed links, and the Ranger's H.2 machinery manages
+ them (promotable, prunable) all covered by tests.
+17. Rollup + Landmarks (G.4.4 + H.7): rollup replaces only `source: ingest`
+ branch summaries (hand-authored branches untouched unless `--all`),
+ runs deepest-first so parents see fresh child summaries, falls back
+ deterministically to an A.4-valid summary when the LLM fails, and
+ propagates the new summary into the parent's `## Sub-branches` entry
+ WITH the coverage suffix preserved; the Ranger populates the master
+ `## Landmarks` with top-degree non-branch nodes, a second run with an
+ unchanged graph produces no new commit, and degree-0 nodes never
+ appear all covered by tests.
+18. Station + ScopedVine (Part J): a fresh deployment plus one API key
+ serves REST, MCP and Studio against a registry of two forests; the
+ **leak suite** proves a principal granted only `projects/` cannot
+ obtain the id, title, summary, body, edge or snippet of any node
+ outside `projects/` through ANY primitive on ANY surface one test
+ per primitive per surface, `harvest` and `move` included; an
+ out-of-scope `look`/`pick` is byte-identical to the genuinely-absent
+ `E_NOT_FOUND`; a scoped `locate`/`scan`/`sniff` returns the same
+ response shape and budget fields as the unscoped call (filtering
+ precedes truncation); writes through the Station carry the acting
+ principal in the commit message and the audit log reconstructs a
+ session's full trail; capability gates reject `query`/`tend`/`plant`/
+ `graft` without the matching cap; and the engine suite passes with
+ zero edits under `src/monkeyllm/` all covered by tests.
+19. Per-forest inference (J.10): a provider's key is never returned by any
+ surface (create, list, or re-edit) and an empty key on update keeps the
+ stored one; a binding is refused for an unknown provider or an unknown
+ role; removing a provider removes the bindings that pointed at it; the
+ two roles can hold different models on the same forest; `answer` and
+ `curate` refuse politely when no model is bound, and enforce the `read`
+ and `write` capabilities respectively; and the load-bearing one for
+ a principal scoped to a subtree, the material handed to the answering
+ model contains no node outside that subtree all covered by tests.
+20. Console, lifecycle and ingest (J.5/J.7/J.8): every user-facing string
+ resolves in all three languages, with a test that fails on the first
+ key missing from any of them; the console renders in both themes and
+ holds no credential in its bundle; forest creation refuses ids
+ containing separators or relative segments **before** joining them to
+ the root, refuses an id that already exists, and grants the creator
+ the forest it just made; ingest refuses without the `ingest`
+ capability and refuses a `dest` outside scope, an uploaded filename
+ that escapes its staging directory is rejected, and a forest with no
+ `ingest` binding still ingests with G.4-derived summaries; and a
+ `projects/`-scoped principal's console offers only `projects/` in its
+ tree, its scope picker and its dataset list all covered by tests.
+21. Credentials (J.2.1/J.2.2/J.5.4): a username and password exchange for a
+ session token that authorises exactly what the same principal's API key
+ would and no more; a Station with no `MONKEYLLM_STATION_PASSWORD` set
+ has **no** password door rather than a default one; a password is
+ stored only as a salted memory-hard hash and a principal without one
+ cannot log in; an **expired** key, a **revoked** key and an unknown key
+ are all rejected identically; `last_used_at` advances on a successful
+ call; listing returns prefixes and never a secret, and a secret is
+ returned exactly once at creation; session tokens do not appear in the
+ token console; and the load-bearing one an administrator of one
+ forest is refused when minting or revoking a key for a principal that
+ also holds a grant on a forest they do not administer, so a token
+ cannot be used to reach across the registry all covered by tests.
+22. Per-forest administration and navigation (J.3.2/J.5.1): every
+ `/v1/admin/*` route refuses a principal without `admin`, proven by a
+ sweep that enumerates the routes rather than a hand-written list that
+ a new route can quietly escape; and on a two-forest registry, an
+ administrator of one forest sees **no** principal, branch prefix or
+ audit entry belonging to the other while an administrator of both
+ sees everything. Navigation lists exactly the permitted consoles for
+ the selected forest, and each console still guards itself when reached
+ with the capability missing all covered by tests.
+23. Person-shaped governance (J.2.3/J.5.5): one `POST /v1/admin/people`
+ creates a principal, grants it, sets its password and mints its key,
+ and the resulting password logs in while the resulting key reads —
+ proving onboarding needs one request; each step re-checks its own rule,
+ so an administrator of one forest is refused the credential steps for a
+ principal that also holds another forest **while the grant it was
+ entitled to make still applies**, and the response names what was
+ refused; clearing a password removes the sign-in; revoking all of a
+ person's keys stops all of them at once; and `GET /v1/admin/people`
+ returns only administered forests' grants and tokens all covered by
+ tests. A grant naming **several** forests lands on each of them, so the
+ key minted in the same request reads in every one; naming a forest the
+ caller does not administer refuses that forest **by id** while the rest
+ still apply; and a multi-forest `revoke_access` removes exactly the
+ grants named also covered by tests.
+
+24. The Gauntlet (Part K): with no embedder, an empty index, or an index
+ whose recorded model differs from the embedder's, `look`, `move` and
+ `scan` return **byte-identical** responses to the same calls made with
+ the feature absent entirely proven by comparing the two, not by
+ inspecting a flag; a mismatched index also turns hybrid `locate` off
+ and is reported by validation rather than silently ranking across two
+ vector spaces; when active, the frontier order changes, the response
+ says it was conditioned and toward what, and the per-call opt-out
+ restores the unconditioned order within the same session; and the goal
+ is embedded once per hunt rather than once per hop all covered by
+ tests.
+
+25. Map projections (J.11): for a scoped principal, `GET /graph` returns no
+ id the same principal cannot `look` at, every edge it returns has both
+ endpoints in scope, and every `degree` it reports equals the degree
+ computed from the returned edges alone proven by recomputing, not by
+ trusting the field; `GET /trails` exposes persistent heat only, never a
+ session scope; both flag `truncated` when a bound cut the answer; and
+ the Explore console's graph, tree and file modes read only these
+ endpoints and the Part C primitives all covered by tests.
+
+Out of scope for Phase 0 (do not implement): embeddings/vectors, `same-as` compaction, S3/R2 sync, multi-writer, Troop (Part E Phase 1.5; only ensure session namespacing in trails.db). Automatic ingest left this list in v0.9 (Part G); evaporation and promotion/pruning left it in v0.10 (Part H).
+
+---
+
+## Part G The Gardener (ingest pipeline, spec v0.9)
+
+The Gardener turns raw directories into forest. It is **trusted
+infrastructure** (it runs with the operator's authority, not an agent's),
+but it writes through the same audited mechanics as everything else: nodes
+are born via C.7 `plant`, datasets via C.7.1, and only `.md` ever reaches
+git (A.3.1). Four stages only one of them needs an LLM:
+
+```text
+0 archive → 1 convert → 2 curate → 3 plant
+(raw copy) (pluggable) (LLM-optional) (existing primitives)
+```
+
+### G.1 Passport policy (normative)
+
+No file enters the forest without a passport: a sibling `.md` node that is
+the file's official presence in the graph. The agent always touches the
+passport first; the native file is payload.
+
+- Every passport records **`source_path`** (the source file's path, as given
+ to adopt/sync) and **`source_hash`** (sha256 of the source bytes) in its
+ frontmatter. These two fields make the forest itself the sync state —
+ there is no separate bookkeeping database to drift.
+- Conversion targets per format: markdown/plain text → `note` (body is the
+ content, no payload); convertible documents (PDF/DOCX/…) → `document`
+ (body is the converted markdown, original archived); tabular (CSV/XLS/
+ XLSX/tabular JSON) → `dataset` (C.7.1 birth with schema + rows; original
+ archived); **native SQLite (`.db`/`.sqlite`/`.sqlite3`) → `dataset` by
+ adoption of the file itself as the payload** (G.2.2); audio/image/video →
+ `media` (body is the transcript/description, original archived).
+- Archived originals live in the node's branch under `_assets/` (gitignored
+ per A.3.1), referenced by `payload` + `payload_hash`. Markdown/plain-text
+ sources are NOT archived (the body is lossless).
+- Node ids are deterministic slugs of the source-relative path (lowercase,
+ ASCII-folded, `[a-z0-9._-]`); a slug collision appends a short hash. The
+ id mirrors the source layout placement in `adopt` mode is structural,
+ not an LLM decision (ids are immutable; deciding placement at birth is
+ mandatory, see A.3).
+
+### G.2 Converter contract (public plugin API v1)
+
+A converter claims file extensions and produces either markdown or a
+dataset description:
+
+```python
+class Converter(Protocol):
+ extensions: set[str] # e.g. {".docx"}
+ def convert(self, path: Path) -> Conversion: ...
+
+Conversion = markdown(title, body) # → note/document/media
+ | dataset(title, schema, rows) # → C.7.1 birth
+ | payload(title, tables, samples) # → G.2.2 adoption
+```
+
+The third kind (v0.44) says *"the source file is already the payload"*: the
+converter reports the structure it read and the Gardener installs the bytes.
+It exists for formats the forest speaks natively, and G.2.2 is the only
+built-in that uses it.
+
+Discovery order (first converter claiming the extension wins):
+
+1. **Command hooks** from the forest's Gardener config (G.6) an external
+ command template (`"{input}"`/`"{output}"` placeholders) that must write
+ markdown; lets operators plug ANY tool (including copyleft-licensed
+ ones) without it ever becoming a dependency of this project.
+2. **Entry points**: packages installed in the environment declaring the
+ `monkeyllm.converters` group (`pip install monkeyllm-whisper` just
+ works). This is the third-party extension surface.
+3. **Built-ins**: `.md`/`.txt` passthrough; `.csv`/tabular `.json` (and
+ `.xlsx` when `openpyxl` is present, `.xls` when `xlrd` is) → dataset
+ with inferred column types, one table per sheet (G.2.4);
+ `.db`/`.sqlite`/`.sqlite3` → dataset by adoption (G.2.2, no optional
+ dependency `sqlite3` is the standard library and already this
+ project's payload format); `.docx` → markdown when `python-docx` is
+ present (G.2.1). Built-ins MUST keep the core dependency-light and
+ MIT-clean.
+
+A file with no claiming converter is reported as `unsupported` never a
+crash, never a silent skip.
+
+#### G.2.1 DOCX built-in converter (v0.12)
+
+Available when `python-docx` is importable (optional `ingest` extra —
+python-docx is MIT, its lxml dependency BSD; same gating pattern as the
+`.xlsx` built-in). Normative behavior:
+
+1. **Document order, single pass.** The converter walks the body's block
+ elements in order: `w:p` (paragraph) and `w:tbl` (table).
+2. **Paragraph text = the join of ALL descendant `w:t` elements.** This
+ captures runs fragmented mid-word by Word (joining merges them for
+ free) AND text living inside embedded text boxes (`wps:txbx`, legacy
+ `v:textbox`) content invisible to naive `paragraph.text` readers.
+3. **Headings**: paragraphs styled `Heading N` (or `Title`) map to
+ markdown `#`-headings (`Title`/`Heading 1` → `##` and deeper `#` is
+ reserved for the node title line). Everything else is a plain
+ paragraph.
+4. **Tables** become GitHub pipe tables: first row = header, cells take
+ the same all-`w:t` join. Nested tables flatten into their cell text.
+5. **Headers/footers are EXCLUDED**: page numbers and letterhead repeat
+ on every page and would pollute the scent (A.4 summaries derive from
+ the opening text).
+6. **Exclusions are not errors**: images/drawings contribute no text
+ (media adoption is G.5's path); an empty document converts to an
+ empty-bodied markdown with the filename title.
+
+Without `python-docx`, `.docx` files are reported `unsupported` (G.2) —
+operators can still route them through a command hook (e.g. a Pandoc or
+MarkItDown wrapper), which keeps priority over this built-in.
+
+#### G.2.2 SQLite adoption the file is the payload (v0.44)
+
+`.db`, `.sqlite` and `.sqlite3` are the one source format a forest already
+speaks: a dataset's payload IS a SQLite database (C.7.1 rule 5). The
+built-in therefore does not convert it **adopts**.
+
+Normative behavior:
+
+1. **A payload conversion, not a dataset conversion.** The converter opens
+ the source **read-only** (`mode=ro`), reads the structure of every table
+ in `sqlite_master` (`PRAGMA table_info` for names and declared types)
+ and the **first 3 rows of each** (G.2.3), and reports them. It never
+ reads the whole file into memory: the largest thing it holds is
+ `3 × columns` values per table.
+2. **The bytes are copied, never re-inserted.** The Gardener installs the
+ source file beside the passport as `.db` the same location and
+ the same `payload`/`payload_type`/`payload_hash` frontmatter a C.7.1
+ birth produces and plants the node with no `schema`, which is the
+ pre-existing "payload already on the filesystem" path (C.7.1 rule 2).
+ Rebuilding the database row by row would be unbounded in the source's
+ size, and lossy where the source's types, `WITHOUT ROWID` tables,
+ views, indexes or BLOBs do not survive a `TEXT|INTEGER|REAL|BLOB`
+ round trip. The destination of that round trip is byte-for-byte what
+ the source already was.
+3. **The C.7.1 schema limits do not apply.** They bound what a *model* may
+ declare (≤ 10 tables, ≤ 50 columns, four types); an operator adopting a
+ database they already own is not declaring anything. The map (G.2.3)
+ is bounded instead, which is where the cost actually lives.
+4. **A file that is not SQLite is an error, never a crash.** The 16-byte
+ header (`SQLite format 3\0`) is checked before anything is opened;
+ a mismatch is `E_SCHEMA` naming the file, reported per-file like every
+ other conversion failure (G.2). An encrypted or corrupt database fails
+ the same way, on the first read.
+5. **Adoption is a copy the rollback owns.** If the plant fails after the
+ file is installed, the copy MUST be removed C.7's atomicity extends
+ to it exactly as it does to a newborn payload. On `sync`, a changed
+ source replaces the payload whole and refreshes `payload_hash`,
+ `source_hash` and the map, through the audited `.md`-only commit (G.3).
+6. **`archive: always` does not double the file.** A payload conversion's
+ original is its payload; copying it a second time into `_assets/` would
+ store the same bytes twice under two hashes. The archive step is
+ skipped for this kind, as it already is for C.7.1 births.
+
+*Why not reference the source in place (informative):* because `sync` would
+then be the only thing standing between a moved directory and a dataset
+that answers `E_NOT_FOUND` to every question. Payloads are local to the
+Vine by design (G.9.4); a remote or foreign-tree payload is the exception
+that `content: reference` and the fetchers cover, not the default a folder
+drop should get.
+
+#### G.2.5 A count limit guards invention, not data (v0.45)
+
+C.7.1 rule 1 caps a declared schema at 10 tables and 50 columns. That
+guard is aimed at a **model**: an agent inventing a schema can invent a
+thousand columns, and nothing downstream would question it. G.2.2 rule 3
+already said the counts do not apply to an adopted SQLite file. The same
+reasoning applies to every conversion, and it took a real 141-column ERP
+export being refused to notice.
+
+Normative:
+
+1. **The Gardener's plants are `adopted`.** A schema the Gardener read off
+ a source is exempt from the two COUNTS tables and columns and from
+ nothing else. Names still match `^[a-z_][a-z0-9_]*$`, types are still
+ one of the four, `primary_key` still references declared columns, and
+ every one of those refusals still fires.
+2. **Exemption MUST NOT be reachable from the wire.** It is a keyword-only
+ argument of the library call, and the host's policy wrapper forwards
+ only the node so a request body carrying it is an error, not a
+ relaxed guard. A flag an agent can set is not a guard.
+3. **The bound moves to where the cost is.** What a wide table actually
+ costs is tokens in the body and in `look`, and G.2.3's caps are what
+ bound those sampled columns, sampled rows, clipped cells. Refusing
+ the *data* to protect a *rendering* was solving the wrong problem.
+4. **Nothing about `tend` or `query` changes.** DML-only, read-only,
+ single-statement, every injection guard exactly as it was. This is
+ about how many columns a table born from a file may have, and nothing
+ else.
+
+#### G.2.3 The sample map (v0.44)
+
+Every dataset passport's body from C.7.1 births, from the tabular
+converters, and from G.2.2 adoptions alike carries two generated
+sections:
+
+```markdown
+## Query manual
+
+Tables:
+- `findings_raw(page TEXT, type TEXT, detail TEXT)`
+
+Example queries:
+- `SELECT * FROM findings_raw LIMIT 5`
+- `SELECT COUNT(*) FROM findings_raw`
+
+## Sample rows
+
+### findings_raw 490 rows
+
+| page | type | detail |
+| --- | --- | --- |
+| /login | error | submit returns 404 |
+| /login | warning | password field has no autocomplete |
+| /admin/users | error | table renders before the fetch resolves |
+```
+
+Normative rules:
+
+1. **Structure and contents, for every table.** The manual names each
+ table and its columns with declared types; the sample names each table
+ and shows its **first 3 rows** (`SELECT * FROM LIMIT 3`, no
+ `ORDER BY` the file's own order is the cheapest and the most
+ honest). "Every table" is the point of the whole section: a database
+ whose second table is the one being asked about is not mapped by its
+ first. **Tables, in name order**: views are excluded because C.2's
+ `query_manual` excludes them, and a body claiming a view the digest
+ never mentions is two answers to one question; name order keeps the
+ map stable, so a `sync` rewrites it when the data moved and not when
+ SQLite happened to reorder its catalog.
+2. **This is what `sniff` reads.** `locate` searches curated metadata and
+ `sniff` searches bodies (C.6b), so a value that appears nowhere but
+ inside the payload is invisible to both a `.db` is opaque to every
+ text primitive the forest has. Three rows per table put the *shape of
+ the values* into the body: the vocabulary, the id format, the date
+ format, the units. It is not a substitute for `query`; it is the scent
+ that tells an agent which dataset to `query`.
+3. **Bounded by construction, and never silently.** ≤ 3 rows per table;
+ **≤ 12 columns per sampled row**; each cell clipped to 120 characters
+ with `…`; BLOBs rendered as ``, never as their bytes;
+ NULL as an empty cell; pipes and newlines escaped so the table stays a
+ table. At most **20 tables** get a sample section. Whenever a cap cuts
+ tables or columns the section MUST state how many were left out. A
+ map that quietly stops is worse than a short one.
+ The column cap is where the real cost lives (v0.45): a 141-column
+ export sampled whole would put three rows of 141 clipped cells into the
+ body and into every `look` of it. The **manual still names every
+ column** that is the structure, and an agent needs it to write SQL —
+ while the sample shows the leading ones.
+4. **Generated, never curated.** Both sections are rewritten whole by the
+ Gardener whenever the payload is rebuilt or replaced (G.3), and only
+ those two sections are: a curator's own headings in the same body
+ survive untouched. A caller-provided `## Query manual` at plant time is
+ still kept verbatim (C.7.1 rule 4) an author who wrote the manual
+ owns it.
+5. **The summary names the tables, not just the first one.** G.4.1's
+ derived summary for a dataset MUST count the tables and name as many as
+ the A.4 budget holds. "Table X with 6 rows" for a twelve-table database
+ is a scent for the wrong thing.
+6. **A wide table says it is wide (v0.47).** When a table has more columns
+ than the sample shows, the manual MUST state the count and that
+ `SELECT *` will not fit C.5.1's budget. The agent otherwise learns this
+ by spending a hop on a truncated result which now works, but a map
+ that could have said so beforehand and did not is a map withholding
+ something it knows.
+
+ This is the boundary of what the Gardener is allowed to contribute, and
+ it is a sharp one. **Column count is arithmetic; column relevance is
+ meaning.** The Gardener MUST NOT name "the important columns", order
+ them by likely usefulness, or otherwise decide what the data is *for* —
+ it does not know, it would be confident anyway, and rule 4 already
+ forbids it inventing rather than reading. Which columns answer the
+ question is `## Notes` (C.2.1), written by somebody who knows. The
+ manual still names **every** column: that is structure, and an agent
+ cannot project without it.
+
+#### G.2.4 Workbooks are multi-table (v0.44)
+
+`.xlsx` (openpyxl, MIT) and `.xls` (xlrd, BSD-3 optional `ingest` extra,
+same gating pattern) convert **every** sheet: one table per sheet, named
+by the slugified sheet name, deduplicated. The first non-empty row of each
+sheet is its header; empty sheets are skipped. Taking sheet one and
+dropping the rest is how a spreadsheet arrives in a forest missing the
+data somebody adopted it for.
+
+**A workbook's declared extent MUST NOT be trusted (v0.45).** The `.xlsx`
+format carries a `` record naming the used range, and a
+read-only reader believes it but files written by anything other than
+Excel (exports, report generators, spreadsheet libraries) routinely
+declare `A1:A1` or omit it entirely. A real 130-row sheet then arrives as
+one row and the file is reported as a workbook with no data: a correct
+message about a wrong reading, which is the worst kind. The extent MUST be
+inferred from the rows actually present.
+
+A workbook with no sheet holding more than a header row is `E_SCHEMA`
+naming the file reported per-file like every other conversion failure,
+never a crash. There is **no cap on sheets or columns** here; see G.2.5.
+
+#### G.2.6 The team's own name for a document (aliases, v0.54)
+
+Every project that files documents under a convention has a vocabulary the
+files themselves never spell. A task living at
+`tasks/back-end/291-provider-budget.md` is called **`BE-291`** in every
+conversation, commit and cross-reference — and that token is in no title,
+no summary and no body, so `locate` returns nothing and `sniff` returns
+the *neighbours*: the documents that cite `BE-291`, wearing scores and
+snippets and the full costume of a correct answer. For an LLM consumer
+that is the worst failure shape this product can produce — plausibly wrong
+beats visibly empty (C.1.1 exists because of the second; this rule exists
+because of the first).
+
+The passport has carried `aliases` since A.3, and `locate` has indexed it
+at weight 3 — between title and tags — since the catalog existed. What was
+missing is a writer. The Gardener now derives aliases at draft time:
+
+1. **What the source states about itself is derived; what only an
+ operator knows is declared (v0.59).** The first version of this rule
+ required the `aliases:` map for anything at all, and the field showed
+ the cost: in a forest where every document has a canonical code,
+ **1,877 of 1,877** ingested nodes carried no aliases, and the most
+ frequent access in that forest fell through to the path measured ~100×
+ slower — while the skill was busy calling `aliases` "the findability
+ lever" and telling agents to fill it by hand. The corrected boundary
+ is not "no map, no aliases"; it is **who knows the name**. A code the
+ document prints in its own title is procedence, and reading it back is
+ not the engine acquiring content vocabulary — the engine invents no
+ words, and it must never derive from a table of conventions it carries
+ itself (that would be the G.4.5 violation this rule guards). A
+ convention the material does not state — *`back-end` means `BE`
+ because a team decided so* — still lives in `gardener.yaml`'s
+ `aliases:` map, which is the forest's own config.
+2. **The derivation is mechanical, deterministic, and has four sources.**
+ For a source file whose stem starts with digits `N`, in folder `F`:
+ - **the bare number** `N` — how a document is referred to inside its
+ own folder, and the cheapest of all;
+ - **the path form** `F/N`;
+ - **the declared prefix** `P-N`, when `gardener.yaml` maps `F` to `P`.
+ The operator's declaration wins and **suppresses the next rule**: a
+ convention stated is not a convention to be guessed alongside;
+ - **the folder's initials** `I-N`, when no map covers `F` and `F` is a
+ compound name (two or more parts separated by `-` or `_`), `I` being
+ the uppercased first letter of each part: `back-end/291` → `BE-291`.
+ A single-word folder derives no prefix — one letter is not a name,
+ and inventing `T-291` from `tasks` would put noise in the field the
+ forest is most often searched by.
+
+ Independently of the file's number, the draft also gains **every code
+ in the shape `LETTERS-DIGITS`** (2–6 capitals, a hyphen, 1–6 digits)
+ appearing in the document's **title or its H1** — `ADR-0002` names
+ itself, and a document stating its own name is the least ambiguous
+ source there is. Bodies below the H1 are not scanned: a code inside
+ prose is usually a reference to a *different* document, which is
+ exactly the neighbour-instead-of-target failure this rule exists to
+ end.
+
+ Derived aliases are deduplicated preserving first occurrence, ordered
+ deterministically (declared before derived, prefixed before bare), and
+ an unnumbered file in an unmapped folder with no code in its title
+ still derives nothing — honestly, rather than by inventing a name for
+ a document that has none.
+3. **`sync` adds, never removes (v0.56).** v0.54 said a changed
+ convention is applied by re-adopting — and the field showed what that
+ costs: an operator added the `aliases:` map to a live forest, ran
+ `sync`, and nothing happened, because the fast-path (G.8) skips
+ unchanged sources and the config is not part of what it compares. A
+ config edit was invisible exactly where it was aimed. `sync` now
+ recomputes the DERIVED aliases for every visited passport — fast-path
+ included; the derivation is two string operations, the conversion is
+ what the fast-path saves — and **unions** the missing ones into the
+ frontmatter. Union, never replacement: adding a name is not rewriting
+ somebody's edit, removing one is, so hand-added aliases survive every
+ refresh and removal stays a human act (`graft`, rule 4). Existing
+ aliases are never displaced by the 16-alias cap (C.8); derived forms
+ that no longer fit are dropped and counted in the sync report
+ (`aliases_clipped`), never silently.
+4. **The backfill is `graft`.** `aliases` is mutable frontmatter (C.8,
+ v0.54), so an already-ingested forest is repaired by writes, not by
+ re-ingesting. Curation MUST NOT touch aliases: a model inventing a
+ team's naming convention is the hallucination G.4.2.1 exists to
+ prevent, in a smaller costume. **And the backfill MUST be reachable
+ without the source tree (v0.61):** every input to this derivation the
+ source path and the title is recorded in the passport, so the repair
+ needs no directory, no converter and no model. It was nevertheless
+ reachable only through `sync`, which resolves a host root and requires
+ `admin` over it and a forest of 1,877 nodes therefore had the feature
+ in the code and not in the corpus, which is the only place it counts.
+ J.13.6 is that pass.
+5. **A derived number is a whole leading segment (v0.61).** Rule 2 derives
+ from "a stem that starts with digits", and the test did not require the
+ digits to end: `9router-free-ai-router.md` derived the aliases `9` and
+ `x/9`. A single digit is not a name it is a token that enters the one
+ index searched by curated metadata alone, matches broadly and ranks,
+ which is noise with authority in exactly the field this rule exists to
+ make trustworthy. The leading digits MUST be followed by a separator
+ (`-`, `_`, `.`, or whitespace) or by the end of the stem. `291-provider-
+ budget` still derives `291`, `BE-291` and `back-end/291`; `9router`
+ derives nothing from its number, and its title's own code (rule 2's
+ last paragraph) is unaffected.
+
+#### G.2.7 The Gardener records where it came from (`origin`, v0.58)
+
+v0.57 gave the passport `origin` and only agents wrote it — yet the one
+writer who always KNOWS the origin is the ingest pipeline, which was
+recording `source_path` (its own bookkeeping, relative to a root only
+the Gardener remembers) and telling the reader nothing. Three rules:
+
+1. **Adopt and refresh fill `origin` with the source file's URI**
+ (`file://…`, absolute, percent-encoded — the A.3 validation passes by
+ construction). An upload staged under `_derived/` gets the entry's
+ declared `source_url` instead (J.8's provenance, already validated)
+ — and nothing at all when none was declared: a path inside the
+ staging area is a fact about plumbing, not about the document.
+2. **Only when absent.** An operator's or agent's hand-written `origin`
+ outranks a derived one and survives every `sync` — G.2.6's union
+ rule, applied to one scalar. The fast-path applies the same
+ `setdefault` it applies to aliases, so a forest ingested before this
+ rule gains origins on its next sync without re-conversion.
+3. **Curation MUST NOT touch it** (G.2.3 rule 4's spirit): an origin is
+ a fact, and a model's guess about facts is the failure this pipeline
+ exists to filter out.
+
+**Extension surface is edges-only (normative):** converters and curation
+hooks extend what goes INTO the forest. Nothing extends the primitives'
+semantics, token budgets, or security guards (`query`/`tend` validation,
+A.3.1, C.9 locking). UIs, upload receivers and automations are clients of
+the MCP server or of the Python library they require no plugin API.
+
+### G.3 `adopt` and `sync` (the brownfield engine)
+
+- **`adopt(source_dir, dest?)`** mirrors an existing tree: each source
+ directory becomes a `branch` (planted before its children), each file is
+ converted and planted as its passport under the mirrored branch.
+ Deterministic: stable ordering, slug ids, no LLM in the loop. `dest`
+ roots the mirror under an existing branch (default: forest root).
+- **`dest` names a branch, in either of the two spellings a branch has
+ (v0.61).** A branch's id ends in `/_index` and that is how it is named
+ everywhere else on the surface `scan("tasks/_index")`, `parent:
+ "notes/_index"`, `coverage`'s roots so `dest` MUST accept
+ `tasks/_index` and `tasks` as the same destination, and `_index` alone
+ as the forest root. Before this it accepted only the bare form and the
+ canonical one produced `tasks/_index/_index`, refused with an
+ `expected_parent` that named the exact string the caller had sent: the
+ advice was to do what had just been done, which is a refusal nobody can
+ act on. Normalisation happens once, at the boundary, before scope is
+ checked against it (J.8) so the scope test and the write agree about
+ which branch is meant.
+- **`sync(source_dir?)`** re-walks the source (default: the adopted root
+ recorded in config) and hash-diffs against the passports' `source_hash`:
+ - **new** file → adopt it;
+ - **changed** hash → re-convert; the passport's body, `source_hash`,
+ `payload_hash` (datasets are rebuilt; an adopted SQLite payload is
+ replaced whole, G.2.2 rule 5) and `updated` are refreshed
+ through the Gardener's audited write path a git commit
+ `gardener(sync): ` of only the `.md`. Curated frontmatter
+ (summary, tags, links, confidence) is PRESERVED, and for a dataset
+ **the two map sections are rewritten and nothing else in the body is**
+ (G.2.3 rule 4): a payload that changed under a sample that did not is
+ a stale claim with a commit behind it, and a curator's own sections
+ are not the Gardener's to overwrite;
+ - **deleted** source → the passport is reported `stale`. The Gardener
+ NEVER deletes nodes; pruning is the Ranger's call (tombstone policy,
+ out of scope here).
+- Continuous watching (filesystem events) is a Ranger-era concern; v1 sync
+ is on-demand and deterministic.
+
+**Source containment (normative, v0.26).** Both entry points resolve their
+source through one gate, because two gates drift:
+
+1. **A source is always named.** `adopt` requires one; `sync` takes the one
+ `adopt` recorded. When neither exists the call is `E_SCHEMA` an
+ absent source MUST NOT fall back to the process's working directory,
+ to a default, or to anything else. "The usual place" for a forest that
+ has no usual place is not a location, and every filesystem API in wide
+ use resolves the empty path to the working directory silently.
+2. **A source MUST NOT overlap the forest.** It may not *be* the forest
+ root, contain it, or sit inside it. The single exception is the
+ forest's own `_derived/` subtree, which is explicitly not forest
+ content and is where a host stages uploaded bytes (J.8).
+3. **A forest is never a source.** A directory carrying the A.5 master
+ index (`_index.md`) that is met *inside* a walk is pruned whole,
+ children included. Its passports are somebody's curated nodes, not
+ documents awaiting conversion, and a source that happens to sit above a
+ registry would otherwise deliver every forest under it into this one,
+ in a single call and across the tenant boundary.
+
+The Gardener is trusted infrastructure running with the operator's
+authority (see the head of this Part), so these rules bound *what a walk
+may reach*, not who may ask for it. Who may ask, and which directories a
+**host** will open on a caller's behalf, is J.8.2 a shell user is
+already standing on the filesystem and is not subject to it.
+
+### G.4 Curation (the only LLM stage always skippable)
+
+Stage 2 enriches the draft node before planting:
+
+1. **Without an LLM** (default in v1): summary derived from the converted
+ content's first sentences (≤ 60 tokens, A.4-validated, with a safe
+ fallback), `source: ingest`, `confidence: 0.7` (unreviewed), default
+ tags from config. The pipeline never blocks on a missing GPU.
+2. **With an LLM** (Gardener v2): A.4 summary with validate-and-retry,
+ tags, edge proposals at link-level `confidence: 0.3` (G.4.2.1), guided
+ by the **curation directives** in the forest config free-text criteria
+ the operator wants the Gardener to "keep an eye on" (e.g. "prioritize
+ contract numbers and client names in summaries"). Entity EXTRACTION
+ (minting new `entity` nodes) is deferred past v0.12: it needs a
+ placement policy and a `same-as` dedup story first.
+3. **`on_curate` hooks**: plugins (entry-point group `monkeyllm.hooks`,
+ name `on_curate`) and/or locally registered callables receive the draft
+ (dict) and may mutate it. Hooks run in discovery order; a raising hook
+ is logged into the report and SKIPPED a broken plugin never aborts an
+ ingest.
+
+#### G.4.2 The scent the Curator writes (v0.75)
+
+`locate` reads curated metadata and never a body (C.6b), so the Curator is
+not decorating a node — it is authoring the only text by which the node
+can be found. Two rules follow, and until v0.75 the implementation held
+neither.
+
+1. **A tag is bounded, never silently dropped.** The validator refuses
+ what it must (see rule 2), and every refusal is COUNTED and reported
+ in the Curator's stats (`tags_dropped`), exactly as G.2.6 counts
+ `aliases_clipped`. The rule is the project's standing one: a filter
+ nobody is told about is indistinguishable from a model that produced
+ nothing, and the operator sent to repair the wrong half is the cost.
+ Before this, an accented tag was discarded in silence and the ingest
+ report read as a curation failure.
+2. **A tag is a token in the document's own language.** Normatively: at
+ least one and at most 40 characters, Unicode letters and digits plus
+ `-` and `_`, starting with a letter or a digit, no whitespace (a tag is
+ one token; a phrase is a summary). Compared for uniqueness after NFC
+ normalization and case folding, so two spellings of one word are one
+ tag. **Diacritics MUST NOT be stripped.** They are not a matching
+ concern: `nodes_fts` is tokenized `unicode61 remove_diacritics 2`,
+ which folds on the way in and on the way out, so a tag stored as
+ `produção` is matched by a query for `producao`. Stripping them buys
+ nothing findable and costs the tag its spelling for every human who
+ reads it — and the ASCII-only rule it came from silently emptied the
+ tags of every forest that is not in English.
+3. **Tags are asked for as discriminating tokens, not tidy ones.** The
+ prompt MUST ask for what a technical corpus is actually searched by,
+ and MUST name the classes rather than leave them to taste:
+ identifiers and codes (ticket, contract, SKU, standard, version,
+ metric), proper names (product, system, client, team, place),
+ categories, and the recurring patterns or techniques the document is
+ about. It MUST NOT ask for "single words": `rate-limit`, `iso-27001`
+ and `be-291` all pass the validator today and all survive the
+ tokenizer intact — a stored `iso-27001` is matched by the phrase
+ `"iso 27001"` — so the compound was never the problem, only the
+ instruction was.
+4. **The cap is a passport budget, and it is 12.** The bound exists so a
+ passport stays a scent rather than becoming an index; five was sized
+ for generic words and starves a node the moment the tags are specific
+ enough to be worth having. Overflow is clipped from the tail and
+ counted with rule 1's other refusals.
+5. **This is the same rule the query side already follows.** C.6b's
+ derived terms keep any code-shaped token whatever its length — a
+ digit, all caps, `-`/`_`/`.`/`/` — because those are the tokens a
+ technical corpus is searched BY (v0.52). A writer told to discard what
+ the reader is told to keep produces a corpus that cannot answer its own
+ questions, and only the reader's half had ever been measured.
+
+6. **The rule is enforced where writes enter, and it grandfathers what
+ it finds.** `plant` refuses a tag the rule refuses (`E_FRONTMATTER`,
+ naming the tag and the rule); `graft` refuses one the node does not
+ already carry and keeps untouched the ones it does — a write may keep
+ what it found, never add what the rule refuses. READING is untouched:
+ the parser accepts what is already on disk, because a rule that
+ strands every pre-v0.75 node behind its own editor is not a repair.
+ And no surface may quietly normalise a refused tag into an accepted
+ one: silently rewriting what somebody typed is the same failure as
+ silently dropping what the model wrote.
+
+Validate-and-retry for the summary is unchanged (A.4, 1-3 sentences, the
+character ceiling, the anti-boilerplate rules). Nothing here relaxes it.
+
+#### G.4.3 The Curator proposes aliases (v0.75)
+
+`aliases` is an FTS column of its own (C.6.1) and it is where a node's
+other names belong — the acronym, the expansion, the code, the former
+name, the spelling somebody will actually type. Until v0.75 the only
+thing that ever wrote one was `derive_aliases` (G.2.6), which reads the
+source PATH and the operator's `aliases:` map. **The one participant that
+reads the whole document (G.7.4) never proposed a single one.** So a
+document that introduces itself in its first line as "BE-291 (Rate
+Limiter)" planted a node findable by neither.
+
+Normative:
+
+1. **The Curator MAY propose aliases, from the document it was given.**
+ Names for THIS node's subject that are not already its title:
+ acronyms and their expansions, product or project codes, ticket and
+ contract identifiers, former names, and alternative spellings.
+2. **An alias the document does not contain is not proposed.** The guard
+ is structural and not an instruction: every proposed alias MUST occur
+ in the curated content, compared under C.6b's fold (the same fold
+ `sniff` matches with, so one rule decides both). A model inventing a
+ plausible acronym is refused by the check rather than trusted by the
+ prompt — the same shape as G.4.2.1 rule 2, where candidates come from
+ the catalog and never from the model's memory.
+3. **Bounded exactly as the field already is.** `MAX_ALIASES` (16) and
+ `ALIAS_MAX_CHARS` (80) apply unchanged; an alias equal to the title
+ under the fold is dropped as a duplicate, and overflow is counted with
+ G.2.6's `aliases_clipped`.
+4. **Union, never displacement.** The proposals are merged with the
+ derived path aliases under G.2.6 rule 3: adds only, twice is once, and
+ **a hand-written alias is never displaced**. An operator who taught a
+ forest a name outranks a model that guessed one.
+5. **Failure never blocks.** Bad JSON, a transport error, or zero
+ surviving proposals yield zero aliases and the node still plants
+ (G.4). Ingest does not depend on a model, and this does not change
+ that.
+
+**F.162 (acceptance).** A tag is bounded, never silently dropped. Curating
+a Portuguese document yields tags spelled `produção` and `segurança` —
+they survive to the passport, they are returned by `look`, and `locate`
+finds the node by the unaccented `producao` (the tokenizer folds; the
+passport does not). A tag refused for a reason the validator does hold —
+whitespace, over 40 characters, over the cap of 12 — is COUNTED in the
+Curator's stats, and a run that dropped nothing reports zero, so the two
+are distinguishable. The pre-v0.75 behaviour, put back, fails this: the
+accented tags vanish and no count anywhere records that they existed.
+
+**F.163 (acceptance).** The scent carries what the corpus is searched by.
+On a document whose body names a ticket `BE-291`, a standard `ISO 27001`
+and a component `rate limiter`, curation produces tags and aliases that
+include those identifiers, and `locate` — which reads no body — returns
+the node for each of them. An alias the model proposes that does NOT
+occur in the document (under C.6b's fold) is refused rather than written,
+verified by feeding a curator whose reply carries a plausible invented
+acronym. A hand-written alias present before the pass is still present
+after it.
+
+#### G.4.2.1 Edge proposals (v0.12)
+
+LLM curation MAY propose links from the node being adopted to nodes that
+ALREADY exist in the forest. The contract is built so a wrong proposal is
+cheap and a fabricated one is impossible:
+
+1. **Candidates come from the catalog, never from the model's memory.**
+ The Curator runs a BM25 search (C.6.1) with the draft's title + curated
+ summary and offers the model a closed list of up to 8 candidates
+ (`id`, `title`, `summary`). Excluded from candidacy: the draft itself,
+ `branch` nodes (a link to a folder carries no scent), and the draft's
+ own parent.
+2. **The model picks from the list or picks nothing.** Anything outside
+ the offered ids is dropped (the hallucination guard is structural, not
+ a prompt instruction). Picking nothing is a valid, common answer:
+ relatedness must be visible in the two summaries.
+3. **Every proposal is `rel: related-to`** (symmetric, generic A.2) with
+ **link-level `confidence: 0.3`** and MAY carry a short free-text `note`
+ (kept out of the summary budget; helps the Ranger's audit trail). Other
+ rels (`mentioned-in`, `same-as`, …) are NOT proposable in v0.12 they
+ assert ontology, not navigational adjacency.
+4. **Caps and dedup**: at most 3 proposals per node; self-links and
+ duplicates of existing links (same `rel` + `target`) are dropped.
+5. **Node-level vs link-level confidence**: the adopted node keeps its
+ G.4 `confidence: 0.7` (unreviewed content); 0.3 lives ON THE LINK —
+ that is exactly the population Part H manages. The lifecycle is the
+ point: **the Gardener proposes (0.3), usage heats both endpoints, the
+ Ranger promotes (0.8) or prunes (cold)**. A proposal nobody ever walks
+ costs one frontmatter line and dies by H.2.
+6. **Failure never blocks**: bad JSON, transport errors or zero valid
+ picks simply yield zero proposals (counted in the Curator's stats);
+ the node still plants.
+
+#### G.4.6 The dataset's scent comes from its map (v0.45)
+
+Curation skipped `type: dataset` because a dataset had nothing to read: no
+body, and a column list that the G.4.1 template already stated better than
+a model would. G.2.3 changed the input every dataset passport now carries
+its structure and the first three rows of every table so the reason is
+gone and the cost of reading it is known in advance.
+
+Normative:
+
+1. **The Curator curates a dataset from its map, and from nothing else.**
+ Not the payload, not the source file, not a sample it takes itself. The
+ map is already in the draft when curation runs (stage 2 follows stage 1),
+ it is bounded by G.2.3 three rows per table, cells clipped, a stated
+ table cap and that bound is what makes this safe: **a 5 MB CSV and a
+ 5 GB database cost the model the same few hundred tokens.** An ingest
+ whose model cost scaled with the source would be an ingest nobody can
+ run on real data.
+2. **The fallback is unchanged.** A refused, malformed or absent answer
+ leaves the G.4.1 factual template in place and counts as a fallback,
+ exactly as it does for a document. Ingest never blocks on a model
+ (G.4), and that is not weakened here.
+3. **Curation writes `summary` and `tags`, never the body.** The two
+ generated sections are the Gardener's (G.2.3 rule 4) and a model
+ rewriting them would put unverified rows where a reader expects the
+ ones that are in the payload. Edge proposals (G.4.2.1) apply to a
+ dataset exactly as they do to any other node.
+4. **Why it is worth a model call at all.** A.4 makes the summary the
+ scent every hop navigates by, and `locate` searches curated metadata
+ only (C.6b). "Adopted from source; pending curation" is therefore what
+ an agent reads when deciding whether this dataset can answer the
+ question for the one node type whose contents it cannot otherwise
+ see.
+
+#### G.4.4 Branch rollup (v0.13)
+
+Curation gives every banana a scent; rollup gives every REGION one. After
+the per-node curation stage of an `adopt`/`sync` (or on demand), the
+Gardener MAY rewrite branch frontmatter summaries bottom-up:
+
+1. **Scope**: only branches with `source: ingest` the Gardener rewrites
+ what the Gardener planted, nothing else. An explicit operator override
+ (`--all`) MAY widen the scope to every non-`_meta` branch.
+2. **Order**: deepest branch first (by id depth), so a parent's rollup
+ always sees its sub-branches' fresh summaries.
+3. **Input**: the branch's own `## Sub-branches` and `## Direct bananas`
+ entry lines (which replicate child summaries verbatim, A.5), clipped to
+ the curation content budget. The model never reads child bodies —
+ rollup is O(branches) LLM calls with bounded prompts.
+4. **Output contract**: an A.4-valid summary (1-3 sentences, ≤ 60 tokens)
+ answering the A.5 blockquote question what lives here + where to go
+ if it is not here. Validate-and-retry as in G.4.2.
+5. **Fallback**: any failure (bad JSON, invalid summary after retries,
+ transport error) falls back to a deterministic summary composed from
+ child titles and counts the pipeline never blocks and never leaves a
+ branch worse than the template it had. Counted in the Curator's stats.
+6. **Write path**: C.8 `graft` on the frontmatter `summary` atomic,
+ `.md`-only commit, catalog upsert, and VERBATIM propagation of the new
+ summary into the parent's `## Sub-branches` entry (coverage suffix
+ preserved, A.5). No new write machinery.
+7. **What rollup is NOT**: it never creates nodes or links, never touches
+ bodies (`## Cross trails` stays hand-authored), and never runs without
+ an operator asking for curation (it is part of the always-skippable
+ LLM stage).
+
+### G.5 Media (multimodal by proxy)
+
+Audio/image/video go through the SAME converter contract: the converter
+(e.g. a Whisper transcriber, a vision-model describer extras or hooks,
+never core dependencies) returns markdown that becomes the `media`
+passport's body. The forest's job is **finding** media fast: `locate`/
+`sniff` search the textual proxy; a multimodal client that wants full
+fidelity follows `payload` to the raw file. Text to find, binary to
+consume. Serving payload bytes over MCP to multimodal clients is the
+`view` tool (C.6d, v0.48): found by its prose, read in its pixels.
+
+### G.5.1 The stub and the describer (v0.48)
+
+Before this version an image was `unsupported`: no converter claimed
+it, so a screenshot the single most common thing a person clips —
+fell out of the report. Two pieces fix that, split exactly along the
+engine/host line.
+
+**The stub (engine, built-in).** A built-in converter claims image
+extensions (`.png .jpg .jpeg .gif .webp`) and audio extensions
+(`.mp3 .wav .m4a .ogg .flac`) and returns markdown: an H1 from the
+filename and a body that states plainly what is known the format,
+the size, and that no description is available yet. The Gardener
+plants it as **`media`**, not `document`: the typing rule becomes
+*text source → `note`; payload type `image`/`audio` → `media`;
+otherwise → `document`*. An image is never `unsupported` again, with
+or without a model.
+
+**The describer (host, injected).** A forest may bind a model to the
+new **`vision`** role (J.10). When one is bound, the host injects a
+describer converter for the image extensions that sends the image to
+the bound model and returns its description as the body: what the
+image shows, and **any legible text in it** that is what makes a
+slide, a whiteboard or a flowchart findable by `sniff`, which reads
+the textual proxy and nothing else (G.5). A describer that fails —
+endpoint down, image refused, over budget MUST fall back to the
+stub: G.4.3's rule reaches conversion too, a broken model never
+aborts ingest. Audio keeps the stub until a transcriber role exists;
+G.5 already names that future.
+
+**The seam (public API v1, additive).** Injected converters enter
+through a new `extra_converters` argument to the Gardener, ranked
+**after the operator's command hooks and before entry points and
+built-ins**: an operator who configured their own `.png` command hook
+keeps it; everyone else gets the describer over the stub. Nothing
+else about G.2 discovery moves, and the engine itself still never
+holds a model: the describer lives in the host.
+
+**The describer holds a lane (v0.48).** Its call runs inside the
+convert stage of a step, and a step holds the forest's one lane (J.9):
+every read on that forest every console open on it waits behind
+it. The describer's request MUST therefore carry a timeout of at most
+**60 seconds**, and a timeout falls back to the stub like any other
+failure, with the reason in the report's `errors`. The 180-second
+patience that suits a chat surface is a frozen panel here; a
+deployment that wants slower vision buys it consciously, never by
+default.
+
+**The staging amendment (G.7).** `archive: never` keeps durable
+originals at the source, and stays the default. But an *uploaded*
+source is staged under the forest's `_derived/` disposable and
+rebuildable by contract so a media node referencing it would
+outlive its own bytes. Media whose source root lies inside the
+forest's `_derived/` MUST be archived into `_assets/` and referenced
+as payload regardless of the archive policy: there, the payload is
+the only copy that exists. A durable disk source under
+`archive: never` is still referenced, not copied, exactly as G.7
+says.
+
+**F.48 (acceptance).** A `.png` adopted with no vision binding plants
+a `media` node with a stub body never `unsupported` and, when
+staged through `upload`, carries the original under `_assets/` as its
+payload even under `archive: never`. The same `.png` with a bound
+`vision` role plants the model's description as the body, and a
+describer that raises falls back to the stub with the failure in the
+report's `errors`. An operator command hook on `.png` wins over the
+injected describer. All covered by tests.
+
+### G.6 Gardener config (`_meta/gardener.yaml`)
+
+Operator-level configuration, read by the Gardener (not a node `_meta/`
+non-markdown files are not indexed):
+
+```yaml
+source_root: D:/dump/docs # written by adopt ONLY; sync's default source
+ # absent = sync has nothing to refresh (G.3)
+ignore: ["~$*", "*.tmp"] # extra ignore globs (defaults: VCS dirs, temp files)
+converters: # command hooks (discovery priority 1)
+ ".pdf": 'pdf2md "{input}" -o "{output}"'
+curation:
+ default_tags: [adopted]
+ directives: > # free text fed to the curation LLM (G.4.2)
+ Prioritize contract numbers and client names in summaries.
+content: inline # inline | cached | reference (G.7)
+archive: never # never (default) | always (G.7)
+```
+
+### G.7 Content & archive policies (v0.11)
+
+The forest's three tiers: SCENT (passport frontmatter always local, in
+git), FLESH (full converted text), BONE (raw binaries stay at the
+source). The `content` policy decides where the FLESH lives:
+
+- **`inline`** (default): the converted body lives in the node `.md`
+ (v0.9 behavior git-versioned content; right for normal corpora).
+- **`cached`**: the node `.md` holds only the title stub and the
+ frontmatter marker `content: cached`; the converted body is written to
+ `_derived/bodies/.md` OUT of git. Right for huge corpora.
+ Regenerable: re-running `sync` while sources are reachable rebuilds the
+ cache (the body is a function of source + converter).
+- **`reference`**: no local body at all; the body IS the source file,
+ read live at harvest time. ONLY valid for passthrough text sources
+ (`.md`/`.txt`); marker `content: reference`.
+
+Normative semantics:
+
+1. **Lazy resolution**: `pick` (and `sniff`, when it reaches such a node)
+ resolves the body from the cache file (`cached`) or from
+ `source_root/source_path` (`reference`). The resolution is transparent
+ same response shape, same budgets.
+2. **Degraded mode is explicit**: when the body cannot be resolved
+ (source share down, cache purged), the read fails with `E_NOT_FOUND`
+ and a hint naming the missing backing file. The MAP keeps working —
+ `locate`/`look`/`scan`/heat never depended on the body.
+3. `look`'s `outline` MAY be empty for non-inline nodes (the digest comes
+ from the catalog; spending I/O to outline a remote body would break
+ the <= 500-token cheapness contract).
+4. Summary derivation and LLM curation (G.4) always see the FULL
+ converted text at ingest time the scent quality does not depend on
+ the content policy.
+5. **`archive`**: `never` (default) durable sources are not copied into
+ `_assets/`; `source_path` + `source_hash` are the reference. `always`
+ inbox mode: the source will vanish after ingest, archive the
+ original (v0.9 behavior). Datasets keep their local `.db` payload
+ under every policy (G.9).
+6. **Containment (normative, v0.26)**: the resolved backing file MUST lie
+ underneath `source_root`; otherwise the read fails as rule 2's
+ `E_NOT_FOUND` and the hint MUST NOT quote what was found there.
+ `source_path` is ordinary frontmatter the Gardener writes it, and
+ `plant` accepts it like any other extra field so a node can name a
+ path its author chose. Without this rule `pick`, a read primitive
+ available to any principal holding `read`, resolves arbitrary host
+ files with the Vine's authority and reports them as the node's own
+ body. The check happens after resolution, so `..` and symlinks are
+ already collapsed.
+
+### G.8 Targeted sync & triggers (v0.11)
+
+- **`sync(path=...)`** reconciles a single source-relative path (new,
+ changed or deleted) without walking the whole tree the building block
+ for event-driven updates.
+- **Containment (normative, v0.26)**: `path` is relative to the source
+ root and MUST resolve underneath it; an absolute path or one that
+ escapes is `E_SCHEMA`. The comparison MUST be made on the **resolved**
+ path. A lexical check passes `../../etc/passwd` joined onto the root
+ it still *starts* with the root, so a purely textual "is it relative to"
+ answers yes, the file is read, and its slugified `..` segments become a
+ branch. Events arrive from watchers, webhooks and queues (below), which
+ is to say from outside; this path is caller input like any other.
+- **Fast-path (normative)**: passports record `source_size` and
+ `source_mtime`; a sync visit MUST skip hashing when both match the
+ stored values (rsync's trick re-hashing 2 TB per cycle is the real
+ bottleneck). Hash remains the authority whenever the fast-path misses.
+- **Events trigger, the reconciler decides**: filesystem watchers, S3
+ event notifications, Drive `changes.watch` and upload webhooks are
+ CLIENTS/plugins that call targeted sync. Events MUST NOT be trusted as
+ state (they get lost); a periodic full `sync` (`--every N`) heals
+ anything an event missed. Same controller pattern as C.6.1: derived
+ state reconciles from the files, never the other way around.
+
+### G.9 Payload fetchers (v0.11)
+
+`payload` and `source_path` MAY carry a URI scheme. Plain paths mean
+`file://` (today's behavior, zero change). Remote schemes (`s3://` first,
+as an optional MIT-licensed extra) resolve through a fetcher registry:
+
+1. Remote payloads/bodies download on first use into
+ `_derived/payloads/`, validated against `payload_hash`/`source_hash`
+ before use (a corrupted or tampered download never reaches the agent).
+2. **Datasets are local-first by design**: SQLite needs a local file, and
+ a hot knowledge base needs sub-millisecond reads object storage
+ holds `.db` files only as backup or cold-archive tiers. A cold
+ dataset's first `query` pays one download; the cache absorbs the rest.
+3. Remote sync uses the store's own change signals (ETag listings)
+ instead of downloading to hash.
+4. **`tend` rejects remote payloads** (`E_QUERY_FORBIDDEN` with hint):
+ writes belong to the local-first tier editing a cached copy of a
+ remote database would fork it silently. Reads (`query`, `look`'s
+ dataset digest) work through the cache transparently.
+5. **Region prefetch (the parachute warms the camp)**: `prefetch(scope)`
+ downloads every remote payload under a branch in one sweep the
+ orchestrator calls it right after `locate` drops the monkey, so the
+ subsequent `sniff`/`query` hops run at local speed. Combined with H.6
+ eviction, the payload cache converges to the shape of the pheromone:
+ hot regions stay warm, cold regions evaporate from disk.
+
+The MAP itself (passports + the forest git) stays local to wherever the
+Vine runs it is the truth, it is ~0.1% of the source, and remote
+clients reach it through the MCP server, not by replicating it. Moving a
+map between machines is what snapshots are for (Part I). `catalog.db` and
+`trails.db` remain disposable caches OF the local map (C.6.1) they are
+never the only local copy of anything.
+
+### G.10 Ingest yields (v0.32)
+
+`adopt` and `sync` were always a loop over documents with a config save at
+the end; G.10 makes the loop's step boundary part of the contract, because
+a host that wants to interleave other work with a batch or count its
+progress needs the seam the loop already had.
+
+`adopt_iter(source, dest?)` and `sync_iter(source?, dest?, path?)` return
+**step iterators**: each step converts, curates and plants **exactly one
+source file**, then yields a progress record
+
+```json
+{"file": "guides/setup.md", "index": 3, "total": 41, "action": "planted"}
+```
+
+where `index` counts steps taken (so it is also "done"), and `action` is
+one of `planted`, `updated`, `unchanged`, `unsupported`, `error`, `stale`,
+`skipped`. Construction is eager where iteration is lazy: resolving the
+source with the G.3/G.8 errors that entails and walking it happen at
+the call, so a bad source fails **before any step runs** and the iterator
+knows its `total` up front; a host can therefore refuse, or promise
+progress out of `total`, before accepting the batch (J.9). Walking is
+filesystem work; no document is touched until the first step. Once
+exhausted, the iterator's `result` is the unabridged `IngestReport` of
+G.3. Normative rules:
+
+- **`adopt` and `sync` MUST remain "drain the iterator and return its
+ report".** One pipeline, two paces. A second code path that batches
+ differently from how it steps would drift, and the drift would be
+ invisible until a sync disagreed with the adopt that preceded it.
+- **A step is a whole document** converter, curation (G.4, model round
+ trip included), content policy (G.7) and plant. Yielding mid-document
+ would suspend an open model call or a half-planted node on the goodwill
+ of a consumer that may never resume; yielding between documents suspends
+ nothing, because C.7 already made each plant atomic and committed.
+- **The source root is recorded before the first step, not after the
+ last.** An abandoned run a crash, a cancel (J.9) leaves the files
+ already stepped planted and committed, and the recorded root is what
+ lets `sync` finish the remainder through the G.8 hash diff instead of
+ the operator starting over. Recording it last, as v0.31 did, made every
+ interrupted adopt a forest with no memory of where it came from.
+- **Abandonment is safe at every yield.** No step depends on a later one:
+ branch indexes are grafted as each document needs them (G.3), so an
+ iterator dropped at step *k* leaves exactly the first *k* documents in
+ the forest, correct and committed, and nothing else.
+- `dry_run` composes unchanged: a preview Gardener steps and yields the
+ same way and writes nothing (J.8.1's object-level guarantee), with
+ drafts accumulating on the report exactly as before.
+- The CLI and every existing caller keep calling `adopt`/`sync`; the
+ iterators add a pace, not a mode. Converters, hooks, budgets and guards
+ are exactly as extensible and no more as G.2 already says.
+
+#### G.10.1 The stage inside the step (v0.45)
+
+A step is a whole document, and the rule above says why: yielding
+mid-document would suspend an open model call or a half-planted node on a
+consumer that may never resume. That objection is about **suspension**, not
+about visibility and a batch of one large document is one step, so a
+consumer counting steps shows nothing at all until it shows everything.
+An operator cannot tell that from a hang.
+
+The Gardener therefore **names the phase it is in** without pausing in it.
+
+`Gardener(vine, …, on_stage=…)` takes an optional observer called as
+`on_stage(file, stage)` as the current document passes through the closed,
+ordered list
+
+```text
+convert → curate → plant
+```
+
+Normative rules:
+
+- **A stage is reported, never yielded.** The observer is called from the
+ thread already doing the work, between operations that are already
+ sequential there. Nothing is suspended, nothing is resumable, and the
+ G.10 step boundary is exactly where it was. A consumer that ignores the
+ observer sees the v0.32 contract unchanged.
+- **The observer MUST NOT be able to affect the ingest.** A raising
+ observer is swallowed and the batch continues, like a broken `on_curate`
+ hook (G.4.3). Progress reporting that can abort the work it reports on
+ is worse than no progress reporting.
+- **Stages are closed and ordered, so a fraction is honest.** A consumer
+ MAY render position within a document as `stage index / stage count`,
+ which is what lets a one-document batch move at all.
+- **A skipped stage is passed, not pending.** Not every document reaches
+ every stage an unsupported file stops at `convert`, an `unchanged`
+ file never leaves it so a consumer MUST NOT wait for a stage that will
+ not come. The step's own `action` (G.10) remains the truth about what
+ happened; a stage says only where the work is.
+- **Stages are not a report.** They are transient, they are never counted
+ into the `IngestReport`, and they are never written into a forest the
+ same boundary J.9 draws around job records.
+
+**J.9 amendment.** A job record carries `stage` beside `current`: the phase
+of the file named by `current`, `null` between documents and on a finished
+job. It is host memory like every other field of the record, so reading it
+still touches no forest, and a restart still forgets records without
+forgetting work.
+
+---
+
+## Part H The Ranger (maintenance, spec v0.10)
+
+The Ranger keeps the forest healthy over time. The compounding loop only
+works if the pheromone can also **forget**: without evaporation every trail
+saturates at `heat = 1.0` and the whisper stops discriminating; without
+pruning, agent proposals pile up as permanent noise. The Ranger is trusted
+infrastructure (operator authority): evaporation lives entirely in the
+derived layer; every node edit goes through the audited `.md`-only commit
+path.
+
+### H.1 Heat evaporation (derived layer only no commits)
+
+- Persistent heat decays exponentially:
+ `heat' = heat × 0.5^(Δt / half_life)`, where `Δt` is the time since the
+ row's `updated` timestamp. Default `half_life_days: 30` (config H.5).
+- Rows whose decayed heat falls below **0.01** are deleted (dust removal —
+ the table stays proportional to what is actually warm).
+- Session scopes (`scope != ''`) older than `session_ttl_hours` (default
+ 24) are cleared crash leftovers from Troop hunts must not survive.
+- Evaporation re-stamps `updated` at decay time (the decay is applied, not
+ re-derived); running the Ranger twice in a row is a no-op within clock
+ precision (idempotence under the synthetic-clock test).
+- `_derived/` remains disposable: deleting `trails.db` loses memory but
+ breaks nothing (A.3 spirit) therefore evaporation never commits.
+
+### H.2 Promotion and pruning of uncertain links
+
+**Scope rule (normative): the Ranger manages ONLY links that carry a
+link-level `confidence < 1.0`** i.e. edges born as proposals
+(`related-to` at 0.3, C.8) or discovered shortcuts (0.5, C.8). Structural
+edges (`part-of`, etc.), links without a confidence field and links at
+`confidence: 1.0` are NEVER touched.
+
+- **Promotion**: a managed link whose BOTH endpoints hold persistent heat
+ `>= promote_floor` (default 0.2) after evaporation is *confirmed by use*:
+ link confidence is raised to `promoted_confidence` (default 0.8). Audited
+ commit `ranger(promote): -> 0.8` of only the `.md`.
+- **Pruning**: a managed link with `confidence <= prune_below` (default
+ 0.5) whose BOTH endpoints have fully evaporated (heat 0 no
+ reinforcement within memory) is removed. Audited commit
+ `ranger(prune): ->`.
+- A link that is neither hot enough to promote nor cold enough to prune is
+ left alone patience is a feature.
+- The Ranger NEVER deletes nodes. Stale passports (G.3) stay reported until
+ a human (or a future tombstone policy) decides.
+
+#### H.2.1 A person can confirm a proposal (v0.75)
+
+H.2 promotes on heat, and heat is the right evidence for a link nobody
+vouched for. It is also, until v0.75, the ONLY evidence there is — so a
+correct proposal between two nodes that nobody has walked stays at 0.3
+forever. In a forest that has just been ingested every node is cold by
+definition, which is exactly when the proposals are worth reading and
+exactly when nothing can act on them.
+
+Normative:
+
+1. **A vote is `accept` or `reject`, on one managed link.** `accept`
+ raises the link's confidence to **1.0**, which by H.2's own scope rule
+ takes it OUT of the managed population permanently. `reject` removes
+ the link. Both write only the `.md`, through the same audited path the
+ Ranger's promote and prune already use, and refresh the catalog the
+ same way.
+2. **Accept means 1.0, not `promoted_confidence`.** 0.8 would leave the
+ link managed, and a link that stays managed is a link a later
+ configuration change can still sweep. A vote that can evaporate is not
+ a vote. 1.0 is the value H.2 already declares untouchable, and it is
+ the honest record of what happened: a person looked at this and said
+ yes.
+3. **Only the managed population may be voted on.** A link at
+ `confidence: 1.0`, a link with no confidence field, and every
+ structural edge are refused — the same boundary H.2 draws for the
+ Ranger, for the same reason. A vote is not a general link editor.
+ Repeats split by direction: `accept` on a link already at 1.0 answers
+ `unchanged` — the outcome the vote asks for already holds, and a
+ retried batch is not an error — while `reject` on one is refused,
+ because rule 2 makes 1.0 permanent; a link with no confidence field
+ refuses either way.
+4. **This is NOT reachable from C.8, and that is deliberate.** `graft`'s
+ `add_links` is create-or-fortify: an existing link is reinforced with
+ heat and its fields are left alone, so a proposal's confidence is
+ unreachable through a patch, and the two-graft workaround (remove, then
+ add) is two commits with a window in between where the edge does not
+ exist. Widening `add_links` into an update would make one operation
+ mean three things and would put every structural edge's confidence
+ within reach of a caller. The vote stays its own bounded operation.
+5. **No MCP tool. A model MUST NOT confirm its own proposal.** The entire
+ purpose of link-level 0.3 is that a model asserted the link and
+ something ELSE has to confirm it — use, or a person. An agent-callable
+ accept would let the proposer close its own loop, and the confidence
+ would stop recording anything. The vote is therefore a host surface
+ with a human principal (J.18) and never a primitive.
+6. **Both endpoints MUST be visible to the voter.** Accepting publishes
+ the target's id into a node other principals may read, so the scope
+ test is the one G.4.2.1 already applies to candidates and C.6c.4
+ applies to a superseder: an out-of-scope endpoint makes the link
+ invisible in the listing and refuses the vote, byte-identically to a
+ link that does not exist.
+7. **The Ranger is unchanged.** Evaporation, heat-based promotion and
+ cold pruning run exactly as before, over exactly the same population
+ minus whatever a person has already settled.
+
+### H.3 Health report (read-only)
+
+One pass over the catalog + files, returned as a dict and printed by the
+CLI:
+
+- **`needs_split`**: branches with > 150 entries or > 3.000 body tokens
+ (A.5 rule).
+- **`fat_nodes`**: nodes with degree > 50 (A.2 rule branch candidates).
+- **`lint`**: error/warning counts from `vine validate`'s engine (includes
+ payload drift, C.10).
+- **`stale_passports`**: passports whose `source_path` no longer exists
+ under the Gardener's `source_root` (when configured).
+- **`uncertain_links`**: inventory of managed links by confidence bucket
+ (what the next promotion/pruning cycle will look at).
+- **`needs_description`** (v0.54): `type: media` nodes still carrying the
+ G.5.1 stub sentence — media that was ingested with no vision binding, or
+ whose describer failed. Such a node is findable by filename and nothing
+ else, and its summary matches every other undescribed media node's; the
+ operator's repair is binding a `vision` model and re-describing, and a
+ repair nobody is told about is not one. The check matches the stub
+ SENTENCE, exported as one constant the converter and this check share —
+ two spellings of a sentinel agree only where somebody compared them.
+- **`heat`**: row count + max/mean of the persistent scope (pheromone
+ health at a glance).
+
+### H.4 Execution model
+
+- `vine ranger [--forest DIR]` one full cycle: evaporate → tend links →
+ health report.
+- `vine ranger --every N` service mode: repeat every N seconds until
+ interrupted (Docker-friendly; the deploy doc's `ranger (cron)` box).
+- The Ranger takes an injectable clock (`now`) the synthetic-clock tests
+ of F.14 depend on it.
+
+### H.5 Ranger config (`_meta/ranger.yaml`)
+
+```yaml
+half_life_days: 30
+session_ttl_hours: 24
+promote_floor: 0.2
+promoted_confidence: 0.8
+prune_below: 0.5
+payload_cache_gb: 5 # H.6 (v0.11)
+```
+
+### H.6 Payload-cache eviction (v0.11)
+
+The Ranger evicts `_derived/payloads/` entries least-recently-used first
+when the cache exceeds `payload_cache_gb`. Eviction is always safe: every
+cached entry is re-fetchable from its source URI and hash-validated on
+return (G.9.1). Evaporation for bytes same philosophy as H.1.
+
+### H.7 Landmarks refresh (v0.13)
+
+The Ranger keeps the master `_index.md`'s `## Landmarks` section (A.5)
+populated the forest's hubs, discovered mechanically:
+
+1. **Selection**: top 10-20 nodes by degree over the catalog's typed-edge
+ table (frontmatter `links`, both directions). Excluded: `branch` nodes
+ (a landmark must carry scent), `_meta/*`, and degree-0 nodes. The
+ folder hierarchy (`parent`) does NOT count toward degree landmarks
+ measure how woven a node is, not how filed.
+2. **Rendering**: A.5 entry lines (`- [[id]] `) inside the
+ `## Landmarks` section of the master branch only. The section is
+ created if the heading is missing.
+3. **Idempotence**: the section is rebuilt in full and compared; an
+ unchanged graph produces no write and no commit.
+4. **Write path**: the audited `.md`-only pattern (H.2) with commit
+ message `ranger(landmarks): refresh`, followed by a catalog upsert of
+ the master index. No LLM, no node creation, no link changes.
+
+### H.8 The repo is tended too (v0.57)
+
+A forest's git repository accumulates one commit per write forever, and
+git left alone accumulates loose objects with them. On a laptop's SSD
+that is invisible; on the overlay filesystems containers actually run on,
+every git operation slows as the object directory grows — and since every
+`plant` IS a commit (C.7), the write path inherits the drag. Measured in
+the first production deployment: the repo's own hygiene was a material
+share of why a plant held its forest's thread long enough for operators
+to describe the product as frozen.
+
+The Ranger's `run()` therefore ends by asking git to tend the repo:
+`git gc --auto --quiet`. Normative:
+
+1. **Git decides, the Ranger only asks.** `--auto` applies git's own
+ thresholds; most runs do nothing, and the Ranger MUST NOT force a
+ repack on every pass.
+2. **Never a commit, never a node.** Repo maintenance touches `.git/`
+ only — it changes no history, no working tree, no catalog row. It is
+ the same class of act as evaporation: housekeeping in a layer the
+ contract does not version.
+3. **Reported, best-effort.** The run's report carries `gc:
+ ran|skipped|unavailable`; a git that fails the call fails nothing
+ else (the same rule as the audit's — maintenance must never fail the
+ work it maintains).
+
+---
+
+## Part I Snapshots (v0.11; the container, v0.74)
+
+A forest snapshot is ONE file. That was true in v0.11 and false from the
+day `--with-payloads` was added, because the payload sidecar made it two
+(see the v0.74 changelog for how that failed in the field). So the
+snapshot is a **container**: `-.forest`, a compressed
+archive holding
+
+- `forest.bundle` — the forest's git repository as `git bundle --all`:
+ the full commit history, every plant/tend/gardener/ranger audit trail,
+ byte-for-byte what a bare bundle always was;
+- `payloads/` — the BONE tier (A.3.1) at its forest-relative path;
+- `README.txt` — how to open the container without this software.
+
+The README is not decoration. The reason a snapshot is a git bundle at
+all is that a backup nobody can open without the vendor is not a backup,
+and a container hides that property behind an extension. Two commands
+stated inside the file is what keeps the promise legible to whoever opens
+it years from now, and the container being compressed is what v0.11's
+never-implemented `.bundle.zst` was reaching for.
+
+**Payloads travel by default (v0.74).** A forest's dataset databases and
+its `_assets/` are not in git (A.3.1), so a snapshot that omits them is
+the only kind of snapshot that loses data — and omitting them was the
+default. Excluding them is now an opt-out, and the result MUST state how
+many files it left behind: otherwise `payloads: 0` means both *this
+forest has no payloads* and *you did not ask for them*, and those must
+never arrive in the same shape. That is C.1.1's rule — a read says what
+it did not do — applied to a backup, where the cost of confusing the two
+is measured in years.
+
+**A restore says what did not arrive (v0.74).** Once the tree is on disk,
+the passports naming a **local** `payload:` whose file is absent are
+counted, and that count rides the result at both surfaces. It is free:
+the restore already rebuilds the catalog, which walks every node anyway.
+And it is the number that matters — not how many payload files were
+packed, which describes the archive, but how many nodes are now dead,
+which describes the forest. A restore MAY legitimately produce them; it
+MUST NOT produce them in silence. Remote payloads (G.9) are not this
+case: their bytes were never local, and counting them would report a hole
+that does not exist.
+
+- `vine snapshot create [--out FILE] [--no-payloads]` →
+ `-.forest`.
+- `vine snapshot restore FILE --forest DIR` → a full forest clone with
+ history and its payloads; `vine reindex` rebuilds the derived layer.
+ FILE is a container, or — for any snapshot taken before v0.74 — a bare
+ bundle, with its sidecar named separately. **The shape is decided by
+ the file's content, never by its name.** A filename is a claim the
+ caller makes, and on the import route (J.13.2) it is an untrusted one;
+ reading the first bytes costs nothing and cannot be lied to.
+- Upload to object storage rides the G.9 fetcher (`--to s3://...`).
+- The Ranger MAY schedule snapshots in service mode (backup policy:
+ interval + retention), config in `_meta/ranger.yaml`.
+- A hosted Station moves snapshots over HTTP — download of the containers
+ it took, import of one into a forest that does not exist yet — under
+ J.13's rules (owner-only, v0.39). Restore into an *existing* forest
+ stays `vine snapshot restore` at a shell.
+
+Use cases (informative): backup/DR, distribution (a team pulls the whole
+MAP in one small download the scent tier is ~0.1% of the source),
+frozen releases of a knowledge base ("the forest as of Q2 close").
+
+---
+
+## Part J The Station (host layer: self-host, governance, scoped access)
+
+### J.0 Position
+
+Parts A-I describe a forest and the Vine that reads it, for one operator
+who owns the filesystem. Part J describes the **Station**: the service that
+serves forests to *many* principals with identity, policy, audit and a
+web console so that a forest becomes a governed corporate asset instead
+of a personal directory.
+
+The Station is a **privileged client**, never an extension (G.0):
+
+- The engine (`src/monkeyllm/`) MUST NOT gain policy, identity or tenancy
+ awareness. Primitive semantics, budgets and guards are identical whether
+ a call arrives through the Station or through `vine serve`.
+- Forests remain content. Principals, tokens and policies MUST live in the
+ **host registry** (host-side storage), never inside a forest a forest
+ handed to another operator carries no credentials.
+- Every write remains a git commit inside the forest (A.3), and binaries
+ remain outside that git (A.3.1).
+
+### J.1 The Station
+
+The Station mounts a **forest registry** a root directory whose valid
+forests are resolved exactly as C.0 registry mode already resolves them —
+and exposes three surfaces:
+
+| Surface | Consumer | Transport |
+|---|---|---|
+| REST | applications, scripts, integrations | HTTP/JSON under `/v1/` |
+| MCP | agents, IDEs, bots | the Part C tool contracts, unchanged |
+| Studio | humans | web console served by the Station (J.5) |
+
+All three surfaces MUST route every forest access through the single
+`ScopedVine` of J.3. An unscoped `Vine` handle MUST NOT be reachable from
+any surface, including internal helpers and background jobs.
+
+The MCP surface MUST remain contract-identical to `vine serve`: an agent
+that works against a local Vine works against a Station-served forest with
+no change beyond endpoint and credentials. Scoping is expressed only as
+narrower *content*, never as a different shape (J.3).
+
+#### J.1.1 The surface that answers only to itself (v0.52)
+
+The MCP mount refuses any request whose `Host` header is not in
+`MONKEYLLM_STATION_ALLOWED_HOSTS` DNS-rebinding protection, and it stays
+exactly as strict as it is. The default list names a local install, and the
+shipped `docker-compose.yml` inherits that default, so **a Station published
+under a domain refuses every MCP request until an operator names the
+domain**. Nothing else about the deployment says so. `GET /v1/health`
+answers `ok`, `GET /v1/forests` lists them, Studio opens, REST serves every
+primitive and the only dark surface is the one the product exists for. The
+client reports `Failed to connect`; the wire carries `421` and nineteen
+bytes of `Invalid Host header`, which name neither the refused host, nor the
+accepted ones, nor the variable that decides. The diagnosis was reached, in
+the field, only because the person holding the failing client also had the
+source tree open.
+
+Three requirements, none of which relaxes the check:
+
+1. **The refusal wears the envelope.** A `421` leaving the MCP mount is
+ answered `{error: {code: "E_HOST_NOT_ALLOWED", message, hint}}`, naming
+ the `Host` that was refused and the environment variable that would admit
+ it. The **decision** stays with the transport guard that makes it today
+ one decider, as everywhere else in this document only the sentence is
+ the host's. The host that appears in the message is the one the caller
+ sent: it is not a disclosure, it is a quotation.
+2. **The boot says it.** A Station that serves MCP with no explicit
+ allow-list MUST log a warning at startup, naming the variable and saying
+ that MCP will answer to local addresses only. A deployment whose main
+ surface cannot answer MUST NOT boot silently. A list containing `*` MUST
+ warn too, naming what it turns off: `*` disables `Origin` checking along
+ with the host check, so it is never the shortcut for "my domain".
+3. **`/v1/health` reflects it, per request.** The health document carries
+ `mcp: {enabled, host_allowed}`, where `host_allowed` is the verdict for
+ **this request's own `Host`**. The operator curls the domain they
+ published and gets the answer about that domain, in the place they were
+ already looking. It discloses nothing the allow-list is never listed,
+ and the host being judged is the one the caller supplied.
+
+The allow-list itself MUST NOT be served to a caller, and no route may be
+added that reveals it. Naming the variable is documentation; printing its
+value is configuration disclosure.
+
+**F.62 (acceptance).** A request to the MCP mount carrying a `Host` that
+is not allowed is answered `421` with `{error: {code:
+"E_HOST_NOT_ALLOWED", …}}` naming that host and the variable; the same
+request with an allowed host completes the handshake. `GET /v1/health`
+carries `mcp.host_allowed` matching that verdict for the host the caller
+sent. A Station that starts serving MCP with no explicit allow-list logs a
+warning naming the variable. No response anywhere lists the allow-list.
+Covered by tests.
+
+#### J.1.2 The block is for the model (v0.54)
+
+The text block of an MCP tool result goes into an LLM's context, where it
+is billed by the token and read by a parser. Everything in this section
+follows from taking that consumer seriously.
+
+1. **Compact serialization.** The result dict is serialized with no
+ indentation and no separator whitespace. Measured against a served
+ Station, pretty-printing cost 29.9% of a `locate`, 24.1% of a
+ `harvest` — ~500 tokens per sweep, a whole `pick` of real content per
+ five-hop hunt, spent on air. Keys, values and their order are
+ unchanged: this is presentation, and the contract has no presentation
+ layer on the wire. A console that wants indentation renders it
+ client-side, where it is free.
+2. **`isError` agrees with the envelope.** A tool result whose body
+ carries the C.12 envelope (`error` at the top level) sets the
+ protocol's `isError` flag. The SDK already flags schema-validation
+ failures, so the two failure families used to wear opposite flags —
+ and a harness that branches on the protocol field treated `E_NOT_FOUND`
+ as success, forwarding the envelope as if it were data. Whether a
+ domain refusal "is" a protocol error is a debate the consumer settles:
+ a machine reads one field, so the two signals MUST agree. The envelope
+ itself is unchanged — `{code, message, hint}` is the body either way.
+3. **The server states its version.** `serverInfo.version` carries the
+ installed package's version. An integrator debugging against a
+ deployment must be able to ask which build answered; a full report
+ cycle was once spent against a build nobody could identify, and the
+ client had no way to detect it.
+4. **No empty promises.** Capabilities with nothing registered behind them
+ (`resources`, `prompts`) are not announced. An announced capability is
+ an instruction to every connecting client to spend a round trip listing
+ it; two round trips per connect, forever, to learn "empty" is a cost
+ with no buyer.
+
+ **A transport method is not a capability (amended v0.64).** This rule
+ reaches the two families it names and no further. In particular
+ `subscriptions/listen` (2026-07-28, SEP-2575) MUST stay served: it is
+ not a feature a client lists, it is the era's only server-to-client
+ channel — what the standing GET stream was before it — and withholding
+ it does not save a round trip, it ends the connection (J.1.4). The
+ consequence is that at that era the SDK derives `tools.listChanged`
+ from the served handler and announces `true` while this Station
+ publishes no such event. That is admitted deliberately: the promise
+ costs a subscriber one stream that stays quiet, which is the cheaper of
+ the two things a client can be told. Earlier eras are unchanged —
+ `tools.listChanged` is `false` at 2025-06-18 and 2025-11-25, where the
+ flag is derived from notification options rather than from the handler.
+5. **The instructions name every tool (v0.55).** The `instructions`
+ served at `initialize` are the one description of this surface every
+ client receives unasked, and an agent that trusts them uses exactly
+ what they name — a consumer team operated a full round without `scan`
+ while writing a feature request for what `scan` already did. Every
+ registered tool appears in the instructions by name, and the suite
+ compares the two lists mechanically: two descriptions of one contract
+ agree only where somebody compared them.
+6. **The first reply states the version (v0.56).** The `forests()`
+ result — the call the instructions prescribe first — and REST's
+ `GET /v1/forests` carry `station: ""`, the same string as
+ rule 3's `serverInfo.version`. Rule 3 put the version where a
+ protocol client can read it; this rule puts it where the MODEL reads,
+ because the model is what navigates by a downloaded skill, and a
+ skill is a snapshot of this surface that nothing ages visibly: the
+ team's round-without-`scan` was a stale skill, not a missing tool.
+ The generated skill stamps the version it was built against (J.5.12)
+ and teaches the comparison; the server cannot push a skill, but it
+ can make staleness visible in the first reply of every session.
+
+7. **A tool description names the neighbour that does what it refuses
+ (v0.77).** A refusal is only as useful as the next call it points
+ to, and an agent reads descriptions, not the spec. Every tool whose
+ contract refuses something MUST name, in its own description, the
+ tool or parameter that does it: `ingest` names `b64` for a file that
+ is not text and says what an image becomes; `view` says a media node
+ without bytes answers `E_NOT_FOUND` and names `look`'s
+ `payload_missing` and `ingest`; `plant` says it carries no bytes and
+ names `ingest`; `sniff` says what `scope` is and what `_meta/` is;
+ `look` names the payload flags. The `instructions` carry the same
+ facts in one clause each. The suite reads the served descriptions
+ and asserts the names, because a description is a contract nobody
+ else compares. This rule is about naming, not length: a description
+ that grows past what a session should pay is rule 1's concern.
+
+#### J.1.3 The door tells the truth about the rooms (v0.55)
+
+v0.52 taught `/v1/health` to report the MCP door (J.1.1), and the next
+outage was behind the next door: a Station whose every forest refused to
+open still answered `status: "ok"`, `writable: true`, and listed both
+forests with full capabilities — while `forests()` is the first call the
+instructions prescribe. An agent was instructed to begin from an answer
+that was false, and its next call could not tell a bad key from a bad
+name from a dead server.
+
+1. **`/v1/health` carries `forests: {served, locked}`** — counts, never
+ ids: health is unauthenticated, and forest ids are J.3's to disclose.
+ `served` is what would open; `locked` is what a live foreign writer
+ currently holds (C.9). The probe reads the lock file and asks the
+ kernel — it MUST NOT open the forest, warm it, or touch a lane.
+2. **`status` says `"degraded"` when `locked > 0`.** A Station serving
+ none of what it exists to serve is not `ok`, and an operator's first
+ curl is the place to say so.
+3. **`forests()` and `GET /v1/forests` mark what cannot serve.** An
+ entry the key may see carries `locked: true` while a foreign writer
+ holds it — the caller was going to learn this anyway, one failed call
+ later and without the reason. An orphan lock marks nothing: it heals
+ at the next open (C.9), so reporting it would name a problem the
+ product no longer has.
+
+#### J.1.4 A refusal is not a disconnection (v0.64)
+
+Streamable HTTP gives one status code a meaning the JSON-RPC layer above it
+cannot override: **404 means the session named by this request no longer
+exists** (2.5.3). A client that receives it has been told, correctly, that
+continuing on this connection is pointless, and the conforming answer is to
+stop — so 404 is the one status a live server MUST NOT spend on anything
+smaller than that.
+
+An unregistered method is something smaller. The SDK answers it with
+`-32601 Method not found` under HTTP 404, which is defensible as HTTP and
+wrong as this transport: the two facts differ in scope, and the client can
+only act on the one the status carries. Measured, that is the whole distance
+between "this server does not offer that method" and **0 tools** — the
+client tore down a session that was healthy, and the call that failed was
+the next one, not the one refused.
+
+1. **The MCP mount MUST NOT answer a served request with 404 for any reason
+ other than a session that is gone.** A method this Station does not serve
+ is a JSON-RPC error carried by a 2xx, on the terms C.12 already sets for
+ every other refusal on this surface.
+2. **A method the era requires is served, not refused** (J.1.2 rule 4 as
+ amended). Where the two rules could disagree, this one does not arise:
+ the method is served, so no refusal is spelled at all.
+3. **The rule is about the wire, not about authority.** J.3's byte-identical
+ `E_NOT_FOUND` for an out-of-scope node is unaffected — it is a JSON-RPC
+ result about a node, and it is exactly the disclosure boundary that
+ requires it to be indistinguishable from an absent one. Nothing here
+ licenses naming what a scope hides.
+
+The general form is the one this document keeps arriving at from different
+directions: a signal has one meaning per layer, and a layer that borrows
+another's vocabulary to say something cheaper will be believed about the
+expensive thing.
+
+### J.2 Identity
+
+- **Principals** are users (humans) or service tokens (machines). Both are
+ registry objects; both carry a stable id used in audit records.
+- **Authentication:** API keys (stored hashed) MUST be supported; OIDC/JWT
+ for corporate SSO MAY be added, mapping claims onto a principal.
+- **Roles** are per-forest: `owner`, `gardener` (ingest and writes),
+ `ranger` (maintenance), `reader`. A principal MAY hold different roles on
+ different forests. Roles are shorthand for capability sets (J.3); an
+ explicit policy grant always refines them.
+
+#### J.2.1 Two doors, one identity
+
+A human should not have to paste a 43-character secret to open a web
+console, and a machine should not have to hold a password. So there are two
+doors:
+
+- **Password** `POST /v1/auth/login {username, password}` returns a
+ **session token**: an ordinary API key with a short lifetime and a
+ `session` kind. Sessions MUST NOT appear in the token console; they are
+ the by-product of a login, not a credential an operator manages.
+- **API key** pasted directly, as before.
+
+Both MUST converge on the same `authenticate()` and the same J.3 policy
+resolution. There is exactly **one** authorization path; the door only
+decides how the principal was established, never what it may do.
+
+**The environment super-admin.** `MONKEYLLM_STATION_ADMIN` and
+`MONKEYLLM_STATION_PASSWORD` define a break-glass account, verified against
+the environment with a constant-time comparison and **never stored**.
+Hashing a value that already sits in the environment protects nothing, and
+storing it would give a rotation two places to go wrong. It carries the
+owner bit (J.2.4), so it governs a registry that holds no forest yet the
+grant-per-forest reading of this rule is what made an empty deployment
+unreachable before v0.25. If the variables are absent, the door simply does
+not exist a deployment that never sets them has no default password,
+which is the only safe default.
+
+It is **break-glass and MUST NOT be the documented way in**. A deployment
+that sets it has chosen an environment-held credential over an owner the
+registry knows; both are legitimate, but they MUST NOT be offered at the
+same time, and J.2.4 states which one wins.
+
+Other principals MAY hold a password, set by an administrator and stored
+**hashed with a memory-hard KDF and a per-principal salt** (unlike API
+keys, a password is guessable, so the plain-digest reasoning does not carry
+over). A principal without a password cannot use the password door at all;
+absence MUST NOT degrade into a blank or default credential.
+
+#### J.2.2 Token lifecycle
+
+A credential that cannot be listed, expired or revoked is not governed. Every
+API key MUST carry:
+
+| Field | Why it is not optional |
+|---|---|
+| `label` | a token nobody can identify is a token nobody dares revoke |
+| `expires_at` | the default answer to a leak nobody noticed |
+| `revoked_at` | the answer to a leak somebody did notice |
+| `last_used_at` | what makes an unused token safe to remove |
+| `prefix` | lets a token be recognised in a list without being disclosed |
+
+Authentication MUST reject expired and revoked keys, and MUST record last
+use. Listing MUST return the prefix and this metadata and never the secret,
+which is shown exactly once, at creation.
+
+**The escalation rule.** A key authenticates a *principal*, and a principal
+may hold grants on several forests. Minting or revoking a key for a
+principal therefore requires `admin` on **every forest that principal is
+granted**, not merely on one of them otherwise the administrator of one
+forest could mint a credential that opens another. For the same reason, the
+token console MUST list only principals the caller fully administers.
+
+#### J.2.3 The person as the unit of administration
+
+Grants, passwords and keys are three tables and one thought. Onboarding
+somebody is not three tasks performed in three places; it is one decision
+with three consequences. `POST /v1/admin/people` therefore applies, for a
+single principal and in this order:
+
+1. **grant** create or replace their access on one or more forests
+2. **revoke access** remove their access to one or more forests
+3. **password** set, replace or clear it (absent field means "leave it")
+4. **issue key** mint one, returned exactly once
+5. **revoke keys** one, or all of theirs
+
+The order is normative because it is what makes first-time onboarding work
+in a single request: the grant lands before the credential steps, so a
+principal that did not exist a moment ago is administrable by the time its
+password and key are created.
+
+**A composite is not an authority.** Each step MUST re-check the rule that
+governs it on its own `admin` on the forest for (1) and (2),
+`administers_fully` for (3), (4) and (5), and the environment account's
+refusal to hold a stored password. A step the caller may not perform MUST
+be refused **without abandoning the steps they may**: the response reports
+what was applied and what was refused, because silently dropping half of a
+submitted form is worse than either doing it or failing it.
+
+**Several forests, one decision.** Access is rarely forest-shaped: an
+analyst joins a team that reads four of the six forests, a CI service reads
+all of them. Steps (1) and (2) therefore accept a **set** of forests —
+`grant` MAY carry `forests: [, …]` in place of `forest: `, and
+`revoke_access` MAY carry a list in place of a string. The scalar forms
+remain valid and mean a one-element list.
+
+A set MUST NOT weaken the per-forest rule. Each named forest is authorised,
+applied and refused **individually**: an administrator of two forests out of
+three who names all three grants two and is told, by forest id, why the
+third was refused. Partial application within the step follows the same
+reasoning as partial application between steps the operator gets what they
+were entitled to, and an explicit account of what they were not. The step
+counts as applied when at least one forest landed.
+
+`allow`/`deny` prefixes in a grant apply to **every** forest it names,
+because a grant is one policy expressed once. Branch names are forest-local,
+so naming several forests and a subtree at the same time is only meaningful
+when the caller knows those subtrees exist in each; the API MUST NOT
+second-guess that, while the console MUST NOT offer it blindly (J.5.5).
+
+**The escalation rule is unaffected.** A key authenticates a principal, so
+minting one still requires `admin` on every forest that principal holds
+(J.2.2). Widening a principal to a forest the caller does not administer is
+refused at step (1) and can never become a back door into step (4): step (4)
+re-reads the principal's grants as they stand after step (1).
+
+`GET /v1/admin/people` returns, per principal the caller administers:
+identity, grants (filtered by J.3.2), whether a password exists, their
+tokens, and when they were last seen. The console MUST NOT have to
+reassemble a person from three endpoints; that shape is the registry's, not
+the operator's.
+
+#### J.2.4 First-run setup, and the owner
+
+A Station is installed before it has an administrator. Until v0.25 that
+first moment had no answer: authority was a sum of per-forest grants, an
+empty registry summed to nothing, and J.7 would not hand over the first
+forest because there was no existing one to be admin of. A product MUST be
+reachable through its own front door on first boot.
+
+**The owner bit.** A principal MAY carry `owner`, a property of the
+principal itself. An owner holds `admin` on every forest in the registry —
+**present and future, including none**. It is not a grant, is not stored
+per forest, and is not re-derived at startup:
+
+- authority that must be able to create the first forest cannot be derived
+ from a forest, or the deadlock simply moves;
+- re-granting at boot drifts every forest created later needs the same
+ loop to run again, and the one time it does not is a support ticket;
+- a grant can be revoked forest by forest, which would silently produce a
+ half-owner. The bit is atomic: an owner is one or is not.
+
+There is **exactly one owner**, and the bit is not grantable through
+`/v1/admin/people` or any other route. Governance is still per forest
+(J.3.2); the owner is the root of it, not a second tier of console.
+
+**The setup route.** `POST /v1/auth/setup {username, password, email?}`
+creates the owner and returns a session token, exactly as a login would.
+
+- It MUST be **unauthenticated**, because there is nobody to authenticate.
+- It MUST exist **only while the registry holds no credential of any kind**
+ no password, no non-session API key. That is the one condition under
+ which an open route creates no privilege escalation: there is no
+ privilege yet to escalate from.
+- It MUST NOT exist while an environment super-admin is configured. That
+ deployment has already declared its first identity, and two open doors
+ competing for it is the race this section exists to forbid.
+- Once it has run it MUST close **permanently**. A closed route MUST answer
+ exactly as an unrouted path does not "already configured", which
+ publishes the deployment's state to anyone who asks.
+- It MUST NOT be closed by the Station's own **startup** (v0.28). The window
+ belongs to the person who opens the console; a process that consumes it by
+ booting removes the feature from every deployment that runs the image as
+ shipped. Only an operator's explicit request may take it instead (J.2.5).
+- The check and the creation MUST be **one atomic transaction**. Two
+ simultaneous requests MUST produce one owner and one refusal, never two
+ owners. This is the whole security surface of the feature: a
+ check-then-write with a gap between them is a back door with a race
+ condition as its key.
+- `email` is **optional**, stored locally as the owner's contact, and MUST
+ NOT be transmitted anywhere. A setup step that depends on a network call
+ cannot complete on an air-gapped host, and asking for an address in
+ exchange for nothing is a poor first sentence for a product to say.
+- The password is stored under the J.2.1 rules memory-hard KDF, salted.
+ The owner is an ordinary principal that happens to carry a bit.
+
+**The first forest.** Setup MAY create one, and the choice MUST be the
+operator's: an empty forest, or a **seeded demo** whose only purpose is that
+`Ask` and `Explore` have something to answer on the first visit. Neither is
+required an owner with no forest is now a valid, workable state, which is
+precisely what v0.24 could not represent.
+
+The seed MUST be **generated, never shipped as content**: a generator that
+calls only public primitives, living outside `src/monkeyllm/` because the
+engine carries no vocabulary of its own (a forest is content, and content
+is not the engine's business). A demo forest MUST NOT be committed to the
+repository the generator is the artifact, the forest is its output.
+
+**F.28 (acceptance).** On a registry with no credential and no environment
+super-admin, `GET /v1/health` reports setup is required and the setup route
+creates an owner whose session **immediately** reports `admin`, holds
+`admin` on a forest created *after* the owner existed, and is refused by
+nothing that `admin` permits; the same route, called a second time, answers
+byte-identically to an unrouted path, and two concurrent first calls produce
+exactly **one** owner and one refusal proven by running them against one
+registry, not by inspecting the code. With an environment super-admin
+configured, the route MUST NOT exist at all. An owner MUST be able to create
+the first forest on an empty registry, and a non-owner without grants MUST
+still be refused it. Clearing every credential MUST NOT reopen setup while
+the owner principal exists all covered by tests.
+
+#### J.2.5 The first run, announced (v0.28)
+
+J.2.4 made a Station with nobody in it reachable. This section makes it
+**findable**, because the two are not the same thing and only the first was
+ever built.
+
+Nobody meets this product in a browser. They meet it in a terminal, watching
+`docker compose up` scroll, and at the end of that scroll they either know
+what to do next or they do not. A console that would have told them is
+behind the door they are trying to open. So the first minute is a log line,
+and the log line is part of the product.
+
+**Starting mints nothing (normative).** A Station MUST NOT create a
+credential as a side effect of starting. The registry it starts on MUST hold
+exactly the same authority after boot as before it no key, no password, no
+principal that can act. This is what makes J.2.4's window survive to be used:
+a process that mints itself a credential on an empty registry closes setup
+before the first request and turns the documented first door into code that
+only tests can reach.
+
+The environment super-admin (J.2.1) is not an exception to this. It creates
+an *identity* and no credential: the password is compared against the
+environment and never stored, so nothing in the registry becomes usable by
+possessing the registry. That it also closes setup is J.2.4's rule about
+declared identities, not a side effect of booting.
+
+**The announcement.** When nobody can yet sign in, the Station MUST write to
+its standard output how to get in, and MUST name the console's URL. There
+are exactly three states and the announcement MUST distinguish them, because
+the operator's next action differs in each:
+
+| Registry state | What the announcement says |
+|---|---|
+| No credential, no environment super-admin | setup is open: the first person to open the console becomes the owner |
+| Environment super-admin configured | sign in as that **username**, with the password held in the environment |
+| Any credential exists | nothing this is not a first run |
+
+- The environment password MUST NOT be printed. The operator set it; the log
+ aggregator did not need a copy.
+- The open-setup announcement MUST also read as a warning. A Station
+ published on a public interface with an unclaimed owner seat is a race
+ against strangers, and the operator learns that here or after losing it.
+- The third row is silence, not a status line. A restart that reports the
+ deployment's authentication state to whatever collects its logs is a
+ disclosure with no reader who needed it.
+- The condition is **the registry**, never a marker file. A first run is a
+ fact about state, and a Station whose registry volume was replaced is
+ having its first run again no matter what the filesystem remembers.
+
+**The bootstrap key (opt-in).** A deployment with no reachable browser —
+headless server, CI, an MCP-only client MUST still have a first door. The
+Station therefore accepts an explicit request, at start, to mint the first
+API key: a `--bootstrap-key` flag, or the equivalent environment variable so
+that a platform UI with no argv field can ask for it too.
+
+- It MUST be explicit. An opt-in that defaults to on is not an opt-in, and
+ the default here is the setup screen.
+- It MUST mint **only into J.2.4's window** no owner, no credential and
+ return nothing at all otherwise. A running deployment MUST NOT be able to
+ grow a new full-authority key by being restarted with a flag; that is what
+ `station key` and the People console are for, both of which require
+ somebody who is already in.
+- The principal it mints MUST take the **owner bit**. A first credential
+ that cannot create the first forest is the v0.25 deadlock wearing a new
+ hat, and this is the one requirement here that is about authority rather
+ than about words on a screen.
+- Minting it **closes setup**, by J.2.4's own rule, and the announcement
+ MUST say so. The flag and the setup screen are two doors onto one
+ one-shot window; whichever is used spends it.
+- The key is shown **once**, at creation, and only its digest is kept
+ (J.2.2). The announcement MUST say that too, because a secret that scrolls
+ past unlabelled is a secret the operator will look for again tomorrow.
+
+**F.31 (acceptance).** On an empty registry with no environment super-admin,
+starting the Station MUST leave `GET /v1/health` reporting
+`setup_required: true` proving the boot minted nothing and the
+announcement on standard output MUST carry the console URL. Started once
+with the bootstrap flag, the same registry MUST then report
+`setup_required: false`, the printed key MUST authenticate a principal
+reporting `owner: true`, and that principal MUST be able to create the first
+forest on the empty registry; started with the flag a **second** time, or
+against a registry that already holds a credential, it MUST mint nothing.
+With an environment super-admin configured, the announcement MUST name the
+username, MUST NOT contain the password, and no key is minted. On a
+registry that already holds a credential the announcement MUST be empty. All
+covered by tests.
+
+#### J.2.6 Pairing a key that narrows (v0.48)
+
+A device that acts for a person all day a browser extension, a phone
+shortcut must hold a credential, and both existing shapes are wrong
+for it. A session (J.2.1) is the principal's whole authority with a
+short life: stored in a toolbar it is both too much and too brief, and
+an extension that silently re-logs in must store the password, which is
+strictly worse. An admin-minted key (J.2.2) has the right lifetime but
+the wrong gatekeeper: a person should not need an administrator to
+connect their own browser to their own grants.
+
+`POST /v1/auth/pair` is the third door. Unauthenticated like `login`,
+it takes `{username, password, label?, caps?, expires_in_days?}` and
+answers `{api_key, principal, caps, expires_at}` an ordinary J.2.2
+key whose row additionally carries a **capability mask**.
+
+- **The mask only narrows.** The key's effective authority is the
+ principal's grants **∩ mask, computed at the moment of use** —
+ wherever the requesting principal's authority is read: the policy
+ built for a forest call, the grants a console is shown, and the
+ admin and owner bits, over REST and MCP alike. A mask never adds a
+ capability, never widens a scope, and a masked key held by an owner
+ is refused every `/v1/admin` route exactly as if the owner bit were
+ absent. Grants revoked after pairing are gone from the intersection
+ immediately: the mask is a filter over live authority, not a copy of
+ it.
+- **`caps` ⊆ `{read, ingest}`, default both.** A pair key exists to
+ clip and to look; asking for `write`, `tend`, `query` or `admin` is
+ `E_SCHEMA`. Those remain what People and `station key` mint,
+ deliberately.
+- **No admin gate, by construction.** Pairing is self-service because
+ it reaches nothing the password could not already reach refusing
+ it would not protect anything, only route the same authority through
+ a wider credential. A principal with no `ingest` grant anywhere still
+ pairs; the key simply cannot ingest, which is the grants speaking,
+ not the door.
+- **It MUST expire.** Default 90 days, ceiling 365; absent or zero
+ means the default, never "unlimited". The lifecycle is otherwise
+ J.2.2's: digest-only storage, shown once, revoked from People,
+ `last_used_at` maintained.
+- **`login` and `pair` MUST be rate-limited.** Both verify passwords
+ and both are now reachable from every browser that holds the origin,
+ not only from the console. A fixed window per (username, client) is
+ enough; the refusal is HTTP 429 with the same one message whether
+ the user exists or not the limiter must not become the directory
+ the login refusal already refuses to be (J.2.1).
+- `/v1/me` and `/v1/forests` answered to a masked key report the
+ **masked** caps: what a console renders from them is what the key
+ can actually do, or every disabled button becomes a support ticket.
+
+**F.47 (acceptance).** Pairing with a valid password returns a key
+whose `/v1/me` shows only the masked caps. That key MUST be refused a
+`plant` its principal would be granted unmasked, MUST still read and
+`ingest`, and minted for the owner MUST be refused every
+`/v1/admin` route and MUST NOT satisfy the admin bit over MCP. A wrong
+password, an unknown user and a user with no password answer
+identically. Failures past the window answer 429 without revealing
+whether the user exists. Every pair key carries an expiry; `caps`
+outside `{read, ingest}` is `E_SCHEMA`. All covered by tests.
+
+### J.3 Policy and enforcement `ScopedVine`
+
+**Unit of scope: the branch prefix.** The hierarchy that Part A already
+maintains is the policy surface a grant names a subtree, not a node list.
+
+A policy binds one principal to one forest:
+
+```yaml
+principal:
+forest:
+allow: [projects/, sales/reports/] # subtree grants
+deny: [projects/secret/] # carve-outs; deny wins
+caps: [read, write, query, tend, ingest, admin]
+datasets: # optional narrowing
+ sales/report-q1-2026: {tables: [sales]}
+```
+
+Resolution rules: absent policy means **no access** (deny-by-default); a
+node is in scope when some `allow` prefix matches its id and no `deny`
+prefix does; `deny` MUST win over `allow` at any depth.
+
+`ScopedVine` wraps the ten primitives plus `harvest` with exactly one rule
+each:
+
+| Primitive | Enforcement |
+|---|---|
+| `locate`, `scan` | candidate set restricted to in-scope nodes **before** ranking, budgeting and truncation |
+| `sniff` | body search space restricted to in-scope nodes |
+| `look`, `pick` | node MUST be in scope, else `E_NOT_FOUND` |
+| `move` | edges whose other endpoint is out of scope MUST be omitted from the response |
+| `harvest` | inherits the above (it is a C.6c composite, not a bypass) |
+| `query` | requires `query` cap and an in-scope `type:dataset` node; the optional table allow-list is checked against the parsed statement (C.5.3); C.9 read-only guards unchanged |
+| `tend` | requires `tend` cap and an in-scope dataset; the optional table allow-list is checked against the parsed statement, **reads included** (C.5.3, v0.50); C.10 guards unchanged |
+| `plant`, `graft` | require `write` cap; the target id MUST be in scope |
+| Gardener `adopt`/`sync` | require `ingest` cap; MUST NOT write outside scope |
+
+**Two invariants, both security-critical:**
+
+1. **No truncation oracle.** Scope filtering MUST be applied before token
+ budgets and `truncated` are computed. A scoped response MUST have the
+ same shape and budget semantics as an unscoped one; a caller MUST NOT
+ be able to infer hidden content from result counts or truncation flags.
+2. **No existence oracle.** An out-of-scope node MUST produce a response
+ byte-identical to that of a node that does not exist (`E_NOT_FOUND`,
+ same hint). This extends to `move`: an omitted edge MUST be
+ indistinguishable from an absent edge, because an error or a placeholder
+ would itself disclose the forbidden node.
+
+Structural consequence: `ScopedVine` composes the public `Vine`; it MUST
+NOT patch, subclass around, or reach into engine internals. Any behavior it
+cannot express through public primitives is a spec gap to be resolved here,
+not a monkey-patch.
+
+#### J.3.2 Administration is per forest
+
+J.3 scopes *content*. The same reasoning governs *governance data*, and it
+is easy to miss because the capability check passes: holding `admin` on one
+forest admits a caller to a host route, and that is all it does. It is
+never a licence to read rows about forests they do not administer.
+
+Therefore every host route that returns registry data MUST filter its
+result to the forests the caller administers:
+
+| Route | Filter |
+|---|---|
+| `/v1/admin/principals` | principals holding a grant on an administered forest, with `grants_detail` reduced to those forests |
+| `/v1/admin/audit` | entries whose forest is administered |
+| `/v1/admin/keys` | principals the caller administers **fully** (J.2.2 a key spans forests, so partial administration is not enough) |
+| `/v1/admin/grant`, `/v1/admin/models` | already per forest; unchanged |
+
+A branch prefix is a description of somebody's world, and an audit entry
+is a record of what they read. Neither becomes public because the reader
+happens to administer a different forest.
+
+*Boundary (normative as of v0.50):* providers (J.10) are a **host**
+resource one row, no forest column, shared by every forest. Their reach
+is therefore the whole deployment: the endpoint decides where every
+forest's material is sent, the key pays for every forest's calls, and
+removing one removes the bindings of forests the remover may not
+administer. Authority over them MUST match that reach.
+
+| Operation | Authority |
+|---|---|
+| list providers (`GET`) | any administrator. A per-forest model binding points at these names, and the response carries `has_key` rather than any secret |
+| create, change, remove, or test a provider | administers **every** forest in the deployment |
+
+"Every forest" rather than "the owner bit" is deliberate, and it is the
+same reasoning J.2.4 gives for the bit itself: authority is stated as what
+it reaches, so the rule keeps working where the bit is unavailable. The
+J.2.1 break-glass account falls back to per-forest grants when the owner
+seat is taken and keeps provider repair, which is the situation
+break-glass exists for; a single-forest deployment is unaffected, there
+being no second forest to cross into; and a deployment's second forest
+narrows the authority the moment it exists, with nobody revoking anything.
+
+Two custody rules follow, and they hold for every principal including the
+owner:
+
+1. **A stored credential belongs to the address it was stored against.**
+ Changing a provider's endpoint MUST require supplying its key again.
+ Leaving other fields alone with a blank key still keeps the stored one
+ — that is the case the blank-key affordance exists for.
+2. **A stored credential is never sent to a caller-supplied
+ destination.** Where a connection test accepts both a saved provider
+ and an endpoint typed into a form, a supplied endpoint that differs
+ from the stored one is a different destination: it carries its own key
+ or none.
+
+A per-tenant provider model remains a later concept, not an implementation
+detail here.
+
+### J.4 Audit
+
+- **Writes** are already commits; the Station MUST stamp the acting
+ principal in the message, following the existing convention
+ (`station(): `, cf. `ranger(promote|prune)`). Git
+ history remains the source of truth for what changed.
+- **Stamped at the commit, never amended after (v0.57).** The stamp used
+ to be an amend: the engine committed, the host read the message back
+ and rewrote the commit with the `station-principal:` trailer — two
+ commits and a log read for every write, on the one thread every write
+ already queues for. The engine now exposes `commit_trailers` — a
+ public, host-writable seam in the J.0 pattern (`embedder`,
+ `hybrid_locate`): lines the next commit appends after a blank line, in
+ git's own trailer convention. The host sets it around each scoped
+ write; the engine stays principal-blind (it appends what it is handed
+ and never reads it). The sha the caller receives is the only sha that
+ ever existed. The amend remains as fallback where the seam is absent
+ (an older engine), because attribution lost is worse than attribution
+ paid for twice.
+- **Reads** extend Part D telemetry with the principal: every scoped call
+ records `(principal, forest, primitive, argument digest, result size,
+ timestamp)`. Bodies and snippets MUST NOT be copied into the audit log —
+ it records access, not content.
+- **An answer served from the store is audited as one** (J.10.7): the row
+ carries the entry's key digest, is marked as served from the store, and
+ the cost it records is the cost avoided, never a second spend. The
+ column that holds it is J.4.2's (v0.73); until then the sentence had
+ nowhere to be true.
+ Reconstruction survives the shortcut the row names the entry, and the
+ entry keeps the original run and its trail.
+- The pair MUST be sufficient to reconstruct any answer's full trail after
+ the fact: which principal, which primitives, which nodes, in which order.
+
+#### J.4.1 Governance is audited too (normative, v0.50)
+
+The bullets above record what was read and what was written. This records
+the changes that decide **who may do either** — the questions any later
+review starts from: when was this key made, and by whom; when did this
+grant widen; when did this provider change address.
+
+The Station MUST record, on the same table and the same terms:
+
+| Event | Carries |
+|---|---|
+| grant / revoke | target, forest, capabilities |
+| key issued / revoked | target, label, the key's non-secret prefix, expiry |
+| password set or cleared | target, and that it happened |
+| provider created, changed, removed, tested | name, endpoint, whether a key was supplied |
+| model binding | forest, role, provider, model |
+| forest created | id, seed |
+| sign-in, pairing, owner setup | username, client host, outcome |
+
+Three rules:
+
+1. **Never the secret.** The digesting of Part D applies unchanged, and
+ nothing secret is passed to it in the first place: a key is named by
+ its non-secret prefix, a password only by the fact that one was set.
+2. **A governance row belongs to no forest** and MUST carry a single
+ agreed placeholder, so that "show me the administration trail" is a
+ filter rather than a guess. A row that *is* about one forest — a grant,
+ a model binding, a forest's creation — carries that forest's id, so its
+ administrator can read it back.
+3. **Placeholder rows are the owner's to read.** They describe the
+ deployment as a whole, and J.3.2's rule applies to them for the same
+ reason it applies to content: administering one forest is not a licence
+ to read the shape of the others.
+
+Failed sign-ins MUST be recorded as well as successful ones. The J.2.6
+limiter counts in memory and forgets on restart, so without a row there is
+no answer to whether anyone was trying. Recording the attempted username
+is intended: it is what was tried.
+
+An audit write MUST NOT be able to fail the action it describes.
+
+#### J.4.2 An audit row carries the bill, the refusal and the clock (normative, v0.73)
+
+The row of J.4 records who read what. Three groups of fields are added to
+it, each the answer to a question an operator arrives with, and none of them
+content:
+
+1. **`usd`, `tokens`, `calls`, `priced` what it cost.** Read from what
+ the response already carries (J.10.2's costing: the provider's own
+ `usage` against the provider's own catalogue) and never computed a
+ second time here, because an audit write MUST NOT be able to fail the
+ act it describes. A provider that publishes no price records its tokens
+ with `priced` false and no `usd`: silence is not zero, which is the rule
+ the model picker already follows. A primitive that calls no provider
+ carries none of these fields at all.
+2. **`error_code` which refusal.** The envelope's code (C.12) and
+ **never its message**: a code is a closed vocabulary, while a message
+ carries hints that name nodes, terms and table names. `E_FORBIDDEN`
+ beside `E_NOT_FOUND` beside `E_SCHEMA` is the difference between
+ somebody reaching for a scope they do not hold and somebody mistyping an
+ id, and the log spent its whole life unable to tell them apart.
+3. **`ms` and `model_ms` what it took.** `ms` is the engine's own span,
+ read off the same Part D slice `Server-Timing: vine` reports and never a
+ second stopwatch; `model_ms` is the provider round trip, when one ran.
+ They stay apart for J.10.4.1's reason: whoever is dominated by one buys
+ a different fix from whoever is dominated by the other. The host's own
+ remainder is deliberately absent the row is written INSIDE the call it
+ describes, so the remainder is not known yet, and a row that had to be
+ revisited to hold it would be a row that is sometimes wrong.
+
+Three rules:
+
+- **Added, never redefined.** Every field J.4 already specified keeps its
+ meaning to the byte, and a row written before this version reads the new
+ ones as **absent**, never as zero: `0.0 ms` and `$0.00` are claims, and a
+ row from an older Station makes neither.
+- **A store hit records the cost avoided** (J.4, since v0.35) and this is
+ the column that finally holds it. The figure is the ORIGINAL run's, and
+ the row's own `result` is what says it was avoided rather than spent
+ two fields saying that would eventually disagree. Nothing reading the log
+ may add a hit's cost into a spend.
+- **J.4's discipline is unchanged.** No bodies, no snippets, no reply text.
+ A cost is a number and an error code is a token from a closed set;
+ neither can carry content, which is exactly why these are admissible
+ where an error message is not.
+
+#### J.4.3 The audit is queried, and the totals describe the query (normative, v0.73)
+
+`GET /v1/admin/audit` accepted `limit` and `principal`. Everything else
+which forest, which call, only the refusals, this week was left to
+whoever was reading, over whatever page they happened to hold.
+
+- **The route MUST accept `principal`, `forest`, `primitive`, `result`,
+ `errors`, `since` and `until`, and MUST apply them before the page is
+ cut.** Each one narrows the set the scope rule (J.3.2, J.4.1 rule 3)
+ already produced; a filter can never widen it. A bound the route cannot
+ read is `E_SCHEMA`, never ignored a filter silently dropped is a lie
+ about what was searched (C.13's rule), and on this route being wrong
+ about it is a false statement about who did what.
+- **The response MUST carry `totals` computed over the whole filtered set
+ and never over the returned page**: how many calls, how many refusals,
+ how many were served from the store, what was spent, what was avoided,
+ how many of them nobody published a price for, how many people, and the
+ window the set actually covers. A number computed over a page is a
+ property of the page size. The unpriced count is there so a console can
+ keep J.5.16 rule 4 without counting rows itself: a spend of zero beside
+ eleven unpriced calls is a different fact from a spend of zero beside
+ none.
+- **The response MUST carry the values the filters can take, for this
+ caller's own set** the people, forests, primitives and codes that
+ actually appear in it. A filter offering a value that returns nothing
+ teaches an operator that the log is empty when it is the filter that is.
+- **The scope decides first, and it decides in SQL.** An administrator of
+ one forest filters inside their own forests and can no more *count* the
+ others than read them; the governance rows of J.4.1 stay the owner's and
+ are counted for nobody else. A total is a finer size oracle than a page,
+ which is the argument C.13.3 already makes about `calendar`.
+
+### J.5 Studio
+
+A web console served by the Station. Studio MUST consume only the
+documented REST surface. It MUST NOT hold a privileged side-channel:
+whatever Studio can do, an API client with the same principal can do and
+whatever a principal cannot do, Studio cannot show. Localisation and
+theming are presentation: they MUST NOT change any request, response or
+permission.
+
+#### J.5.1 Information architecture
+
+The console is organised into three groups, answering three different
+questions an operator arrives with:
+
+| Group | Console | Answers |
+|---|---|---|
+| **Use** | Overview | what is in this forest and what may I do here |
+| | Ask | what does the forest know about *X* |
+| | Explore | where does a fact live, and what is next to it |
+| | Playground | what exactly does an agent see, and what would this call cost |
+| | Data | what do the datasets contain |
+| | Skills | how does my own AI learn to use this forest as memory (J.5.12) |
+| **Build** | Ingest | how do I put my documents in |
+| | Models | who reads this forest, and who summarises it |
+| | Webhooks | what this forest tells my other tools, and when (J.16) |
+| **Govern** | Access | who exists, what they may see, how they sign in |
+| | Audit | who saw what, what it cost, and what was refused |
+| | Health | what the Ranger sees, and how to snapshot |
+| | MCP / API / Integrations | how agents, apps and deployments plug into this Station |
+
+Navigation MUST carry an icon per console alongside its label: the console
+is used by people who did not choose these names, and a name alone is a
+weak target.
+
+One label is normative (v0.49): the integration manual's entry MUST read
+**MCP / API / Integrations** the three surfaces it documents, in that
+order. The menu is where a newcomer decides what this deployment *is*,
+and the previous label, a lone abstract noun in the govern group, read as
+an appendix. The consoles are a window; the surfaces named on this door
+are the product: external intelligences plugging into a brain a person
+grows for themselves. Localisation MAY translate the trailing noun
+("Integrações", "Integraciones"); `MCP` and `API` are names and travel
+as-is, like the node types of A.1.
+
+Navigation MUST list exactly the consoles the principal's capabilities
+permit on the selected forest, and MUST re-evaluate when that forest
+changes capabilities are per forest, so the menu is too. `Ask` is the
+default landing console for a principal holding `read`; a principal
+without it lands on the first console they do have.
+
+**Hiding is presentation and MUST NOT be the control.** Each console keeps
+its own capability guard, because a hidden entry is still reachable by
+anyone who can set application state, and the API remains the authority: it
+already refuses, and it would refuse a request the console never sent.
+Where a console has something for *every* principal Overview, which
+describes the key itself, its scope and its capabilities it is always
+listed; an entry that could only ever refuse is hidden instead (the govern
+consoles for a non-admin), because a menu reads as a list of what you may
+do, and an entry that only ever refuses teaches nothing (v0.49 this
+corrects the earlier example: Access is admin-gated and hidden like its
+group; the principal's own half of that story lives in Overview).
+
+#### J.5.2 The vocabulary rule
+
+The console MUST address the operator in the operator's vocabulary. The
+policy model of J.3 is storage, not interface:
+
+- **Roles before capabilities.** Access is granted by choosing a named
+ role; the resulting capability set MUST be shown as a *consequence* of
+ that choice, never demanded as the input. Refining the set directly MAY
+ be offered as an explicit deviation from a role.
+- **Scope is picked, not typed.** Branch prefixes MUST be selectable from
+ the forest's actual branch tree. Free text MAY remain available; it MUST
+ NOT be the only way in, because a typed prefix that matches nothing is
+ indistinguishable, in a text field, from one that matches everything.
+- **The grant MUST be restated in a sentence** before it is saved: which
+ principal, which branches out of how many, what they will be able to do,
+ and what they will not. A policy an operator cannot read back is a
+ policy they cannot audit.
+
+#### J.5.3 Localisation and theme
+
+- The console MUST ship **English, Portuguese and Spanish**, complete: a
+ missing translation is a defect, not a fallback. It MUST detect the
+ browser's preference on first load and persist an explicit choice.
+- The console MUST offer both a **light and a dark** presentation, follow
+ the operating system preference until told otherwise, and persist an
+ explicit choice.
+- Content is not chrome. Node ids, titles, summaries, bodies, SQL and
+ model output are forest data and MUST be rendered as stored the
+ console translates its own words only.
+
+#### J.5.4 Credentials, and the panel that does not exist
+
+Issuing, listing and revoking API keys follows J.2.2: label, principal,
+expiry, last use, prefix. The secret appears once, at creation, and the
+console MUST say so at the moment it is shown rather than afterwards.
+
+The access **levels** MUST be documented in the console itself the named
+roles, what each one can do, and what it cannot. An operator choosing a
+level should not have to leave the screen to learn what the choice means.
+
+#### J.5.5 The People console
+
+Governance is presented **per person**, not per table:
+
+- **Onboarding is one form.** Who they are, what they may see, how they
+ sign in, and a token if they need one submitted together, because that
+ is one decision. Splitting it across screens makes the operator hold the
+ model that the interface should be holding for them.
+- **Forests are chosen as a set, not one at a time.** The form MUST offer
+ every forest the operator administers as a multi-selection with an
+ all-or-nothing control, in a bounded, scrollable region so that a registry
+ with fifty forests looks like the same form as one with three. A
+ single-choice control here would be the interface asserting that access is
+ forest-shaped, and it is not: repeating the whole form once per forest is
+ how a five-forest grant becomes four forests and a forgotten one.
+- **Branch scope appears only when it means something.** Branch names are
+ forest-local (J.2.3), so the subtree picker is offered when exactly one
+ forest is selected; with several selected the grant covers each forest
+ whole, and the form MUST say so rather than silently applying one forest's
+ branch names to another's.
+- **The list is the maintenance surface.** Every person the caller
+ administers appears with their level, scope, whether they can sign in,
+ how many live tokens they hold, and when they were last seen. Changing
+ any of it starts from that row, not from a separate screen.
+- **Credentials stay visible as credentials too.** A second view lists
+ tokens across people, for the operator auditing what exists rather than
+ who exists. Two views over one truth; not two places to maintain it.
+- Secrets a generated password, a new key appear once, at the moment
+ they are created, in the same place the operator was already looking.
+
+**There is no separate super-administrator panel, and there MUST NOT be
+one.** One console over one API, with capabilities deciding what appears: a
+principal without `admin` does not see Tokens, and a principal with `admin`
+on one forest does not see the credentials of another. A second panel would
+require a second authentication path, and a second authentication path is
+where the backdoor goes this is the same reasoning as J.5's
+no-side-channel rule, applied to the console's own front door.
+
+#### J.5.4 Forest Views
+
+A forest is a graph of nodes and typed trails with heat on it, and a tree
+of files on disk. Both are the same truth; a console MUST be able to show
+either without the operator changing consoles. The Explore console
+therefore carries **modes over one selection** the selected node survives
+a mode change, because the operator did not stop looking at it.
+
+| Mode | Shows | Reads |
+|---|---|---|
+| graph | nodes and trails, laid out spatially | J.11's `/graph` |
+| tree | the branch hierarchy as a list | `scan` |
+| files | the forest as files, one open at a time | `look`, `pick`, `query` |
+
+**Encoding rules for the graph mode.** Every visual channel MUST carry a
+fact the forest actually holds, and MUST NOT invent one:
+
+- Node type comes from the forest's own dialect (`_meta/schema.md`, A.1).
+ A console MUST NOT hardcode the type list: a forest that added a type
+ gets a legend entry, not an unlabelled colour.
+- Heat is the pheromone value of Part D, and a console that shows heat MUST
+ show the value it received rather than a rank heat is comparable across
+ nodes and a rank is not.
+- Link-level `confidence` below 1.0 MUST be visually distinct from curated
+ links: it is a proposal under the Ranger's management (H.2), not an
+ assertion. `discovered-shortcut` (C.8) MUST be distinguishable in turn.
+- Colour MAY encode the node's type or its home branch the dialect and
+ the id, both facts the forest holds. A console MUST NOT colour by a
+ category the forest does not hold, and whichever fact colour encodes,
+ the legend names it (v0.38).
+- Layout is presentation and carries no meaning. A console MAY animate it;
+ it MUST honour a reduced-motion preference by settling immediately —
+ and it MUST settle regardless (v0.38): a map at rest holds still,
+ spending motion only on new data, the operator's hand, or an explicit
+ reorganize. A forest cannot be pointed at while it trembles. Distinct
+ regions MUST read as distinct: a layout that piles unrelated branches
+ into one heap answers the operator wrongly.
+- View tuning filters, grouping, label visibility, node scale, link
+ width, force strengths is presentation and belongs to the operator
+ (v0.38). It MAY persist in browser storage, per forest; it MUST NOT
+ enter the address (J.5.8: the address carries the selection, not the
+ taste) and it MUST NOT spend a call or a write.
+- **Growth replay** (v0.38). A console MAY replay the region in `created`
+ order (ties by id): nodes appear as they were planted, trails appear
+ when both ends exist. Replay is presentation over the projection
+ already in hand it MUST NOT spend another call and it MUST NOT
+ write. Under reduced motion the replay is a scrubber, not an
+ animation.
+
+- **The timeline is a window** (v0.76). A console offering the replay
+ MUST offer a start beside the end, on the same scale and in the same
+ `created` order (ties by id; a node without a date is older than every
+ record): a node is shown iff its rank lies inside `[start, end)`, a
+ trail iff both its ends are shown. With the start at its origin the
+ picture MUST be the replay scrubber's, unchanged. A node outside the
+ window leaves the picture and MUST NOT leave the layout: what is shown
+ keeps the position the whole forest gave it, so a narrow window reads
+ as a constellation in the shape of the forest — the question is *where*
+ the material of those days landed, and a layout recomputed over the
+ survivors alone answers it wrongly (a leaf whose branch was planted a
+ year earlier has no spring left and falls to the centre). The readout
+ MUST state both days and the count inside the window over the total;
+ "now" restores the whole forest; play grows the window from its start
+ towards now and holds the start. The window is presentation — no call,
+ no write, never in the address (J.5.8) and never persisted (a window
+ remembered from yesterday opens tomorrow's forest empty) — and its
+ scale is `created`, never `updated` and never an indexed date (C.13:
+ `_derived/` is disposable).
+
+**Rendering rules for the files mode.** A file MUST be rendered as what it
+is, and its stored form MUST remain reachable:
+
+- A node body is markdown: rendered by default, with the stored source
+ available in one action. A body whose content is HTML is rendered as a
+ page, sanitised; it MUST NOT be able to script the console, and it MUST
+ NOT be able to reach the console's credentials.
+- A `type: dataset` node's payload is a database: its tables MUST be
+ listable and browsable through `query` and nothing else the same single
+ SELECT, the same injected LIMIT, the same timeout (C.5). A console
+ browsing a payload is a `query` client, not a second access path.
+- Frontmatter shown beside a body is the passport as the Catalog holds it.
+ A console MUST NOT present a reconstructed passport as the file's bytes.
+- A body over the `pick` budget MUST show the outline the primitive
+ returned rather than pretending to have the whole text.
+
+**Editing.** A console MAY offer editing, and every edit MUST leave as a
+Part C write `graft` for a node, `tend` for a dataset row. No surface,
+the console included, may write a node file or a payload directly: the
+commit, the validation, the index propagation and the audit record are the
+write, and a "save" that skips them is a forest that no longer describes
+itself. A console offering editing MUST show the operations before they are
+applied: the operator is authoring a commit, and a commit is not a
+keystroke.
+
+Whole-note editing (v0.43): a console MAY edit the entire body in one
+place and save it as one `graft` carrying `replace_body` the note as
+the unit of edit, one commit for one thought. Two rules keep it honest:
+the console MUST NOT compose a `replace_body` from a truncated `pick`
+(a body over the pick budget is edited at the section grain, because
+writing back less than was read is how notes lose their tails), and a
+rich editor that cannot represent everything markdown can say MUST
+offer the stored source as an editing surface, so what the operator
+cannot see is still not silently dropped.
+
+#### J.5.6 The setup screen
+
+The console has exactly two pre-identity screens. The Gate (sign in) is one;
+the setup screen is the other, and which one appears is not the console's
+choice `GET /v1/health` already says whether a password door exists, and
+it MUST also say whether setup is required. The console asks and renders the
+answer. A console that decided this locally would eventually show a sign-in
+form on a Station nobody can sign in to, which is the bug this whole section
+exists to remove.
+
+- It MUST collect a username and a password, MAY collect an email, and MUST
+ label the email as optional in the interface rather than only in the API.
+- It MUST offer the first-forest choice of J.2.4 empty or seeded demo —
+ and MUST let it be skipped. An owner with no forest is a valid state and
+ the console MUST render it without looking broken: the existing "no
+ forest" empty state already carries the create action for an
+ administrator, and the owner is one.
+- It carries the language and theme controls, for the Gate's reason (J.5.3):
+ the first screen a person sees cannot require a session to be legible.
+- It MUST NOT be reachable once setup has closed. The route is gone by then,
+ so the console MUST treat a failed setup call as "someone else got here
+ first" and fall back to the Gate rather than retrying.
+
+Everything else in J.5 is unchanged: there is still no second panel, the
+owner uses the same nine consoles as anyone else, and what appears is still
+decided by capabilities (J.5.4).
+
+#### J.5.7 Shaping the forest (v0.27)
+
+A.5 gives a new forest one branch, its master index. Part G grows more, but
+only by mirroring a source tree `adopt` derives structure, it does not
+invent it. So a forest whose documents arrive through `upload` or `compose`
+has nowhere to put them except the root, permanently, and the operator's
+only recourse is to leave the product, arrange a folder tree on some other
+machine, and mirror that instead. The console MUST be able to add a branch.
+
+- **Through `plant`, and through nothing else.** A branch made here MUST be
+ the same node an agent's `plant` would make: same validation, same commit,
+ same audit row, same entry grafted into the parent index by the engine.
+ The console composes a call; it does not write files, does not maintain
+ the parent's `## Sub-branches` list, and adds no second idea of what a
+ branch is. This is the J.7 rule (`the Station adds no second way to make a
+ forest`) one level down.
+- **The operator names it; the console derives the id.** The name is
+ slugified into a path segment and the id is composed as
+ `//_index`. Ids MUST NOT be typed by hand in this flow:
+ they are immutable (C.7), so a typo is not a mistake to correct but a
+ node to abandon, and the engine's own check that an id lives under the
+ parent it names is unhelpful to someone who did not know they were
+ writing a path.
+- **The parent is chosen, never typed**, and only from branches the
+ principal can already reach. A parent it cannot read is not a parent it
+ may write under, and offering one produces a refusal the operator cannot
+ act on.
+- **The summary is required, and the engine judges it.** It is the A.4
+ scent every later hop navigates by, so it is not optional and not
+ derived from the name. The console MAY show the 60-token budget while
+ typing; it MUST NOT implement its own rule, because two validators
+ disagree eventually and the engine's is the one that decides.
+- **A new branch carries the A.5 index skeleton** `## Sub-branches`,
+ `## Direct bananas`, `## Cross trails` so it reads like every other
+ branch from the first moment rather than growing headings the first time
+ something is planted into it.
+- **Scope is the engine's, restated in the interface.** `ScopedVine`
+ already refuses a write outside the grant, so a scoped principal cannot
+ create at the root; the console MUST NOT offer a parent it knows will be
+ refused. Hiding is presentation and never the control (J.5.1) the API
+ still refuses what the console never sent.
+- **Where it lives.** Two places, one component: the Explore console, where
+ an operator is looking at the shape of the forest, and inline in the
+ Ingest destination picker, because "where do these go?" is exactly the
+ moment a missing branch is noticed. The picker's create action MUST make
+ the branch and then select it, so the ingest that prompted it continues
+ without a second trip.
+
+**What this is not.** There is no move, no rename and no delete. A node's
+id encodes its branch and ids are immutable, so relocating one would mean
+a new id and every `related-to` link pointing at the old one going stale;
+no primitive does it, and none is specified here. The console can create
+structure and curate what is in it (`graft`, and the Curator through
+J.10). An operator who puts a branch in the wrong place lives with it or
+rebuilds the region deliberately which is the honest cost of ids that
+are also addresses, and is stated here so that nothing downstream is
+designed against a file manager this product does not have.
+
+**F.30 (acceptance).** A principal holding `write` creates a branch through
+the console's call and gets a node whose `type` is `branch`, whose id is
+`//_index`, whose parent index gained exactly one
+`## Sub-branches` entry, and whose creation appears in the audit log with
+a commit sha none of it written by the console. The same call with a
+name that slugs to nothing is refused, a duplicate id is refused, and a
+summary that breaks A.4 is refused by the engine rather than accepted and
+truncated. A principal scoped to a subtree is refused a branch at the root
+with `E_FORBIDDEN` and succeeds inside its own grant. All covered by tests.
+
+#### J.5.8 The address bar (v0.30)
+
+A console is a place, and a place has an address. Studio's did not: every
+screen was served at `/`, and which forest, which console and which node
+were open lived in application state alone. So a reload started the console
+over first forest of the list, default console, nothing selected a
+selection could not be sent to a colleague, and the browser's Back button
+left the product because no history entry had ever been written.
+
+**The URL is the console's state, not a decoration of it.** What is on
+screen MUST be derivable from the address, and the address MUST be the
+thing the console reads when it renders. A console that keeps a second copy
+of where it is will disagree with the address bar eventually, and the
+address bar is the copy the operator can see, share and edit.
+
+| Part | Carries | Why there |
+|---|---|---|
+| `/f/{forest}/{console}` | the forest, and the console open on it | the forest scopes every request the page makes, and the console is what the page *is* |
+| `?node=` | the selected node, across consoles | a node id contains `/` (A.2) and is not a path segment |
+| `?…` | what the open console needs to be the same page | `mode`, `dataset`, `table`, `tab` the console's own selection, not its scroll position |
+
+- **The forest comes first, and it is never re-chosen for the operator.** It
+ is the scope of every call on the page, so a console that picks it again
+ on a reload has changed *which data is on screen* without being asked. A
+ URL naming a forest MUST win over any remembered preference.
+- **Moving is a history entry; adjusting is not.** Choosing a forest, a
+ console or a node MUST push; toggling a mode, a tab or correcting a
+ selection that turned out not to exist MUST replace. Back is for undoing
+ navigation, and a Back that walks a per-keystroke trail is a Back nobody
+ presses twice.
+- **The address is written by navigation, never by rendering.** Loading data
+ MUST NOT rewrite it. The one exception is a selection the forest does not
+ contain a table dropped since the link was written which MAY be
+ corrected to what is actually shown, by replacement, because the
+ alternative is an address that describes a page that is not there.
+- **A parameter the console does not understand MUST be ignored.** Links
+ outlive the version that wrote them, and a shared address is edited by
+ hand. An unknown console falls back exactly as J.5.1 already says
+ capabilities fall back; an unknown parameter value falls back to its
+ default. Neither is an error screen.
+- **The last place is remembered only to resolve a bare `/`.** Which forest
+ somebody was last in is a convenience for the door, not an authority: it
+ MUST NOT override an address, MUST NOT survive the grant that justified it
+ (a forest no longer in `GET /v1/forests` is not a place to restore), and
+ MUST NOT be the console's idea of where it is.
+- **A forest the principal cannot see is said, not swapped.** An address
+ naming a forest absent from this principal's list MUST be reported as
+ what it is, with the forests they do have offered as the way on. Silently
+ redirecting to a forest they can see is worse than an error: the operator
+ followed a link to a specific place, and landing somewhere else without
+ being told is how a person comes to believe they are in a forest they are
+ not.
+- **Restoring a place MUST NOT restore an action.** The address carries what
+ is being looked at, never a call to make. A deep link MUST NOT cause a
+ model call, a write, an ingest or a snapshot on arrival: J.10 composites
+ cost money and Part C writes cost commits, and a reload is not consent to
+ either. What an operator typed MAY be restored; what it produced is
+ re-asked for by hand.
+- **What has an address MUST be a link.** Every navigation entry, every
+ forest in the switcher and every reference to a node MUST be a real
+ anchor carrying that address, so the browser's own affordances open in
+ a new tab, copy link, middle click, the status bar work. Intercepting
+ the plain click is how it stays a single-page application; intercepting a
+ modified click is how it stops being a web page. A control that can be
+ unavailable is not an address and stays a button: `disabled` is a
+ statement about a capability, and an anchor cannot make it.
+- **Hiding is still not the control (J.5.1).** An address is application
+ state a person can type, so a console reachable by URL but not by menu
+ MUST refuse exactly as the menu implies which it already does, because
+ the API is the authority and the guard is in the console, not in the way
+ it was reached.
+
+**The host answers the console's addresses.** A deep link is a `GET` of a
+path that belongs to Studio and not to the API, and the Station is what
+receives it.
+
+- A `GET` matching no API route, no mount and no file in the build MUST be
+ answered with the console's shell, so that reloading `/f/x/explore`
+ reaches the same application that pushed it.
+- It MUST be answered that way **only for document requests** a request
+ that accepts HTML. A missing script or stylesheet MUST stay a `404`: an
+ asset answered with the shell is an HTML body served under a JavaScript
+ MIME type, which fails later, elsewhere, and unrecognisably.
+- `/v1` MUST keep answering as the API (the J.1 rule that an unrouted `/v1`
+ path is a JSON error, never the console), and the MCP mount is untouched.
+- The shell MUST NOT be cached by the browser. It names the hashed assets of
+ one build; a stale copy asks for files the deployment no longer has.
+
+**F.34 (acceptance).** Opening a console, selecting a node and reloading
+MUST return the same forest, the same console and the same selection. The
+address MUST survive being copied into another browser with the same
+principal, and Back MUST retrace the consoles visited rather than leaving
+the console. A `GET /f/{forest}/explore` on the Station MUST return the
+shell with `200`; `GET /assets/missing.js` MUST return `404`; `GET
+/v1/nope` MUST return the JSON error envelope of J.1. An address naming a
+forest outside the principal's grants MUST show that forest's name and an
+explanation, and MUST NOT change which forest the console is reading.
+Covered by tests.
+
+#### J.5.9 The runs already made (v0.31)
+
+Ask holds one result and then loses it: the next question replaces it and a
+reload leaves none. So the console cannot do the thing it exists for. The
+same question after an ingest, with the walk on (J.10.5), or against a model
+rebound since are different answers to one question, and judging a forest is
+reading them next to each other.
+
+The console MUST keep the runs it made, and MUST keep them in the browser.
+
+**A run is one submission.** The record is what was sent the question and
+the parameters exactly as they went and what came back, whole: answer,
+evidence, the material the model was given, the walk, the trace, the clocks
+and the cost. Half a record is not a run: an answer without the material it
+was drawn from is the markdown download, which the console already has and
+which is not evidence of anything. The same question asked twice MUST leave
+two runs, because those two are the comparison.
+
+**It stays on the machine that asked, and nothing carries it anywhere.** A
+run is the operator's working note about an evaluation, not a fact about the
+forest, and the forest has already recorded the call it describes an audit
+row (J.4) and pheromone (Part D), both written when the call ran. The
+console MUST NOT send a run anywhere, and MUST NOT make a request in order
+to show one: a history that needed the host would be a slower copy of
+something the host does not have.
+
+**Keyed by principal and by forest, and dead with the credential.** A run
+carries node bodies read under a grant, and a grant is per forest (J.3). The
+history offered MUST be the current principal's, on the current forest, and
+signing out MUST discard what was kept. A browser is shared furniture, and a
+console showing the previous operator's answers would be showing what the
+API refuses J.5's no-side-channel rule, arriving through the back.
+
+**Restoring a run restores a record, never a call.** This is J.5.8's rule
+one level in. Selecting a run MUST put back the question and the parameters
+as they were sent, and MUST show the response as received, labelled with
+when it was made and which model made it: an answer whose model has since
+been rebound reads as current otherwise, and telling the two apart is the
+entire purpose. Asking again MUST be a deliberate act, and MUST leave a new
+run rather than overwrite the one being read.
+
+**A run has no address.** J.5.8 made the console's places linkable and a run
+is not one: it exists only in the browser that made it, so an address naming
+it resolves for its author and is broken for everybody else. The address bar
+carries the console; the history is opened there, not linked to.
+
+**The bound is stated out loud.** Retention is finite bodies are large and
+browser storage is not the operator's disk. The console MUST keep a stated
+number of runs per forest, MUST discard the oldest first, MUST show what it
+is holding, and MUST offer to discard it. Silent eviction is C.6's
+truncation rule in a different costume: what was dropped is said, or a
+partial history is read as a complete one.
+
+**Storage is a convenience and MUST NOT become a failure.** Private
+browsing, a refused quota, storage switched off the answer on screen is
+unaffected. The console MUST report the history as unavailable and MUST NOT
+turn a storage error into a failed `ask`. It MAY offer the kept runs as a
+file, asked for by hand, which is the only thing that should ever move them.
+
+**F.35 (acceptance).** Two questions asked on one forest leave two runs.
+Restoring the first puts back its question and its parameters, shows its
+answer with the time it was made and the model that made it, and issues no
+request to the Station. Asking again leaves the restored run intact and adds
+a third. Signing out leaves no run readable, and a second principal signing
+in on the same browser finds an empty history. The store never exceeds its
+stated bound and says what it holds. With storage unavailable the console
+still answers and says the history is not. Covered by tests.
+
+#### J.5.10 The Data console (v0.44)
+
+The Data console is a database client over the forest's datasets, and it
+shipped without the three things every database client has: a way to make
+one, a way to bring one in, and a way to leave the one you are in. What it
+gains here are those three, and colour on the surface where SQL is typed.
+
+**A dataset is born through one `plant`.** Exactly the J.5.7 rule for
+branches, for the same reason: the id lives under a chosen parent, the
+parent-index entry and the commit are the engine's, and the console composes
+a single C.7.1 call carrying a declarative `schema`. Normative:
+
+- The console MUST NOT write DDL, and MUST NOT offer a free-text SQL box for
+ creation. Table and column names, the four types and the primary key are
+ fields; the `CREATE TABLE` is the Vine's (C.7.1 rule 1). A console that
+ typed DDL would be a second definition of what a dataset is.
+- Ids are **never typed** and never moved. The leaf is slugified from the
+ name, shown before the call, and immutable after it no primitive
+ relocates a node.
+- Creation requires the `plant` capability and a `dest` in scope. A console
+ MUST NOT offer the control to a principal who cannot use it.
+- Schema evolution stays absent. `tend` is DML-only forever (C.10) and there
+ is no `ALTER` for agents or consoles; a table is changed by rebuilding it.
+
+**A dataset is imported through J.8, never beside it.** The console MAY
+accept `.db`, `.sqlite`, `.sqlite3`, `.csv`, `.json`, `.xls` and `.xlsx`
+files and MUST send them as an `upload` ingest (J.8) the same converters
+(G.2), the same curation (G.4), the same commits, the same J.9 job and the
+same pill (J.9.3) as a folder dropped on the ingest console. Normative:
+
+- Import requires the `ingest` capability, scope-checks `dest`, and MUST NOT
+ let the caller name a host path (J.8: that is `admin` and J.8.2's roots).
+- Binary sources travel as `b64`; the wire contract is J.8's `{name,
+ text|b64}` and nothing else.
+- The console MUST NOT plant an imported file itself, parse it in the
+ browser, or pre-compute its schema. An importer that understood the file
+ would be a converter living where nobody can extend it, and it would
+ disagree with the Gardener the first time either changed.
+- The answer is a job, not a dataset. The console says so, and the list
+ refreshes when the job settles.
+
+**A connection can be left.** While a dataset is selected the picker MUST
+show that dataset its id, its scent, its tables and their row counts —
+and MUST offer an explicit control that returns to the list. Rationale: the
+list is a browse surface and the selection is a working surface, and a
+console that keeps eleven other databases one mis-click away from the query
+you are editing is inviting the mis-click. Leaving is a **navigation**, so
+it clears the selection in the address (`?dataset`, `?table` J.5.8) and
+pushes; it MUST NOT drop a pending write. Unapplied edits are a draft the
+operator can still see, so the control MUST refuse (or confirm) while one
+is staged rather than discarding it silently.
+
+**A person teaches the dataset (v0.46).** A **Notes** tab beside Rows,
+Structure and SQL edits the C.2.1 section, and composes ONE `graft` —
+`replace_section`, or `append_section` the first time. Normative:
+
+- It requires `write`, and a console MUST NOT offer it without.
+- The tab value goes in the address like every other (J.5.8), which means
+ it MUST be in the route validator's allow list. A tab that is not is a
+ tab that bounces back to the first one when clicked.
+- The console MUST NOT write the notes anywhere but the node's body. A
+ side store would be teaching the agent cannot read, which is the one
+ thing this must not be.
+- What is being taught is prose about data, so the editor is the markdown
+ one, coloured like every other source surface.
+
+**Source is coloured where it is typed.** The console already colours every
+literal surface it *prints* (statements pending a `tend`, stored DDL,
+fenced blocks in an answer). The surfaces it did not colour were the
+editable ones the SQL box and the markdown body editor which are the
+surfaces a person actually reads while composing. The markdown one matters
+most where the rich editor refuses: a body holding a table, which every
+dataset's does. A plain `