§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.
| Concept | Question it answers | Established by | Notes |
| 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
| Field | Purpose | Example |
| id | A public handle. Lets a token be named in an API call, in history, or in a revocation without quoting its secret. | — |
| principal | The person accountable for anything this token does. | jimmy |
| secret | Stored 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. | — |
| scopes | A 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 |
| capabilities | Restriction 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 |
| label | Human-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 |
| state | active 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_at | Optional. 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 drift | Answer |
| 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 reader | Known — 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.
| Type | What it is | Anchors | Load 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.
| Type | Completes? | Contains? | Role |
| thread the container | Optionally — with a disposition (§4.6) | yes, including other threads | A 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. |
| task | Yes | yes | A unit of work with state. Absorbs today's idea as the status proposed. |
| reminder | Yes — 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 it | leaf | A 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
idea → task 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
| Mechanism | Means | Shape | Rules |
| Parent | "is part of" | Tree, single parent, work items only | Same 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 items | Optional, 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-many | A relation rather than a status value (§4.5). Carries what is blocking, which a status cannot. Never cascades. |
| Link | "relates to" | Undirected peer association | Cross-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.
| Operation | Cascades? | Why |
| Complete a container | Requires a disposition | Open 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. |
| Archive | Yes, freely | A visibility concern. Archiving asserts nothing about whether work was finished, so nothing can be falsified by cascading it. |
| Delete | Yes, freely | Lifecycle, admin-only, soft. Same reasoning as archive. |
| Move scope | Yes | Same-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). |
| Anchors | Never | Deleting 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: idea → task 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
finding → fact, 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.
| Tier | Trigger | Contains | Bound |
| 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.
| # | Section | Sort within it | On overflow |
| 1 | Rules | Oldest first — a stable order, so the set only changes when its membership does | Cannot overflow: the cap refuses the write instead (§5.2) |
| 2 | Facts | Oldest first, matching rules — a stable order, and one that does not push durable orientation facts out behind freshly-edited ones | Cannot overflow: the cap refuses the write instead (§5.2) |
| 3 | Threads | Most recent activity first; each as id, one line, open-count | Truncate, with an exact count |
| 4 | Reminders | Due soonest first, within a fixed 14-day window | Truncate, with an exact count |
| 5 | Scopes and tag vocabulary | Most-used first, then alphabetical | Hard 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:
| # | Key | Why it ranks here |
| 1 | Open before terminal | Finished and dropped work is history. If anything is cut, it should be the part nobody needs to act on. |
| 2 | Priority, descending — urgent · high · normal · low, a field on work items, settable through update and carried in slim | A 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. |
| 3 | Position, 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. |
| 4 | Most recent activity first | The 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
| Operation | Class | HTTP | MCP tool | Notes |
| Orientation | read | GET /orientation | orient | T0. Optional scope filter — safe now, because authorization intersects membership per request regardless of what is asked for. |
| Drill | read | GET /items/{id}/context | open | T1. Item, open children, anchored rules then facts, links as summaries. |
| Detail | read | GET /items?ids= | read_items | T2. Explicit ids are never status-filtered — naming an id is an explicit request. |
| Browse & search | read | GET /items?… GET /search?q= | find_items | Structural 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. |
| History | read | GET /history?item= GET /history?conversation= | read_history | The conversation filter is what replaces session_activity — see §5.5. |
| Create | write | POST /items | add_items | Batch, any type. Anchors are optional; the always-loaded budget is enforced here for unanchored knowledge, rules and facts alike (§5.2). |
| Update | write | PATCH /items/{id} | update_item | Fields, 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). |
| Resolve | write | POST /items/resolve | resolve_items | Batch. 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. |
| Delete | write · admin | DELETE /items | delete_items | Soft, cascading, admin-only. Separate from resolve because the confirmation posture differs. |
| Relate | write | POST /relations DELETE /relations | relate_items | Anchors 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. |
| Log | write | POST /items/{id}/log | add_log | Appends a history event without changing the item. Drives thread activity — today's confusingly named add_note. |
| Scopes | read | GET /scopes | list_scopes | Membership as the caller sees it. |
| Scope administration | write · admin | POST /scopes POST · DELETE /scopes/{id}/members | manage_scope | One 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. |
| Tokens | write · privileged | POST /tokens GET /tokens DELETE /tokens/{id} | manage_tokens | Mint, 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. |
| Probes | open | GET /health GET /version | version | Unauthenticated 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.
| Shape | Carries | Returned by |
| ref | id · summary | Link targets, anchor targets — anywhere an item is mentioned rather than presented. |
| slim | ref · type · last activity · plus, for work items only, status and open-count | T0 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. |
| full | Everything — detail, metadata, timestamps, anchors, links, parent | T2 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
| Condition | HTTP | MCP | Body carries |
| Malformed or invalid input | 400 | tool error | Which field, and what shape was expected — the fix for the type-error message that once cost forty failed calls |
| Missing or bad key | 401 | tool error | Nothing beyond the class |
| Scope not granted | 403 | tool error | The scope refused, never the contents |
| Credential lacks the capability | 403 | tool error | Which 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 invisible | 404 | tool error | Indistinguishable from not-permitted by design |
| Illegal transition or conflict | 409 | tool error | Current state and what was attempted |
| Always-loaded budget exceeded | 409 | tool error | The cap, the current count, and retirement candidates — a refusal that tells the caller how to proceed rather than only that it cannot (§5.2) |
| Unexpected | 500 | tool error | Masked; 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.
| Layer | Question it answers | Established by | Enforced where |
| Admission | May you talk to this instance at all? | Per-instance bearer token | hermesd |
| Speaker identity | Which 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 selection | Which credential does the write carry? | The narrowed token held for that human on that instance. No mapped human, no token, no write. | hermesd |
| Effective authority | What may that credential touch? | token.scopes ∩ current membership, plus capability limits | Mimir, 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.