Found by reading the tree rather than by anything failing, which is the point: each of these is a thing that looks healthy and is not. Each entry says what breaks and how to prove it, so the fix can be checked rather than believed.
Status, on re-inspection. Items 1 through 4 are fixed in the code, and 5 is partly done. They are kept rather than deleted because the reasoning is the valuable part and because a fixed defect with no record is one that comes back. What is not acceptable is this file quietly listing repairs as outstanding — which it did, which is the same doc drift item 5 is about.
Still open: most of #5. The fixture for #2 landed, and immediately earned itself — see that entry.
Where: packages/interchange/src/map.ts, the conformance block inside the
returned envelope.
What: toInterchange hardcodes
{ "produce": 2, "consume": 0, "operate": 0, "bindings": ["file"] }into every envelope it builds, and apps/server/src/interchange/routes.ts serves
that envelope from GET /api/interchange. Meanwhile src/conformance.ts — the
constant GET /api/conformance serves, and the one pnpm measure checks — says
produce: 4, consume: 4, operate: 4 over ["file", "http", "mcp"].
Why it matters: a consumer that reads the envelope, which is the more natural thing to do because it arrives unasked, concludes Hermes is a file-only level-2 producer that cannot read and cannot be written to. This is the failure the route file's own header comment describes ("A promise the data does not back is the exact failure the manifest rule exists to catch, and it caught its own author"), arriving a second time through the opposite door — and this time the checked manifest is the optimistic one, so nothing catches it.
Talaria's discrepancies() will not catch it either: it holds said from
/conformance against the envelope's behavior, and never looks at the
envelope's own inline manifest.
The fix is not simply to copy CONFORMANCE in. A file export genuinely
cannot demonstrate consuming or operating, so what the envelope should carry is a
real question the spec does not currently answer. Whatever is decided, the two
must stop disagreeing silently.
Where: packages/interchange/src/import.ts, the collection loop.
const placement = (c.placement ?? {}) as { semantic?: boolean; regions?: string[] };
const names = placement.regions ?? [];
matrix_regions: names.map((n) => ({ title: n, tag: n }))
const region = typeof m.region === "string" ? names.indexOf(m.region) : -1;What: regionsOf in map.ts is careful to emit { name, label, "hermes:…" }
whenever the slug and the label differ, because a board whose regions a consumer
can match on and cannot render draws "Region 3" over somebody's own words. The
importer casts that list to string[] and indexes straight into it.
What breaks, against a labeled region:
names.indexOf("delegate-wait")returns-1, so every member on that board loses its placement;- the finding fires as
placement.region-not-declaredwithowner: "format", blaming the specification for the importer's cast; matrix_regionsis rebuilt with a region object where a title string belongs;- the region's
hermes:-prefixed extras are dropped, which is the round-trip rule broken by the code that publishes the region.
This is a known bug in its second home. Talaria found and fixed exactly this
on its own side; the comment on regionNameAt in
talaria/packages/daemon/src/interchange.ts describes it: "both of which cast
the region list to string[] and indexed straight into it. That was true until a
region grew a label." The cast was what silenced the compiler there too.
Why nothing caught it: pnpm foreign round-trips example/library.json,
which has no labeled regions. Same shape as placement/position-is-opaque
passing under a locale-aware sort because its data was a0 / a0V / a1 — a
fixture that does not contain the discriminating case.
Proved. placement/labeled-region-survives is that case — the collection
declares { "name": "delegate-wait", "label": "Delegate & Wait", "hermes:color": "#5fa4b5" }, a member sits in it, and it round-trips. One
regionName() helper is shared between map and import.
Worth knowing what the fixture then caught, since this is the argument for
writing one at all. It went red on a second, unrelated bug: the importer gives
every region tag: name because Hermes wants one, and the exporter carried that
back out as hermes:tag, so a bare "do" returned as an object and the round
trip gained a key it never arrived with. measure had been failing on it, and
the manifest overclaiming — produce and consume said 4 and earned 1 — with
nothing else in the repository saying so.
The block above unsupported still reads:
Recurrence is the honest one: Hermes plainly has it and cannot say so here, because the format wants a series and Hermes has no series.
Migration 0030_series.sql landed the series table,
HERMES-CORE-CHANGES.md §3 records it verified, outSeries emits real series
objects, and features includes "series". A comment explaining an absence that
has since been filled is worse than no comment, because the next reader will
trust it.
Check the neighboring comments in the same function while fixing this one.
Where: apps/server/src/interchange/routes.ts, the /interchange handler.
head.seq and head.oldest are selected from changes with no ownerId
filter, while the delta rows below are eq(changes.ownerId, userId).
The cursor being global is fine — it is opaque and monotonic, and another user's
write merely advances yours harmlessly. oldest is the one to look at twice: it
decides whether to answer 410 Gone, and a global minimum can only be older than
this owner's own oldest retained row, so the check under-fires. Time-based
retention (KEEP_DAYS = 7) moves them together in practice, which is why this
has not bitten — but the reasoning that makes it safe is not written down
anywhere, and a follower quietly missing objects is the worst of the three
outcomes and the one that looks like success.
Either scope both to the owner or write down why global is correct.
In descending order of consequence.
mirror.ts's header described blocks as "the JSON that /sync/blocks
returned". sync.ts says that stopped being true — it used to call
/sync/blocks, /sync/changes and /block-types, and now calls
GET /interchange. Same family as the stale map.ts comments above: a comment
describing a route the code no longer calls. Fixed.
talaria/DESIGN.md §3.2 still argues seriesId should be synthesized as
hash(typeId, title, rule) and calls the real series table deferred.
HERMES-CORE-CHANGES.md §3 says it landed 2026-08-23 and demoted synthesis to a
fallback for blocks written before Hermes had series. The header of DESIGN.md was
updated; §3.2 was not.
talaria/DESIGN.md §1.4a says hermes queue. §8.4 is the deviation
establishing that the command is talaria, because hermes already belongs to
Hermes Agent on this machine.
pkm-interchange/check/README.md says "export the eight operations".
fixtures/README.md and AGENTS.md both say ten.
Case counts disagree across documents — the root README.md says 69,
packages/interchange/README.md says 66 and 64 in different paragraphs. Probably
fixture growth, but they read as claims.
LIMITS.md is unreachable. The root README points at AGENTS.md for known
limits, and AGENTS.md's Known limits of v0 holds two. LIMITS.md holds seven
open ones from the only real port anyone has attempted, and nothing links to it.
It is the most useful document in the set for deciding what v0.1 owes people.
talaria/DESIGN.md F5 still lists sudo xcode-select -s as outstanding.
Found by adding url to the format rather than by reading the code, which is
why it is worth writing down.
Where: packages/interchange/src/import.ts, the collection loop.
Objects have carried their unrecognized keys into pkm:carried since level 2
was claimed. Collections never did — and nothing was visibly wrong, because
every key the format had for a collection was consumed by the handler just
above it. There was genuinely nothing left over to lose.
Then the format grew url, and a collection's address vanished on import with
no finding and no trace. The round-trip rule broken not by mishandling a key but
by there being no place a new one could land.
The shape worth remembering: an exhaustive handler is only exhaustive until the format grows, and it fails silently at exactly the moment it stops being so. An object's loop was written as "everything I do not recognize"; a collection's was written as "these six keys". The first survives a new field and the second cannot.
To prove it: round-trip a collection carrying a top-level key nothing reads. It should come back byte-identical.
Found by making #7's acceptance scenario work, which is exactly what it was for.
hermes.ts and interchange.ts each defined export class OfflineError extends Error {}. server.ts imported the one from hermes.js and caught it around a
write that goes through the binding — so err instanceof OfflineError was
false, and an offline create fell past the queue into a 500. The daemon's whole
offline story ("this is queued and will go out on reconnect") was broken for
creates, and had been since the write path moved onto the binding.
It read as a network error rather than as a bug, because both classes carry the
same message: fetch failed. The CLI said talaria: Internal Server Error and
nothing was logged.
One class now, in errors.ts, imported and re-exported by both. An error used
to decide control flow across modules cannot be a per-module definition.
On the Mac too. The daemon is shared; this was not a Linux fault and is not a Linux fix.
- The remaining drift in #5.
Fixed. The stub speaks pkm-interchange now —talaria/acceptance/run.shhas been passing vacuously.GET /conformance,GET /interchangewith a cursor,PUT/PATCH /interchange/objects/:id— and translates its own blocks into objects and types with profiles, because a stub that answered the way Hermes does would only prove the daemon works against Hermes. The runner checks every claim the scenario makes and exits non-zero when one fails; ten checks, and it found the bug in #11 on its first honest run.- The old text of the acceptance item, for the record:
run.shIts stub producer answers Hermes' old private routes and notGET /interchange, so steps 3 and 6 print "this producer does not implement GET /interchange" and step 8 — the replayed-create check, which is the whole point of the scenario — dies onCannot read properties of undefined (reading 'id'). The script still ends with "done" and exits 0, so nothing has flagged it. It went stale when the daemon moved onto the binding; verified againstHEADas pre-existing rather than caused by theorderwork. The stub needs the interchange routes, and the runner needs to fail loudly instead of printing an error and carrying on.
Both url items are done, and the fixture had a sting in it. fixtures/address.json
already existed with all five cases — but it requires the addresses feature, and
Hermes' manifest never declared one while emitting url on every object and every
collection. Under-claiming measures identically to not implementing: all five were
scoped away as not-applicable and had never once run.
Declared, they ran, and four passed immediately. The fifth failed in both
implementations — neither validator knew that producer.urlTemplate must be
rejected, so the rule had never been enforced anywhere. It is in both now, and the
spec suite went from 78/83 with five skipped to 83/83 with none.
.claude/settings.local.json contains a Hermes bearer token in plain text
(several times, inside the permitted-command strings) and a personal calendar
feed URL. .claude/ is not in .gitignore, and this repository is public.
Check whether the file is tracked. If it is, the key needs revoking in
Settings → Access Keys, not just removing from the working tree — history keeps
it. Add .claude/settings.local.json to .gitignore either way; the
.local.json suffix is the convention for a file that is not meant to be shared.
There is also a .claude/settings.local 2.json, which is a sync conflict copy
and carries the same contents.
Found while testing the new order work against a live daemon, not by looking
for it. GET /board/:id answered 500 Cannot read properties of undefined (reading 'slice') for the Task Inbox, and only for that one.
cellValue in talaria/packages/daemon/src/server.ts read b.updatedAt off the
raw mirror row. Those rows are interchange objects now, and an interchange
object spells it updated. So b.updatedAt was undefined and .slice(0, 10)
threw, taking the whole board down — every card, every column, not just the cell.
The same wrong name had a quiet twin beside it: a created column matched
nothing at all and drew every cell blank, which nobody would have reported as a
bug because a blank column reads like a column with no data in it.
This is the third instance of one pattern in this codebase — a read still
spelling a field the way Hermes' own rows spelled it, after the mirror moved onto
the format. The other two are noted in server.ts itself: membership_mode
becoming membership.mode, which made every board answer "not smart" and stop
filtering, and matrix_regions, which drew four regions called "Region 1"
through "Region 4". All three failed differently — a crash, a silent wrong
answer, and visible nonsense — and all three came from the same place.
talaria/app/check.sh did not catch it, and that is the more useful finding:
its board check opened boards().first, which is a calendar. It opens every
board now and reports the kinds it saw, because the kinds take different paths
through that route and break separately.
Two separate faults on one board, found by looking at why the Eisenhower matrix stayed wrong after the producer started exporting the right thing.
The board preferred its own stale answer. Talaria evaluated a smart
collection's query itself and cached the result at query.<id>, refreshing it
only when the collection had no membership rows. Once Hermes began shipping the
query's answer as members, every such collection had rows — so the cache
stopped being refreshed and was still being read. The board drew eight cards
from a query result computed days earlier. The producer's answer wins now, and
the cache is cleared on sight rather than left for something to prefer later; it
survives only as the fallback for a producer that says materialized: false and
ships no snapshot, which is the one case it was ever for.
Refresh read the mirror. BoardView's button and the menu's Refresh both
called load(), which re-read local rows and redrew the same board — the button
flashed and nothing moved, which is worse than having no button, because it
looks like confirmation the board is right. Both ask the daemon to read the
library first now.
It re-reads everything rather than catching up, and that is the interesting
part: a catch-up asks what changed since a cursor, and a smart collection's
answer changes without anything changing. A task whose date rolls into range
today was not edited, so no feed carries it and no cursor advances past it —
catching up returns "nothing new" and leaves the board exactly as wrong as it
was. That is LIMITS.md's first open entry, a way to ask for a re-evaluation
through the binding, met in the wild rather than in the abstract. Re-reading
the library is what a client can do about it today.
And ordering was emitted without being declared. The envelope's features
is computed from the data on purpose, and the new order block was not added to
the detector — so GET /conformance claimed the feature and the envelope did
not. Second instance of exactly the fault address.json was found by:
under-claiming measures identically to not implementing, because every case
requiring the feature is scoped away as not-applicable and silently never runs.
Worth a rule of its own: a feature added to the data is not added until the
detector knows about it.