Hermes · Structured Memory · Target Architecture

Mimir 1.0 + Hermes — the target architecture

A clean-break redesign of the structured-memory server and the stack around it. Derived from first principles against four stated requirements, then subtracted down: Mimir becomes memory and nothing else, conversations become Hermes's business, and HTTP becomes the interface every client speaks.

DRAFT — complete, for review clean break · new schema · one-time data migration scope: Mimir 1.0 + console integration hermesd & Telegram — iteration 2 retrieval out of scope

Scope of this iteration

In: Mimir 1.0 — the memory store, its identity and token model, the load path, the API surface, security, and the console-side integration in hermes-template that changes as a result. Out: hermesd, the Telegram gateway, and everything that depends on them. Those are iteration 2, designed separately in hermesd-architecture.html.

The conversation plane still appears throughout — in the stack diagram, in the token model, in §8 — because the whole point of an HTTP-primary, conversation-stateless design is that those surfaces cost an adapter rather than a redesign when they arrive. What changes with this scoping is only when they get built, not whether the architecture has to accommodate them.

survives / new changes shape deleted still open
§0 · Where this came from

Requirements and decisions

The rework was derived by stating what the system must do, then removing everything not required by that statement. These four requirements are the whole brief.

#RequirementWhat it forces into the design
R1Multi-userPrincipals with credentials. Nothing about the system may assume a single operator.
R2Shared scopesScope + membership as the sharing primitive. Sharing is a property of the item's scope, not of who created it.
R3Read and write only where you have accessAuthorization checked per request in one place, backstopped in the database. Never inferred from session state, never enforced by prompt discipline.
R4Easy access from the console and other applicationsOne complete interface all clients share. No transport is privileged; no client needs an LLM in the path to write a task.

What is conspicuously absent

Sessions, activity cursors, the shared-activity feed, write attribution, tag-consolidation heuristics, and scope ceilings are not implied by any of R1–R4. Together they account for the majority of the last four months of development — v0.13.0, v0.14.0, and v0.15.0 are almost entirely session and identity machinery. Their absence from the brief is the reason this is a rework rather than a refactor.

Decisions on record

ConcernDecisionReasoning
Migration strategyclean breakNew schema, new API, one-time data migration, no backward compatibility. The alternative turns every deletion into a deprecation — which is how the current design accumulated.
Conversation state in MimirnoneMemory is memory; any conversation may use it. Mimir holds no session, no conversation record, and no opinion about who is talking to it.
Concurrent-conversation isolationfreeIsolation was never a feature — it was the mitigation for a shared stateful object. With no session object there is nothing to share and nothing to isolate. Ten concurrent conversations collide no more than ten inserts do.
Conversationshermesd owns themEngine-neutral, behind EngineAdapter; naming, enumeration and resume survive an engine swap. Not a Claude Code feature and not a Mimir record. Distinct from Mimir's thread, which is a memory container — the two were once the same word, see §4.11.
Primary interfaceHTTPMCP becomes one adapter with full parity. This is simultaneously the fix for external-application access and the thing that makes the stack model-agnostic.
Identitytokens become objectsThe person stops being the credential. Tokens are first-class — principal, scope subset, capability limits, individually revocable — which retires service principals, scope ceilings and on-behalf-of attribution outright. See §3.
Shared activity feedcutTakes the entire cursor apparatus with it, and removes an active incompatibility with long-lived concurrent threads.
Write attributionfree, and correctPrincipal and token label recorded on every history row. Not a feature so much as a consequence — the credential identifies the person, so there is nothing to assert and nothing to validate. The acting-on-behalf-of layer is retired rather than deferred.
Tag consolidationcutHeuristic suggestion machinery with no requirement behind it.
Tiered loadingkept, retargetedThe anti-bloat mechanism. Currently aimed at you rather than at what you are doing; §3 retargets it.
Initial load modelbounded orientationFixed, cheap, same for every conversation: scopes, slim project list, always-on knowledge. Everything else is pulled.
Retrievalout of scopeKeyword search gets fixed (hyphen parsing, partial matching) but not replaced. Semantic retrieval is a later layer on a stable core.
Item taxonomyseven types → fivethread · task · reminder · rule · fact. Type carries force (obeyed vs known); anchors carry reach (everywhere vs on-topic). idea, note, project/goal all fold. See §4.
§1 · Invariants

Six principles the design must not violate

Stated so that later reviews, and the implementation plan, do not re-litigate them.

P1

Mimir is memory. It is stateless with respect to conversations.

No session, conversation, or connection state. Every request carries everything needed to authorize and perform it. A conversation identifier, if supplied, is an opaque label on a history row — never an object with a lifecycle.

P2

HTTP is the contract; MCP is an adapter over it.

Both transports expose the same operations over the same domain layer, and the HTTP contract is normative. A client that does not speak MCP is not a second-class client.

P3

A person is not a credential.

Identity, the token that carries it, and what that token may reach are three separate things. Today they are one column, which is why acting for someone else required inventing a synthetic identity. Every principal is a person; components that act for people carry those people's narrowed tokens; authority is the token's scopes intersected with live membership, checked on every request.

P4

Mimir exposes capability, never ceremony.

The server offers operations. When to call them — at conversation start, at the end, on a schedule — is the client's business. No server behavior may depend on a client having performed a ritual.

P5

Correctness may not depend on prompt discipline.

If a rule must hold, the schema, the API shape, or a database constraint enforces it. An instruction in a behavior file is not an enforcement mechanism — the current system carries a fact whose entire job is warning the model that two tools disagree.

P6

Conversations belong to Hermes; engines are swappable behind one seam.

Naming, enumeration and resume of conversations are Hermes features implemented in hermesd, never inherited from whichever engine is running. Engine-native resume is an optimisation used when the adapter advertises it, with a rendered carry-over summary as the honest fallback. "Conversation" is deliberate: thread names Mimir's memory container (§4.11).

P7

Nothing is a chokepoint. Runtimes and memory are independent.

Hermes is an identity; runtimes animate it and memory serves them all. The console and hermesd are peer runtimes, not a default and an exception — one hosts its own engine because a terminal can, the other exists because a phone cannot. hermesd is an ordinary Mimir client with no privileged access, no memory operation is routed through it, and losing either runtime never disables the other or memory.

P8

Structure only where the system branches. Everything else is prose.

A field earns schema when some behavior depends on it — a load tier, a filter, a cascade, a trigger. If the only consumer is a language model reading the text, prose is already the right representation and typing it adds ceremony without capability. This is what keeps the model general: a repository, a construction site, and a legal matter differ in every detail and in none of the behaviors.

P9

The schema names the mechanism; the user names the thing.

Internal type names exist for the API. What a user calls an item is a label they choose, and the model reads it back to them in their own vocabulary — one deployment's "projects" are another's "work items" and another's "matters", with no schema difference between them. Naming is a product surface, not a structural one.

§2 · The whole stack

System diagram

Hermes is not a process. It is an identity — behavior, commands, workspace, and the memory it keeps — which runtimes animate and surfaces reach. Getting that order wrong is what produced the current system's four-hop path from Telegram to a task update, and an earlier draft of this section repeated the mistake by naming a plane after the daemon that serves part of it.

LayerWhat it isInstances
Identity
"Hermes"
The persona: behavior files, commands, skills, the workspace. Engine-neutral prose and configuration on a volume — the thing that makes every surface feel like the same assistant rather than three different ones. One source per principal, rendered per runtime and profile. A group-bound rendering is the same identity with a narrower brief, not a different assistant.
Runtime Something that animates the identity with an engine. Costs an LLM turn. This is where a conversation actually happens. Two, and they are peers. The console runtime — a TUI in tmux, where the surface hosts its own engine. And hermesd — engine processes driven over an API, for surfaces that cannot host one.
Memory Read and write items. No LLM in the path, no turn cost, deterministic. Mimir, over HTTP, reachable by any runtime and by clients that have no runtime at all — a script, a dashboard, cron.

Where the console sits — it is a runtime, not an exception

The console is not outside the conversational part of the system; it is the other runtime. A terminal can host a process, so it hosts its own engine and needs nothing else. A phone cannot, which is the entire reason hermesd exists — hermesd is the runtime for surfaces that cannot host their own, not the place conversations live.

Read that way, the console invariant stops being a special exemption and becomes a consequence: two independent runtimes of one identity, so work on either cannot break the other. And it explains what is genuinely shared — both animate the same behavior files and both read the same memory, which is why the assistant on your phone is the same assistant as the one in your terminal, rather than a second one that happens to have similar instructions.

hermesd is a gateway to conversations, not a gateway to memory

Nothing routes memory access through hermesd. hermesd is simply another Mimir client, with the tokens of the people it serves — the same standing as a GUI or a shell script. Making it a memory chokepoint would recreate the exact defect this rework removes, one layer higher, and would contradict the deliberate decision that nothing in the stack occupies a central position everything else must pass through.

Identity — "Hermes", the thing every runtime animates

Behavior · commands · skills · workspace one source, rendered per runtime and profile

  • Engine-neutral prose and configuration on a volume — not a process, and not owned by any runtime
  • This is what makes the assistant on a phone the same assistant as the one in a terminal, rather than a second one with similar instructions
  • A group-bound rendering is the same identity with a narrower brief; the source is shared, the render differs
animated by
Surfaces — where a human is

Terminal SSH → docker exec → tmux

  • Interactive TUI on the subscription
  • Hosts its own runtime — a terminal can run a process, so nothing else is needed
  • Zero new dependencies — the standing invariant, now a consequence rather than an exemption

Telegram via a channel adapter

  • DMs → that user's runtime; groups → the groups runtime. A channel adapter, not a gateway to everything — a GUI speaks the conversation API directly and never touches it
  • One adapter per channel type, deployment-wide: a bot token permits exactly one poller
  • No LLM required in the path for plain memory operations — a command that completes a task is an HTTP call

Chat GUI future — a conversation surface

  • Speaks hermesd's conversation API, exactly as the gateway does
  • Conversation list, journal replay, SSE streaming and approvals come for free
  • A GUI wanting a memory browser as well also holds a Mimir key and reads the memory plane directly — the two are independent
hosts its own runtime
bearer → hermesd
bearer → hermesd
Runtimes — two peers, both animating the same identity

Console runtime engine TUI, in the user's container

  • An engine process the human drives directly — the surface and the runtime are the same place
  • Conversation naming and resume are the engine's own
  • Reaches Mimir over the MCP adapter; depends on nothing else in this diagram

hermesd runtime co-hosted in each user's container; one more for groups

  • Exists because a phone cannot host a process. That is the whole justification — it is not where conversations live, it is the runtime for surfaces that cannot run their own
  • Owns conversations — id, title, state, journal; engine-neutral. Approvals, metering, failure semantics
  • Runs inside hermes_<user> alongside the console, supervised — same home, same uid, same tokens, so a separate container bought no isolation. Groups get one standalone instance that mounts no user home
  • Talks to Mimir over HTTP, carrying the requester's own narrowed token
Memory — reachable by both runtimes, and by clients with no runtime at all

Engine processes via the MCP adapter

  • The assistant reading and writing memory mid-conversation
  • MCP because engines speak MCP — not because it is privileged

hermesd via HTTP, the requester's token

  • Holds one narrowed token per human it serves
  • No identity of its own in Mimir; no special access

Everything else via HTTP

  • Memory GUI, scripts, cron, a phone shortcut, another assistant
  • No MCP implementation required, no LLM in the path
  • This is what P2 buys — a new client costs a key, not a spec

MIMIR 1.0 — memory, and nothing else

single process · SQLite · no conversation state

HTTP transport normative contract

Every operation. x-api-key → principal → grants, resolved per request. Versioned, documented, and the surface every non-engine client uses.

MCP adapter generated from the same contract

Tool surface for engines. Same operations, same authorization, same errors. Carries no privileges HTTP lacks and no state HTTP cannot express.

Domain layer transport-agnostic · the single authorization point

One implementation per operation, shared verbatim by both transports — the best structural property of the current system, preserved. Authorization is checked here, on every path, against the calling principal's grants. Nothing above this layer may bypass it and nothing below it may assume it ran.

Queries all SQL lives here

Structural reads, keyword search, history. No SQL above this layer.

Storage SQLite · WAL

Access-enforcement triggers as the final backstop, re-checking membership on every write regardless of what the domain layer did.

The change that matters most

Today an external application reaching Mimir goes Telegram → gateway → hermesd → a full engine process → MCP → Mimir. The reason is narrower than it first appears, and worth stating precisely: a REST write is attributed to a principal today — the key resolves to a user and history records the actor. What binds to the MCP connection is conversation attribution, because the session id comes from a per-connection registry, and delegated attribution, which REST drops. That is the coupling the current channels design turns into "the engine must own the session lifecycle" — and it is enough, because a memory system whose purpose is recording who decided what in what context cannot route half its clients through a path that loses the context. Removing conversation state removes the coupling entirely, and the engine stops being load-bearing for memory operations.

§3 · Identity

The user was the credential

That is the whole defect. users.api_key is a column on the person, so the credential, the permission set and the byline are one object. The console never noticed — there they genuinely are the same person. A group chat with two humans and a bot breaks it immediately, and every workaround in the current release is a consequence.

ConceptQuestion it answersEstablished byNotes
Principal Who is accountable? A token resolves to exactly one person. Every principal is a person. There are no service identities: automation runs on a person's narrowed token, because an action for which nobody is accountable should not be expressible.
Token new Which credential is acting? Issued at provisioning. Carries its principal, a scope subset, and capability limits. Individually revocable and rotatable. A first-class object rather than a column. Recorded on every history row, so "who" and "through what" are both answerable — a write reads jimmy, via hermesd-family.
Grant What may this request touch? token.scopes ∩ principal's current membership, evaluated per request. A ceiling checked at use, never at issue. Losing membership invalidates every token's reach into that scope immediately, with no revocation sweep required.

Separating credential from person dissolves three features at once

The current release contains service principals, scope ceilings, and on-behalf-of attribution — three features, one release, each patching a facet of the same conflation. And the security model still has to record as an accepted residual that group writes attribute to a synthetic principal rather than to the human who asked: a memory system whose purpose is recording who decided what, documenting that it cannot.

None of them survive here — not because they were replaced, but because the thing they worked around is gone. A component acting for someone uses that person's token, so there is no synthetic identity to attribute to, no intersection of two identities to compute, and no session ceiling to persist. is_service, on_behalf_of and scope_ceiling cease to mean anything. All three arrive together in migration 005, published in v0.15.0 on 2026-08-05 and deliberately not deployed (§12) — so they are retired from a released artefact that never ran, rather than from a branch.

3.1 · What a token carries

FieldPurposeExample
idA public handle. Lets a token be named in an API call, in history, or in a revocation without quoting its secret.
principalThe person accountable for anything this token does.jimmy
secretStored hashed. The server verifies; it never needs to recover. So the plaintext exists once, at issue, and afterwards only where a consumer actually needs it.
scopesA subset of that person's scopes — narrower or equal, never wider — or *, meaning whatever the principal currently has (§3.2).* for the console; [family] for a group-bound deputy
capabilitiesRestriction flags — a deny-list narrowing what this credential may do.read_only on a dashboard; no_delete anywhere but the console
may_write_rules
privileged
Grants, not restrictions. Both default to false. privileged gates the two operations §6.1 marks admin (soft-delete, scope administration) and may only be set for an administrator principal. may_write_rules gates creating a rule, promoting a fact into one, and editing an existing rule's text — all three, because any of them produces a binding directive.both true on the console token; both false on every deputy
labelHuman-readable, recorded on history so the trail names the credential and not only the person. Unique among a principal's active tokens — it is the provisioning idempotency key, and the qualifier is what lets rotation overlap (§3.2).hermesd-family · console · nightly-report
stateactive or revoked. Revocable individually, without touching the person or any other credential they hold.
created_at
last_used_at
Answers one question: which credentials are dead? It cannot answer where or by whom a token was used — it is a single overwritten scalar, throttled to hourly, carrying no source and no series. Detecting misuse is history's job, since every write records the token label.
expires_atOptional. A credential minted for one job should be able to die without anyone remembering to kill it.null for standing tokens

Why two grants when everything else is a restriction: a deny-list cannot express default-deny. If administrative authority were absent from the token it would remain a property of the person, so every narrowed credential that person holds — including the ones a deputy stores — would carry it, and forgetting a flag would fail open on the most dangerous operations. Defaulting to false inverts that: forgetting fails closed. It also closes a specific escalation — a credential that can call scope administration can add its own principal to a scope and reach it next request, making membership removal reversible by the very credential it was meant to revoke.

Rule authorship is a grant for exactly the same reason, and an earlier draft got this wrong by leaving it as the deny-flag no_rule_create. Under a deny-list, a token minted without the flag can write binding directives — fail-open on the one escalation path §7.4 exists to close, in a design that reasons fail-closed two rows above. The grant also has to cover three operations rather than creation alone: creating a rule, promoting a fact into a rule, and editing an existing rule's text. Gating creation only leaves two unguarded routes to the same durable directive, and the third needs no type change at all.

Worth stating plainly: today there is one key per user, in a column, in plaintext, with no rotation, no revocation, and no way to issue anything narrower. That gap is independent of every other decision in this document. This design does not add a token model in order to enable deputies so much as it stops treating a person and a password as the same object.

3.2 · Keeping tokens current

A credential store that drifts from reality is worse than none, because it is trusted. Three things could drift, and the design answer differs for each — the first needs no synchronisation at all, which is the property worth protecting.

Could driftAnswer
A token names a scope its principal has left Nothing to synchronise. Authority is token.scopes ∩ current membership, evaluated per request, so a stale entry simply stops reaching anything. Removing someone from a scope takes effect on their next call, everywhere, with no revocation sweep and no distributed state to update. This is the whole reason the intersection is checked at use rather than resolved at issue.
A principal joins a new scope and existing tokens cannot reach it Correct for a deliberately narrowed credential — a deputy issued for family should not silently gain work. Wrong for a credential that means "me". Hence scopes: * — but only for the console, where a human is at a keyboard and full authority is the point. Every deputy token is explicit, including the one serving a user's own direct messages: that runtime ingests mail and web content, so giving it a wildcard would contradict the narrowing argument §7.5 rests on. The cost is that a deputy needs reissuing when its scopes change, which is the right trade and falls only where narrowing was deliberate.
Rotation would break running consumers Only if a principal could hold one token at a time. They can hold several active at once, so rotation is: mint, distribute, restart consumers, revoke the old — with both valid during the overlap. Rotation without a window means rotation with downtime, which in practice means rotation that never happens.

Where the plaintext lives

  • Mimir holds a hash. The plaintext is returned once, at issue, and never again — so a token cannot be recovered from the database, only replaced.
  • One authoritative distribution point — not one copy. The deploy environment file is where consumers are configured from, but configuring them materialises the plaintext into each one: today provisioning writes it into every user's .mcp.json, into the generated seed SQL, and into the rendered integration script. An earlier draft claimed the plaintext lived "in exactly one place", which is false and was worth correcting, because a security claim that overstates gets designed against.
  • What genuinely changes is that the database stops being a credential store: it holds hashes, so losing it leaks nothing and a token can be replaced but never recovered.
  • Never in the conversation journal, transcripts, metering, or logs (§7.5) — the one place a copy would be actively dangerous, because those stores are designed to be replayed and read back.

Provisioning and revocation

  • Idempotent on label, which is unique among active tokens only. Re-running provisioning mints a token only when no active token carries that label. Churning tokens on every config change would break every running consumer, so the natural key matters.
  • Rotation therefore needs a generation. Two tokens sharing a label cannot both be active, so a rotation mints <label> at the next generation, and the overlap ends when the old one is revoked — at which point the label is free again and the next provisioning run finds exactly one active holder. Without this, label-uniqueness and rotation-with-overlap are mutually exclusive, and §12's step-1 gates cannot both pass.
  • Adding someone to a bound scope also mints their token on the instances serving it — the coupling recorded in §8, and the reason that validation is checkable rather than advisory.
  • Revocation is pull-based. A revoked token starts failing on its next request. Nothing is pushed, nothing is invalidated remotely, and a consumer holding a dead credential learns by being refused.

Accepted limitations

Capabilities are restriction flags — a deny-list. A genuinely new class of dangerous operation is therefore permitted by existing tokens until a flag is added for it. An allow-list would be safer and was rejected: it would require touching every issued token whenever an operation class is introduced, which is the kind of maintenance that gets skipped and then quietly grants nothing. The mitigation is that new operation classes are rare and adding a flag is part of introducing one.

last_used_at needs write throttling, and that limits what it can answer. Updating it on every request is real write amplification against a single SQLite file, so it is recorded at hourly granularity — which makes it a liveness signal and nothing more. It cannot detect a stolen credential in concurrent legitimate use, because the legitimate use keeps it fresh. Misuse detection rests on history recording the token label per write; reads leave no trace at all, and that is the honest residual.

A database restore predating a token's creation invalidates it silently. The consumer still holds a plaintext that no longer hashes to anything, and gets a plain authentication failure. Worth knowing before diagnosing it as a network fault.

§4 · Data model

Two families, and what each one costs to load

Everything remains an item row. What changes is that type now determines lifetime and load behavior rather than mostly subject matter — so how much context an item costs is a structural property, not a scoring decision that can be wrong.

4.0 · The model at a glance

Every item, whatever its type

scope · summary · detail · tags · lifecycle · history

Knowledge — type carries force

ruleobeyed — binding on the reader
factknown — informs judgement

Reach is carried by anchors, not by type. Unanchored loads in every conversation; anchored loads only on its threads. Both types take either.

Work & structure

threadcontains · completes optionally
taskcompletes · status flow
remindertriggers on a date

A thread is any strand of concern — a repository, a dispute, a grocery list. What it is called is a tag the user chooses, not a schema type (P9).

Three relations, three jobs

parent is part of

thread task task

Tree, single parent, work only. Same scope enforced. Archival, deletion and scope moves cascade down it; completion requires an explicit disposition instead (§4.6).

anchor is knowledge about

rule / fact thread Athread B

Many-to-many, knowledge → work. Drives drill-tier loading. Optional, and rationed by the budget rather than required by policy (§4.1). Never cascades — deleting a thread must not destroy what was learned doing it.

link relates to

any itemany item

Undirected peer association. Cross-scope when the caller can see both ends. Never affects loading, never cascades. Purely navigational.

4.1 · Knowledge — two properties, not three types

An earlier draft proposed three knowledge types — rule, fact, finding — and it had a hole: it assumed anything scoped to one thread is known rather than obeyed. That is false, and commonly so. "Run this project's tests with conda run -n book-generator python -m pytest, not plain pytest" is a directive, binding, and worthless outside that one thread. Under three types it could only be filed as a finding, losing its force, or promoted to a global rule, inflating every conversation.

The mistake was fusing two independent properties into one axis. They are orthogonal:

Obeyed — binding on the readerKnown — informs judgement
No anchors
relevant everywhere
rule
"One commit per issue." · "Never include a Co-Authored-By trailer." · "Write directly — no fluff."
fact
"This container has no docker socket." · "GitHub handle is jimmy-larsson."
Anchored
relevant on its threads
rule + anchors
"Sign GK contracts as 代表社員 ラーション・ホールディングス株式会社, 職務執行者 Jimmy." · the pytest invocation above
fact + anchors
"hls.js treats EVENT playlists as live." · "Tokio Marine E&O covers Japan-performed work only."

Type carries force; anchors carry reach. Two knowledge types, and the load tier falls out of whether anchors are present. finding disappears as a type — it was always just "an anchored fact" — and the missing fourth cell appears for free. The word remains useful in prose; it is simply not a type.

TypeWhat it isAnchorsLoad tier
rule A directive to be complied with. Binding, not advisory — following it is not a judgement call. Rendered to the reader as a constraint, not as background. Optional Unanchored → always · anchored → on drill
fact Something true and worth knowing. Informs judgement; does not constrain it. The anchored case is the bulk of memory by volume and grows without bound. Optional Unanchored → always · anchored → on drill

Anchors are optional; being unanchored is scarce. An earlier draft said anchors were "required for anything topic-bound", which cannot be enforced — nothing can determine from content whether a piece of knowledge is topic-bound, so that was a convention, and P5 forbids resting correctness on one.

The enforcement already exists elsewhere: the always-loaded budget (§5.2). Unanchored knowledge is rationed rather than forbidden — permitted right up until the set is full, at which point the write is refused and names retirement candidates. Same outcome, structurally enforced, and nobody has to remember a rule. Going unanchored remains an explicit claim that something belongs in every conversation; what makes the claim expensive is that it consumes a bounded resource, not that a policy says so.

Two independent questions — neither answer constrains the other

Force — do I want this obeyed, or known? Obeyed → rule. Known → fact. Deliberately a question about the author's intent rather than the content, because content-derived tests do not survive contact with real data — the same situation supports both "there is no docker socket" and "never attempt local docker; use the remote context". That is not an ambiguity to resolve but a choice to make: record a constraint, an instruction, or both.

Reach — what is this about? Everything → no anchors. Specific threads → anchor it to them. A question about relevance breadth, not generality: the FastMCP thread-pool hazard is stated in fully general terms ("any Python MCP server with shared client state") and is still worthless to a conversation about a land purchase. Generality is a property of the sentence; loading is governed by whether the knowledge earns space in every conversation.

Why rule earns a type rather than a tag. Its behavioral difference is not at the server — rules and facts load identically — it is at the consumer. The reader of this system is a language model, and "comply with this" and "know this" are different instructions to that reader. A rule is binding; a fact informs judgement. Marking the difference structurally is what lets a directive be rendered as a constraint instead of being left as background prose the model may or may not weigh — which is principle P5 applied to knowledge rather than to schema.

Promotion is a real operation. A finding whose relevance turns out to be universal becomes a fact; a fact that keeps being treated as optional when it should not be becomes a rule; a rule that stops applying everywhere demotes to an anchored finding. Knowledge changes category as understanding improves, and the model should permit that rather than requiring the first guess to be right.

Why the anchor is a reference, not a parent

A finding often applies to more than one thing. The FastMCP thread-pool hazard was learned in one MCP server, states in its own text that it applies to the others, and is exactly the kind of knowledge that must surface when any of them is opened. Under a single-parent tree it would have to pick one home and be invisible from the rest. So knowledge carries one or more anchors, and opening any anchored item surfaces it. Anchoring is never required — it is made attractive by the alternative being rationed (§4.1), which is the forcing function the current "unparented means global" convention lacks, and the reason roughly half the live database's global facts are really project knowledge filed in the wrong place.

4.2 · Work and structure — one container, completion optional

This section went through two wrong drafts. The first assumed every container is a bounded effort that finishes — which a grocery list is not. The second split the container in two, project for things that end and topic for things that do not. That split fails a test this design applies everywhere else: the classification must be mechanically decidable at creation time. Is "the apartment" a topic, or the project of renovating it? Is a land purchase that has dragged on for years still a project? A fuzzy line adjudicated on every create is precisely the failure mode the rule/finding test was designed to avoid, and it should not be reintroduced one section later.

One container type, neutral on completion, removes the decision entirely: complete it if and when it ends, and never complete the ones that do not. Nothing is lost — completion still cascades when it happens; the grocery list simply never triggers it.

TypeCompletes?Contains?Role
thread the containerOptionally — with a disposition (§4.6)yes, including other threadsA strand of concern. The mimir rework and the marriage registration end; the grocery list and the cats do not. Both are threads, and the difference is whether completion ever arrives rather than which type was chosen up front.
taskYesyesA unit of work with state. Absorbs today's idea as the status proposed.
reminderYes — done if it was acted on, dropped if it was dismissed. An earlier draft said "dismissed", naming a state no enum defines; a reminder is a work type and the existing terminals cover itleafA time trigger. The only type with push behavior — it appears because a date arrived, not because someone asked.

What this fixes beyond the naming complaint

Several of the live database's global facts exist only because there was nowhere else to put them. "Jimmy and Alexandra have four cats; considering pet insurance" is a top-level fact loaded into every conversation, because "the cats" was not a thing the model could represent — it is not a project, so it was never created, so its knowledge had nowhere to anchor and defaulted to global. A container that does not have to end gives that knowledge a home and takes it out of the orientation payload. The missing container was quietly inflating boot cost, and no amount of ranking would have found that.

With one honest caveat. Threads are listed most-recent-activity-first and truncate (§5.1a), so a container created for a genuinely dormant subject sorts last and falls out — taking its anchored knowledge out of the proactive path with it, because the reader never learns the thread exists. Nothing is destroyed (truncation reports an exact count, and search still reaches both) but the cue is, which is the same discoverability gap as a stranded anchor and the second half of the open question tracked at §11.

4.3 · Two types removed

ideatask with status proposed

  • An idea is uncommitted work, which is a state, not a kind.
  • Today's promotion path is convert_item, which changes type and resets status — a status change wearing a type change.
  • As a status it becomes a normal update, and "show me my ideas" is a normal filter.

note → anchored fact or detail

  • A note is prose about something. If it is worth recalling it is a fact anchored to what it concerns; if it is background on one item it belongs in that item's detail.
  • The type earns no distinct load behavior, no distinct lifetime, and no distinct query.
  • Unrelated and confusingly named: the add_note operation writes a history event and drives goal activity. That survives, renamed, as a history concern rather than an item type.

4.4 · Containment and association stop overlapping

MechanismMeansShapeRules
Parent"is part of"Tree, single parent, work items onlySame scope enforced; archival, deletion and scope moves cascade. Completion does not cascade silently — see §4.6.
Anchor"is knowledge about"Many-to-many, knowledge → work itemsOptional, and rationed by the budget rather than required by policy. Drives drill-tier loading. Never cascades — but deleting or archiving a sole anchor is refused (§4.6), because "never destroyed" and "still reachable" are different guarantees. Creation requires the caller to see both ends, the same rule links carry: an anchor names an item, so permitting one to an unseen target would make the write an existence oracle.
Blocked-by new"cannot proceed until"Directed, work → work, many-to-manyA relation rather than a status value (§4.5). Carries what is blocking, which a status cannot. Never cascades.
Link"relates to"Undirected peer associationCross-scope when the caller can see both ends. Never affects loading or cascade. Purely navigational.

Today parent and link exist with overlapping and partly accidental semantics — a parent is same-scope and cascading, a link is cross-scope and inert, and knowledge uses whichever it happened to get, while blocking is recorded only as prose. Naming each job separately means each can be reasoned about, queried, and cascaded on its own terms.

4.5 · Status separates from lifecycle

Work status — where the work is

  • proposed · open · in_progress · done · dropped
  • Applies to work types only. Knowledge has no status.
  • dropped is not done — abandoning work and finishing it are different facts about the past, and today both land in "completed".
  • blocked is not here, deliberately. A task is routinely in progress and blocked; as a status value the two are exclusive, so recording one overwrites the other and unblocking becomes a guess about where the work actually was. And the useful part is what is blocking it, which no status value can hold. It becomes a relation (§4.4), so "show me blocked work" stays a one-line query and the dependency is visible instead of living in prose.

Lifecycle — whether the row is live

  • archived and deleted become flags orthogonal to status, not values inside it.
  • Fixes the current model, where one column mixes eight values across two unrelated axes and non-work types borrow active to mean "exists".
  • Knowledge validity stays a date (valid_until); expiry is derived on read, never a stored status.

4.6 · Completion stops cascading silently

Today, completing a project marks every descendant complete. With dropped in the model that becomes an active falsehood: a thread with three open tasks almost never means those three were finished — it means they were judged unnecessary. Cascading done writes a false claim into history, and history is what the audit trail rests on.

OperationCascades?Why
Complete a containerRequires a dispositionOpen descendants must be resolved explicitly — completed, or dropped — as part of the same call, transitively. The API refuses rather than guessing, and the refusal enumerates the open descendants so the caller can answer it without a separate traversal; the budget refusal already names its candidates and this should match. Threads nest, so a descendant may itself be a thread — and since a thread need never complete, dropped is usually the honest disposition for one whose parent effort ended. The cost is one extra decision at the moment someone is already deciding the work is over; the return is a record that says what actually happened to each piece.
ArchiveYes, freelyA visibility concern. Archiving asserts nothing about whether work was finished, so nothing can be falsified by cascading it.
DeleteYes, freelyLifecycle, admin-only, soft. Same reasoning as archive.
Move scopeYesSame-scope parentage is a trigger-enforced invariant, so a subtree cannot be split across scopes. Note the consequence: anchored knowledge in the old scope may become unreadable to some readers afterwards, which is correct behaviour and reader-dependent by design (§7.3).
AnchorsNeverDeleting a thread must not destroy what was learned doing it — but "not destroyed" is not the same as "reachable", so a runtime rule is required rather than implied. Deleting or archiving an item that is some knowledge's sole anchor is refused, and the error names that knowledge. The caller then re-anchors it, drops it deliberately, or promotes it to unanchored — which the budget will then ration like any other unanchored write. Without this, the design's own guarantee degrades to "survives, findable only by keyword search", which §9 concedes is the weak path.

4.7 · Inapplicable fields are refused, not absorbed

The current API documents that source and valid_until are "silently stored but ignored" for types they do not apply to. The result is a database containing fields that look meaningful and are not, with no way to tell which by reading a row — and a caller who believes an expiry was set when nothing will ever act on it.

One item table, nullable columns, but validation at the boundary: a due_date on a rule, or a valid_until on a task, is a mistake and is rejected with the reason. Saying so at write time costs one error message. Absorbing it costs the ability to trust any field in any row — and silent acceptance is the failure mode this design keeps ruling out everywhere else, because it produces confident, wrong readers.

The taxonomy, settled

Five types: thread · task · reminder · rule · fact. Down from seven, and every survivor earns its place on behavior rather than on subject matter.

What folded and where: ideatask with status proposed (§4.3) · note → an anchored fact or a detail field (§4.3) · project and the proposed topic → one thread with optional completion (§4.2) · goal → a thread carrying a cadence (§4.8) · profile and findingfact, with reach expressed by anchors instead of by type (§4.1). Claiming the word thread requires renaming hermesd's conversation object while it is still unbuilt (§4.11).

4.8 · Does goal survive?

Introducing topic puts goal under scrutiny, because a goal is a standing container that never completes — which is now exactly the definition of a topic. What distinguishes it is a cadence and an activity record, and in the current system both are already non-structural: cadence is freeform text in metadata, and last_activity is derived from history rather than stored.

Fold it — a goal is a thread with a cadence

  • Both distinguishing properties are already expressible: a thread need never be completed, and cadence becomes a field.
  • Consistent with absorbing idea — distinctions carried by a field rather than a type.
  • The distinguishing data is not there. Cadence is freeform text in metadata and last_activity is derived from history — and, checked against the live database, not one of the five goals carries a cadence at all. The field the type exists to hold has never been used, which is a stronger argument for folding than any structural one.
  • Correction worth recording: an earlier draft claimed nothing in the engine branches on the type. That is false — today there is a goal-only boot query and a goal-only slim shape. Folding therefore removes code rather than merely renaming a value.
  • Threads and goals already share every structural property that matters: both group work, both never have to complete, both accrete.
  • "Show me my goals" survives as a filter — threads carrying a cadence.

Keep it — commitment is not subject matter

  • Being accountable to something differs in kind from tracking it, and a type states that where a field merely records it.
  • A field can be left unset. With a type the choice is forced at creation, so a commitment cannot silently degrade into a list.
  • First-class visibility: a type is discoverable in the API and hard to forget; an optional field is easy to never use.

Recommendation: fold it — this reverses the earlier recommendation in this document. That recommendation rested on the claim that a system unable to distinguish a commitment from a list cannot notice neglect. The claim is true and the conclusion did not follow: the signal that detects neglect is the presence of a cadence, not the type. A field carries it exactly as well.

An earlier version of this recommendation attached a condition — that cadence become a structured field so neglect would be "computable". That condition is withdrawn under P8: nothing in the system branches on a cadence. The only reader is a language model, which can judge "3x/week, last touched eleven days ago" without the string being parsed into a schema first. Cadence stays freeform, and the filter "threads I have committed to" is a tag like any other — which the migration applies, and which is worth stating plainly because it, not cadence, is what actually carries the goal signal forward: the tag will be present on every migrated goal, where cadence is present on none.

4.9 · Threads differ, and the schema should not care how

A boat-race predictor has a repository, a default branch, a local path and an isolated environment. A house being built has a plot, a contractor and a permit. A legal matter has a case number and a counterparty. An earlier draft of this section proposed typed attachment blocks — a repo block, and by implication a block per domain forever. That does not generalise, and it was solving a problem the model already solves.

Repository metadata is already anchored facts

"The default branch is master", "the local path is ~/repositories/private/mimir", "the conda environment is mimir" — each is true, each is known rather than obeyed, and each is relevant only on its own thread. That is precisely fact + anchor, with nothing left over. The live database is already doing this correctly in one place — the environment name is an item-scoped fact on the mimir thread today — while the same thread's path, remote and branch sit flattened into a prose detail field. The fix is to move the second group to join the first, not to invent a mechanism.

Typed fields would only earn their place if code consumed them. Nothing does: no cascade, no filter, no trigger reads a default branch. The reader is a language model, and a language model reads "default branch: master" perfectly well. P8.

Category is a label, and the label is the interface

The stronger reason not to add thread.kind is that the category is not something the system needs to know — it is something the user means, and the model relays. Call a thread a project and it will be read back as a project. Another user calls the same structure a work item, or a matter, or a case, and that is what their assistant will call it. The vocabulary is per-user and costs nothing, because the component doing the interpreting is a language model rather than a switch statement.

So the category lives in the same freeform place the user's other vocabulary lives — tags — and the schema stays out of it. Drift between "project" and "projects" is tolerable here for the same reason: the consumer reads tolerantly. This also settles, retroactively, how much weight the naming debates in §4.10 and §4.11 deserved. The internal type name matters for the API. What anything is called is a product surface. P9.

4.10 · Names considered and rejected

Kept

  • rule — direct, and it leaves no room for wiggling. A rule is always followed; that is the whole content of the word, and the reason the type exists.
  • fact — the unmarked default that rule is a marked departure from, and the word already in use, so most existing rows simply stay facts and the migration reads as a clarification rather than a rename.

Rejected, and why

  • profile — a profile is a collection of facts, so naming one row a profile is a category error; in software it also reads as a settings bundle, and it strains for company and system subjects.
  • finding — a good word, but it named a load tier rather than a behavior. Once reach moved to anchors, "finding" was exactly "anchored fact" and the type dissolved. It survives as prose, not as schema.
  • learning — matches the existing end_session(learnings=…) vocabulary but is awkward as a count noun.
  • Collapsing rule into fact — proposed and withdrawn. The argument was that the two behave identically, which holds only at the server; at the consumer they are different instructions to the reader, and every fact is emphatically not a rule.

One residual objection is recorded rather than hidden: a rule is also, in logic, a fact — so using fact for the other branch is taxonomically sloppy. It is accepted deliberately. "That is a rule" and "that is just a fact" are distinctions people already draw in ordinary speech without confusion, and the alternative is a coined word every reader must learn in exchange for a purity nobody was troubled by. Under P9 this matters less than it appears: these names are the API's, and what a user calls things is theirs.

4.11 · The word thread, and what it costs to claim it

thread is the right name for a container that may or may not conclude — it is neutral on completion where project and topic each presume an answer, and it is the word already reached for when the gap was first described. Claiming it means hermesd's conversation object is renamed to conversation: POST /conversations, conversations.db, per-conversation metering, the created → active ⇄ parked → archived machine.

Why the rename is affordable

  • hermesd does not exist yet. It is Stage 2 of an unstarted migration. The cost today is a revision of one design document; after it ships the cost is an API break plus every client.
  • conversation is more accurate than thread was — it is literally what the object is, and the spec's own prose already calls it that in the places where precision mattered.
  • The decision is forced either way. Both objects are user-facing and both are referred to daily; leaving them to collide later is a choice too, just an unmade one.

What it honestly costs

  • Linguistically backwards. Slack threads, email threads, forum threads — the canonical thread is a conversation. This takes the word from its literal sense and gives it to the metaphorical one.
  • Informal ambiguity survives the rename. "I keep about ten threads going" meant conversations when it was said earlier in this design discussion. Naming the memory container thread makes that sentence wrong in the new vocabulary, and habits outlive schemas.
  • /threads is a better Telegram command than /conversations. Minor, but it is the surface where the word is typed most.

Recommendation: claim it, and rename hermesd's object now. The unified container is the stronger design regardless of naming, and among the available words only thread is neutral on completion. The counterarguments are real but they are about habit and idiom, while the rename window is about cost — and that window closes when Stage 2 ships.

§5 · Load path

What arrives unasked, and what has to be fetched

The taxonomy exists to make this section possible: what an item costs to load is now a property of what it is. Three tiers, one of them bounded by construction.

TierTriggerContainsBound
T0 · Orientation One read at the start of a conversation. Any client, any transport. Scopes · unanchored rules · unanchored facts · thread list (slim) · reminders due inside the window (14 days) · tag vocabulary Hard UTF-8 byte budget, allocated per section (§5.1)
T1 · Drill Reading one thread — because the conversation is about it. The thread in full · open children, slim · its anchored rules, then its anchored facts · linked items as summaries Child cap with an exact overflow count
T2 · Detail Naming specific items by id. Whole rows — detail, metadata, timestamps, anchors, links. Never filtered by status: an explicit id is an explicit request. Maximum ids per call

5.1 · Why T0 is a budget and not a query

Today's boot payload has no ceiling. It returns every top-level fact, and since facts accumulate permanently, the cost of starting any conversation rises every time something is learned in any other one. That is the defect, and it is structural rather than a tuning problem: no ranking function fixes an unbounded set, it only reorders it.

Global knowledge rule + fact~25,000 B
Thread / project list~4,000 B
Tasks · ideas · goals · tags~2,500 B
T0 after the rework~12,000 B
today, live payload (estimated) proposed ceiling

Roughly 31,000 bytes today against a ceiling near 12,000 — estimated by classifying the live boot payload, not measured, so treat the ratio rather than the digits as the claim. The reduction is worth having, but it is not the point. The point is that the right-hand number is a ceiling and the left-hand one is a running total. Most of what leaves is the episodic knowledge that becomes anchored — which is also the longest material, and the only material that grows without limit.

Why bytes rather than characters — and why not tokens

An earlier draft budgeted in characters because they are deterministic. They are also a poor proxy for what is actually billed. English prose runs about four characters per token; Japanese runs closer to one. This database is full of Japanese — corporate registry terms, visa document names, contract signature blocks — so a character budget prices exactly the heaviest content at roughly a quarter of its cost.

UTF-8 byte length is the better proxy. English is about four bytes per token and Japanese about three, so bytes track tokens within roughly 1.3× across scripts instead of 4×. Still deterministic, still computable without a model.

Counting real tokens would be more accurate and is rejected: tokenisers are vendor- and version-specific, so embedding one would tie the budget — and therefore the whole load path — to a particular model, breaking the engine-agnostic property the rest of this design is built on. A stable 1.3× approximation is worth more than an exact number that pins the vendor.

5.1a · T0 has a fixed composition and a fixed order

Ordering is not presentation here. It determines what survives truncation, and it determines whether the payload is byte-identical between conversations — which is what makes its leading sections prompt-cacheable by the engine — a property of the model provider's cache, not of Mimir, which caches nothing (§6.3). Unstable ordering means every conversation pays full price for content it has already sent.

#SectionSort within itOn overflow
1RulesOldest first — a stable order, so the set only changes when its membership doesCannot overflow: the cap refuses the write instead (§5.2)
2FactsOldest first, matching rules — a stable order, and one that does not push durable orientation facts out behind freshly-edited onesCannot overflow: the cap refuses the write instead (§5.2)
3ThreadsMost recent activity first; each as id, one line, open-countTruncate, with an exact count
4RemindersDue soonest first, within a fixed 14-day windowTruncate, with an exact count
5Scopes and tag vocabularyMost-used first, then alphabeticalHard count cap per scope. Nothing bounds a freeform vocabulary on its own — the live database already carries ~348 distinct tags in one scope, roughly 5 KB against a 12 KB ceiling, and consolidation tooling is cut. Truncate with an exact count and a pointer to the full list

Rules lead deliberately: they are binding, and material at the start of a payload is the least likely to be skimmed. Everything below them informs; only they constrain.

Only the leading sections are stable, and that is enough

Sections 1 and 2 are write-capped and ordered oldest-first, so they change only when their membership does — a stable prefix across conversations. Sections 3 and 4 cannot be: threads sort by recent activity, which any co-member's write reorders, and the reminder window is relative to now, so it moves with the clock alone. An earlier draft claimed the whole payload was byte-identical between conversations; only the prefix is, which is precisely what prefix caching needs. §12's gate holds both time and data fixed, which is the correct test of the property that actually exists.

The budget is per scope, not per reader

A write knows only its own item's scope, so that is the only unit the cap can be enforced in. A reader in N scopes therefore receives up to N × the per-scope allowance, and §5.1's headline figure describes a single-scope reader. Stated because the alternative is worse in both directions: a per-reader cap cannot be enforced at write time at all, and leaving it unstated invites an implementation that silently truncates the always-loaded set — the one thing §5.2 exists to prevent.

The consequence worth naming: a co-member filling a shared scope enlarges your orientation, and no write of yours is refused. Shared scopes are shared cost, the same way they are shared visibility. At two people and one shared scope this is bounded; it is the figure to watch if a deployment ever grows.

5.2 · The cap is enforced at write time, on everything unanchored

Two separable questions get separate answers here. What may enter the always-loaded set is controlled at write, by refusal. What fits in one payload is controlled at read, by truncation. An earlier draft conflated them and applied write-refusal to rules alone — which left facts, the type §4.1 identifies as the unbounded bulk of memory, rationed by nothing at all and so undercut the argument §4.1 rests on.

Unanchored knowledge — hard cap, refused at write

  • Rules and facts alike. Creating unanchored knowledge past the budget fails, and the error names the least-recently-updated candidates for retirement.
  • For a rule the argument is correctness: a rule is binding, so a silently dropped one is not a degraded payload but a correctness failure — the reader complies with what it was given and cannot know what it was not.
  • For a fact the argument is that "unanchored is scarce" (§4.1) is only true if something makes it scarce. Truncation at read does not: the set still grows, and what falls out is whatever sorts last. Rationing has to happen where the growth happens.
  • Annoying by design. It converts an invisible, permanent tax on every conversation into a visible, one-time decision at the moment someone is choosing to add the cost.
  • The escape hatch is not a bigger cap — it is anchoring, which is usually what the knowledge deserved anyway. Note the consequence: this deliberately steers material into the drill tier, which has its own unresolved bound (§11, open).

Every item — a bounded summary

  • The cap limits how many items are always-loaded and says nothing about how large one may be. Several live facts carry 200-word summaries — detail smuggled into the summary field — and a single one of those can consume a fifth of the budget.
  • So summary length is bounded, enforced at write, for every type. There is already a fact in this database requiring summaries to be self-describing to a reader with no context; nothing enforced it, which is precisely why the consolidation sweep needs a category for summaries that fail it (§11).
  • detail stays unbounded for every type except rule. It lives one tier down, where length costs nothing until someone asks for it.
  • A rule has no detail at all — its summary is the whole of it. Detail is carried only by the full shape, which is T2-only, so a rule with detail would bind on content the reader is never shown: §5.2's own standard says a reader "complies with what it was given and cannot know what it was not", and that condemns half a directive exactly as it condemns a missing one. Refusing detail on rules (§4.7's rule for inapplicable fields) is the only version that holds. Consequence for §10.2's summary-splitting queue: an over-long rule must be shortened or demoted to a fact, never split.
  • The bound also makes the budget predictable: a ceiling on item count means something only when item size is bounded too.

Threads and reminders — soft cap, loud overflow

  • These are not rationed at write, because their count is a consequence of doing work rather than a choice about what to always load. So they truncate at read rather than refusing at write.
  • Truncation is always reported with an exact count: "42 further threads not shown". A silent truncation reads as completeness, which is the failure mode worth engineering against.
  • Threads order by most recent activity, reminders by due date. Both are pull-able in full through browse — with the caveat that offset paging over a recency-sorted set is not stable under concurrent writes (§6.6).

5.3 · Drill, and how anchored rules arrive

T1 is where the type distinction pays. Opening a thread returns its anchored rules first and marked as binding, then its anchored facts as context. "Run this project's tests with conda run -n <env> python -m pytest" arrives as a constraint at the moment the thread is opened, rather than as one line of background among forty — and it costs nothing to every unrelated conversation, which is what kept it out of the global set.

Children default to open items, with an explicit status override — the asymmetry between today's set_context and list_items is not carried forward. Large threads page: one live thread has 68 children, so the cap and its overflow count are load-bearing rather than theoretical. "Cap plus an overflow count" is meaningless without saying which children survive it, so the order is specified:

#KeyWhy it ranks here
1Open before terminalFinished and dropped work is history. If anything is cut, it should be the part nobody needs to act on.
2Priority, descendingurgent · high · normal · low, a field on work items, settable through update and carried in slimA deliberate statement about what matters, so it outranks every automatic signal. Named here because an earlier draft used it as a sort key without ever declaring it.
3Position, ascending (unpositioned last)Explicit sequencing where someone bothered to set it — also deliberate, so it outranks recency, but it is unset on most items and therefore rarely decides anything.
4Most recent activity firstThe tie-break that actually does the work. Priorities and positions are mostly unset, so in practice children arrive most-recently-touched first — which is what you want when returning to a thread. Derived from history, so it reflects real activity rather than incidental field edits.

Read top to bottom, the rule is: deliberate signals win where they exist, recency decides everything else. Since priority and position are unset on most items, recency is the effective sort for the common case, and the explicit keys are there for when someone has said otherwise. If deriving last-activity per child proves costly on wide threads it becomes a denormalised column maintained on history write — a performance detail, not a semantic one.

5.4 · Staleness needs no machinery

A long conversation's orientation goes stale, and someone else may write to a shared scope meanwhile. Because Mimir holds no conversation state, the remedy is to read it again: T0 is an idempotent GET with no side effects, no cursor to advance, and no session to disturb. Compare the machinery this replaces — cursors, delivery-advance, fold-on-clean-end, force-path semantics — all of which existed to answer "what changed since you last looked" without asking again. Asking again is cheaper than remembering.

5.5 · Refresh after compaction, and when the reader drifts

Context compaction destroys the orientation a conversation was working from, and a long conversation drifts even without one. Hermes therefore needs re-orientation to be routine. Three consequences fall out of the model already described.

Refresh is not an operation

  • T0 is an idempotent GET. Reading it again is the refresh — there is no boot-versus-refresh distinction to draw, so refresh_context disappears as a separate call.
  • With it goes the hazard it was invented to avoid. Today's recovery procedure has to warn: do not call start_session again after compaction — it creates a new session and orphans the active one. That trap exists only because a session is a stateful object. No object, no trap, no warning to remember.
  • The parked "compact refresh mode" idea is resolved rather than implemented: a bounded T0 is already small enough to re-read freely, which is all that mode was for.

"What did I already write?" is a history query

  • Compaction can swallow the fact that a write already happened, producing either a duplicate or a silent omission.
  • Because attribution is request data — every write carries the conversation label its client supplied — recovering that is a history read filtered by the same label. Mimir still tracks no conversations; it records the label it was given and lets you query by it.
  • This is the one capability today's session_activity provides, kept in full, at the cost of a column instead of a lifecycle.

Deciding when to re-orient is the client's job, and it must be mechanical

Today the instruction lives in prose — a behavior file telling the model to call refresh_context when its context has been compressed. That is a rule addressed to the component least able to notice the problem: a model whose context was just truncated is not well placed to remember an instruction that may itself have been truncated. P5.

Structural triggers instead, on both planes. On the console, the engine already emits a compaction event — a hook fires the re-read and injects it, with no reliance on the model electing to. For channel conversations, hermesd holds per-conversation metering, so it re-injects on a threshold of turns or tokens since the last orientation — a use for metering beyond billing. And in both cases an explicit user-invoked refresh stays available for the case no counter catches: the reader has plainly lost the thread and the human can see it.

Detecting degradation itself is out of scope — no component here can measure its own answer quality. Proxies are honest and sufficient: compaction happened, N turns elapsed, or a human said so.

Accepted residual — T0 is bounded, cumulative drilling is not

Every tier has a ceiling per call. Nothing bounds the total. A conversation that opens six threads pays six drills, and because Mimir holds no conversation state it cannot know that the first five already happened — so it cannot taper, deduplicate, or refuse.

This is not fixable server-side without reintroducing the conversation state this design deliberately removed, and it is not worth that price: the failure mode is a long conversation growing large, which is what long conversations do, and the remedy — compaction followed by re-orientation — already exists (§5.5). The only place accumulation could be bounded is the client, which knows how much context it has spent; Mimir bounds each answer, not the sum of the questions. Recorded so it is a known ceiling rather than a surprise.

Accepted residual — the model must decide to drill

Pull-based loading means a thread's contents arrive only if something asks. Nothing in the server can guarantee that, and pretending otherwise would violate P5. Three things bound the damage rather than eliminate it: T0 carries open-counts per thread, so a thread with work in it advertises itself; drilling is cheap and idempotent, so over-fetching costs little and is the safe direction to err; and on the conversation plane a client that already knows which thread a conversation concerns — hermesd binding a channel to a thread — can pre-drill without Mimir holding any state to make that possible. This is the cost of choosing bounded orientation over the unbounded push, and it was chosen knowing it.

§6 · API surface

One registry, two projections

P2 says HTTP is the contract and MCP is an adapter over it. That is not the same as making the two identical: HTTP wants resources, a language model wants few tools with rich parameters. Both are generated from one operation registry, so parity is capability parity, not endpoint-count parity, and neither surface can quietly grow an operation the other lacks.

What a registry entry declares single source of truth

Inputs and their validation · the response shape (§6.2) · read or write · idempotent or not · the authorization it requires — scope membership, admin, or a token capability · and its projection into each surface: the HTTP method and path, and the MCP tool it appears under.

Parity means reachability, not equal counts

The mapping is deliberately not one-to-one. find_items is one MCP tool but two HTTP routes, because a language model wants few tools with rich parameters while HTTP wants resources. Several operations may share one tool; every operation must be reachable from both surfaces. Where operations fold, the registry declares not just the tool name but the input that selects this operation within it — otherwise the test can assert a tool exists without asserting the operation behind it is reachable, which is not parity.

Two things the registry buys that are easy to miss

Annotations stop being hand-maintained. MCP tools carry read-only, destructive and idempotent hints that drive client confirmation prompts. Today they are written per tool and can drift from what the tool does. Derived from the registry's read/write class, a mislabelled destructive operation becomes impossible rather than merely unlikely — which matters, because those hints are what stands between a model and an unconfirmed delete.

Tool-surface size becomes a load-path concern. Schemas and the server instructions prose are billed on every turn, so the shape of the MCP projection is not cosmetics. Thirteen tools rather than today's twenty-seven answers the standing backlog question about shrinking the surface — as a by-product of this rework rather than as a separate investigation.

6.1 · The operation set

OperationClassHTTPMCP toolNotes
OrientationreadGET /orientationorientT0. Optional scope filter — safe now, because authorization intersects membership per request regardless of what is asked for.
DrillreadGET /items/{id}/contextopenT1. Item, open children, anchored rules then facts, links as summaries.
DetailreadGET /items?ids=read_itemsT2. Explicit ids are never status-filtered — naming an id is an explicit request.
Browse & searchreadGET /items?…
GET /search?q=
find_itemsStructural filters (type, status, parent, anchor, tag) and keyword query in one tool. Kept distinct from read_items because filtered and explicit reads have different status semantics.
HistoryreadGET /history?item=
GET /history?conversation=
read_historyThe conversation filter is what replaces session_activity — see §5.5.
CreatewritePOST /itemsadd_itemsBatch, any type. Anchors are optional; the always-loaded budget is enforced here for unanchored knowledge, rules and facts alike (§5.2).
UpdatewritePATCH /items/{id}update_itemFields, status, tags, parent, position, priority, and type. Three validations, not one: a rule losing its anchors must fit the budget; promoting a fact to a rule requires may_write_rules; and editing an existing rule's summary requires it too — both are ways to author a binding directive, and gating creation alone leaves them open (§3.1).
ResolvewritePOST /items/resolveresolve_itemsBatch. One operation, two cascade behaviours (§4.6): archived cascades freely, while done and dropped refuse a container with open descendants unless the call carries an explicit disposition for them. The request therefore takes an optional per-descendant disposition map, and omitting it on a container with open work is a 409, not a guess. Idempotent: re-resolving reports transitions and writes no duplicate history.
Deletewrite · adminDELETE /itemsdelete_itemsSoft, cascading, admin-only. Separate from resolve because the confirmation posture differs.
RelatewritePOST /relations
DELETE /relations
relate_itemsAnchors and links in one tool with the relation named. Parent is not here — it is a field on the item, because it is single-valued and cascades.
LogwritePOST /items/{id}/logadd_logAppends a history event without changing the item. Drives thread activity — today's confusingly named add_note.
ScopesreadGET /scopeslist_scopesMembership as the caller sees it.
Scope administrationwrite · adminPOST /scopes
POST · DELETE /scopes/{id}/members
manage_scopeOne tool with a named action. Acceptable overloading precisely because it is rare — the prose it saves is billed every turn, the flexibility it costs is spent once a year.
Tokenswrite · privilegedPOST /tokens
GET /tokens
DELETE /tokens/{id}
manage_tokensMint, list, revoke. Absent from an earlier draft, which left the design's central object with no lifecycle inside the very registry §6 calls complete — so minting would have happened outside the domain layer that §2 makes the single authorization point. Requires privileged, or a credential could mint itself a wider one. Mint returns the plaintext once; list returns everything but the secret, which is what makes last_used_at answerable.
ProbesopenGET /health
GET /version
versionUnauthenticated over HTTP. /version is also exposed as an MCP tool — otherwise an MCP-only client cannot floor-check the server on a release with no backward compatibility, and the parity rule below would be violated by the table asserting it.

Thirteen tools, measured against today's twenty-seven — not against the registry, which both surfaces expose in full. Six disappear with sessions (start_session, end_session, refresh_context, set_context folded into open) and tag consolidation (suggest_tag_consolidations, merge_tags); the rest fold — convert_item into update_item, reorder_items into the position field, archive_items and complete_items into resolve_items, get_facts and list_items and search_items and list_children into find_items and open, three scope-admin tools into one.

6.2 · Three item shapes, named once

The same item is returned three different ways across the load path, and an earlier draft defined each only in the prose of the section that used it. That guarantees every endpoint invents its own and no client can share a parser. Named here instead, with each operation declaring which it returns.

ShapeCarriesReturned by
refid · summaryLink targets, anchor targets — anywhere an item is mentioned rather than presented.
slimref · type · last activity · plus, for work items only, status and open-countT0 listings including the rule and fact sections, T1 children, browse and search results. The workhorse. Status and open-count are omitted for knowledge rather than nulled — §4.5 gives knowledge no status and §4.4 gives it no children, and §4.7 refuses inapplicable fields rather than absorbing them.
fullEverything — detail, metadata, timestamps, anchors, links, parentT2 only, on explicit ids.

This is also what makes the byte budget computable rather than estimated. A ceiling on payload size means nothing while the payload's shape is decided per endpoint: fixing the shapes is what turns §5's budget from an aspiration into an arithmetic the server can check before it answers.

6.3 · Every write carries its own context

Attribution as parameters

  • conversation — an opaque client-supplied label, recorded on history. Mimir never interprets it, never validates it against a registry, and holds no object for it.
  • The character whitelist dies with the sentinel files. Today a conversation id must match [A-Za-z0-9._-]+ because it becomes a filename in the crash-sweep scheme. No sentinels, no filename, no constraint — the label is a string.
  • There is no attribution parameter. The token identifies the person, so every write is already attributed — including hermesd's, because it carries that person's token (§7.5). Nothing in the API can claim to act for someone.
  • History records the principal and the token label, so "who" and "through which credential" are both answerable without either being asserted by the caller.

Replay records — writes only, and not a cache

  • Writes accept an idempotency_key. The server records that the key produced an outcome, so a retry does not write twice. A real present-day gap: today a retried add_items — after a timeout, a dropped stream, a client restart mid-turn — silently duplicates. It matters more once HTTP clients are first-class, because retrying is what HTTP clients do.
  • Keyed by the key, never by the body. Two identical bodies under different keys are two separate writes; that is the caller's stated intent and the server does not second-guess it. Same key with a different body is rejected — a client bug, and honouring the first body would hide it.
  • The record stores the outcome, the affected ids, and a digest of the request body — never the response. On replay the server re-reads those items through the replaying token's own grant and returns their current state, marked as a replay. Three reasons in order: storing the response body would let a retry describe a version of an item that no longer exists; the digest is what makes the same-key-different-body rejection above implementable at all; and re-reading through the caller's grant rather than the principal's stops a narrowed token from replaying a wider token's key to reach items outside its own subset — the keying is per principal, but authority is per token, and only the second is the security boundary.
  • Two cases an earlier draft left open. A replayed delete re-reads ids that are now invisible, so a correct retry would return 404s for its own work — deleted items are readable by explicit id on the replay path, marked as deleted. And a second request arriving with a key still in flight gets a 409 rather than being queued or duplicated, because the outcome is not yet known and guessing either way is worse than saying so.
  • Persistent, not in-memory. If the server restarts between the original write and the retry, an in-memory record is gone and the retry duplicates — precisely the failure the mechanism exists to prevent. Small table, pruned by age.
  • Unique per principal, not globally — two people must be able to pick the same key without colliding. Retained for a bounded window (24 hours); afterwards the key is forgotten and a replay is simply a new write.
  • Failures are not recorded. A 5xx leaves no record, so the retry proceeds normally. Recording a transient database error would make it permanent for that key and refuse exactly the retry the mechanism exists to allow.

Nothing about a read is ever cached

Reads carry no idempotency key and leave no record. Read an item, update it, read it again, and both reads go to the database and return current state — the replay mechanism is not on that path at all. An earlier draft of this section said successful responses were "cached", which invited exactly the opposite reading; the word was wrong and the behaviour it described does not exist.

Caching reads would be a correctness bug, not an optimisation. Scopes are shared: another principal can write to family between two of your reads, and a cached answer would hide it. Today's behaviour files already carry a standing instruction to always fetch live and never trust the boot snapshot — that instruction is the evidence, because a system that could safely cache reads would never have needed to write it down.

This design makes the same guarantee cheaper rather than weaker: orientation is a bounded, idempotent read, so re-reading is the remedy for staleness instead of something to be avoided (§5.4).

6.4 · Batch writes are atomic

Today's batch semantics are inconsistent: add_items reports per-item errors and applies the rest, while complete_items validates every target up front and fails whole. Going forward, every batch validates completely, then applies completely, or fails having changed nothing.

The cost is real — one bad scope in a batch of fifty rejects the batch. It is accepted because the caller is usually a language model, and partial success obliges that caller to diff intent against outcome and construct a correct retry from the difference. "It worked" or "it did not" is a contract a model can act on; "seven of ten, here are the three" is a contract it will sometimes act on wrongly, and silently. Atomicity moves the failure into the open.

6.5 · Errors, and one new class

ConditionHTTPMCPBody carries
Malformed or invalid input400tool errorWhich field, and what shape was expected — the fix for the type-error message that once cost forty failed calls
Missing or bad key401tool errorNothing beyond the class
Scope not granted403tool errorThe scope refused, never the contents
Credential lacks the capability403tool errorWhich capability was required — may_write_rules, privileged, or a restriction flag. A distinct class because the scope was granted: a deputy refused rule authorship inside a scope it legitimately holds needs to be told that, and "the scope refused" would be a false statement
No such item, or invisible404tool errorIndistinguishable from not-permitted by design
Illegal transition or conflict409tool errorCurrent state and what was attempted
Always-loaded budget exceeded409tool errorThe cap, the current count, and retirement candidates — a refusal that tells the caller how to proceed rather than only that it cannot (§5.2)
Unexpected500tool errorMasked; correlation id only

6.6 · Versioning and paging

Version

  • A clean break means a new major: /version reports it, and every response carries the version header.
  • Clients floor-check at boot — hermesd and the deploy validator already do this against the current server, and the mechanism survives unchanged.
  • The old surface is not maintained alongside. That was the point of choosing a break.

Paging — and the thing it is not

  • limit + offset with an exact total on every truncated response, so "not everything is here" is always explicit.
  • T0 is not paginated. Its overflow counts (§5.2) are a different mechanism: a pointer to browse, not a cursor. There is no page two of your rules, because the cap means there is no page two — and an offset parameter on orientation would quietly defeat the budget it exists to enforce. Stated because it is exactly the kind of thing that gets added for symmetry.
  • Offsets are not stable under concurrent writes, and volume is not the reason to care. The recency-sorted sections reorder whenever anyone writes, so a co-member's write between two pages can skip or repeat a row. Opaque cursors solve exactly that and are still declined — they cost the caller state to hold, the affected sets are tens of items, and a repeated or missed row while paging a browse listing is recoverable in a way a missing rule is not. A known ceiling, declined on the right grounds; an earlier draft argued only from volume, which is not the failure mode.
§7 · Security model

Four layers, one vulnerability class removed, one new one created

R3 — read and write only where you have access — is the requirement most of this rework serves. Removing conversation state eliminates an entire class of authorization bug by construction. Introducing rule creates a new escalation path that needs a structural answer.

L1 Container and mount isolation

One container and volume per principal. hermesd instances mount either a user's home or a single scope's channel volume, never both — a kernel boundary rather than a tool policy. The compose network is shared and explicitly untrusted; every component on it authenticates.

L2 Token authentication changed

A token resolves to its principal, its scope subset, and its capability limits, on every request, identically on both transports. HTTP being first-class means this is one code path rather than a middleware and a separate helper. Only /health and /version are open.

L3 Per-request authorization changed

token.scopes ∩ principal's current membership, plus the token's capability limits, evaluated on every call. No session narrows it, no parameter widens it. A scopes argument is a filter applied after the grant, never an input to it.

L4 Database backstop

Triggers re-verify scope membership on every item write regardless of what the application layer did, and enforce same-scope parentage. Unchanged, and still the layer that assumes the ones above it are wrong.

Today's L3 is two layers — app checks plus a session scope ceiling — and the fifth layer covering privilege and attribution collapses into L3 as well, because with authorization resolved per request, "admin only" and every capability restriction are ordinary token checks rather than a separate regime.

7.1 · The vulnerability class that disappears

Not patched — made unrepresentable

The live server takes start_session(scopes=[…]) and feeds the requested scopes raw into every boot-payload query, so any authenticated key reads another scope's contents by naming it. The fix, published in v0.15.0 but not yet deployed, adds loud validation plus a persisted per-session ceiling — a correct patch that leaves the shape intact: a request still names scopes, and something still has to check them before they reach a query.

Under per-request authorization there is no boot-with-requested-scopes operation to attack. The grant is derived from the key; the filter is applied to results the caller is already entitled to. There is nothing to validate because nothing untrusted reaches the query, and no ceiling to persist because no session exists to hold one. The bug class ends with the shape that allowed it — which is the strongest argument in this document for a clean break over an evolution, because the patch preserves the shape and the rework does not.

7.2 · Two credential kinds, and why they stay independent

What holds what

  • Mimir tokens → a principal, a scope subset, capability limits. Held by every memory-plane client, and by hermesd — one per human it serves, narrowed to that instance's scopes.
  • hermesd bearer token → per-instance, admission to the conversation API only. Held by conversation-plane clients: the gateway, a chat GUI.
  • A hermesd bearer confers no Mimir access whatsoever. The two kinds never substitute for each other.
  • Both are issued by the same provisioning step into the deploy environment, so several secrets remain one operational act.
  • Mimir stores hashes; the deploy environment file holds the one plaintext copy (§3.2). So the database is not a credential store — a reader of it can verify a token but never recover one. There is exactly one place to protect, and losing the database does not leak credentials.
  • Replay records (§6.3) are keyed per principal, so one caller cannot probe another's idempotency keys — worth stating because a global key space is the easier implementation and would be a cross-principal read.

Why hermesd does not ask Mimir who you are

  • Mimir already is the principal registry, so having hermesd validate callers against it is tempting and would give clients a single identity.
  • Rejected because it couples availability: Mimir down would stop conversations, not merely memory. P7 says losing one plane must not disable the other, and a shared authentication authority breaks exactly that.
  • The token model gets the benefit without the coupling — hermesd presents a credential Mimir already trusts, rather than asking Mimir a question at request time.

7.3 · Cross-scope anchors, and reader-dependent drills

Anchors drive loading, so they are a potential disclosure path: knowledge in one scope anchored to a thread in another would surface when that thread is opened. Two options, and the safe-looking one is worse.

Forcing anchors same-scope removes the question but pushes people to copy knowledge across scopes to reach it — and two copies drift, which is the same argument that killed stored thread focus in §4. Duplication is a correctness problem dressed as a safety measure.

So anchors may cross scopes, and drill results are filtered by the reader's grants — silently. Silently is deliberate: reporting "three anchored facts you cannot see" discloses their existence, which is the thing being protected. The consequence must be stated plainly rather than discovered: a drill payload is reader-dependent. Two people opening the same thread legitimately receive different knowledge, and no payload should be treated as the canonical contents of a thread.

7.4 · The rule type is an injection target

A new escalation path, created by this design

Making rules structurally binding raises the value of writing one. Today the worst an attacker achieves by planting memory is an advisory fact the model may weigh. Under this design, a planted rule is rendered to every future conversation as a constraint to comply with — a persistent, privileged instruction. The path is real: content arriving from mail, the web, or a group chat is data, but a model manipulated by that content into writing a rule converts a transient injection into a durable one.

Three structural controls, none of which is an instruction to the model:

A capability, withheld

Rule creation is a token capability. Deputy tokens for shared scopes are issued no_rule_create, so nothing acting on one person's word in a room with other people can write a directive that binds them. This is a property of the credential rather than a rule about identities — the same mechanism that makes a dashboard token read-only.

The cap bounds the blast radius — for unanchored rules only

The always-loaded budget (§5.2) refuses writes, so the binding set cannot be flooded and existing rules cannot be displaced. Anchored rules are outside that bound — and §5.2 deliberately steers overflow there, so this control covers the tier an attacker has least reason to target.

Visibility, and where it stops

An unanchored rule appears in every orientation payload, first in the payload (§5.1a), making it the least concealable thing in the system. An anchored rule is surfaced only when its thread is opened — still binding, still marked as such, but seen on that thread's schedule rather than every conversation. History records the person, the token and the conversation in both cases. Stated because an earlier draft claimed universal visibility, which holds for one of the two forms.

7.5 · The deputy problem, and why hermesd carries tokens

hermesd receives a request and writes to Mimir on the requester's behalf. A bearer token says you may talk to this instance; it says nothing about who you are or what you may touch. So the question is whose authority the resulting write carries. Two answers were considered and one was rejected after being written up in full.

Rejected — one deputy identity, plus attribution

  • hermesd holds a single service credential and names the human on each write; Mimir intersects the two identities.
  • Bounds the blast radius correctly, and was the design in an earlier draft of this section.
  • Rejected because it preserves the defect it was built to contain. The actor is still a synthetic identity with a human recorded beside it — which is exactly the residual §3 calls the core problem. It softens the symptom and keeps the cause.
  • It also requires Mimir to implement an intersection of identities correctly, forever, for a case that need not exist.

Chosen — the deputy presents the person's own token

  • hermesd holds one narrowed token per human it serves: a family-bound instance holds jimmy@family and alexandra@family, never their full credentials.
  • Mimir sees an ordinary request from an ordinary person. No deputy concept reaches the server at all — the confused deputy is not defended against, it is not constructible.
  • Attribution is correct by definition rather than by convention: the write was made by that person's credential, so history says so.
  • Blast radius matches the rejected design exactly, because the tokens are narrowed to the instance's scopes.
LayerQuestion it answersEstablished byEnforced where
AdmissionMay you talk to this instance at all?Per-instance bearer tokenhermesd
Speaker identityWhich human is speaking?The channel binding, default-deny — a numeric Telegram user id mapped to a known human, an authenticated GUI session, the console's own user. Never inferred from message content.The channel adapter
Token selectionWhich credential does the write carry?The narrowed token held for that human on that instance. No mapped human, no token, no write.hermesd
Effective authorityWhat may that credential touch?token.scopes ∩ current membership, plus capability limitsMimir, at L3

Two invariants this design is only sound with

1 — Tokens never reach the journal, transcripts, metering, or logs. hermesd holds credentials while also processing model output and untrusted channel content. A token that lands in a transcript is a token in a store designed to be replayed, paged through an API, and read by a language model. This is a hard invariant with a test, not a coding guideline.

2 — A token's scopes are a ceiling checked at use, never a grant issued once. Authority is token.scopes ∩ the principal's current membership. Remove someone from family and every @family token naming them stops reaching it on the next request, with no revocation sweep and no distributed state to update. Without this, a stale credential outlives the membership that justified it — and it is the one piece of intersection logic that survives, because it is unavoidable in any token model.

A direct client needs none of this machinery. Holding its own token, it writes as itself — the deputised path is not a privileged mode, it is the same path with the credential supplied by a component instead of by a person.

Accepted residuals

  • Within a scope, write access is trust. Any member can write a rule that binds every other member's assistant. That is what a shared scope means, and the household case makes it appropriate; a scope shared with a stranger would not be.
  • hermesd is a credential store, and that is a real cost. It holds live tokens in a component that also ingests untrusted content. Narrowing bounds the loss, capability limits bound it further, and per-instance partitioning keeps one compromise from becoming all of them — but there is no arrangement in which a deputy's credential is less valuable than what it deputises for. Unpreventable, but no longer undetectable: last_used_at (§3.1) makes a credential used at an unexpected time or from an unexpected pattern visible after the fact, which is a different residual from the one an earlier draft recorded.
  • The pre-migration database is a plaintext credential store, and the migration deliberately preserves it. Today's schema keeps users.api_key in the clear, and §10.3 writes a new file rather than mutating the old one — so the original survives, still holding usable keys. It is meant to survive: the consolidation sweep reads it as a reference. Rotate at cutover, per §12 step 8 — not after the sweep. An earlier draft deferred it and justified the delay by saying the sweep needs those credentials; it does not. The sweep needs the old file's data, which rotation does not touch, and it runs against the new server anyway. Nothing is bought by waiting, and what is spent is a window in which a plaintext wildcard credential authenticates against 1.0.
  • A compromised deputy can write as the people it holds tokens for, and the trail will name them. The token label is what distinguishes it — a write reads jimmy, via hermesd-family, so a forged write still identifies the credential that made it. Non-repudiation lives at token granularity, which is the honest granularity: a console token can be stolen too. Partitioning bounds this per user — each user's instance holds only their credential — but one groups instance holds every bound scope's, so a groups compromise reaches every shared scope rather than one. At a single shared scope that is a null difference; it is the number to watch if more are bound.
  • Adding someone to a shared scope now also requires minting them a token on any instance bound to it. Provisioning already regenerates on config change and the channel identity map already needs the same edit, so it is more work at an existing moment rather than a new moment — but it is a genuine coupling the rejected design did not have.
  • 404 and 403 are deliberately hard to distinguish on item reads, so probing cannot map what exists in scopes the caller cannot see.
  • No read-side database triggers exist, in SQLite or in this design. L3 is the only barrier on reads; L4 backstops writes only. This was true before and remains true — stated so nobody assumes symmetry.
  • L4 backstops principal membership, not the token. The triggers see only created_by/updated_by, so a token's scope subset and every capability grant are enforced at L3 alone — the novel half of this design's authority model has one enforcement point, not two. The four layers are real; they are not four layers deep everywhere, and §12's "the trigger backstop catches a deliberately bypassed call" tests the half that was already covered.
  • A deputy that names the wrong speaker is indistinguishable from the right one. §7.5 removes the deputy's ambient authority — it has no credential of its own — but token selection still happens inside it, so an ordinary bug or a wrong binding row produces a correctly-authorized write attributed to someone who did not ask. Bounded, because an instance holds only one scope's tokens and deploy validation requires every mapped member to be a scope member, so a mis-map lands on another member of the same scope. The residual is attribution integrity, not authorization — and the earlier wording named only compromise.
§8 · Impact on Hermes — iteration 2 input

How much of the channels design was working around Mimir

Nothing in this section is built in this iteration. It exists because the memory rework changes what the conversation plane needs to be, and that finding should be recorded while the reasoning is fresh rather than rediscovered later. The daemon is designed from first principles in hermesd-architecture.html; what follows is the specific list of things this rework removes the need for, which is that document's most useful input.

It is deliberately not a delta on the earlier channels spec: that spec was designed against the Mimir being deleted, so inheriting its structure would preserve decisions whose reasons are gone. Every major component was re-examined for whether its justification was Mimir-shaped or independent.

What was re-derived, and what was not

Each component below was tested with one question: would I choose this knowing Mimir is a stateless token-authenticated store? The survivors are listed with the reason they survive, so the reason is on record rather than assumed. What this is not is a clean-sheet redesign of hermesd — the channels spec is a large, already-reviewed document and most of it concerns problems Mimir never touched. Components not listed here were not examined, and may still carry Mimir-shaped assumptions that a full review would find.

8.0 · Survives, and why — not by inheritance

ComponentIndependent reason it survives
Journal as source of truth
SSE is a convenience view
Needed for engine neutrality and for any GUI to render history. A stateless Mimir does not store what was said, and should not — §5's boundary. Nothing to do with sessions.
EngineAdapter + capability descriptorsServes engine-agnosticism directly. Would be invented from scratch under any memory design.
A mount boundary between groups and user homesConfirmed, and inherited rather than re-derived. A group conversation must not be able to read a user's home — files, credentials, transcripts — and only a mount boundary provides that. Correction on provenance: the earlier spec states exactly this reason, calls it "a kernel/mount boundary, not tool-policy", and names it the accepted resolution of a prior review finding. Tokens removed the secondary justification (per-channel principals), leaving the mount reason standing alone. Note the shape also changed: the companion design consolidates per-scope instances into one groups instance, since group profiles have no filesystem tools — so instance count tracks users plus one, and §7.5's partitioning argument holds at user granularity rather than per scope.
Approvals with approver-binding and one-shot noncesExists because headless execution cannot prompt a terminal. Unrelated to memory.
MeteringBilling posture is volatile and per-conversation cost must be attributable. Gains a second job in §8.4.
Transcript watcher justified, deferredThe architecture survives: console conversations must appear in enumeration, and the watcher is one-directional by design — the alternative, having the console report itself, would give it a new dependency and break the console invariant. But the companion design defers building it: an undocumented, drifting transcript format is the most brittle component for the thinnest requirement. This column records why a component is justified, not when it ships.
Hook-free thread invocationRests on a verified fact about the engine — hooks load from project settings by cwd — not on anything about Mimir.
Conversation state machineChannels need to park and resume; a GUI needs to list. Independent of memory entirely.

8.1 · Constraints that were not facts

ConstraintWhat it forcedNow
1 — the scopes parameter filters but does not enforce, so channel isolation needs per-channel principals Synthetic hermes-<scope> Mimir users, seeded like people dissolved A token's scope subset is enforced. Isolation comes from narrowed credentials, and no synthetic identities exist.
4 — an eternal open session freezes shared activity for that user, so long-lived conversations require rotation; "this gates v1.0, it is not polish" A whole rotation subsystem: nightly marks, idle thresholds, epoch counters, learnings-and-carry-over turns, degraded-rotation journal events deleted No sessions, no cursors, no feed to starve. Deleted outright, not demoted — see §8.2.
9 — REST cannot open sessions or carry learnings; attribution binds through the engine's own MCP connection, therefore the engine must own the session lifecycle The §4.3 actor split, and hermesd reduced to a "learnings-less force-close fallback" dissolved There is no lifecycle to own. Attribution rides the token, so an HTTP write is attributed identically to an MCP one.
10 — client session ids must match [A-Za-z0-9._-]+, because the sweep skips non-matching sentinel filenames A conversation-identifier scheme that is a contract with a filename whitelist: <channel>.<uuid>.r<N>, dots not colons, with a rotation epoch baked in dissolved No sentinels, no filenames. The conversation label is an opaque string, and the epoch suffix goes with rotation.
8 — a Mimir version floor, checked at deploy validation and hermesd boot GET /version floor check kept New major, same mechanism. The only one of the four Mimir-shaped constraints that was a real fact.

8.2 · What deletes from hermes-template

The sentinel suite, entirely

  • state/.sessions/<csid> markers, per-turn heartbeat touches, the 36-hour stale sweep, the throttle files, and the shared lib/lifecycle/ scripts that implement them.
  • Every one of those exists to notice that a conversation died so its Mimir session can be closed. With nothing to close, the entire mechanism has no remaining job.
  • Also retired: the cross-principal awkwardness that the console sweep "cannot close scope-principal sessions", so scope instances must sweep their own at boot, with a 7-day server GC as last resort.

Rotation, and the identifier scheme with it

  • A delta would have kept rotation as optional context hygiene. Re-derived, it goes: its only remaining purpose would be summarising a conversation before the engine's resume horizon expires — and the resume-failure path already reseeds from a rendered summary when that happens. Rotation would be a scheduled subsystem duplicating a fallback that must exist anyway.
  • So: no nightly marks, no idle thresholds, no epoch counters, no zero-write-epoch special case, no degraded-rotation events, and no release gate.
  • The conversation identifier collapses to a plain uuid. The channel prefix was redundant with a channel field; the .r<N> epoch suffix existed for rotation; the dots-not-colons rule existed for a filename whitelist. All three reasons are gone.

Session ceremony in the hooks

  • SessionEnd — the safety-net REST close. Nothing to close.
  • Stop — the sentinel heartbeat. The /end nudge may stay, but as a prompt to write down learnings, not as hygiene.
  • SessionStart — survives, minus the sentinel write and the crash sweep. It still emits the banner and triggers the briefing.
  • The context-recovery procedure in the workspace behavior files, including its warning not to re-open a session after compaction (§5.5).

8.3 · What changes shape

AreaChange
Vocabularyhermesd's thread becomes conversation throughout — API routes, conversations.db, the state machine, the Telegram command. Mimir claims thread for the memory container (§4.11). Free now; an API break after Stage 2 ships.
Scope-instance derivationInstead of deriving a synthetic principal, a scope-bound instance is provisioned with one narrowed token per member of the bound scope. The channel volume, the daemon, and the engine credentials are unchanged.
A soft warning becomes impossibleDeploy validation currently only warns when a mapped group member is not a Mimir member of the bound scope. Now it cannot happen: there is no token to mint for a non-member, so the mapping simply has no credential to select.
Thread orientationThreads run hook-free, so the design compensates for the lost prompt-time Mimir enrichment by instructing the model to search proactively — a documented fidelity gap. hermesd now injects the bounded orientation payload at spawn instead. An instruction becomes a mechanism (P5).
Boot reconciliationKeeps the part that matters — conversations found active with no live engine are marked failed and the channel is notified. Loses the Mimir half entirely, including the planned open-sessions endpoint it depended on.
Stage 2 gatesAttribution under a conversation identifier becomes trivially true. Rotation-demonstrated, including its zero-write-epoch case, drops entirely. The kill-9 reconciliation gate stays, smaller.

8.4 · What hermesd gains

A credential store

Narrowed Mimir tokens, one per human served, under the two invariants in §7.5: never into the journal, transcripts, metering or logs; and scopes re-checked against live membership on every use. This is the cost side of the token decision and the only genuinely new responsibility.

Re-orientation triggers

Per-conversation metering already exists for billing. It now also answers "how long since this reader was oriented?", so hermesd re-injects on a turn or token threshold — and on the engine's compaction event (§5.5).

Nothing else

No new subsystem. The engine seam, the journal, approvals, the transcript watcher, metering, failure semantics and the console surface are all unaffected — they never depended on Mimir's session model in the first place.

8.5 · Where the v0.15.0 work lands

ItemOutcomeWhy
Per-session activity cursorsretiredThe shared feed is cut, and the cursor apparatus existed only to serve it.
REST session lifecycle — learnings routing, open-sessions listingretiredNo sessions to close or enumerate; it existed to serve boot reconciliation.
on_behalf_of attributionretiredSuperseded by tokens — the person's own credential makes the write (§3).
Scope ceiling — the security fixretiredPer-request authorization removes the shape the vulnerability lived in (§7.1).
complete_items idempotencycarried forwardSurvives as a property of resolve_items: true transition counts, no duplicate history on re-run.
Children default to open itemscarried forwardBaked into the T1 drill defaults, with the list/children asymmetry not carried over at all.

An unresolved tension for the sequencing section

Four of the six retire, which raises whether v0.15.0 should still ship at all. The argument for shipping is that the branch is already finished, the cross-tenant read is live today, and the rework is weeks away at minimum — a tag closes real exposure now. The argument against is that it releases code destined for deletion and adds a schema migration the rework then has to migrate away from. Recorded here, decided in §12.

§9 · Keyword search

Two fixes, and an honest ceiling

Semantic retrieval is out of scope, which makes the keyword path the only way to find anything not pushed at orientation. It therefore has to at least work — and today it has two defects that make it fail in ways the caller cannot see.

DefectFailure todayFix
Hyphens parse as operators Search terms go to the full-text engine unescaped, where - is column-spec negation, not a NOT operator. Verified: hermes-google — the name of an actual subsystem — raises no such column: google. It never silently excludes; it always errors, which is at least loud. Every hyphenated identifier in the database is unsearchable, and dates like 2026-08-05 too. Treat the query as text, not as an expression: tokenise it and quote each term before it reaches the engine. Operator syntax stops being reachable by accident, which is the only way it was ever reached.
Every term must match Implicit AND, no fallback. A three-word query where one word is absent returns nothing — indistinguishable, to the caller, from "there is nothing on this topic". Silent, and the worst kind. OR with relevance ranking, so complete matches still rank first. Plus report which terms matched per result, so a partial match is visibly partial rather than mysterious.

The result total must be counted after grant filtering, not before

§6.6 requires an exact total on every truncated response, and the full-text index spans every scope. Count the matches before applying the caller's grants and the number itself discloses: "three results, showing one" says two matching items exist in scopes this caller cannot see — and varying the query probes their contents a word at a time.

Stated as a requirement because the wrong version is the cheaper query: counting without the scope predicate is one join instead of two. It is also directly testable — a query matching only another scope's content must return no results and a total of zero, not zero results and a total of three.

Structural browse is safe by construction — its grant predicate is already in the same WHERE clause, so its counts are post-filter without anyone deciding. The full-text path is the one place where the naive count is the cheaper query, which is why the rule lives here rather than being restated generally.

One read does need saying, and it is not search: history filtered by conversation label. The label is client-supplied and Mimir never validates it (§6.3), so the namespace is unowned — history reads are filtered by the caller's grants like every other read, and a guessed label returns only what that caller could already see. Without the rule stated, a label is a bearer token nobody minted.

What search returns

  • slim items (§6.2) — search does not invent a shape of its own.
  • Matched terms are result metadata, not item fields. The per-result term list sits alongside the item rather than inside it; folding it in would make the fixed shapes stop being fixed.
  • Anchored knowledge is searchable exactly like global knowledge. Anchors govern loading, never findability. This is load-bearing for §11: the argument for anchoring aggressively is that nothing is lost because an anchored fact is still fully searchable, and an implementation that scoped search to the global set would quietly remove the reason to anchor at all.

Why the matched-terms report matters more than it looks

A ranked OR search can return something plausible for entirely the wrong reason — one common word matched, the distinctive ones did not. Without the per-result term list the caller cannot tell a strong hit from a coincidence, and a language model reading the results will reasonably assume relevance. Returning the matched terms turns a guess into a judgement.

Recorded ceiling: these two fixes make keyword search stop failing. They do not make it good. The assistant still has to guess the word, and knowledge phrased differently from the query stays invisible. That is the accepted cost of deferring retrieval, and it is the single largest known weakness of this design — worth revisiting as the first candidate for iteration 3.

§10 · Data migration

One shot, and the reclassification is the real work

A clean break means the schema is new and the data moves once. Most of the move is mechanical. One part is not, and it is the part that determines whether the load path actually delivers what §5 claims.

10.1 · The mechanical part

TodayBecomesNotes
users.api_keyA principal plus one token, scopes: *, labelled legacyWildcard rather than an explicit list, so it tracks membership as §3.2 intends. Rotate immediately after cutover — a credential that has lived in a column and a config file for months should not survive the rework unchanged.
scopes · scope_membersUnchangedDirect copy.
projectthreadCompletion carries over.
goalthread, cadence retained as metadata, tagged so "my goals" stays a filterFreeform cadence stays freeform (P8).
ideatask, status proposedSix live ideas.
task · reminderUnchangedStatus values map onto the new work states; archived and deleted move from the status column to lifecycle flags.
status = blockedopen, and flagged for reviewThe relation cannot be back-filled. blocked stopped being a status (§4.5) and became a blocked_by relation — but a status never recorded what was blocking, so there is nothing to point the relation at. A live example: the corporate-account task blocked "on funding the company first", where the blocker is a circumstance rather than an item. The reason survives where it already lives, in the summary; a real relation is created during the sweep if a blocker exists as an item.
source · valid_until
on types that ignore them
dropped, and reportedToday these are documented as "silently stored but ignored" for inapplicable types, and §4.7 now rejects them at write. Migration drops them — but reports each one, because a caller who once set an expiry deserves to learn it was never going to fire rather than have it disappear quietly.
noteAn anchored fact — id, history and links preservedNo top-level notes exist, so this affects only nested ones. Merging into the parent's detail is offered per note but is not the default: it destroys the id, orphaning that note's history and any link pointing at it. One live nested note is a four-thousand-word audit, which merging would bury.
item_facts (parented)Anchored facts — the parent becomes the anchorFree and exact, but singular. The parenting convention was already expressing an anchor, so the migration reads it as one — and produces exactly one per fact. Every additional anchor is judgement work (§10.2): the thread-pool hazard wants three servers, the insurance findings want the Tieto engagement and the GK entity. Single-anchor import is correct and incomplete.
item_linksUnchangedLinks keep their meaning.
item_historyCarried, marked as pre-1.0, with two documented lossesOld event names are not rewritten — restating what past events meant would falsify an audit trail. But "verbatim" was overstated: session_id references the sessions table this migration drops, so it becomes a dead label rather than a foreign key; and history rows belonging to a merged note lose their subject. Notes are therefore not merged by default — they become anchored facts, keeping their id, their history and any links. Merging into a parent's detail is available but opt-in per note, and the reconciliation report lists every history and link row affected.
sessions · activity_cursordroppedNot migrated. Nothing reads them and nothing will.

10.2 · The part that is not mechanical

Roughly 105 top-level facts have to become either rule or fact, and either anchored or global. No script can decide that: the rule/fact test is authorial intent (§4.1), and intent is not recoverable from the text. Meanwhile the anchoring decision is what determines whether the orientation payload actually lands near its ceiling or stays where it is today.

How it runs

  • Three judgement queues, not one. Type and anchors per §10.2; additional anchors beyond the single one import gives; and over-long summaries, which §5.2 now bounds and which cannot be fixed mechanically — truncating destroys content, and splitting summary from detail is a decision. All three gate cutover for the same reason the cap does.
  • Proposal pass — classify each fact with a suggested type and suggested anchors, with the reasoning shown. Cheap, and roughly 60% of the set is unambiguous once the question is asked.
  • Confirmation in batches, by topic rather than by id, so related decisions are made together and consistently.
  • Everything unanchored requires an explicit claim that it belongs in every conversation. Under today's convention, global was the silent default; here it is an assertion someone makes.
  • Runs in parallel with the build — it is data work with no code dependency.

The cap does the enforcing

  • The always-loaded budget is a hard cap (§5.2). The migration cannot complete while more knowledge is marked global than the budget permits.
  • That is the point. The reason the current payload is oversized is that nothing ever forced a decision; the migration is where the decision is finally unavoidable.
  • Nothing is lost by anchoring aggressively — an anchored fact is fully searchable and arrives whenever its thread is opened. The only thing at stake is whether it is billed to every conversation.
  • Expect the boot payload to land near its ceiling only if this pass is done properly. Skipping it produces a new schema with the old problem.

10.3 · Running it

Write a new file, never mutate the old

The migration reads the current database and writes a second one. The original is then untouched by construction rather than by remembering to copy it first — and the dry run becomes the same code path with its output discarded, instead of a separate read-only mode that can quietly drift from what the real run does.

Reconciliation, not counting

"No item lost" is not checkable, because items are legitimately transformed — notes merge into detail, ideas become tasks. So every source row is accounted for: mapped (to what), merged (into what), or dropped (why). A report someone reads, not two numbers that agree.

Gates before cutover

Global knowledge inside the byte budget · every summary inside its bound · no orphaned parent or anchor · every blocked item reviewed · every dropped field listed · projected orientation size shown. The report is read before the second file replaces the first.

§11 · Data consolidation

The content is as misorganised as the schema was

A new schema does not clean old data. The live database carries superseded facts nobody retired, status snapshots filed as durable knowledge, structured data flattened into prose, cross-references written as sentences, and knowledge with no container to belong to. A defined sweep with detection rules, because an undefined one stalls at item forty.

Why this is post-migration, not part of it

Most of the cleanup cannot be expressed in the old schema. Anchors, thread, rule and lifecycle flags do not exist there, so "anchor this to the mimir thread" is not a statement the current model can hold. The migration must also stay mechanical and verifiable; folding open-ended judgement into it makes the one step that must complete into the one step that never does.

So the split is: the migration carries only the decisions that gate cutover — and everything else runs afterwards, iteratively, using mechanisms that only exist once 1.0 is live.

Part of this work is therefore pulled forward into the migration's judgement queues (§10.2), because the budget and the summary bound cannot be satisfied without it. Those queues are, exactly: type and anchors — which spans rule/fact classification and C5's missing containers — additional anchors beyond the single one import gives, and over-long summaries (C7). An earlier draft of this callout named a different three and dropped the anchors queue. The rest of this section is genuinely post-cutover. Read §10.2 and §11.1 together — they describe one body of judgement work, divided by whether a release gate depends on it.

Open: this prevents recurrence for global knowledge, not for anchored knowledge

The table in §11.3 argues every cause of rot now has a structural counterpart. That argument holds for the always-loaded set, which the cap disciplines. Anchored knowledge has no cap, no budget and no review trigger — and it is precisely what accumulates: every superseded-chain, status-snapshot, resolved-state and overlapping-analysis example in §11.1 is topic-bound. The seven-fact insurance cluster is the shape of the problem.

Deliberately left open rather than resolved here, and tracked as its own design question (Mimir task #746). Candidates under consideration: surfacing knowledge for confirmation when its thread is opened — which is exactly when its accuracy matters — a last-confirmed timestamp, decay scoring that demotes never-referenced knowledge below the drill cap, and retirement candidates derived from supersession links. Until it is answered, the consolidation sweep is a one-off that will eventually be needed again, and this document should not pretend otherwise.

11.1 · Categories, each with a detection rule

CategoryHow to find itDecision rule
C1 · Superseded chains Text search for supersedes, refines, replaces, no longer. Live examples: one fact states it "supersedes earlier disk-pressure guidance in fact #506" while #506 remains active; two others say "refines fact #357" and "refines fact #279" with both originals still loose. Consolidate into one authoritative statement, or keep both and record the relationship as a link. Never leave a superseded fact active with no marker — a reader who finds the old one first has no way to know.
C2 · Status snapshots Facts whose content is an event with a date: "dashboard foundation complete — 120 commits merged", "Phase 3 shipped as v1.8.1", "provider-ops infrastructure deprecated as of 2026-04-18". Retire. These are history, and history is already held by the history table and the git log. Keep only the part a future reader needs — the six UX findings a hotfix addressed are knowledge; the fact that it shipped is not.
C3 · Resolved in-flight state Facts containing awaiting, pending, blocked on, outstanding, or a date now past. Check reality; replace with the resolved fact or retire. Going forward these carry valid_until, so the same class expires itself instead of accumulating.
C4 · Structured data in prose Detail fields containing key-value runs. The clearest case is the mimir thread's own detail: local path / remote / default branch / classification as a text blob. Split into individually anchored facts so each is separately searchable and updatable. A branch rename should not require editing a paragraph.
C5 · Knowledge with no container Global facts about a recurring subject that has no thread. Four cats and pet-insurance research sit at top level because "the cats" was never expressible. Create the thread, anchor the knowledge. This is the category that most directly shrinks the orientation payload.
C6 · Cross-references as sentences Any # followed by digits inside a summary or detail: "see task #706", "tracked in project #718". Also the unnumbered kind — "blocked on funding the company first". Convert to link, keeping the prose only where it says how they relate, since a link records that they relate but not why. Route dependencies to blocked_by instead (§4.4) where the blocker exists as an item — that is the category the migration explicitly could not back-fill, since a status never recorded what was blocking.
C7 · Summaries that fail their own rule Both ends of the range. Too short to stand alone — one live fact's entire summary is hermes_file_import_complete — and too long to belong in a summary at all, where several run 200 words. Short ones: rewrite or delete. There is already a fact in the database requiring summaries to be self-describing to a reader with zero context, so this is that rule applied to itself. Long ones: split summary from detail, since §5.2 now bounds summary length and detail is unbounded one tier down. The long variety is the more common, and it is the one that gates cutover (§10.2).
C8 · Stale work items Open tasks with no activity for months, especially where the world has moved. One open task is "restart the Mimir server to pick up v0.5.0" — production runs 0.14.0. Another asks for a global-facts payload reduction that this entire rework supersedes. Complete, drop, or re-scope. dropped now exists as a distinct terminal state, so abandoning work no longer has to be recorded as finishing it.
C9 · Overlapping analyses Clusters of facts answering one question in layers. The insurance cluster is seven facts across two months covering coverage scope, territory limits, run-off, the certificate wording, and an alternative insurer. Consolidate to one authoritative fact per question, anchored to the threads that consume it. Hardest category and the highest value — seven partial answers read as seven topics.
C10 · Tag drift The tag list itself. Consolidation tooling is cut from the design, so this is a one-time manual pass. Pick canonical forms and merge once. Tags now also carry user vocabulary (P9), which makes consistency worth more than it was.

11.2 · How it runs

Detect mechanically, decide by topic

Detection is automatable for every category above. Confirmation is batched by subject, not by category — deciding all seven insurance facts together produces a coherent result; deciding all C9s across unrelated subjects does not.

Mechanical categories go first

C6 links, C7 summaries and C4 extractions need little judgement and clear the noise that makes the judgement categories harder to read. C9 last, when the surrounding structure is already right.

Done means every item touched once

Exit criterion is coverage, not emptiness: every global item and every thread reviewed once, each category's queue drained. After that it is maintenance, and §11.3 is what makes maintenance rare.

11.3 · Why it rotted, and what prevents a repeat

A cleanup that does not explain the accumulation buys one tidy database and the same problem in eighteen months. Each cause has a structural counterpart in this design, which is the actual argument that this sweep is the last big one:

Why it accumulatedWhat now prevents it
Global was the silent default. Unparented meant global, and unparented was the path of least resistance.Going global is now an explicit claim against a bounded resource, not a default anyone falls into. The mechanism is the same one as the row below — this cause and that one are prevented by one thing, the cap, rather than two.
Nothing ever forced retirement. Adding cost nothing and the cost was paid invisibly, by every future conversation.The hard cap refuses the write and names retirement candidates (§5.2). The cost is paid at the moment of choosing, by the person choosing.
Supersession lived in prose. "Supersedes fact #506" is invisible to every query.link makes the relationship machine-visible, so a superseded fact can be found from the one that replaced it — and a fact linked-from a newer one becomes a mechanically detectable retirement candidate.
Time-bound facts had no expiry. valid_until existed and was almost never used.open Still a convention, and it cannot be made mandatory — nothing determines from content whether knowledge is time-bound, which is the same unenforceable classification that killed "anchors are required for anything topic-bound" (§4.1). Tracked as its own design question rather than claimed solved.
No review ever triggered. There was no moment at which anyone had to look.The cap is self-triggering: review happens exactly when the always-loaded set is full, which is exactly when it is worth doing. No cadence to maintain and no reminder to ignore. But this disciplines the global set only — see the callout below.
§12 · Sequencing

What ships first, and the v0.15.0 call

v0.15.0 — done, and deliberately not deployed

§8.5 left this open because four of its six items are retired by this rework, which made releasing it look like shipping code destined for deletion. That instinct is misapplied: it exists to avoid future work, and there was none — the branch was finished, reviewed and tested, so the cost of shipping was one tag. The technical objection, that migration 005 adds columns the 1.0 migration must then migrate away from, does not survive contact with the plan: the 1.0 migration reads data and never looks at the cursor, ceiling or attribution columns.

Merged, tagged and published to PyPI and GHCR on 2026-08-05. Not deployed — the running server stays on v0.14.0 by choice, since 1.0 supersedes it shortly and a restart buys little. The consequence is worth stating plainly rather than leaving implied: the cross-tenant boot read remains live in production until that deploy happens, and the fix now sits in a published artefact rather than an unmerged branch.

One finding from the release worth carrying: it failed the first time, and not because of the code. ruff>=0.8 was an unbounded floor rather than a pin, so CI's fresh install resolved to a newer linter than the code had been verified against and produced 70 errors on unchanged files. Now bounded to a minor series. The general form — a linter floor is not a pin — applies to every repository here, and the failure mode is a release breaking for reasons unrelated to what is being released.

#StepGate before moving on
0Ship v0.15.0 doneMerged, tagged, published 2026-08-05. Deploy deliberately deferred, so the boot-read fix is available but not yet running.
1Schema and token model — fresh schema, principals, tokens with scope subsets and capability limits, access triggersSecrets stored hashed and unrecoverable · a token narrower than its principal is enforced · * scopes track membership as it changes · two active tokens coexist during a rotation · provisioning re-run is idempotent on label and churns nothing · membership removal invalidates reach without touching the token.
2Domain layer and queries — one authorization point, all SQL below itNo path reaches a query without passing the grant check; the trigger backstop catches a deliberately bypassed call.
3Operation registry, then both transports — HTTP as the normative projection, MCP generated from the same registryParity asserted by a test, not by inspection — and parity means reachability: every registry operation reachable on both surfaces, with the same errors and authorization, even where several share one MCP tool. Annotations are derived from the read/write class rather than hand-written. The three item shapes are fixed, so no endpoint invents its own.
4Load path — T0 with its byte budget, T1 drill, T2 detail, the rule capOrientation inside the byte budget with synthetic data at ten times current volume, including CJK-heavy content · summary bound enforced at write · two runs against unchanged data produce byte-identical payloads, which is the prompt-cacheability property made testable · drill children ordered correctly under overflow · the cap refuses a write and names retirement candidates · truncation always reports an exact count.
5Keyword search fixesHyphenated identifiers are searchable; partial matches return ranked with matched terms reported.
6Migration dry run and the judgement passesOutput written to a second file, original untouched · the reconciliation report accounts for every source row as mapped, merged or dropped · all three judgement queues drained — type and anchors, additional anchors, over-long summaries · global set fits the byte budget · every blocked item reviewed. Data work, so it can start at step 1 and run alongside.
7Console integration in hermes-template — delete the sentinel suite, trim the hooks, add compaction-triggered re-orientation, rewrite the behavior files against the new APIA live console session survives the flip; no hook references a session; re-orientation fires on compaction without the model electing to.
8Cutover — run the migration into a new file, read the reconciliation report, swap, deploy, verify, reconnect clients, rotate the legacy tokensDowntime is acceptable and expected; there is one user pair and no rollback machinery by standing preference.
9Consolidation sweep (§11) — mechanical categories first, then the judgement ones by subjectEvery global item and every thread reviewed once. Deliberately after cutover: most of it is inexpressible in the old schema, and none of it blocks the release.

Why this order

  • Identity first, because everything checks it. Building the API before the token model means retrofitting authorization into every operation.
  • Registry before either transport, or the asymmetry this rework exists to fix gets rebuilt by hand.
  • Load path after the API, because it is a set of reads over an existing surface rather than a subsystem.
  • Console integration last among the code — it is the only step that touches a live workspace, and it is much easier once the server it targets exists.
  • Consolidation after cutover, because anchors, threads and links are the tools it needs and they do not exist until then. The migration carries only what the budget gates on.

Explicitly not in this iteration

  • hermesd and the Telegram gateway. Iteration 2, on the derivation in hermesd-architecture.html. This iteration's job is to leave nothing in their way — and §8 is the record of what it removes from their path.
  • Semantic retrieval. Iteration 3 at the earliest, and the first thing to reconsider once 1.0 is stable (§9).
  • A GUI. The HTTP surface exists so one can be built, which is not the same as building it.
  • Any backward compatibility. The old tool names and the old schema are gone at cutover. That was the whole point of choosing a break.
§13 · Verification record

What was checked, what was rejected, and why

Kept because the expensive part of a review is not finding problems — it is establishing that something is not one. Without this, the same three claims get re-derived by the next reader, and the same wrong conclusion gets reached twice.

RoundMethodOutcome
1 · FindFive independent reviewers, one per section group, each verifying claims against the source tree and the live database rather than reading alone~60 raw findings
2 · FilterThree adjudicators over disjoint subsets, each returning a verdict per claimTwo dismissed, roughly a third narrowed
3 · ConsensusFifteen agents — five subsets, three independent votes on every finding, identical prompts within a subset41 findings × 3 = 123 verdicts

Seven findings reached independently by two or more first-round reviewers were applied before the consensus round; the remaining 26 were applied after it. Every claim the panel could check against source or live data now matches it.

13.1 · Rejected, and not to be re-raised

ClaimVoteWhy it is wrong
"Knowledge anchored to a task or reminder has no load path — drill is thread-only" 3/3 refuted Drill is GET /items/{id}/context — item-generic. §5.3 and the tier table use "thread" as the common case, not as a restriction, so a task or reminder is drillable and its anchored knowledge loads normally. Two separate first-round reviewers raised this before three others refuted it, which is exactly why it is recorded. The genuine residual is narrower and stated in §4.2: drilling a thread surfaces its own anchors, not its descendants'.
"§12 step 7 depends on step 8, since the 1.0 server does not exist until cutover" 2/3 refuted "The flip" is step 7's own live-workspace change, not step 8's swap — the term is inherited from the earlier channels spec's Stage 1b, where it named exactly that. The 1.0 code exists after step 5; only a running server waits for step 8, and §12 already concedes the ordering is for convenience.
"R3 promises a database backstop the design does not provide for reads" 3/3 dismissed SQLite has no read-side triggers, so no design satisfies the symmetric reading — and the asymmetry is disclosed three times, including verbatim in §7's residuals. A wording imprecision in a one-line requirement summary, already corrected where it matters.

13.2 · Where the panel corrected the reviewers

  • The goal fold's best argument was not the one being made. The structural case was thin; the decisive fact is that not one live goal carries a cadence. The panel also found the supporting claim "nothing in the engine branches on the type goal" to be false — there is a goal-only boot query and a goal-only shape today — so folding removes code rather than renaming a value.
  • Per-speaker token selection is not impossible, only expensive. The finding claimed no implementation path existed. Engines are already reaped and resumed between turns with queue depth one, so respawn-per-speaker is available; the real cost is a spawn and a cold prompt cache on every speaker alternation in a group.
  • The prompt-cache gate was right and the claim was wrong. A reviewer said §12's test was weaker than §5.1a's property. The panel inverted it: only the write-capped prefix can be stable, which is all prefix caching needs, so the claim narrowed and the test stayed.
  • The replay record had no contradiction. Storing a request digest is compatible with "not the response" — what was missing was simply the digest, plus the delete and concurrency cases.

Still open after all of it

One finding survived every round unresolved: anchored knowledge has no cap, no budget and no review trigger, and it is precisely the material that accumulates. §11.3's prevention argument holds for the always-loaded set and not for the anchored one. Deliberately deferred rather than answered here, and tracked as its own design question. Until it is closed, the consolidation sweep is a one-off that will eventually be needed again.