MeridiansMeridians

API & State Principles — the grounded vocabulary and handling rules

Source path: knowledge-base/knowledge/architecture/API_PRINCIPLES.md

# API & State Principles — the grounded vocabulary and handling rules

> **Why this doc.** The generated [MCP manual](../../generated/MCP_MANUAL.md) 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)

| 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](../governance/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_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:

| 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 + 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 `OpEvent`s 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):

| 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.
Open on GitHub

Raw Markdown source