Static lowering landed in GameCult/Hermodr: a library and CLI rendering a surface graph to standalone HTML through the same renderer the browser uses, with command controls inert because a static page has no back-channel. All five phases of the Hermodr extraction are complete. This document stays as the record of what left Odin and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Hermodr Extraction Plan
Objective
Hermodr is the Eve-to-browser lowering bridge. It should resolve providers through Odin's directory, subscribe to those providers for their state, render their surfaces to browsers, carry operator intent back to the provider that owns the consequence, and — new — emit a surface as static bytes for audiences that are not Verse participants.
It currently runs its own provider ingress and its own state store instead. This plan deletes that, moves the daemon into its own repository, and adds static lowering as a second output mode.
What Odin is
Odin is a rendezvous organ. It tells you what state is available, what schemas speak it, and where to reach it. It does not tell you what the state is, and it is not in the data path.
A provider owns its state and serves it. Odin owns the directory. A consumer resolves through the directory and then talks to the provider.
Hermodr's command path already works this way. findProviderCommandRoute looks
a route up in the catalog and then publishes the command to the provider's own
advertised endpoint. Erycina's surface says the same thing from the other side:
discoveryOwner: "Odin", while canonicalSurfaceUri points at
cultmesh://asgard.starfire.erycina/eve/operator/surface — Odin tells you where,
you go ask Erycina.
Current mechanism
Hermodr lives in Odin as Node inside a Rust repository: src/hermodr-daemon.cjs
(1,462 lines), src/hermodr-state-stream.cjs (32), test/hermodr-*.test.cjs
(191), four PowerShell lifecycle scripts, and a persona avatar. Eve holds the
browser-side consumer at web/hermodr-provider-catalog.mjs.
The intent path is already correct
POST /hermodr/commands/eve is generic across providers. It resolves a
cultmesh:// target from the body or from the provider catalog, wraps intent as
gamecult.eve.command.v1, stamps publishedBy: "hermodr-browser-lowering",
publishes to the provider's advertised route, and returns a receipt. Eve's client
passes commandSink: publishCommandIntent into the renderer and refuses any
response that is not a correlated gamecult.eve.command_receipt.v1 in state
reconciled.
This path is kept unchanged. It is also the reference for what the state path should look like.
The state path is the defect
main() constructs an OdinLivePublicationSource — an in-process Map with a
read side (latest, watch, watchLifecycle) and a write side (accept,
withdraw). It opens its own provider-session ingress, bound to its own address
with its own session token, and feeds accepted documents into that private
store. HermodrStateStreamRegistry watches the store and pushes lifecycle
events to browsers.
So providers publish their state directly into Hermodr, and Hermodr decides what it holds. A surface reaches a browser only if its provider binds to Hermodr, which makes a required ingress out of something named a bridge.
The class is called OdinLivePublicationSource and lives in src/odin/. Odin
does not use it. Neither the coordinator nor any Odin test references it, and
the same is true of provider-session-ingress.cjs. This is Hermodr's own state
machinery wearing Odin's name and filed under Odin's shared modules, which is
why it reads as though Odin sanctioned it.
The loop does not close
The command path was added after the fact, on discovering the original bridge was entirely non-interactive. It was added well, but to one leg only, and the rest still assumes a viewer.
Intent leaves correctly, to the provider that owns the consequence. The consequence returns as newly published provider state — and that reaches the browser only through Hermodr's private ingress. A provider must therefore publish twice: once wherever the Verse expects it, and once into Hermodr, so the operator can see their own action land.
The core issue is not a missing write path. Intent goes out the right door and consequence comes back through the wrong one. Deleting the ingress is what closes the loop: once state arrives by subscription to the provider, a command's effect returns by the same route as every other change.
The client already carries the seed of the static case. publishCommandIntent
checks liveHermodr and, when absent, logs the intent instead of posting it.
Invariants
- A provider owns its state and serves it. Nothing else holds it as truth.
- Odin owns the directory: what exists, what schema, where. Never the data path.
- Hermodr owns the browser edge: session, transport, lowering, relay of intent. It holds projections with sequence numbers, never truth.
- A lowering runtime cannot make something true by rendering it.
- Static output carries no back-channel and must not pretend otherwise.
Authority map
Owner. Each provider owns its own state, its surface document, and its command routes. Odin owns provider discovery, schema awareness, and route advertisement.
Inputs to Hermodr. Odin's catalog, to resolve providers and routes. Provider subscriptions, for state. Browser requests.
Outputs. Lowered surfaces; live state streams; typed
gamecult.eve.command.v1 documents published to provider routes; receipts;
static artifacts.
Derived, not owner. Everything Hermodr holds about provider state is a projection carrying a sequence number. Its staleness marks are display facts, not withdrawal decisions.
Forbidden writers. Hermodr may not accept or withdraw a publication. It may not bind a provider session. It may not author a command as itself. Odin may not become a state relay; if a consumer can read state from Odin, the directory has become the data path.
Shared paths. State resolution and command routing must use the same catalog lookup. Live and static lowering must render from the same surface graph through the same composition primitive; if they diverge visually, the static path is wrong, not the graph.
Intended change
- Delete the ingress and the private store. Hermodr resolves a provider through the catalog, exactly as the command path already does, and subscribes to that provider for state.
HermodrStateStreamRegistrysurvives untouched. It consumes onlysource.forProvider(id).watchLifecycle(schemaId, recordKey, cb), which is already a narrow port. Replace what sits behind that port with a provider subscription client and every stream, sequence number, and browser keeps working.- Extract the daemon to
GameCult/Hermodr. - Add static lowering as a library with a CLI entry point.
Cut line
Deleted outright:
- the
OdinLivePublicationSourceconstruction and every write into it createProviderSessionIngress, and the--provider-session-bindand--provider-session-tokenoptions
Leaves Odin with Hermodr, because Odin never used it:
src/odin/live-publication-source.cjsandsrc/odin/provider-session-ingress.cjs, both referenced only byhermodr-daemon.cjsandtest/hermodr-live-publication.test.cjs. They are misfiled Hermodr code, not shared Odin modules. If the subscription client fully replaces them, they are deleted rather than moved.src/odin/utils.cjs(68 lines), also Hermodr-only. Copy the handful of helpers actually used; do not extract a utility library.
Moved out of Odin, for its own reasons:
src/odin/idunn-rudp.cjs(324 lines) follows Idunn toGameCult/Idunnas a published health-client contract. Genuinely shared — the coordinator and a health test use it too — so both consume it from its owner rather than each carrying a copy.
Resolved, not copied:
src/odin/documents.cjs(536 lines) is the only true shared dependency, used by the coordinator, Hermodr, and two discovery tests. It survives as a decode contract and must become a published package. Forking it creates two definitions of one wire format, which is the failure this plan exists to remove.
Subtraction budget
Hermodr loses its ingress and its state store. Odin loses the Node daemon, four
lifecycle scripts, three misfiled Hermodr modules, and idunn-rudp.cjs. One
shared document package appears. Net: a duplicate acceptance authority deleted,
src/odin/ reduced from fourteen modules to ten, one package added, one
repository added.
The static lowering library is the only genuinely new surface. It earns its keep by making public storefronts hostable without a mesh, a runtime, or a daemon — the difference between a founding creator being able to self-host and not.
Build budget
Node only; no Rust targets change. Odin's crates are untouched. The new repository is one package with a test target, and static lowering adds a CLI entry to that same package rather than a second package.
Phases
Phase 1 — Idunn RUDP client. Done, as a split. The transport moved to
CultLib as cultnet-ts's signed-daemon-health; Idunn now documents its
connection id and schema names; Odin keeps a 65-line adapter that declares that
contract. The whole-file move originally specified was not carried out, for the
reasons below.
Phase 2 — Delete the ingress, in place. Done. ProviderSubscriptionSource
replaces the private store behind the existing port, the ingress and its options
are gone, and src/odin/ is down to twelve modules. Verified by unit tests;
behaviour against a live provider over RUDP remains an operator step.
Phase 3 — Publish the document contract. Done. The 45 definitions are
cultcache-ts's defineSwarmDocuments, verified byte-identical to what
documents.cjs produced. Odin keeps a 26-line adapter under the name its
consumers already call. utils.cjs left with Hermodr, which used one of its six
functions.
Phase 4 — Extract. Done. GameCult/Hermodr, MIT, sixteen files. The three
dependencies on Odin were resolved rather than carried: the catalog comes from
CultLib, Idunn's health contract is declared locally over the CultLib transport
with sourceRuntimeId hermodr, and parseArgs is seventeen lines of
args.cjs. src/odin/ is down from fourteen modules to eleven, and Odin's
CommonJS surface from sixteen files to twelve.
Phase 5 — Static lowering. Done, in GameCult/Hermodr. A library and CLI
rendering a surface graph to standalone HTML through the same renderEveSurface
the browser uses, under jsdom, with command controls disabled and labelled
because a static page has no back-channel. Runnable without the daemon.
All five phases are complete. This document stays here as the record of what left Odin and why; the work now happens in Hermodr.
Verification
Positive:
- A provider that publishes only to its own advertised address reaches a browser through Hermodr. This fails today and is the point of the work.
- Live state streams keep their sequence numbers, and stale and reconnected transitions still fire across a provider restart.
- A browser command still reaches its provider and returns a correlated receipt.
- Static output of a surface renders equivalently to the live lowering of the same graph.
Negative, and these are the ones that prove the authority moved:
- Hermodr cannot be made to accept a provider publication. The code path is gone, not merely unused.
- Starting Hermodr with the removed ingress options fails loudly rather than silently ignoring them.
- With Odin stopped, Hermodr cannot resolve providers it has not already resolved, but existing provider subscriptions keep delivering. If stopping Odin stops live state, Odin is in the data path and this plan failed.
- Nothing reads state from Odin. A consumer able to do so proves the directory became a relay.
- Two Hermodr instances against the same provider show the same state.
- Static output contains no command controls that appear operable. A dead button in a static page is a lie about a back-channel that does not exist.
Why Phase 1 was split rather than moved
Reading idunn-rudp.cjs before moving it changed the answer. Of its 324 lines,
13 mention Idunn, and most of those are error message strings. The genuinely
Idunn-owned content is two constants: the connection ID 0x1d0d0001 and the
schema name idunn.signed_daemon_health.v1. Everything else — Ed25519 signing,
RUDP session management, packet receipt, endpoint parsing, msgpack framing — is
generic CultNet client plumbing.
Moving the file to Idunn would put 311 lines of transport code into a Rust
repository with no Node packaging, and would give Odin a cross-repo filesystem
dependency on a sibling checkout. The file already resolves CultLib by walking
__dirname/../../../CultLib, which works only while Odin sits beside CultLib;
Erycina had the identical bug and it broke the moment that repository moved.
Repeating it deliberately, in the plan whose purpose is removing misplaced
ownership, would be a poor trade.
It also buys nothing yet. The duplication it prevents does not exist until Hermodr leaves in Phase 4, because both consumers still live in this repository.
Three options, in the order I would rank them:
- Generic to CultLib, constants to Idunn. The transport belongs beside
cultnet-ts, which Odin already resolves into and Hermodr will too. Idunn owns the connection ID and the schema name. This is the real fix, and it removes the sibling-path bug rather than relocating it. - Defer to Phase 4. Correct and free. Move it when the duplication actually threatens, and decide the shape then.
- Move it whole to Idunn. As originally written. Cheapest to type, and it puts transport code where it does not belong while adding a fragile path.
Option 1 was chosen and carried out. idunn-rudp.cjs fell from 324 lines to 65:
Idunn's three constants and the call shapes Odin's consumers already use.
packages/cultnet-ts/src/signed-daemon-health.ts holds the transport, taking
the contract as a parameter. Idunn's
docs/signed-daemon-health-authority.md gained the wire-contract table it was
missing — the connection id had existed only in this consumer's code.
Both the moved module and the adapter dropped the sibling-path assumption on the
way, honouring CULTLIB_ROOT instead of walking up three directories from
wherever the file happens to sit.
Phase 3's remaining decision is unchanged: documents.cjs is now the only
genuinely shared dependency left between Odin and a future Hermodr.
Open questions for the operator
documents.cjsownership. Published from Odin, or promoted into CultLib beside the other typed contracts? It is a wire format several services share, which argues for CultLib, but Odin authors it.- Ephemeral view state. Expanded panels, unsent field contents, optimistic pending states. Client-side today by omission rather than decision.
- Static lowering and identity. A live surface knows who is viewing; a static artifact does not. Either static surfaces are public-only, or the lowering takes an audience parameter and emits per-audience artifacts. Public-only is smaller and covers the storefront.