T-1, T-2: W1.11: the TypeScript side holds a seat as the Go side does

A TypeScript holder, the forge module first, must judge a verb's caller by the runtime's proof, say its seat's events
and answer its first views' read and ask-again tools (design 47, The forge module's tools come from mesh-sdk's
TypeScript side; G-F1 option A). The stdio session now hands each tool the caller and call id the runtime wrote in
_meta (callers.callerOf, the Go rules for Callers and VerbCallers), binds the runtime for seatPublish over
mesh/seat.publish and config, and views gains the Follower and its two status tools over the state first-views.
@novox/mesh-sdk 0.1.19.

Builds: design 47 G §196, §200, §336

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 10:51:07 +01:00
parent 86ccaae967
commit 0d546d37f3
12 changed files with 864 additions and 9 deletions
+10
View File
@@ -115,6 +115,16 @@ runtime's (`node-tools/internal/launch`, mesh-tools).
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).
### `runtime`, `callers` and `views` — holding a seat from TypeScript
The TypeScript side of what the Go side's `stdio` and `views` give a seat's holder, with the same rules (design 47
*The forge module's tools come from mesh-sdk's TypeScript side*): `runtime.seatPublish` says an event or submits an
accept on a seat (`mesh/seat.publish`) and `runtime.config` reads a config value; `callers.callerOf(args)` is the
caller the runtime proved in the call's `_meta`, never an argument, and `callers.refusal` judges it against a
holder's list per verb (`parseVerbCallers`, `configuredVerbCallers`: `*` any proved caller, `operator` any person);
`views.Follower` keeps a first view of an owner's state from its pages and its events, asking again only on a gap,
and `views.statusTools` serves its read and ask-again tools (`<module>_first_views`, `<module>_first_view_again`)
over the reader's state `first-views`. Checked by `test/stdio.test.ts` and `test/sdk.test.ts`.
### `seats` and `channel` — publishing on a seat, the channel's benches (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`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.18",
"version": "0.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@novox/mesh-sdk",
"version": "0.1.18",
"version": "0.1.19",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.18",
"version": "0.1.19",
"description": "The stable spine a Novox Mesh module's own code builds against.",
"type": "module",
"exports": {
@@ -12,7 +12,10 @@
"./events": "./dist/events/index.js",
"./primitives": "./dist/primitives/index.js",
"./stdio": "./dist/stdio/index.js",
"./state": "./dist/state/index.js"
"./state": "./dist/state/index.js",
"./callers": "./dist/callers/index.js",
"./runtime": "./dist/runtime/index.js",
"./views": "./dist/views/index.js"
},
"files": [
"dist"
+176
View File
@@ -0,0 +1,176 @@
// Who called a tool, as the runtime proved it, and who may call a verb (novox/hq issue 365, ADR 0459 §2, ADR 0627 §2;
// design 51 *Who may call which verb*). The TypeScript side of the Go `stdio` package's caller.go and callers.go, with
// the same rules, so a holder in either language judges a caller alike.
//
// The runtime names the caller in a `tools/call`'s `_meta`, which a caller cannot write: the bus user that published
// the call, taken from the subject it arrived on. It is handed to the tool here, never read from the arguments: a
// `caller` argument names nobody.
import { config } from "../runtime/index.js";
/** The caller of a call the runtime named no caller for, said as a word so a record shows it was not proven. */
export const UNPROVEN = "unproven";
/** Where in a `tools/call`'s `_meta` the runtime names the caller (the Go side's `stdio.MetaCaller`). */
export const META_CALLER = "mesh/caller";
/** Where in a `tools/call`'s `_meta` the runtime names the id of the call in flight (`buscalls.MetaCall`). */
export const META_CALL = "mesh/call";
// A bus user's name: `<node>.<module>`, `person.<name>`, `controller`; anything else is no caller.
const busUser = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
// A call's id: one subject-safe token.
const callID = /^[A-Za-z0-9_-]{1,128}$/;
interface InFlight {
readonly caller: string;
readonly call: string;
}
// The caller and the call's id of every call while its tool runs, by the arguments object that call was handed.
const calls = new WeakMap<object, InFlight>();
/** The caller a `tools/call`'s `_meta` names, or UNPROVEN. */
export function callerIn(meta: unknown): string {
const s = meta && typeof meta === "object" ? (meta as Record<string, unknown>)[META_CALLER] : undefined;
return typeof s === "string" && busUser.test(s) ? s : UNPROVEN;
}
/** The call's id a `tools/call`'s `_meta` names, or empty. */
export function callIn(meta: unknown): string {
const s = meta && typeof meta === "object" ? (meta as Record<string, unknown>)[META_CALL] : undefined;
return typeof s === "string" && callID.test(s) ? s : "";
}
/**
* Run `run` with the caller and the call's id `meta` names held for `args` while it runs: the stdio session does this
* for every `tools/call`.
*/
export async function runAs<T>(meta: unknown, args: object, run: () => Promise<T>): Promise<T> {
calls.set(args, { caller: callerIn(meta), call: callIn(meta) });
try {
return await run();
} finally {
calls.delete(args);
}
}
/** Who called the tool that was handed `args`, as the runtime proved it, or UNPROVEN. Valid while the tool runs. */
export function callerOf(args: object | undefined | null): string {
return (args && calls.get(args)?.caller) || UNPROVEN;
}
/** The id of the call that was handed `args`, as the runtime named it, or empty. Valid while the tool runs. */
export function callIdOf(args: object | undefined | null): string {
return (args && calls.get(args)?.call) || "";
}
/**
* A module's trusted list of who may call it (ADR 0459 §2): an entry is a module (on any machine), exactly
* `<machine>.<module>`, or exactly `person.<name>`. A wildcard is no entry and an unproven caller never matches.
*/
export type Callers = readonly string[];
/** A callers file, `{"callers": [...]}`, and nothing else. */
export function parseCallers(raw: string): Callers {
let f: unknown;
try {
f = JSON.parse(raw);
} catch (err) {
throw new Error(`the callers setting is not a list of callers: ${err instanceof Error ? err.message : String(err)}`);
}
if (!f || typeof f !== "object" || Array.isArray(f)) throw new Error("the callers setting is not a list of callers: not an object");
const extra = Object.keys(f).filter((k) => k !== "callers");
if (extra.length) throw new Error(`the callers setting is not a list of callers: unknown field "${extra[0]}"`);
const list = (f as { callers?: unknown }).callers ?? [];
if (!Array.isArray(list) || list.some((c) => typeof c !== "string")) {
throw new Error("the callers setting is not a list of callers: callers is not a list of words");
}
return list as string[];
}
/** Whether `caller`, as the runtime proved it, is one the list names. */
export function allows(callers: Callers, caller: string): boolean {
if (!caller || caller === UNPROVEN) return false;
for (const entry of callers) {
if (!entry || /[*> ]/.test(entry)) continue;
if (entry === caller) return true;
if (!entry.includes(".") && !caller.startsWith("person.")) {
const at = caller.indexOf(".");
if (at >= 0 && caller.slice(at + 1) === entry) return true;
}
}
return false;
}
/** A holder's trusted list per verb (ADR 0627 §2): for each verb it serves, the callers that may call it. */
export type VerbCallers = Readonly<Record<string, readonly string[]>>;
/** The entry of a VerbCallers list that lets every proved caller call the verb. */
export const ANY_CALLER = "*";
/** The entry of a VerbCallers list that lets every operator's session (`person.<name>`) call the verb. */
export const ANY_PERSON = "operator";
/** A holder's config value of verb to callers; empty is `defaults`, and a verb the holder does not serve is refused. */
export function parseVerbCallers(raw: string, verbs: readonly string[], defaults: VerbCallers): VerbCallers {
if (raw.trim() === "") return defaults;
let c: unknown;
try {
c = JSON.parse(raw);
} catch (err) {
throw new Error(`callers is not a JSON object of verb to callers: ${err instanceof Error ? err.message : String(err)}`);
}
if (!c || typeof c !== "object" || Array.isArray(c)) throw new Error("callers is not a JSON object of verb to callers");
for (const [verb, list] of Object.entries(c)) {
if (!verbs.includes(verb)) throw new Error(`callers names the verb "${verb}", which the holder does not serve`);
if (!Array.isArray(list) || list.some((e) => typeof e !== "string")) {
throw new Error(`callers is not a JSON object of verb to callers: ${verb} is not a list of words`);
}
}
return c as VerbCallers;
}
/** Undefined when `caller`, as the runtime proved it, may call `verb`; else the refusal naming both. */
export function refusal(c: VerbCallers, verb: string, caller: string): Error | undefined {
if (!caller || caller === UNPROVEN) {
return new Error(`${verb}: refused: the request carries no caller the tool runner proved`);
}
const person = caller.startsWith("person.");
for (const entry of c[verb] ?? []) {
if (entry === ANY_CALLER) return undefined;
if (entry === ANY_PERSON) {
if (person) return undefined;
continue;
}
if (allows([entry], caller)) return undefined;
}
return new Error(`${verb}: refused for ${caller}: not on the holder's caller list for ${verb}`);
}
/** The module of a proved module caller, which the runtime names `<machine>.<module>`. */
export function moduleOf(proved: string): string {
const at = proved.indexOf(".");
return at >= 0 ? proved.slice(at + 1) : proved;
}
/**
* The holder's list as mesh-config resolved its config value `key` for this machine: an object of verb to callers, or
* that object written as a string; unset or empty is `defaults`. A value that cannot be read is an error, never the
* defaults, so a broken list is said, not widened.
*/
export async function configuredVerbCallers(key: string, verbs: readonly string[], defaults: VerbCallers): Promise<VerbCallers> {
let raw: unknown;
try {
raw = await config(key);
} catch (err) {
throw new Error(`the config value ${key} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
}
return parseConfiguredVerbCallers(raw, verbs, defaults);
}
/** configuredVerbCallers' reading of a value as the runtime answered it. */
export function parseConfiguredVerbCallers(raw: unknown, verbs: readonly string[], defaults: VerbCallers): VerbCallers {
if (raw === undefined || raw === null) return defaults;
if (typeof raw === "string") return parseVerbCallers(raw, verbs, defaults);
return parseVerbCallers(JSON.stringify(raw), verbs, defaults);
}
+3
View File
@@ -13,3 +13,6 @@ export * as messaging from "./messaging/index.js";
export * as events from "./events/index.js";
export * as primitives from "./primitives/index.js";
export * as state from "./state/index.js";
export * as runtime from "./runtime/index.js";
export * as callers from "./callers/index.js";
export * as views from "./views/index.js";
+37
View File
@@ -0,0 +1,37 @@
// The runtime that launched this bundle, as one channel of requests (novox/hq ADR 0193, ADR 0198): what a module's code
// asks of its node's tool runner beyond emitting, subscribing and its state, each named by the runtime's method
// (`mesh/seat.publish`, `mesh/provision.get`). The stdio session binds it when the runtime launched the process; a
// bundle never names a transport.
/** What the runtime is asked through: the stdio session provides it. */
export type RuntimeAsk = (method: string, params: unknown) => Promise<unknown>;
let bound: RuntimeAsk | undefined;
/** Bind the channel. The stdio session calls this when the runtime launched the process; a test binds its own. */
export function useRuntime(ask: RuntimeAsk | undefined): void {
bound = ask;
}
/** Ask the runtime one of its methods; refused with the reason when no runtime launched this bundle. */
export async function askRuntime(method: string, params: unknown): Promise<unknown> {
if (!bound) {
throw new Error(`no runtime to ask ${method} of: it is reached through the runtime that launched this bundle (novox/hq ADR 0198)`);
}
return bound(method, params);
}
/**
* Submit an accept, or say an event, on a seat (novox/hq ADR 0259 §3): `subject` whole, as the seat's contract names
* it (`mesh.seat.<seat>.event.<event>`), de-duplicated by `id` (the same id twice is one message; empty is the
* runtime's to make). The runtime does it as this module, only as far as its membership lists, and refuses the rest
* with the reason: the Go side's `stdio.SeatPublish`.
*/
export async function seatPublish(subject: string, body: unknown, id = ""): Promise<void> {
await askRuntime("mesh/seat.publish", { subject, body, id });
}
/** This module's config value `key`, as mesh-config resolved it for this machine (the Go side's `stdio.Config`). */
export async function config(key: string): Promise<unknown> {
return askRuntime("mesh/provision.get", { kind: "config", key });
}
+9 -1
View File
@@ -16,6 +16,8 @@ import type { ToolDefinition } from "../contracts/index.js";
import { collectTools } from "../tools/index.js";
import { useBroker, type Broker, type Envelope } from "../messaging/index.js";
import { useStateChannel, type StateChange } from "../state/index.js";
import { useRuntime } from "../runtime/index.js";
import { runAs } from "../callers/index.js";
/** The protocol version this speaks; the runtime says the same. */
export const PROTOCOL = "2025-03-26";
@@ -123,6 +125,8 @@ function startSession(): Session {
close: async () => {},
};
useBroker(() => viaRuntime);
// Every other method of the runtime a module's code asks (a seat's publish, its config): the same channel.
useRuntime(ask);
let name = "bundle";
let byName = new Map<string, ToolDefinition>();
@@ -158,7 +162,11 @@ function startSession(): Session {
return;
}
try {
const result = await tool.run((request.params?.arguments as Record<string, unknown> | undefined) ?? {});
// The caller the runtime proved, in `_meta`, is held for these arguments while the tool runs (callerOf), never
// read from the arguments (novox/hq issue 365).
const given = request.params?.arguments;
const args = (given && typeof given === "object" && !Array.isArray(given) ? given : {}) as Record<string, unknown>;
const result = await runAs(request.params?._meta, args, () => tool.run(args));
say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(result ?? null) }] } });
} catch (err) {
// The tool failed; said as a tool error, not a protocol one, because the call was well-formed.
+460
View File
@@ -0,0 +1,460 @@
// A reader's first view of an owner's state, and its read and ask-again tools (novox/hq ADR 0615, ADR 0621; design 25
// § 12.2; design 47 *The forge module's tools come from mesh-sdk's TypeScript side*): the TypeScript side of the Go
// `views` package's Follower and StatusTools, with the same rules, so a TypeScript part answers `<module>_first_views`
// and `<module>_first_view_again` as the Go parts do and keeps no follower of its own.
//
// A follower asks the owner's first view at its start (every page), then keeps its picture from the owner's events
// alone, asking once more only on a gap in revisions (said in its log), never on a timer. Started while the owner is
// down it keeps asking, waiting between asks, and past its bound raises its condition; the answer clears it. It names
// no seat and no owner: the part hands it how to ask (a seat's verb through the seat's TypeScript contract, or an
// owner's tool) and the events it hears, each in the form below.
import type { ToolDefinition } from "../contracts/index.js";
import type { State } from "../state/index.js";
import { callerOf } from "../callers/index.js";
/** The version of every value this writes and the only one it reads. */
export const VERSION = 1;
/** The state in which a reader keeps each first view's status, as the Go side's `views.StatusState`. */
export const STATUS_STATE = "first-views";
/** How long a reader asks before it raises its condition naming the owner and the tool (ADR 0615 §6), in ms. */
export const BOUND_MS = 2 * 60 * 1000;
/** The key of a reader's status of its first view of `owner`'s `state`. */
export function statusKey(owner: string, state: string): string {
return `${owner}.${state}`;
}
/**
* One page of a first view: the state's values by key, from `after` on, at the revision of the state the owner said up
* to before it read them; `next` is the key to ask after for the rest, empty when the answer is whole. A reader keeps
* the first page's revision.
*/
export interface Answer {
readonly v?: number;
readonly owner?: string;
readonly state?: string;
readonly revision: number;
/** When the owner's state was made on the bus (RFC 3339): another one is a state made again. */
readonly created: string;
readonly values: Readonly<Record<string, unknown>>;
readonly next?: string;
readonly at?: string;
/** When the owner's bus wrote each key (RFC 3339); a key whose time the owner does not say is absent. */
readonly written?: Readonly<Record<string, string>>;
}
/** One change of one key of the owner's state, at the revision it was written at. */
export interface ViewEvent {
readonly v?: number;
readonly owner?: string;
readonly state?: string;
readonly key: string;
readonly revision: number;
readonly created: string;
readonly deleted?: boolean;
readonly value?: unknown;
readonly at?: string;
/** The value was too large to carry: the reader asks its first view once. */
readonly gap?: boolean;
}
/** What a reader's read tool says of one first view: the Go side's `views.Status`, field for field. */
export interface Status {
owner: string;
tool: string;
state: string;
revision: number;
created?: string;
keys: number;
asked?: string;
answered?: string;
asks: number;
"by-hand": number;
raised: boolean;
heard?: number;
"heard-created"?: string;
behind?: string;
why?: string;
error?: string;
}
/** How a part makes a follower: the owner and the tool it asks (for the status and the log), and the hooks. */
export interface FollowerOptions {
readonly owner: string;
readonly tool: string;
readonly state: string;
/** Ask the owner for the page after `after` (empty: the first). */
readonly ask: (after: string) => Promise<Answer>;
/** The reader's condition of this first view: raised past the bound, cleared by the answer. */
readonly raise?: (s: Status) => void | Promise<void>;
readonly clear?: (s: Status) => void | Promise<void>;
/** Told of each key the picture changes, after the change is held. */
readonly changed?: (key: string, value: unknown, deleted: boolean, at: string) => void | Promise<void>;
/** Told of the status each time an ask starts, is answered or fails, and of each event taken: where a reader keeps it. */
readonly said?: (s: Status) => void | Promise<void>;
readonly log?: (line: string) => void;
readonly now?: () => Date;
/** How long it asks before it raises, in ms; BOUND_MS when absent. */
readonly bound?: number;
/** The first wait between two asks that failed, in ms, doubling to a minute; five seconds when absent. */
readonly wait?: number;
/** Waits ms between two asks; a test hands one that does not sleep. */
readonly pause?: (ms: number) => Promise<void>;
}
const ms = (t: string | undefined): number => (t ? Date.parse(t) || 0 : 0);
/** A reader's picture of one state of an owner, kept from its first view and its events. */
export class Follower {
private readonly o: FollowerOptions;
private values = new Map<string, unknown>();
private st: Status;
private asking = false;
private catching = false;
private during: ViewEvent[] = [];
private heardRev = 0;
private heardCreated = "";
private answered = false;
private closed = false;
private markReady!: () => void;
/** Resolves once a first view was answered. */
readonly ready: Promise<void>;
constructor(o: FollowerOptions) {
this.o = o;
this.st = { owner: o.owner, tool: o.tool, state: o.state, revision: 0, keys: 0, asks: 0, "by-hand": 0, raised: false };
this.ready = new Promise((resolve) => (this.markReady = resolve));
}
get owner(): string {
return this.o.owner;
}
get state(): string {
return this.o.state;
}
get tool(): string {
return this.o.tool;
}
private now(): string {
return (this.o.now ? this.o.now() : new Date()).toISOString();
}
private log(line: string): void {
this.o.log?.(line);
}
private pause(wait: number): Promise<void> {
return this.o.pause ? this.o.pause(wait) : new Promise((resolve) => setTimeout(resolve, wait));
}
/** The status now, with the keys held, the highest revision heard and why the picture is behind it. */
status(): Status {
const s: Status = { ...this.st, keys: this.values.size };
if (this.heardRev) s.heard = this.heardRev;
if (this.heardCreated) s["heard-created"] = this.heardCreated;
const behind = this.behind();
if (behind) s.behind = behind;
else delete s.behind;
return s;
}
private async say(): Promise<void> {
if (this.o.said) {
try {
await this.o.said(this.status());
} catch (err) {
this.log(`views: the status of the first view ${this.o.tool} is not kept: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
/** A copy of the picture. */
picture(): Map<string, unknown> {
return new Map(this.values);
}
/** One key of the picture. */
get(key: string): unknown {
return this.values.get(key);
}
/** Why the picture is behind an event heard, or "" when it holds every revision heard. */
behind(): string {
if (!this.heardCreated) return "";
if (ms(this.heardCreated) > ms(this.st.created)) {
return `the state was made again at ${this.heardCreated}, the picture holds the one made at ${this.st.created ?? "none"}`;
}
if (ms(this.heardCreated) === ms(this.st.created) && this.heardRev > this.st.revision) {
return `revision ${this.heardRev} heard, the picture holds ${this.st.revision}`;
}
return "";
}
/** Ask the first view until it is answered (ADR 0615 §6); resolves once answered, or once the follower is closed. */
start(): Promise<void> {
return this.askUntil("start", false);
}
/** Ask the first view once more by hand (the re-read tool, recorded by its caller as a hand act). */
again(why: string): Promise<void> {
return this.askUntil(`by hand: ${why}`, true);
}
/** Ask the first view once more for a gap the reader saw: counted among its asks, never among those by hand. */
gap(why: string): Promise<void> {
return this.askUntil(`a gap: ${why}`, false);
}
/** Stop asking: a loop waiting between two asks ends at its next turn. */
close(): void {
this.closed = true;
}
private async askUntil(why: string, byHand: boolean): Promise<void> {
if (this.asking) return; // one ask at a time; the one running brings the picture to its newest
this.asking = true;
this.during = [];
this.st.why = why;
if (byHand) this.st["by-hand"]++;
const bound = this.o.bound ?? BOUND_MS;
let wait = this.o.wait ?? 5000;
const first = ms(this.now());
try {
while (!this.closed) {
let failed: unknown;
try {
await this.askOnce();
} catch (err) {
failed = err ?? new Error("the ask failed");
}
if (failed === undefined) {
// An event heard that the answer does not hold yet leaves the picture behind: asked once more, by itself.
this.asking = false;
const behind = this.behind();
if (behind && !this.catching) {
this.catching = true;
this.log(`views: ${this.o.owner}'s ${this.o.state} is behind an event heard (${behind}): its first view ${this.o.tool} is asked once more`);
void this.catchUp();
}
return;
}
this.st.error = failed instanceof Error ? failed.message : String(failed);
const raise = !this.st.raised && ms(this.now()) - first >= bound;
if (raise) this.st.raised = true;
this.log(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is not answered (${this.st.error}): asked again in ${wait} ms`);
if (raise) await this.o.raise?.(this.status());
await this.say();
await this.pause(wait);
wait = Math.min(wait * 2, 60_000);
}
} finally {
this.asking = false;
}
}
// catchUp asks once more, each time after a pause (doubling to a minute, so an owner that answers from before its
// last write is not asked in a hot loop), until the picture holds every revision heard. One at a time: while it runs,
// an answered ask starts no other.
private async catchUp(): Promise<void> {
let wait = this.o.wait ?? 5000;
try {
while (!this.closed) {
await this.pause(wait);
const behind = this.behind();
if (!behind || this.closed) return;
this.log(`views: ${this.o.owner}'s ${this.o.state} is still behind (${behind}): its first view ${this.o.tool} is asked once more`);
await this.askUntil(`behind: ${behind}`, false);
wait = Math.min(wait * 2, 60_000);
}
} finally {
this.catching = false;
}
}
// askOnce asks every page and, once whole, takes it as the picture.
private async askOnce(): Promise<void> {
this.st.asked = this.now();
this.st.asks++;
await this.say();
let firstPage: Answer | undefined;
const values = new Map<string, unknown>();
const written = new Map<string, string>();
let after = "";
for (;;) {
const a = await this.o.ask(after);
if (a.v !== undefined && a.v !== VERSION) throw new Error(`views: unknown value version ${a.v}: this reader reads ${VERSION}`);
firstPage ??= a;
for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v);
for (const [k, t] of Object.entries(a.written ?? {})) written.set(k, t);
if (!a.next || a.next === after) break;
after = a.next;
}
const old = this.values;
this.values = values;
const wasRaised = this.st.raised;
this.st.revision = firstPage!.revision;
this.st.created = firstPage!.created;
this.st.answered = this.now();
this.st.raised = false;
delete this.st.error;
// The events heard while it asked, after the answer's revision and in order, are taken again.
const during = [...this.during].sort((x, y) => x.revision - y.revision);
this.during = [];
for (const ev of during) {
if (ms(ev.created) === ms(this.st.created) && ev.revision === this.st.revision + 1 && !ev.gap) {
if (ev.deleted) this.values.delete(ev.key);
else this.values.set(ev.key, ev.value);
if (ev.at) written.set(ev.key, ev.at);
this.st.revision = ev.revision;
}
}
if (!this.answered) {
this.answered = true;
this.markReady();
}
if (wasRaised) await this.o.clear?.(this.status());
await this.say();
if (this.o.changed) {
for (const [k, v] of this.values) {
if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.changed(k, v, false, written.get(k) ?? "");
}
for (const k of old.keys()) if (!this.values.has(k)) await this.o.changed(k, undefined, true, "");
}
}
/**
* Take one event into the picture and say whether it was a gap: another `created` (the owner's state made again), a
* revision past the one after the picture's, or a value too large to carry. A revision at or below the picture's is
* in it already and is passed over; so is any event before the first view is answered (the answer holds it).
*/
async apply(ev: ViewEvent): Promise<{ gap: boolean; why: string }> {
if (ev.v !== undefined && ev.v !== VERSION) throw new Error(`views: unknown value version ${ev.v}: this reader reads ${VERSION}`);
if (!ev.key || !ev.revision) throw new Error("views: an event names no key or revision");
if (ms(ev.created) > ms(this.heardCreated)) {
this.heardCreated = ev.created;
this.heardRev = ev.revision;
} else if (ms(ev.created) === ms(this.heardCreated) && ev.revision > this.heardRev) {
this.heardRev = ev.revision;
}
if (this.asking) this.during.push(ev);
if (!this.answered) {
this.log(`views: ${this.o.owner}'s ${this.o.state} at revision ${ev.revision} was heard before its first view was answered: passed over, the answer holds it or is asked again`);
return { gap: false, why: "" };
}
let why = "";
if (ms(ev.created) !== ms(this.st.created)) {
why = `${this.o.owner}'s ${this.o.state} was made again (or not marked when the first view was answered): created ${ev.created}, held ${this.st.created ?? "none"}`;
} else if (ev.revision <= this.st.revision) {
this.log(`views: ${this.o.owner}'s ${this.o.state} at revision ${ev.revision} is in the picture already (it holds ${this.st.revision}): passed over`);
return { gap: false, why: "" };
} else if (ev.revision > this.st.revision + 1) {
why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.st.revision}: ${ev.revision - this.st.revision - 1} changes missed`;
} else if (ev.gap) {
why = `${this.o.owner}'s ${this.o.state}.${ev.key} at revision ${ev.revision} was too large to carry`;
}
if (why) return { gap: true, why };
if (ev.deleted) this.values.delete(ev.key);
else this.values.set(ev.key, ev.value);
this.st.revision = ev.revision;
await this.say(); // the revision the picture holds follows the events, not only the asks
await this.o.changed?.(ev.key, ev.value, !!ev.deleted, ev.at ?? "");
return { gap: false, why: "" };
}
/**
* A reader's handler of one event: taken into the picture, and a gap asks the first view once more, said in the log
* and not waited for (the event is taken; the ask brings the picture to its newest).
*/
async hear(ev: ViewEvent): Promise<void> {
const { gap, why } = await this.apply(ev);
if (gap) {
this.log(`views: a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`);
void this.gap(why);
}
}
}
/** A Follower's `said` that keeps its status at statusKey in the reader's state STATUS_STATE. */
export function keepStatus(st: State<Status>): (s: Status) => Promise<void> {
return async (s) => {
await st.put(statusKey(s.owner, s.state), s);
};
}
/** A hand act a part records before it does it (ADR 0565 clause 11): what is done, why, and its cause and verb. */
export interface HandAct {
readonly what: string;
readonly why: string;
readonly cause: string;
readonly verb: string;
}
/**
* A reader's read tool of its first views (`read`) and its re-read tool (`again`), as the Go side's StatusTools: read
* lists what each follower last said, kept in `st` (STATUS_STATE); again records a hand act through `acts` (none
* given: refused, nothing asked), then asks that follower's first view again, naming the caller the runtime proved.
*/
export function statusTools(
reader: string,
read: string,
again: string,
followers: readonly Follower[],
st: State<Status>,
acts: ((act: HandAct) => Promise<void>) | undefined,
): ToolDefinition[] {
return [
{
name: read,
description:
`${reader}'s first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, the tool it asks, the ` +
"revision its picture holds now (events taken in included), the highest revision it heard and why it is behind it, if it " +
"is, when it asked and was answered, how many asks (by hand among them) and whether its condition (unanswered past two " +
"minutes) is raised.",
input: { type: "object", properties: {} },
run: async () => {
const out: Status[] = [];
for (const k of await st.keys()) {
const e = await st.get(k);
if (e?.value && typeof e.value === "object") out.push(e.value);
}
return { "first-views": out };
},
},
{
name: again,
description:
`Ask ${reader}'s first view of an owner's state again now, by hand (a hand act, its caller and why kept in the status ` +
`${read} reads): for a picture a person doubts. The part asks once and follows the owner's events after the answer.`,
input: {
type: "object",
required: ["owner", "state", "why"],
properties: {
owner: { type: "string", description: "the owner, as the read tool names it" },
state: { type: "string", description: "the owner's state, as the read tool names it" },
why: { type: "string", description: "why it is asked again, in plain words" },
},
},
run: async (args) => {
const owner = typeof args.owner === "string" ? args.owner : "";
const state = typeof args.state === "string" ? args.state : "";
const why = typeof args.why === "string" ? args.why.trim() : "";
if (!owner || !state || !why) throw new Error("again names the owner, the state and why");
const f = followers.find((x) => x.owner === owner && x.state === state);
if (!f) throw new Error(`${reader} holds no first view of ${owner}'s ${state}`);
const by = callerOf(args);
if (!acts) throw new Error("the hand act was not recorded, so nothing was asked again: no way to record a hand act is given");
try {
await acts({ what: `asked ${reader}'s first view of ${owner}'s ${state} again by hand`, why, cause: "first-view-again", verb: again });
} catch (err) {
throw new Error(`the hand act was not recorded, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`);
}
void f.again(`${why} (${by})`);
return { asked: { owner, state, why, by }, read };
},
},
];
}
+11 -2
View File
@@ -1,8 +1,17 @@
// A tools bundle written against the protocol: the module's own tool, a seat's verb, and one that fails.
// A tools bundle written against the protocol: the module's own tool, a seat's verb, one that fails, and one that says
// who called it as the runtime proved it and refuses a caller its verb's list does not name.
import { serveStdio } from "../../dist/stdio/index.js";
import { callerOf, callIdOf, refusal } from "../../dist/callers/index.js";
const verbs = { "node-lamp.on": ["mesh-delivery-forge-bridge"] };
await serveStdio("lamp", [
{ name: "brightness", description: "set it", input: { level: { type: "number", description: "0 to 10" } }, run: async (a) => ({ level: a.level }) },
{ name: "node-lamp.on", description: "the seat's verb", input: {}, run: async () => ({ on: true }) },
{ name: "node-lamp.on", description: "the seat's verb", input: {}, run: async (a) => {
const no = refusal(verbs, "node-lamp.on", callerOf(a));
if (no) throw no;
return { on: true };
} },
{ name: "fail", description: "it throws", input: {}, run: async () => { throw new Error("the bulb is gone"); } },
{ name: "who", description: "who called", input: {}, run: async (a) => ({ caller: callerOf(a), call: callIdOf(a) }) },
]);
+4 -1
View File
@@ -1,8 +1,11 @@
// A launched bundle whose tool emits (novox/hq ADR 0193): the emit travels to the runtime over stdio.
// A launched bundle whose tool emits (novox/hq ADR 0193): the emit travels to the runtime over stdio, and so does an
// event it says on a seat (ADR 0259 §3).
import { registerModuleTools } from "../../dist/tools/index.js";
import { emit } from "../../dist/events/index.js";
import { seatPublish } from "../../dist/runtime/index.js";
import { serveRegisteredOverStdio } from "../../dist/stdio/index.js";
registerModuleTools("chime", () => [
{ name: "ring", description: "rings and says so", input: {}, run: async () => { await emit("rang", { times: 1 }); return { rang: true }; } },
{ name: "peal", description: "says it on its seat", input: {}, run: async () => { await seatPublish("mesh.seat.bell.event.pealed.anchor", { times: 2 }, "p-1"); return { pealed: true }; } },
]);
await serveRegisteredOverStdio();
+123
View File
@@ -662,3 +662,126 @@ test("the provider loop is named for what it is, and its old name stays an alias
// The events and tools are what the controller and the operator read on the bus: their names stay.
assert.equal(loop.FAILING_EVENT, "provisioner.failing");
});
import { allows, refusal, parseCallers, parseVerbCallers, parseConfiguredVerbCallers, moduleOf, UNPROVEN } from "../dist/callers/index.js";
import { Follower, statusTools, keepStatus, statusKey, type Answer, type Status } from "../dist/views/index.js";
test("callers and verb callers judge a proved caller by the Go side's rules (ADR 0459 §2, ADR 0627 §2)", () => {
const rows: [string, string[], string, boolean][] = [
["a module on any machine", ["mesh-delivery-forge-bridge"], "ace.mesh-delivery-forge-bridge", true],
["exactly a machine's module", ["ace.mesh-delivery-forge-bridge"], "g14.mesh-delivery-forge-bridge", false],
["exactly a person", ["person.jochen"], "person.jochen", true],
["a module entry is not a person", ["jochen"], "person.jochen", false],
["a wildcard is no entry", ["*", ">"], "ace.anything", false],
["an unproven caller never matches", ["unproven"], UNPROVEN, false],
];
for (const [what, list, caller, want] of rows) assert.equal(allows(list, caller), want, what);
assert.deepEqual(parseCallers('{"callers":["a","person.b"]}'), ["a", "person.b"]);
assert.throws(() => parseCallers('{"callers":["a"],"more":1}'), /unknown field "more"/);
const verbs = parseVerbCallers('{"pull":["*"],"merge-pull":["mesh-delivery-forge-bridge"],"comment":["operator"]}', ["pull", "merge-pull", "comment"], {});
assert.equal(refusal(verbs, "pull", "ace.anyone"), undefined);
assert.match(String(refusal(verbs, "pull", UNPROVEN)), /carries no caller the tool runner proved/);
assert.equal(refusal(verbs, "merge-pull", "novox.mesh-delivery-forge-bridge"), undefined);
assert.match(String(refusal(verbs, "merge-pull", "person.jochen")), /refused for person.jochen: not on the holder's caller list for merge-pull/);
assert.equal(refusal(verbs, "comment", "person.jochen"), undefined);
assert.ok(refusal(verbs, "comment", "ace.mesh-delivery-forge-bridge"));
assert.ok(refusal(verbs, "status", "ace.anyone"), "a verb the list does not name refuses everyone");
assert.throws(() => parseVerbCallers('{"nope":["*"]}', ["pull"], {}), /names the verb "nope", which the holder does not serve/);
const defaults = { pull: ["*"] };
assert.equal(parseVerbCallers(" ", ["pull"], defaults), defaults);
assert.equal(parseConfiguredVerbCallers(null, ["pull"], defaults), defaults);
assert.deepEqual(parseConfiguredVerbCallers('{"pull":["a"]}', ["pull"], defaults), { pull: ["a"] });
assert.deepEqual(parseConfiguredVerbCallers({ pull: ["b"] }, ["pull"], defaults), { pull: ["b"] });
assert.throws(() => parseConfiguredVerbCallers("{", ["pull"], defaults), /not a JSON object/);
assert.equal(moduleOf("ace.mesh-delivery-forge-bridge"), "mesh-delivery-forge-bridge");
});
test("a first view follows its owner's events, asks again on a gap and by hand, and its two tools read and steer it (ADR 0615)", async () => {
const created = "2026-11-09T10:00:00.000Z";
let held: Record<string, unknown> = { a: 1, b: 2, c: 3 };
let revision = 4;
let down = 2;
const asked: string[] = [];
const raised: Status[] = [];
const cleared: Status[] = [];
const pauses: number[] = [];
let clock = Date.parse(created);
// The owner answers two keys a page, and is down for its first two asks.
const ask = async (after: string): Promise<Answer> => {
asked.push(after);
if (down > 0) {
down--;
throw new Error("no responders");
}
const keys = Object.keys(held).sort().filter((k) => k > after);
const page = keys.slice(0, 2);
return { v: 1, revision, created, values: Object.fromEntries(page.map((k) => [k, held[k]])), next: keys.length > 2 ? page[1] : "" };
};
const kept = new Map<string, Status>();
const st = {
get: async (k: string) => (kept.has(k) ? { key: k, value: kept.get(k)!, revision: 1 } : null),
put: async (k: string, v: Status) => (kept.set(k, structuredClone(v)), 1),
delete: async (k: string) => void kept.delete(k),
keys: async () => [...kept.keys()].sort(),
watch: async () => {},
};
const f = new Follower({
owner: "mesh-catalog", tool: "mesh-catalog.revision", state: "catalog",
ask, said: keepStatus(st), raise: (s) => void raised.push(s), clear: (s) => void cleared.push(s),
now: () => new Date(clock), bound: 1000, wait: 10,
pause: async (ms) => { pauses.push(ms); clock += 1000; },
});
// An event before the first view is answered is passed over: the answer holds it.
assert.deepEqual(await f.apply({ key: "a", revision: 4, created, value: 1 }), { gap: false, why: "" });
await f.start();
assert.deepEqual(pauses, [10, 20], "a failed ask waits, doubling");
assert.equal(raised.length, 1, "past its bound it raises its condition");
assert.equal(cleared.length, 1, "the answer clears it");
assert.deepEqual(Object.fromEntries(f.picture()), { a: 1, b: 2, c: 3 }, "every page is taken");
assert.deepEqual(asked.slice(-2), ["", "b"]);
let s = kept.get(statusKey("mesh-catalog", "catalog"))!;
assert.equal(s.revision, 4);
assert.equal(s.asks, 3);
assert.equal(s.raised, false);
assert.equal(s.keys, 3);
// The next revision is taken; one at or below the picture's is passed over.
assert.deepEqual(await f.apply({ key: "d", revision: 5, created, value: 4 }), { gap: false, why: "" });
assert.deepEqual(await f.apply({ key: "a", revision: 3, created, value: 0 }), { gap: false, why: "" });
assert.equal(f.get("d"), 4);
assert.equal(f.get("a"), 1);
assert.equal(kept.get(statusKey("mesh-catalog", "catalog"))!.revision, 5, "the kept status follows the events");
// A revision past the next one is a gap: hearing it asks the first view once more.
held = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 };
revision = 7;
const before = asked.length;
await f.hear({ key: "f", revision: 7, created, value: 6 });
await f.ready;
for (let i = 0; i < 10 && f.status().revision !== 7; i++) await Promise.resolve();
assert.ok(asked.length > before, "a gap asks again");
assert.equal(f.status().revision, 7);
assert.equal(f.status().why?.startsWith("a gap:"), true);
assert.equal(f.get("e"), 5);
// The two tools: read lists the kept status; again records the hand act first and asks by hand with the caller.
const acts: unknown[] = [];
const [read, again] = statusTools("gitea", "gitea_first_views", "gitea_first_view_again", [f], st, async (a) => void acts.push(a));
const listed = (await read.run({})) as { "first-views": Status[] };
assert.equal(listed["first-views"].length, 1);
assert.equal(listed["first-views"][0].tool, "mesh-catalog.revision");
await assert.rejects(again.run({ owner: "mesh-catalog", state: "catalog" }), /names the owner, the state and why/);
await assert.rejects(again.run({ owner: "x", state: "catalog", why: "w" }), /holds no first view of x's catalog/);
const [, refused] = statusTools("gitea", "r", "g", [f], st, undefined);
await assert.rejects(refused.run({ owner: "mesh-catalog", state: "catalog", why: "w" }), /nothing was asked again/);
const byHandBefore = f.status()["by-hand"];
await again.run({ owner: "mesh-catalog", state: "catalog", why: "the picture is doubted" });
for (let i = 0; i < 10 && f.status()["by-hand"] === byHandBefore; i++) await Promise.resolve();
assert.equal(acts.length, 1);
assert.equal(f.status()["by-hand"], byHandBefore + 1);
assert.match(f.status().why ?? "", /^by hand: the picture is doubted \(unproven\)$/);
// Another created is the state made again: a gap too.
f.close();
assert.equal((await f.apply({ key: "a", revision: 1, created: "2026-11-09T11:00:00.000Z", value: 9 })).gap, true);
});
+24 -1
View File
@@ -26,7 +26,7 @@ test("a bundle on stdio lists its tools and answers a call, a seat's verb among
assert.equal((await ask(1, "initialize", { protocolVersion: "2025-03-26", capabilities: {} })).result.serverInfo.name, "lamp");
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
const listed = (await ask(2, "tools/list")).result.tools;
assert.deepEqual(listed.map((t: any) => t.name), ["brightness", "node-lamp.on", "fail"]);
assert.deepEqual(listed.map((t: any) => t.name), ["brightness", "node-lamp.on", "fail", "who"]);
assert.deepEqual(listed[0].inputSchema, { type: "object", properties: { level: { type: "number", description: "0 to 10" } } });
const called = (await ask(3, "tools/call", { name: "brightness", arguments: { level: 7 } })).result;
assert.deepEqual(JSON.parse(called.content[0].text), { level: 7 });
@@ -34,6 +34,21 @@ test("a bundle on stdio lists its tools and answers a call, a seat's verb among
assert.equal(failed.isError, true);
assert.equal(failed.content[0].text, "the bulb is gone");
assert.equal((await ask(5, "tools/call", { name: "nothing" })).error.code, -32602);
// The caller the runtime proved in `_meta` is handed to the tool, never one in the arguments (issue 365).
const proved = { "mesh/caller": "ace.mesh-delivery-forge-bridge", "mesh/call": "c-17" };
const who = (await ask(6, "tools/call", { name: "who", arguments: { caller: "person.mallory" }, _meta: proved })).result;
assert.deepEqual(JSON.parse(who.content[0].text), { caller: "ace.mesh-delivery-forge-bridge", call: "c-17" });
const unnamed = (await ask(7, "tools/call", { name: "who", arguments: { caller: "person.mallory" } })).result;
assert.deepEqual(JSON.parse(unnamed.content[0].text), { caller: "unproven", call: "" });
const malformed = (await ask(8, "tools/call", { name: "who", arguments: {}, _meta: { "mesh/caller": "a b", "mesh/call": "x y" } })).result;
assert.deepEqual(JSON.parse(malformed.content[0].text), { caller: "unproven", call: "" });
// A seat's verb judges its callers by the proof: the named module is answered, another and an unproven one refused.
assert.deepEqual(JSON.parse((await ask(9, "tools/call", { name: "node-lamp.on", arguments: {}, _meta: proved })).result.content[0].text), { on: true });
const other = (await ask(10, "tools/call", { name: "node-lamp.on", arguments: {}, _meta: { "mesh/caller": "ace.mallory" } })).result;
assert.equal(other.isError, true);
assert.equal(other.content[0].text, "node-lamp.on: refused for ace.mallory: not on the holder's caller list for node-lamp.on");
const none = (await ask(11, "tools/call", { name: "node-lamp.on", arguments: {} })).result;
assert.match(none.content[0].text, /carries no caller the tool runner proved/);
} finally {
child.stdin.end();
child.kill();
@@ -100,6 +115,14 @@ test("a launched tool's emit is asked of the runtime, and the call answers once
send({ jsonrpc: "2.0", id: asked.id, result: {} });
const answered = await until((m) => m.id === 2);
assert.deepEqual(JSON.parse(answered.result.content[0].text), { rang: true });
// A seat's event is asked of the runtime as a seat's publish, its subject whole and its id kept (ADR 0259 §3).
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "peal", arguments: {} } });
const said = await until((m) => m.method === "mesh/seat.publish");
assert.ok(said, "the seat's event was not asked of the runtime");
assert.deepEqual(said.params, { subject: "mesh.seat.bell.event.pealed.anchor", body: { times: 2 }, id: "p-1" });
assert.equal(seen.find((m) => m.id === 3), undefined, "the call answered before its seat's event was published");
send({ jsonrpc: "2.0", id: said.id, result: {} });
assert.deepEqual(JSON.parse((await until((m) => m.id === 3)).result.content[0].text), { pealed: true });
} finally {
child.kill();
}