test(good-fences): lend-the-mower journey (red)

The spec is a sequence diagram of the a2030.md story: I lend a governed
mower; the neighbor's harness shows the model only `about`, runs its
code as a tenant, and the governor stops the tipsy tenant at a 2-cell
fence. The test runs the actors through a causal tracer and compares
the trace with the diagram, then checks the yard and what the model saw.

The tracer comes from agoric-sdk PR #12895, extended to name
participants made mid-sequence (governor, tenant) and to draw thrown
errors as replies.

Red: src/ doesn't exist yet (ERR_MODULE_NOT_FOUND for src/fence.ts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dckcandClaude Opus 5.5 committed 2026-10-08 22:17:48 -05:00
1 parent b4ab820486
commit 9a3e7348c6
6 files changed
+486

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# Lend the mower
The journey from [Good Fences Make Good Agents](../../../a2030.md): I lend
my neighbor a governed mower; the neighbor's tenant, a little tipsy,
pushes it until the fence stops it. The design decisions behind each actor are in that
doc's Design IBIS (numbers in parentheses below).
Actors, by authority:
- **fence page**, **js playground**, **governed mower**, and **yard** run
in my browser (3a, 8b, 6b).
- **Mower factory** trades a webkey for a mower (3b-ii); each **mower DO**
is given the yard and cuts the cells it enters (6a, 6d).
- The **neighbor's harness** holds the things I send and shows the
**model** only their `about` (1b, 2a); it runs the model's code as a
**tenant**, endowed with the things (2c, 5a).
The yard is 8×8 here and the fence is 2 cells, to keep the diagram short
(the story's 10 cells is 6c). The mower starts at (4, 4), facing north.
## Lend a governed mower to a tipsy tenant
```mermaid
sequenceDiagram
%% ->> is my action at the fence page; -->> is a call or reply it causes.
participant F as fence page
participant MF as Mower factory
participant P as js playground
participant G as governed mower
participant M as mower DO
participant Y as yard
participant N as neighbor's harness
participant L as model
participant TL as tenant loader
participant T as tenant
F->>F: lend({ webkey: 'mower-key', limit: 2, say })
F-->>MF: mower('mower-key')
MF-->>F: mower
F-->>P: govern(mower, 2)
P-->>F: governed
F-->>N: chat({ role, content, things: { mower }, about: { mower } })
N-->>L: complete([{ role, content, about: { mower } }])
L-->>N: code
N-->>TL: load(code, { mower })
TL-->>N: tenant
N-->>T: run()
T-->>G: start()
G-->>M: start()
M-->>Y: cut(4, 4)
T-->>G: push()
G-->>M: push()
M-->>Y: cut(4, 3)
T-->>G: push()
G-->>M: push()
M-->>Y: cut(4, 2)
T-->>G: push()
G-->>T: throws 'fence: 2 cells from start'
T-->>N: 'fence: 2 cells from start'
N-->>F: 'fence: 2 cells from start'
```
The model never sees the mower itself, only `about.mower`. The third
`push()` never reaches the mower DO: the governor refuses it (7b).
+11
View File
@@ -0,0 +1,11 @@
{
"name": "good-fences",
"private": true,
"type": "module",
"scripts": {
"test": "node --test test/*.test.ts"
},
"dependencies": {
"ses": "^1.14.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
ses:
specifier: ^1.14.0
version: 1.15.0
packages:
'@endo/cache-map@1.1.0':
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
'@endo/env-options@1.1.11':
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
'@endo/immutable-arraybuffer@1.1.2':
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
ses@1.15.0:
resolution: {integrity: sha512-Y7RvgVXhkwQEqVActNn44QBxB2BCUbPfexAaMiUFjbJkDXDnxPisQC5jYQeWOZ3QBOfecnmLfPn0sQKLiYGBAQ==}
snapshots:
'@endo/cache-map@1.1.0': {}
'@endo/env-options@1.1.11': {}
'@endo/immutable-arraybuffer@1.1.2': {}
ses@1.15.0:
dependencies:
'@endo/cache-map': 1.1.0
'@endo/env-options': 1.1.11
'@endo/immutable-arraybuffer': 1.1.2
+131
View File
@@ -0,0 +1,131 @@
/** @file The lend-the-mower journey, traced and compared with its sequence diagram. */
import './lockdown.ts';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { makeFencePage } from '../src/fence.ts';
import { makePlayground } from '../src/governor.ts';
import { aboutMower, makeMowerFactory } from '../src/mower.ts';
import { makeNeighbor } from '../src/neighbor.ts';
import { makeCompartmentLoader } from '../src/tenant-loader.ts';
import { makeYard } from '../src/yard.ts';
import {
type CausalSequenceViz,
makeCausalSequenceTracer,
md,
mmd,
} from '../tools/sequence-diagram-actor-sim.ts';
const designDoc = new URL('../docs/lend-the-mower.md', import.meta.url);
/** a tenant that pushes until something stops it, or gives up after 20 */
const tipsyTenant = `(env) => {
env.mower.start();
try {
for (let i = 0; i < 20; i += 1) env.mower.push();
} catch (err) {
return err.message;
}
return 'mowed 20 cells';
}`;
/** `{ a, b: { c } }`: keys, and the keys of object-valued members */
const shape = (record: Readonly<Record<string, unknown>>) =>
`{ ${Object.entries(record)
.map(([key, value]) =>
value !== null && typeof value === 'object' && !Array.isArray(value)
? `${key}: { ${Object.keys(value).join(', ')} }`
: key,
)
.join(', ')} }`;
const quoted = (result: unknown) => `'${String(result)}'`;
const viz = harden({
lend: {
label: args => {
const { webkey, limit } = args[0] as { webkey: string; limit: number };
return `lend({ webkey: '${webkey}', limit: ${limit}, say })`;
},
},
mower: { result: () => 'mower' },
govern: { args: args => `mower, ${String(args[1])}`, result: () => 'governed' },
chat: {
label: args => `chat(${shape(args[0] as Record<string, unknown>)})`,
result: quoted,
},
complete: {
label: args =>
`complete([${(args[0] as Record<string, unknown>[]).map(shape).join(', ')}])`,
result: () => 'code',
},
load: {
args: args => `code, { ${Object.keys(args[1] as object).join(', ')} }`,
result: () => 'tenant',
},
run: { result: quoted },
}) satisfies CausalSequenceViz;
test('a governed mower stops a tipsy tenant at the fence', async () => {
const lines = (await readFile(designDoc, 'utf8')).split('\n');
const section = md.skipToH(2, 'Lend a governed mower to a tipsy tenant')(lines);
const diagram = md.eachFence('mermaid', section).next().value;
if (!diagram) throw Error('lend-the-mower Mermaid block not found');
const documented = mmd.extractArrows(diagram);
const ES = makeCausalSequenceTracer(viz);
const yard = makeYard({ width: 8, height: 8 });
const factory = makeMowerFactory(ES, {
yard,
size: { width: 8, height: 8 },
mintKey: () => 'mower-key',
});
const key = factory.make({ x: 4, y: 4 });
const mower = factory.mower(key);
const prompts: unknown[] = [];
const model = harden({
complete(messages: readonly unknown[]) {
prompts.push(messages);
return tipsyTenant;
},
});
const playground = makePlayground(ES);
const loader = makeCompartmentLoader(ES);
const neighbor = makeNeighbor(ES, { model, loader });
const fence = makeFencePage(ES, { factory, playground, neighbor });
ES.declareParticipants({
F: fence,
MF: factory,
P: playground,
M: mower,
Y: yard,
N: neighbor,
L: model,
TL: loader,
});
ES.declareMade({ G: [playground, 'govern'], T: [loader, 'load'] });
ES.start(fence);
const outcome = ES(fence).lend({
webkey: key,
limit: 2,
say: 'you can use this mower for the weekend',
});
assert.deepEqual(ES.snapshot(), documented);
assert.equal(outcome, 'fence: 2 cells from start');
assert.deepEqual(yard.cutCells(), ['4,4', '4,3', '4,2']);
assert.deepEqual(prompts, [
[
{
role: 'user',
content: 'you can use this mower for the weekend',
about: { mower: aboutMower },
},
],
]);
});
+4
View File
@@ -0,0 +1,4 @@
/** @file Harden the realm before any test module runs: tenants share it. */
import 'ses';
lockdown();
@@ -0,0 +1,236 @@
/**
* @file Helpers for sequence-diagram actor simulations.
*
* From agoric-sdk `packages/portfolio-contract/tools/` (Apache-2.0), with
* two additions: `declareMade` names participants that actors create
* mid-sequence, and a thrown error is drawn as a reply (`throws '...'`).
*
* @see https://github.com/Agoric/agoric-sdk/pull/12895
*/
import { AsyncLocalStorage } from 'node:async_hooks';
export type SequenceArrow = Readonly<{
from: string;
kind: '->>' | '-->>';
to: string;
label: string;
}>;
export const formatBigInt = (value: bigint) =>
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_');
export type SequenceArgsRenderer = (args: readonly unknown[]) => string;
export type SequenceResultRenderer = (
result: unknown,
args: readonly unknown[],
) => string;
export type SequenceMethodViz = Readonly<
{
args?: SequenceArgsRenderer;
label?: SequenceArgsRenderer;
} & (
| { result?: SequenceResultRenderer; resultOnly?: never }
| { result?: never; resultOnly: SequenceResultRenderer }
)
>;
export type CausalSequenceViz = Readonly<Record<string, SequenceMethodViz>>;
export type SequenceParticipant = object | readonly object[];
/**
* Make an E-like causal tracer for sequence-diagram actor simulations.
* Inspired by Causeway, the message-oriented distributed debugger.
*
* Method visualization is currently selected by method name alone. If actors
* need different visualization for methods with the same name, this can be
* extended to account for the target participant.
*
* @see https://shiftleft.com/mirrors/www.hpl.hp.com/techreports/2009/HPL-2009-78.pdf
*/
export const makeCausalSequenceTracer = (
methodViz: CausalSequenceViz = harden({}),
) => {
const arrows: SequenceArrow[] = [];
const names = new WeakMap<object, string>();
const proxies = new WeakMap<object, object>();
const made = new Map<object, Map<string, string>>();
const activeActor = new AsyncLocalStorage<object>();
let starter: object | undefined;
const participantName = (participant: object) => {
const name = names.get(participant);
if (!name) throw Error('sequence participant is not declared');
return name;
};
const formatValue = (value: unknown): string => {
if (typeof value === 'bigint') return `${formatBigInt(value)}n`;
if (typeof value === 'string') return `'${value}'`;
return JSON.stringify(value, (_key, item) =>
typeof item === 'bigint' ? `${item}n` : item,
);
};
const send = <Target extends object>(target: Target): Target => {
const extant = proxies.get(target);
if (extant) return extant as Target;
participantName(target);
const proxy = new Proxy(Object.create(null) as object, {
get: (_proxyTarget, property) => {
const method = Reflect.get(target, property);
if (typeof method !== 'function' || typeof property !== 'string') {
return method;
}
return (...args: unknown[]) => {
const activeSender = activeActor.getStore();
const sender = activeSender ?? starter;
if (!sender) throw Error('ES.start(participant) is required');
const isStart = activeSender === undefined;
if (isStart && sender !== target) {
throw Error('the started participant must receive the first send');
}
if (isStart) starter = undefined;
const viz = methodViz[property];
const abbreviated = viz?.args?.(args);
const shownArgs = abbreviated ?? args.map(formatValue).join(', ');
const label = viz?.label?.(args) ?? `${property}(${shownArgs})`;
const renderResultOnly = viz?.resultOnly;
if (!renderResultOnly) {
arrows.push(
harden({
from: participantName(sender),
kind: isStart ? '->>' : '-->>',
to: participantName(target),
label,
}),
);
}
let result: unknown;
try {
result = activeActor.run(target, () =>
Reflect.apply(method, target, args),
);
} catch (reason) {
const message =
reason instanceof Error ? reason.message : String(reason);
arrows.push(
harden({
from: participantName(target),
kind: '-->>',
to: participantName(sender),
label: `throws ${formatValue(message)}`,
}),
);
throw reason;
}
const madeName = made.get(target)?.get(property);
if (madeName && result !== null && typeof result === 'object') {
if (names.has(result)) throw Error('participant already declared');
names.set(result, madeName);
}
const renderResult = renderResultOnly ?? viz?.result;
if (renderResult) {
arrows.push(
harden({
from: participantName(target),
kind: '-->>',
to: participantName(sender),
label: renderResult(result, args),
}),
);
}
return result;
};
},
});
proxies.set(target, proxy);
return proxy as Target;
};
const ES = Object.assign(send, {
declareParticipants(
participants: Readonly<Record<string, SequenceParticipant>>,
) {
for (const [name, participantOrAliases] of Object.entries(participants)) {
const aliases = Array.isArray(participantOrAliases)
? participantOrAliases
: [participantOrAliases];
if (aliases.length === 0) {
throw Error(`sequence participant ${name} has no objects`);
}
for (const participant of aliases) {
if (names.has(participant)) {
throw Error('participant already declared');
}
names.set(participant, name);
}
}
},
/** name what `maker[method](...)` returns, when called through the tracer */
declareMade(
participants: Readonly<Record<string, readonly [object, string]>>,
) {
for (const [name, [maker, method]] of Object.entries(participants)) {
participantName(maker);
const byMethod = made.get(maker) ?? new Map<string, string>();
made.set(maker, byMethod);
byMethod.set(method, name);
}
},
snapshot: () => harden([...arrows]),
start(participant: object) {
participantName(participant);
if (activeActor.getStore() || starter) {
throw Error('a sequence is already active');
}
starter = participant;
},
});
return harden(ES);
};
export type CausalSequenceTracer = ReturnType<typeof makeCausalSequenceTracer>;
export const md = {
skipToH: (level: number, title: string) => (lines: readonly string[]) => {
const heading = `${'#'.repeat(level)} ${title}`;
const at = lines.findIndex(line => line === heading);
if (at < 0) throw Error(`${heading} not found`);
return lines.slice(at + 1);
},
*eachFence(language: string, lines: readonly string[]) {
const open = `\`\`\`${language}`;
for (let at = 0; at < lines.length; at += 1) {
if (lines[at] !== open) continue;
const end = lines.indexOf('```', at + 1);
if (end < 0) throw Error(`unterminated ${language} fence`);
yield lines.slice(at + 1, end);
at = end;
}
},
};
export const mmd = {
extractArrows(
lines: readonly string[],
nodePattern: RegExp = /\w+/,
): readonly SequenceArrow[] {
const arrowPattern = new RegExp(
`^\\s*(?<from>${nodePattern.source})\\s*(?<kind>--?>>)\\s*(?<to>${nodePattern.source})\\s*:\\s*(?<label>.*?)\\s*$`,
);
return harden(
lines.flatMap(line => {
const groups = line.match(arrowPattern)?.groups;
if (!groups) return [];
const { from, kind, to, label } = groups;
return [harden({ from, kind, to, label }) as SequenceArrow];
}),
);
},
};