Skip to content

Release 0.78.0 — the forest keeps what it is handed - #21

Merged
JimmyWesley merged 10 commits into
mainfrom
develop
Sep 3, 2026
Merged

JimmyWesley merged 10 commits into
mainfrom
develop

Conversation

@JimmyWesley

@JimmyWesley JimmyWesley commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Release 0.78.0 — the forest keeps what it is handed

Four spec versions, v0.75 through v0.78, and the package moves 0.74.0 → 0.78.0.
Three of the four are one defect in different clothes: something the forest
was handed was dropped, and nothing said so.
The fourth is a window.

curator writes  produção, segurança, orçamento   →  stored: none of them, no count         (v0.75)
plant type: media, origin: <the harness's store> →  created: true · view: E_NOT_FOUND
                                                    · client: "tool call failed"            (v0.77)
upload {name, b64} + the scent already known     →  a stub summary, a second commit,
                                                    a window in which nothing finds it      (v0.78)
"what was planted last week", 1,878 nodes        →  forty dots among eighteen hundred       (v0.76)

None of those raised. Each was a filter, a description or a default doing
exactly what it was written to do, in a place where doing it quietly was the
defect.

What changed

The writer keeps what the reader was taught to keep (v0.75). _clean_tags
validated against ^[a-z0-9][a-z0-9_-]*$, so every accented tag the Curator
wrote for a Portuguese forest was dropped on its way to disk — no error, no
count, no line in the report. 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 bought
nothing: nodes_fts is unicode61 remove_diacritics 2, so a stored produção
IS matched by producao (measured). The same measurement cleared the
identifiers — be-291, iso-27001, p95 always passed the regex; only the
prompt's "single words" and a cap of five stood between a document and its own
ticket number. v0.52 had already taught the reader to keep code-shaped tokens.
Two halves of one match, and only one had been measured.

Now one tag rule in models.py (validate_tag / tag_key: Unicode
categories, ≤40, NFC+casefold dedup — the fold decides uniqueness only and
never respells), cap 12, every refusal counted (tags_dropped), the prompt
asking for identifiers, names, categories and patterns. Enforced at
plant/graft with exact-spelling grandfathering, so old forests still open.
The Curator also proposes aliases from the document, guarded structurally:
an alias must occur in the body under the one exported vine.fold, and the
union never displaces a hand-written one.

Three more gaps of the same shape — something the engine could already do that
nothing in the product asked it to:

before after
language inferred at every ingest, spent once, discarded lang (A.3.2): BCP-47, absent is a real state, a model is never a source, exact match (pt ≠ pt-BR), filter never a boost, counted by coverage
a correct proposal between two cold nodes 0.3 until the Ranger cold-pruned it — "stays at 0.3 forever" was false, and the spec was corrected mid-round a human vote (H.2.1 / J.18): accept → 1.0 and OUT of the managed population; no MCP tool, because a model must not confirm its own proposal
an already-ingested forest recurate re-derived aliases with no model — incapable of producing one of the tags above derive: ["scent"] (J.13.6.1): the one derive member allowed a model, a J.9 job, summary replaced, tags/aliases unioned, source: ingest nodes only

And the operator gets the hands the agent already had: prune and transplant
(J.5.17 — E_ANCHORED rendered as navigable anchors, force a second
decision), tags edited and applied in bulk (J.5.18 — bulk unions and never
overwrites), link proposals accepted by group from the Links console.

The path was there and nothing named it (v0.77). An agent operating a
1,877-node forest through MCP needed to keep a screenshot. It planted a
type: media node with an origin naming its own harness's file store, got
created: true, got E_NOT_FOUND from view, watched its client render the
envelope as "tool call failed", and filed that the product cannot store a
binary. ingest(mode: "upload", files: [{name, b64}]) has done exactly that
since v0.48 — and the tool description said [{name, text}], the skill taught
text, plant accepted a media passport naming no bytes, look could not say
whether bytes existed, and sniff(scope: "_meta") refused naming an id the
caller never typed. A surface whose every path exists and whose descriptions
name none of them is, to the model reading it, a surface with no paths.

None of the report's own fixes is taken (the upload channel exists; a fourth
view code breaks J.3's oracle; A.3 forbids dereferencing origin). Taken:
every tool description names the neighbour that does what it refuses (J.1.2
rule 7, asserted off the SERVED descriptions); wire plant of media requires a
payload that is contained and exists (C.7.5; the Gardener is exempt because
archive: never legitimately references bytes at the source); look carries
payload_missing or payload_type + payload_bytes for media — a stat, never
an open, the one place "will view work?" is answerable before the call; a
bare _meta scope is E_SCHEMA and an unknown scope is E_NOT_FOUND naming
what was sent. view's envelope is deliberately unchanged: absent,
out-of-scope and payload-less stay byte-identical.

The passport travels with the bytes (v0.78). The agent that followed
v0.77's path uploaded a screenshot it had already looked at, and the scent it
knew — what the picture shows, which nodes it belongs beside — had nowhere to go
until a second call and a second commit, with a window in between where the
node existed under a stub summary no locate finds. An upload entry may now
carry passport: {title, summary, tags, aliases, links, notes}: shape-checked
for every entry BEFORE the first byte stages (a batch with one bad passport
stages nothing — the source_url rule), applied at curation under exactly the
reviewed-draft rules of J.8.1, notes becoming the node's ## Notes. An entry
with a passport is never sent to the curation model; the rest of the batch
keeps the bound curator. Not a second write path: converter, content policy,
plant and commit are untouched.

Two things the first cut got wrong and the review caught before commit, both
this release's own theme: a tag the rule refused was dropped uncounted (the
same flaw sat in J.8.1's approval_hook, fixed with it), and bytes re-sent for
an existing node were refreshed with their passport silently ignored — now
named in passports_ignored, so the caller reaches for graft instead of
finding the old scent on the next look. look, harvest and the walk's
entry carry ## Notes for type: media as they do for a dataset.

The timeline had one end (v0.76). Explore's docked timeline was a
single-thumb scrubber — the forest as it stood on that day — and the question a
fed forest asks is what arrived BETWEEN two days. Now a start beside the end on
one created scale. The trap is the obvious filter: hide the outside nodes the
way the replay hides the unborn (out of the physics too) and the survivors —
leaves whose branch is a year older — have no spring left and fold into the
centre, so the picture answers "what" and lies about "where". The window is a
PAINT split, never a physics one: a node outside it leaves the picture and
never the layout. Never persisted, never in the address.

Measured

produção stored, producao queried, through nodes_fts (remove_diacritics 2) matched — the accent rule bought nothing
be-291 / iso-27001 / p95 through _TAG_RE and the tokenizer always storable, always findable
F.169: old vs new curator on bench/questions-locate-v1.json (baseline recall@1 0.711) not run — needs the local model

Known gaps, stated rather than discovered

  • F.169 is pending. The v0.75 curator change is implemented and specified;
    its effect on the labelled set has not been measured.
  • PDF and unknown formats still ingest as unsupported. The v0.77 report's
    "send any file type" is a Part G round of its own: a PDF converter and a
    fallback for formats no converter claims.
  • lang reaches a passport from caller frontmatter only; the docx/HTML/config
    precedence of A.3.2 rule 3 is not wired.
  • J.1.2 rule 2 (isError on every refusal) stays. The client that dropped the
    envelope reads the flag alone, the client rule 2 was written for reads the
    body alone, and a Station cannot write for both at once.

For existing callers

  • Tags: a plant/graft carrying a tag the new rule refuses is
    E_FRONTMATTER; tags already on disk are grandfathered by exact spelling.
  • Media through the wire: plant of type: media without an existing,
    contained payload is E_SCHEMA. Send the bytes through ingest first.
  • sniff(scope: "_meta") is E_SCHEMA; an unknown scope is E_NOT_FOUND
    with a message naming the scope, distinct from a missing node's.
  • ScopedVine enumerates its parameters — a new engine kwarg (lang)
    needed its own lines in policy.py and mcp_surface.py, which cost a live
    400 during the round.
  • Re-download the skill from Studio → Skills: it teaches b64 and the
    passport now, and forests() tells an agent when the one it holds is stale.

Acceptance

F.162 – F.172. Full suite green locally (1675 passed, 6 skipped, Python 3.12) and running in CI on this PR;
python -m build + twine check passed (wheel 205 kB, sdist 506 kB).

… behind

Canopy's module docstring said "Pure-Python, stdlib only (no numpy): the
forest is small ... so a flat scan over a few thousand 1024-d vectors is
trivially fast". That was true when it was written. At 1,877 nodes the
scan measured 67.3 ms per locate — the same 68 ms J.10.4.1 named dense_ms
after and prescribed an index for — and the prescription was wrong for
this corpus. The arithmetic is 1.9 M multiply-adds, which is 35 ns each:
not a corpus that outgrew a flat scan, a flat scan written one Python
float at a time.

Worse, save() had already written vectors.f32 as one contiguous float32
block, the exact layout the scan wants, and load() spent 49 ms taking it
apart into 1,877 Python lists. The file was right and the reader undid it.
So this deletes more than it adds: load reads the block, search is a
matmul. Measured 67.3 -> 0.517 ms and 49.2 -> 2.74 ms, store 77 -> 15 MB,
and numpy wins at every size tested from N=5 up — there is no small-forest
crossover to guard.

In memory the store is float64, on disk it stays float32 and byte-
identical. The accumulator is what that decides, and the code being
replaced summed Python floats, so float64 reproduces the ranking rather
than approximating it: against that older calculation float32 drifts
2.04e-08 and float64 drifts 5.55e-17, while the tightest gap between
neighbouring hits across six real bge-m3 indexes is 5.51e-07. A 27x margin
is not one to ship a silent reordering on for 4x on a scan that is already
130x faster. All six of those indexes load and rank identically, as does
the whole hybrid locate end to end on two forests.

numpy is the optional monkeyllm[canopy] extra and the engine's own three
dependencies are unchanged; without it the Python scan runs exactly as
before, and a test compares the two paths, their bytes, and their
behaviour across upsert and remove. It resolves on first use and never at
import — v0.62's rule about the fold table, applied to a dependency, since
`import numpy` costs 32.2 ms of start-up and a plant, a validate or a
BM25-only forest must not pay it for a layer they never touch.

Then the part that was not about speed. struct.unpack refused a buffer
that was not an exact multiple of the vector width; np.frombuffer takes
the whole vectors and drops the remainder. A half-written vectors.f32
would have loaded with ids longer than vectors, and the frontier ranking's
zip drops the unpaired tail, so those nodes score -1.0 — last, forever,
with nothing saying why. Both stores refuse it now, and the check covers
the id-count half too, which was silent in both from the start.

That refusal then exposed a defect older than any of this. Vine.__init__
acquires the writer lock and the release lives in close(), so a raise
between them held the lock forever: the operator did exactly what the
error asked, rebuilt the canopy, and found the forest still refusing —
now blaming a live writer that did not exist. The lock is released if
construction fails. catalog.reindex() sits in the same block, so the
symlink gap pinned in test_reindex_meets_a_symlink stops leaking too.

Releasing it correctly uncovered what it had been covering: over the API a
forest that cannot open is byte-identical to a cold one, and /v1/health
reports ok. Boot now names it on stderr with the file and the repair, on
the same station: channel as the other boot refusals — that half needed no
contract. The API half does: closing it means a new canopy_status.state,
which K.4 enumerates, or serving a boot report nothing serves today. It is
pinned by a passing test in the shape the symlink gap uses, not endorsed.

Spec debt this leaves, for the next cut: J.10.4.1 states 68 ms as a
measured fact and prescribes an index for whoever dense_ms dominates; the
float64 accumulator is a ranking property that belongs in the contract
rather than in a constant somebody can tune back; and the corrupt-index
blast radius wants deciding, since the dense layer is optional and
_derived/ is disposable, so degrading to BM25-only is the proportionate
answer this could not take without adding contract.

Signed-off-by: Jimmy Wesley <[email protected]>
`locate` searches curated metadata and nothing else, and the Curator
authors half of it under a rule that was quietly emptying it: tags were
validated against `^[a-z0-9][a-z0-9_-]*$`, so in a Portuguese forest
`produção`, `segurança` and `orçamento` were dropped on their way to the
passport — no error, no count, no line in any report. The operator
watching that sees a model that will not write tags, and goes to tune a
model that was doing its job.

The rule bought nothing. The FTS row is tokenized `unicode61
remove_diacritics 2`, which folds 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 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 cleared the identifiers — `be-291`, `p95` and
`iso-27001` always passed the regex and always survived the tokenizer, so
the only thing between a document and its own ticket number was the
prompt's "single words" beside a cap of five. v0.52 taught the READER to
keep code-shaped tokens; the writer was still told to throw them away.

Spec v0.75: the Curator writes a findable scent, counted when clipped and
never silently dropped (G.4.2), and proposes aliases the document itself
contains (G.4.3); a node MAY state its language, and absence means nobody
said — never a guess (A.3.2); a person can confirm or reject an uncertain
link, because heat can delete a cold proposal and can never say yes to
one (H.2.1, J.18); re-curation MAY use the forest's ingest model, as a
job with the bill stated before it is spent (J.13.6.1); and the operator
finally holds the hands the connected agent always had — prune,
transplant, tags edited, applied in bulk and browsed (J.5.17, J.5.18).
Acceptance F.162-F.169.

Signed-off-by: Jimmy Wesley <[email protected]>
The v0.75 implementation, engine to console.

One tag rule in models.py — Unicode categories, not an ASCII range, so
`produção` and every Indic script survive; NFC+casefold decides
uniqueness and never respells; every refusal counted (`tags_dropped`)
where `aliases_clipped` already travels. Cap 12. The prompt asks for what
a technical corpus is searched by: identifiers, codes, proper names,
categories, patterns. Enforced at plant/graft with exact-spelling
grandfathering, so a pre-v0.75 node still opens and still round-trips.
The Curator proposes aliases guarded by occurrence in the body under the
one exported fold — an invented acronym is refused by the check, not the
prompt — and the union never displaces a hand-written alias.

`lang` is a fact or it is absent: BCP-47-shaped, exact match, a filter
where candidates are chosen and never a boost, counted by coverage,
cleared by null like origin. No stage guesses one — a model is not a
source. `yaml.safe_load` reads `lang: no` as False and `no` is Norwegian;
a before-validator maps it back.

`derive: ["scent"]` re-curates an already-ingested forest with its own
model, as a job whose bill is stated before it is spent: summary
replaced, tags and aliases unioned, only what the Gardener planted,
branches left to the rollup, datasets curated from their map, the
principal trailer set per step.

The vote settles what heat cannot — under the shipped defaults a cold 0.3
proposal is exactly what the Ranger's cold-prune deletes, so heat could
kill a proposal and never confirm one. links.py owns the audited rewrite
and the Ranger's own path is now a wrapper over it. Accept writes 1.0 and
leaves the managed population; reject removes; a batch of fifty is fifty
decisions, not one. No MCP tool can cast a vote, and the tests assert the
absence.

Consoles: prune and transplant with E_ANCHORED rendered as navigable
anchors and force as a second decision; the Links review entered from the
Health count it explains; scent editable where it is read; bulk tags that
union and never overwrite; the vocabulary counted via json_each over the
stored arrays — never the FTS row, which would report the folded
spelling. ScopedVine and the MCP surface learned the new parameters,
because the Station enumerates what it forwards.

Suite 1653 green. F.169's bench measurement against
bench/questions-locate-v1.json (baseline recall@1 0.711) needs the local
model and is the round's stated remainder.

Signed-off-by: Jimmy Wesley <[email protected]>
Explore's docked timeline was 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 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 just joined. On a 1,878-node forest "what was
planted last week" is forty dots among eighteen hundred.

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, 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.

Spec v0.76: J.5.4 gains the window. A start beside the end on one
`created` scale, a node shown iff its rank lies in [start, end); the
start at its origin is the v0.38 scrubber unchanged; a node outside the
window leaves the picture and never the layout; the readout states both
days and the count inside over the total; "now" restores the whole
forest; play grows the window from its start and holds it. Presentation
only — no call, no write, never the address, never persisted, and the
scale is `created`, never an indexed date. Acceptance F.170.

Signed-off-by: Jimmy Wesley <[email protected]>
The v0.76 implementation. The graph view's timeline carries two native
ranges over one drawn track, each a real slider for the keyboard and
assistive technology; the thumb nearest the pointer is raised whole, so a
click on the track moves the nearest end the way the single range did,
and the choice is frozen while a thumb is held. The window is never empty
except at the origin (start <= end - 1), which is what keeps both thumbs
reachable at every position.

The load-bearing line is a paint split, not a physics one: `on` stays
the simulated set (filters + born), `shown = on && bornRank >= from` is
what draw, hit-test, camera and heat read, and `step()` never sees
`shown`. A leaf whose branch was planted a year before the window keeps
the place the whole forest gave it, so a narrow window reads as a
constellation in the shape of the forest. Play from a window replays
from its own start and does not reseed — the reseed belongs to the
origin. "Now" moves both ends, because a window that merely ends today
is not the forest as it stands.

Readout `2026-07-14 → 2026-08-21 · 770/1878` when a start is set, byte-
identical to the v0.75 text otherwise; a day balloon over each thumb,
one shared balloon when they are close. Never persisted (the quick
search's rule), never in the address. Three locales, handbook sentence
in three languages.

F.170 is apps/studio/check-window.mjs, 30 static checks on F.137's
boundary, run from tests/test_v076_window.py; 23 of them fail with the
v0.75 view put back. Studio builds; suite green.

Signed-off-by: Jimmy Wesley <[email protected]>
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 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.

None of the report's fixes is taken: the upload channel exists, a fourth
view code would break J.3's oracle rule, and A.3 forbids dereferencing
origin 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.

Spec v0.77: J.1.2 rule 7 — a tool description names the neighbour that
does what it refuses, asserted off the served descriptions; C.7.5 — a
media passport planted through plant names bytes the forest holds,
refused E_SCHEMA at the write with a hint naming ingest and b64, the
Gardener exempt because archive: never references bytes at the source
by G.7's design; C.2.2 rule 6 — look carries payload_missing, or
payload_type with payload_bytes, for media, a stat and never an open;
C.6b — the bare _meta is E_SCHEMA naming the dialect and an unknown
scope is E_NOT_FOUND naming the scope the caller sent; J.5.12 — the
skill teaches bytes. view's envelope, origin, and J.1.2 rule 2 stay as
they are, each for a stated reason. Acceptance F.171.

Signed-off-by: Jimmy Wesley <[email protected]>
The v0.77 implementation, engine to handbook.

Engine: plant of a type:media node without a payload — or with one that
escapes the forest or names no file — is E_SCHEMA before the first
write, rehearsed by dry_run and refusing a batch, with a hint that says
where bytes go (`_media_payload_problems`); the Gardener's adopted=
plant is exempt. look carries payload_missing for a media node without
bytes and payload_type + payload_bytes when they are there, decided with
a stat, and the flag survives a fields filter exactly as a dataset's
does. _scope_where refuses the bare _meta as E_SCHEMA naming the dialect
and an unknown scope as `scope not found: <what the caller sent>`,
shared by sniff and calendar. view's envelope is unchanged to the byte.

MCP: the instructions and the descriptions of ingest, view, plant,
sniff and look each name the tool or parameter that does what they
refuse — b64 and what an image becomes, look's flag before a view,
"plant carries no bytes", what a scope is, what _meta/ is. The skill's
saving block teaches b64 (check-skill F.171); the handbook's curl, the
Integrations console's example and the tool table say the same in three
languages.

Tests that planted media passports pointing at nothing now plant real
bytes and then delete or hand-edit them, which is how those states arise
in the field. tests/test_v077_mcp_clarity.py walks the whole path once
over MCP — ingest of a base64 PNG, look with type and size, view
returning the same bytes, prune moving them to the graveyard — and reads
the served tool list for J.1.2 rule 7. Suite 1666 green, 6 skipped.

Signed-off-by: Jimmy Wesley <[email protected]>
v0.77 named the path bytes take into a forest, and the first agent to
walk it found the next gap: it had SEEN the screenshot it was uploading,
knew what the picture showed and which nodes it belonged beside, and the
only place that knowledge could go was a second call after the plant.
Between the two calls the node existed with a stub summary — a scent no
locate finds — and a batch of twenty screenshots was forty calls and
forty commits. A media node's worth is its bytes; its findability is its
passport, and the caller who holds the bytes very often holds the
passport too.

Spec v0.78: an upload entry MAY carry `passport: {title, summary, tags,
aliases, links, notes}` (new J.8.4). Shape-checked for every entry
before the first byte stages — a batch with one bad passport stages
nothing, the source_url rule applied to scent — and 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. An entry with a passport is never sent to
the curation model; the rest of the batch keeps the bound curator, one
gate decided per draft. Not a second write path: the passport enters
where the approved draft already enters, so converter, content policy,
plant and commit are untouched.

Three things the review of the first cut added. Every refusal is
counted: a passport tag the rule refuses and an alias the cap clips are
counted in the report exactly as the model's are (G.4.2 rule 1). An
unapplied passport is named: bytes re-sent for a node that already
exists are refreshed and their passport is NOT applied, because a
refresh never curates, and the report MUST say so in
`passports_ignored`. And C.2.1 now carries a media node's ## Notes in
look, in the sweep's material and in the walk's entry — the first cut
claimed look carried it "everywhere" when it carried it for datasets
alone, and the path to a picture is look then view, the same shape as
look then query with the same gap. The describer still runs where a
vision role is bound: the body is its description, a passport carries
no body. The skill teaches the passport (J.5.12). Acceptance F.172.

Signed-off-by: Jimmy Wesley <[email protected]>
The v0.78 implementation, Station to handbook.

Station: `validate_passport` checks the shape of every entry's passport
once, before anything stages, and `stage_upload` hands the validated
map back beside the provenance map. `compose.PassportGate` is the
on_curate hook: a draft whose staged name carries a passport gets the
reviewer's own rules — `_summary`, `Curator.clean_tags`, `_links` — and
never meets the curation model; any other draft goes to the bound
curator through the same object, whose `stats` merge the gate's own
counters over the curator's so `tags_dropped` and `aliases_clipped`
count a passport's refusals too. J.8.1's `approval_hook` threw the same
count away and is fixed with it. The gate records what it pinned by
staged name, and `_finish_ingest` writes `passports_ignored` off that
set: a re-sent file is refreshed, its passport is not applied, and the
caller is told by entry instead of finding the old scent on the next
look.

Engine: `look` carries `notes` for `type: media` under the dataset's
budget and truncation mark; `harvest` and the walk's entry carry it for
media as they do for datasets (C.2.1 rules 2 and 6).

MCP: the ingest description names the passport, what it becomes, and
that a malformed one is refused before any byte stages (J.1.2 rule 7).
The skill's saving block shows the passport beside b64 and check-skill
asserts it (F.172); the handbook's curl comment and the Integrations
tool table mention it in three languages.

tests/test_v078_upload_passport.py walks the path over MCP — a base64
image with a passport, look with the declared scent and the link, ##
Notes in the body, payload_type and payload_bytes — then a mixed batch,
six malformed passports that stage nothing, links dropped not fatal,
the served description, counted refusals, a re-sent passport named,
notes in look and in the sweep, and the review hook's own count.
Suite 1674 green, 6 skipped.

Signed-off-by: Jimmy Wesley <[email protected]>
Four spec versions since 0.74.0 (v0.75 through v0.78).

Behaviour changes worth reading before upgrading:

- The tag rule changed (v0.75). Accented tags and identifiers
  (produção, BE-291, iso-27001, p95) are KEPT now. The old
  ^[a-z0-9][a-z0-9_-]*$ dropped every accented tag the Curator wrote,
  silently, and bought nothing: nodes_fts folds diacritics on the way
  in and on the way out, so a stored produção was always matched by
  producao. One rule in models.py (Unicode categories, <=40 chars,
  NFC+casefold dedup), curation cap 5 -> 12, every refusal counted as
  tags_dropped. plant and graft refuse an invalid tag as
  E_FRONTMATTER; a tag already on disk is grandfathered by exact
  spelling, so an old forest still opens and an editor round-trip
  still saves. The Curator also proposes aliases from the document
  (union; a hand-written alias is never displaced). An already-
  ingested forest gets the new scent through POST /v1/admin/recurate
  {derive: ["scent"]}: a model-backed J.9 job that replaces the
  summary, unions tags and aliases, and touches only source: ingest
  nodes.

- A node MAY carry lang (BCP-47, v0.75). Absent is a real state; the
  filter is exact (pt is not pt-BR), never a boost, counted by
  coverage; a model is never its source. YAML reads `lang: no` as
  False and the validator maps it back to Norwegian.

- Link proposals gain a human vote (v0.75): POST
  /v1/forests/{f}/links/vote, write-gated. Accept sets confidence 1.0
  and leaves the Ranger's managed population; reject deletes. There is
  no MCP tool for it, by design: a model must not confirm its own
  proposal. The default Ranger cold-prunes a cold 0.3 proposal, so
  "stays at 0.3 forever" was never true.

- Through the wire, plant of a type: media node REQUIRES a payload
  that resolves inside the forest and exists (v0.77, C.7.5), refused
  as E_SCHEMA with a hint naming ingest(mode: "upload", files: [{name,
  b64}]). A client that planted media passports pointing at nothing
  must send the bytes first. The Gardener is exempt (archive: never
  legitimately references bytes at the source). sniff(scope: "_meta")
  is E_SCHEMA, an unknown scope is E_NOT_FOUND naming the scope sent,
  and look on a media node carries payload_missing or payload_type +
  payload_bytes. view's envelope is unchanged to the byte.

- An upload entry MAY carry passport: {title, summary, tags, aliases,
  links, notes} (v0.78, J.8.4): shape-checked before the first byte
  stages (one bad passport stages nothing for the whole batch),
  applied at curation under the reviewed-draft rules, and an entry
  with a passport is never sent to the curation model. Bytes re-sent
  for an existing node are refreshed and their passport is NOT
  applied; the report names them in passports_ignored. look, harvest
  and the walk's entry now carry ## Notes for type: media.

- Every MCP tool description changed (v0.77, J.1.2 rule 7): each names
  the neighbour that does what it refuses. The generated skill teaches
  b64 and the passport; re-download it from Studio -> Skills.

- Studio: Explore's timeline is a window with a start and an end
  (v0.76); prune and transplant from the console (J.5.17); tags
  edited, applied in bulk and browsed (J.5.18); link proposals
  accepted from the Links console (J.18).

Not measured yet: the v0.75 curator change against
bench/questions-locate-v1.json (F.169, baseline recall@1 0.711) needs
the local model. Implemented and stated, not measured.

Signed-off-by: Jimmy Wesley <[email protected]>
@JimmyWesley
JimmyWesley merged commit e3659fd into main Sep 3, 2026
3 checks passed

This branch was previously deployed

1 inactive deployment
pypi — daa3223a Deployed Sep 4, 2026 by JimmyWesley via publish #16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant