API & State Principles — the grounded vocabulary and handling rules
From the Meridians Wiki · Public · Maintained · joint
Why this doc. The generated MCP manual is the catalog (which calls exist — every read, op, action, route + the affordance graph, generated). This is the constitution (why they're shaped this way, what the words mean, how to handle them). It changes rarely, so it's hand-maintained — the catalog is what stays current via its generator. Read this once; it grounds everything in
src/lib/core/reducer/andsrc/lib/server/ops/.
1. The one invariant
The reducer is the single writer of state. State changes only by dispatching a reducer
Action through one deterministic write path (applyLocalMutation → the document reducer). There
is no other door — not a component, not an operation, not a loop. Everything else is a caller of
that door.
This is what makes the system trustworthy: one place to reason about "how did state get here", one place to gate authority, one place to attribute, one deterministic replay. Keep it inviolable.
2. Vocabulary (use these words, precisely)
| Term | Meaning | In code |
|---|---|---|
| Document | A persisted, versioned unit of record — the thing the daemon owns on disk. Three classes: domains, constellations, extractions. | DocClass (src/lib/core/io/record-classes.ts) |
| Reducer | A pure (document, action) → document function. Deterministic under a pinned clock. The single writer. | applyDomainAction, applyConstellationAction |
| Slice | A feature-scoped fragment of a document reducer (scenes, branches, streams, …). Composition, not inheritance. | domain-reducer/slices/* (18), app-reducer/slices/* |
| Action = Tier 1 | A pure, synchronous state transition. Zero machinery. The write API. Dispatched via POST /api/local/mutate. | Action union (src/lib/core/reducer/actions.ts), ACTION_META |
| Operation = Tier 2 | A named unit of async machinery (LLM/embeddings/firecrawl/multi-step) that terminates in Tier-1 actions. Its output is actions. Run via POST /api/local/op. | OperationSpec (src/lib/server/ops/catalog.ts), handlers |
| Reducer-terminus | The single helper every operation commits through — read doc → applyLocalMutation → echo. Guarantees the invariant as Tier 2 scales. | ops/commit.ts |
| Command bus | The uniform façade that dispatches both tiers headlessly (dispatchAction, runOperation). | ops/client.ts |
| Gate | The authority check by action category. Advisory in the UI; the real boundary is re-run on the daemon against a verified role. Headless callers (MCP) are gated the same way, against a pinned role (MERIDIANS_ACTOR_ROLE, default director, down-only) — see SECURITY.md 🔒 §4.6. | canDispatchAction / canDispatchCategory |
| Projection | The rule that the fs record is canon and IndexedDB is a rebuildable cache. The reducer writes the record; the cache follows. | E1 / src/lib/server/record/ |
Two words we avoid conflating: an Action is a fact ("this happened → new state"); an
Operation is a process ("do the work that decides which facts to record"). A UI view
action (cursor moves) is neither persisted nor an API call — it's local ephemeral state.
3. The two tiers, precisely
POST /api/local/mutate POST /api/local/op
│ │
Tier 1 (Action) ─────────────┤ │
pure state transition ▼ ▼
applyLocalMutation ◀───── ops/commit.ts ◀── Operation.run() Tier 2
(gate → reducer → (reducer-terminus) runs AI machinery,
persist → echo) emits OpEvents,
│ terminates in Tier-1
▼ actions
the document (canon)
- Tier 1 has an exhaustive policy catalog, but persisted ownership is explicit.
ACTION_METAcovers the entireActionunion; view/sync/collection actions are not Domain-document mutations. Adding a persisted action requires placing it in the owning Node-safe document slice and proving the mutate route changes that document. A policy entry alone makes an action known—it does not make a no-op a write. - Tier 2 is a composition over Tier 1. An operation never invents a new way to write state; it runs machinery and then commits Tier-1 actions. If you find an operation mutating a document any other way, that's a bug against §1.
- Every Tier-2 op declares its
commits(the Tier-1 actions it may fire) in its spec — so the catalog literally shows how Tier 2 folds back into Tier 1.
4. Reducer organisation — the pattern, and the answer to "is there a cleaner one?"
The current shape is already principled: document-scoped reducers composed of feature slices.
domain-reducer/owns adomainsdocument. 18 slices (scenes,branches,entities,streams,forecasts,readings, …) — one file per feature area. Entry:applyDomainAction.app-reducer/owns the app shell + the other documents:slices/knowledge-base/{constellations,domains,extractions}— the document reducers/collections.slices/{view,sync,catalog,game}— not documents:view= local UI cursor (ephemeral),sync= replication-internal,catalog= the research-catalog aux store,game= the browser-master Scenario state.
So the honest taxonomy — and the language to use — is three kinds of reducer target:
| Kind | Persisted? | Example slices | Tier-1 category |
|---|---|---|---|
| Document | yes (versioned record) | domain slices, slices/knowledge-base/constellations | edit / contribute / constellation / … |
| Collection | yes (which docs exist) | slices/knowledge-base/domains add/delete | extraction / delete |
| App / view | no (ephemeral, per-session) | view, sync, game | view / sync / game |
The app-reducer/domain-reducer split is not the problem — it's correct (each reducer owns a
bounded document). The one genuine cleanliness win is to remove a small duplication: today the
daemon dispatches by class with a hardcoded branch (mutation.ts:
cls === "constellations" ? applyConstellationAction : applyDomainAction), and the renderer keeps
its own full applyActionToDomain in store.tsx.
Recommended refinement (not yet done): a document-reducer registry —
DOCUMENT_REDUCERS: Record<DocClass, (doc, action, ctx) => doc> — so applyLocalMutation, the
renderer, and any future document class dispatch through one table instead of an if/else. It makes
"one reducer per document class" a data structure, not a convention, and adding a document class
becomes a single registry entry. Low-risk, high-clarity; do it when a third mutable document class
lands (or sooner). Until then, the convention holds and is documented here.
Rule of thumb when adding state: decide which document owns it first. If nothing does, it's either a new document (rare) or app/view state (not an API call). Never widen a slice to reach across documents — compose at the operation layer instead.
5. Handling rules (the contract every caller relies on)
- Determinism. A reducer must be pure and clock-injected (
withPinnedClock). Same document + same action + samenow→ byte-identical result. This is what lets the renderer apply optimistically and the daemon confirm without a merge. NoDate.now()/Math.random()inside a reducer. Reducer dependencies may notify or patch through an explicitly owned compatibility seam; they never delete documents or assets. Irreversible cleanup belongs after canonical acknowledgement in the caller's transaction. - A dispatch is a state transition, not a durability receipt. At an explicit Save, generated-asset, upload, clear, or destructive boundary, the renderer calls the store's awaited document commit adapter with the existing Action. That adapter sends the compact Tier-1 request, orders same-document commits, and rolls back/reconciles an optimistic projection on refusal. Local view actions and intentionally coalesced background edits may use plain dispatch; a surface that closes, navigates, or deletes old bytes after saving must wait for canonical acknowledgement.
- No-op is first-class. An action no slice owns returns the same reference → no write, no
version bump, no attribution. Callers treat "unchanged" as "not a document mutation", never an
error. For every persisted action introduced or moved, include one test that demonstrates an actual
document change through its canonical apply path; a 200 response with
changed:falseis not proof. - Gate up-front, enforce at the terminus. An operation checks
canDispatchCategorybefore running machinery (don't burn tokens for a forbidden call); every commit re-checks viagateAction. The terminus is the real boundary. - Attribution is automatic.
ACTION_METAdecides log/toast; the terminus builds the audit entry. Don't hand-roll logging in a handler. - Streaming is uniform. Token/reasoning/progress/commit/done/error
OpEvents over SSE, framed identically to/api/ai/generateand/api/local/stream. A handler streams viactx.emit, never by returning partials. - Provider transport is capability-injected. Browser calls use thin provider HTTP adapters;
headless operations receive direct typed generation/embedding/research services at the command
bus. Engine features call the shared AI clients, never a private relative
/api/*fetch. - An outer harness may fulfil generation, but never become a writer. An MCP operation started with
computeMode: "harness"suspends each shared text-generation capability call as a typedcompute-request; the authenticated caller submits the complete response, and execution resumes at the original parser/validator. The operation gate and reducer terminus remain unchanged. This is a transport choice for text generation, not an alternate operation, parser, or persistence path; embeddings, retrieval and image generation retain their own declared capability boundaries. - Operation ordering follows the semantic target. Effectful operations serialize on one Domain branch (an omitted branch resolves to canon), while explicitly addressed sister branch tips may wait on models and generate concurrently. Other document classes remain document-scoped. Every individual commit is still a synchronous read → reduce → atomic write through the one reducer terminus.
- Errors are structured, not thrown across the boundary. Operations surface
errorevents (withfatal), not exceptions; generation failures carry a diagnosis + optional repair (ai/diagnose.ts,ai/repair.ts). Aplannedop returns a clean "not yet implemented". - Cold-start is the one non-reducer write. Creating a new document (e.g.
create-domain) isctx.createDoc, not a reducer action — there's no prior state to reduce. Everything after creation is Tier-1.
6. Maintainability model — what stays current, what may lag
The deliberate contract (so the experience stays good without pretending everything is always perfect):
| Artifact | Source of truth | Freshness |
|---|---|---|
GET /api/local/manifest | ops registry + ACTION_META, at runtime | always current |
GET /api/local/openapi.json | ops registry + ACTION_META, at runtime | always current (Swagger-loadable) |
| MCP_MANUAL.md — reads/ops/control | generated from QUERY_SPECS/OPERATION_SPECS/CONTROL_QUERY_SPECS/CONTROL_ACTION_SPECS | current on npm run gen:mcp-manual |
| MCP_MANUAL.md — Tier-1 actions | generated from ACTION_META | current on npm run gen:mcp-manual |
| MCP_MANUAL.md — HTTP routes / principles prose | hand-maintained in the generator | may lag — low churn |
| This doc | hand-maintained | may lag — principles change rarely |
The discipline: declarative sources (the Action union + ACTION_META, and the query/op specs)
are the truth; docs are rendered from them. So adding an action or an operation updates the live
surface automatically, and a stale prose sentence never lies about what calls exist — the catalog is
generated. Run npm run gen:mcp-manual in the same change that adds an action/op (or let CI do it);
the two runtime endpoints need nothing.