Skip to content

Latest commit

 

History

History
347 lines (262 loc) · 17 KB

File metadata and controls

347 lines (262 loc) · 17 KB

Open defects and doc drift

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.


1. The envelope contradicts the manifest — fixed

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.


2. A region with a label does not survive import — fixed, with the fixture

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-declared with owner: "format", blaming the specification for the importer's cast;
  • matrix_regions is 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.


3. Two comments in map.ts now assert things that are false — fixed

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.


4. Cursor and prune horizon are computed across all owners — fixed

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.


5. Documentation drift

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.


6. A collection's top-level keys had nowhere to land — fixed

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.


11. Two OfflineError classes, and no offline creates — fixed

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.


7. Still to do

  • The remaining drift in #5.
  • talaria/acceptance/run.sh has been passing vacuously. Fixed. The stub speaks pkm-interchange now — GET /conformance, GET /interchange with 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.sh Its stub producer answers Hermes' old private routes and not GET /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 on Cannot 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 against HEAD as pre-existing rather than caused by the order work. 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.


8. Housekeeping, not a defect but time-sensitive

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


9. A table with an "Edited" column served a 500 — fixed

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.


10. Refresh refreshed nothing, and a stale query answer outlived its own cache — fixed

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.