MeridiansMeridians

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/ and src/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)

TermMeaningIn code
DocumentA 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)
ReducerA pure (document, action) → document function. Deterministic under a pinned clock. The single writer.applyDomainAction, applyConstellationAction
SliceA feature-scoped fragment of a document reducer (scenes, branches, streams, …). Composition, not inheritance.domain-reducer/slices/* (18), app-reducer/slices/*
Action = Tier 1A 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 2A 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-terminusThe single helper every operation commits through — read doc → applyLocalMutation → echo. Guarantees the invariant as Tier 2 scales.ops/commit.ts
Command busThe uniform façade that dispatches both tiers headlessly (dispatchAction, runOperation).ops/client.ts
GateThe 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
ProjectionThe 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_META covers the entire Action union; 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 a domains document. 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:

KindPersisted?Example slicesTier-1 category
Documentyes (versioned record)domain slices, slices/knowledge-base/constellationsedit / contribute / constellation / …
Collectionyes (which docs exist)slices/knowledge-base/domains add/deleteextraction / delete
App / viewno (ephemeral, per-session)view, sync, gameview / 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 registryDOCUMENT_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 + same now → byte-identical result. This is what lets the renderer apply optimistically and the daemon confirm without a merge. No Date.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:false is not proof.
  • Gate up-front, enforce at the terminus. An operation checks canDispatchCategory before running machinery (don't burn tokens for a forbidden call); every commit re-checks via gateAction. The terminus is the real boundary.
  • Attribution is automatic. ACTION_META decides 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/generate and /api/local/stream. A handler streams via ctx.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 typed compute-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 error events (with fatal), not exceptions; generation failures carry a diagnosis + optional repair (ai/diagnose.ts, ai/repair.ts). A planned op returns a clean "not yet implemented".
  • Cold-start is the one non-reducer write. Creating a new document (e.g. create-domain) is ctx.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):

ArtifactSource of truthFreshness
GET /api/local/manifestops registry + ACTION_META, at runtimealways current
GET /api/local/openapi.jsonops registry + ACTION_META, at runtimealways current (Swagger-loadable)
MCP_MANUAL.md — reads/ops/controlgenerated from QUERY_SPECS/OPERATION_SPECS/CONTROL_QUERY_SPECS/CONTROL_ACTION_SPECScurrent on npm run gen:mcp-manual
MCP_MANUAL.md — Tier-1 actionsgenerated from ACTION_METAcurrent on npm run gen:mcp-manual
MCP_MANUAL.md — HTTP routes / principles prosehand-maintained in the generatormay lag — low churn
This dochand-maintainedmay 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.