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>
306 lines
15 KiB
Markdown
306 lines
15 KiB
Markdown
# 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
|
|
|
|
1. 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.
|
|
2. `HermodrStateStreamRegistry` survives untouched. It consumes only
|
|
`source.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.
|
|
3. Extract the daemon to `GameCult/Hermodr`.
|
|
4. Add static lowering as a library with a CLI entry point.
|
|
|
|
## Cut line
|
|
|
|
Deleted outright:
|
|
|
|
- the `OdinLivePublicationSource` construction and every write into it
|
|
- `createProviderSessionIngress`, and the `--provider-session-bind` and
|
|
`--provider-session-token` options
|
|
|
|
Leaves Odin with Hermodr, because Odin never used it:
|
|
|
|
- `src/odin/live-publication-source.cjs` and
|
|
`src/odin/provider-session-ingress.cjs`, both referenced only by
|
|
`hermodr-daemon.cjs` and `test/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 to `GameCult/Idunn` as 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:
|
|
|
|
1. **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.
|
|
2. **Defer to Phase 4.** Correct and free. Move it when the duplication actually
|
|
threatens, and decide the shape then.
|
|
3. **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
|
|
|
|
1. **`documents.cjs` ownership.** 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.
|
|
2. **Ephemeral view state.** Expanded panels, unsent field contents, optimistic
|
|
pending states. Client-side today by omission rather than decision.
|
|
3. **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.
|