mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
A settings proposal reached the phone with its values masked, so the operator could not tell what they were approving. An ask now carries Whole, shown in place of Explanation on a kind that proves its sender and carries the ask's answers, and Details, what the Details answer shows. Both enter the digest only when given, so an ask without them digests as before and a router and an asker of different builds still agree on it.
159 lines
9.8 KiB
Markdown
159 lines
9.8 KiB
Markdown
# mesh-sdk
|
|
|
|
The stable spine a Novox Mesh **module's own code builds against** — the tool it uses to plug
|
|
into the mesh, and nothing that belongs to a specific module or to another tier.
|
|
|
|
It earns its place by **rarely changing** (novox/hq [ADR 0039](https://git.novox.be/novox/hq)).
|
|
The test for anything here: *if editing it recompiles unrelated modules and it changes often, it
|
|
does not belong.* Per-module code (a service's API client, its tool implementations, its
|
|
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
|
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
|
|
|
## What is in it
|
|
|
|
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
|
|
|
### `contracts` — the runtime shapes module code touches
|
|
Not the manifest schema (the control plane owns and parses that, in Go). The shapes a module's
|
|
*running code* receives and returns: a provisioning **grant** and its credentials, the
|
|
**mesh interface** definitions (what an `analytics` grant contains, what an `oidc-client` grant
|
|
contains — the contract both a provider and a consumer conform to), and the tool and event types.
|
|
These change rarely and deliberately; when one does, a rebuild of everything is *correct*.
|
|
|
|
### `provisioner` — the reconcile harness every provider shares
|
|
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
|
|
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
|
|
keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create
|
|
a database", "create a umami site" — which is the volatile, per-service half and belongs in the
|
|
module. This is the ADR 0039 line drawn through provisioning: stable loop here, per-service create
|
|
in the module.
|
|
|
|
**A consumer the mesh stops asking for is retired, not withdrawn whole** (novox/hq ADR 0230). ACTIVE
|
|
becomes RETIRED only once the same set of consumers has gone unasked in five consecutive passes that
|
|
read the file AND for ten minutes (`stableForMs`, `STABLE_FOR_MS`; a pass that cannot read the file
|
|
starts both again). An adapter with `retire` disables the consumer's access, reversibly, and marks it;
|
|
`reenable` (optional) undoes that before `create` when it is asked for again. **An adapter without
|
|
`retire` is mark-only**: nothing in the backend changes — the consumer keeps its access — and the
|
|
harness marks it "retired, access kept, needs deletion", says so loudly and announces it. **`remove` is
|
|
never called to retire**; it is the delete of an adapter without `delete`, reached only through a
|
|
person's `provisioner_delete`. A stable set of more than 3, or more than half of those held where more
|
|
than one is held, is not retired without a person: the provider waits for
|
|
`provisioner_retire_approve` or `provisioner_retire_reject`, and says `provisioner.retirement`
|
|
(`waiting`) through the `announce` option. Asked for again, a retired consumer is created — re-enabled
|
|
— at once. Given `module`, the four tools (`provisioner_retirement`, `provisioner_retire_approve`,
|
|
`provisioner_retire_reject`, `provisioner_delete`) are registered for it; `provisionerTools(handle)`
|
|
builds them otherwise. Additions and changes are never delayed.
|
|
|
|
### `tools` — the tool-serving harness
|
|
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
|
|
tools and exposes them through the mesh's command surface. *How* a tool is declared and served is
|
|
settled; it does not change when an individual tool does. The tools themselves, and the API client
|
|
they call, live in the module.
|
|
|
|
### `messaging` — the broker and event framework
|
|
The broker client (connect, request/reply, publish/subscribe), the event-consumer a module uses to
|
|
react to mesh events, and the envelope/routing-key conventions. Transport primitives.
|
|
|
|
#### Taking an event — the one rule
|
|
|
|
A module's handler (`on(pattern, handler)` here, `stdio.Subscribe` in Go, the same in every language)
|
|
**takes an event by returning, and asks for it again by throwing** (Go: returning an error). Nothing
|
|
else counts: what a handler returns is not read.
|
|
|
|
On the wire, the runtime hands the bundle `mesh/event` with the envelope and acknowledges the event on
|
|
the bus when the answer is **anything but a JSON-RPC error** — `{}` (what the SDKs send), `null`, or no
|
|
`result` at all. An `error` answer, no answer within the runtime's event timeout (two minutes), or the
|
|
bundle exiting leaves it unacknowledged: it is offered again after five seconds, up to the module
|
|
consumer's maximum deliveries, after which the bus gives it up and the controller raises a condition
|
|
(`max-deliveries`). The runtime's line for an event not taken quotes a handler's error as the
|
|
handler's own words, so it is never mistaken for the runtime's.
|
|
|
|
So, for a handler:
|
|
|
|
- **Throw only when the work was not done** — the service refused, was unreachable, the store failed.
|
|
Never for something that went right but answered oddly: an API that answers an empty body is a
|
|
success, and reading it as JSON is a throw that has every event offered five times, its work done
|
|
five times, and a false alarm raised (novox/hq issue 276).
|
|
- **Delivery is at-least-once**, so the work must be safe to do twice, or the handler skips an event
|
|
whose `x-event-id` (`event.id`) it already did — remembering it only once the work succeeded, so one
|
|
that failed is done when it is offered again.
|
|
- An event no handler matches is answered at once and taken.
|
|
- A subscribe the runtime refused leaves no handler behind, so asking again until it is taken hands
|
|
each event to the handler once.
|
|
|
|
Checked by the SDKs' stdio tests (TypeScript `test/stdio.test.ts`, Go `go/stdio_test.go`) and the
|
|
runtime's (`node-tools/internal/launch`, mesh-tools).
|
|
|
|
### `state` — a module's current state on the bus
|
|
`state(name)` reaches a state the module declared it keeps (`state` in its manifest) or reads
|
|
(`reads`, as `<module>.<name>`): `get`, `put`, `delete`, `keys`, and `watch`, which hands the current
|
|
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
|
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
|
|
|
### `seats` and `asks` — asking a person, answering on a channel (Go)
|
|
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
|
|
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
|
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
|
|
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
|
|
compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the
|
|
contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves
|
|
who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on
|
|
(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the
|
|
subjects each travels on.
|
|
`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then
|
|
publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and
|
|
`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
|
|
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
|
|
`stdio.ErrChanged`, told by the runtime's error code.
|
|
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
|
|
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
|
|
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
|
|
|
|
### `primitives` — sealing, semver, resolved-env
|
|
Seal/unseal for secrets, semantic version comparison, and the accessor a module uses to read its
|
|
own resolved environment.
|
|
|
|
## What is NOT in it, and where it goes
|
|
|
|
- **A module's API client and its tool implementations** → in the module. (The Plex client, the
|
|
Umami client, `tools/plex.ts` — all module-local. ADR 0039.)
|
|
- **Delivery machinery** — build executor, bundler, dependency resolver, artifact manager, feature
|
|
handlers → **mesh-controller**. It co-evolves with the pipeline.
|
|
- **Host synchronisers** — config-sync, ufw config, vhost generation, systemd, health checks →
|
|
**mesh-host**'s apply engine. The host applies these; they are not module code.
|
|
- **Domain logic** — tasks, workflows, agents, provider integrations → **tier-2 contexts**.
|
|
|
|
That is why this stays small: everything volatile has somewhere else to be.
|
|
|
|
## Using it
|
|
|
|
A module's tools:
|
|
|
|
```ts
|
|
import { registerModuleTools, ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
import { UmamiClient } from "../client.js"; // the client lives in the module, not here
|
|
|
|
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
|
|
```
|
|
|
|
A provider's provisioner:
|
|
|
|
```ts
|
|
import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner";
|
|
|
|
runProvisioner("analytics", {
|
|
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
|
|
async remove(grant: Grant): Promise<void> { /* delete the site */ },
|
|
});
|
|
```
|
|
|
|
The adapter is the only thing the module writes; the watching, sealing and grant-file handling are
|
|
the harness's.
|
|
|
|
## Where the reasoning lives
|
|
|
|
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq):
|
|
|
|
- `02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md` — the stability rule this repository is
|
|
- `02-DECISIONS/0040-what-a-module-is.md` — a module, and why its per-module code lives in the module
|