wiring-diagram: who holds what, drawn like MarkM's ERTP slides

A capability wiring diagram as a self-contained HTML/SVG page plus a
headless-Chromium PNG. Every arrow has to point to something in the
code, and a Mallory holding what's likeliest to leak shows what that
doesn't reach. template.html has the drawing helpers, adapted from
capsite's cookie-rotation-wiring.

README: a TODO to consider adapting agoric-sdk's
sequence-diagram-actor-sim as its companion.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-11 14:22:44 -05:00
1 parent 9538b4ea0a
commit 237b470be8
3 files changed
+257

No files matched your search

+6
View File
@@ -47,6 +47,12 @@ Code](https://code.claude.com/docs/en/skills) and
TADA, then how, then the challenges, tagged for an emoji retro. TADA, then how, then the challenges, tagged for an emoji retro.
- [ibis-design](ibis-design/SKILL.md) — search a design space: - [ibis-design](ibis-design/SKILL.md) — search a design space:
issues, positions, and arguments. issues, positions, and arguments.
- [wiring-diagram](wiring-diagram/SKILL.md) — who holds what, drawn
like MarkM's ERTP slides: every arrow cites the code, and Mallory
shows what a leak doesn't reach.
TODO: consider copying or adapting its companion for message order,
sequence-diagram-actor-sim (agoric-sdk `.agents/skills/` on branch
`connolly/ago-1299-agentic-planning-tdd`).
- [obsidian-markdown](obsidian-markdown/SKILL.md) — the workspace is - [obsidian-markdown](obsidian-markdown/SKILL.md) — the workspace is
an Obsidian vault: one line per paragraph, relative links, no file an Obsidian vault: one line per paragraph, relative links, no file
symlinks you expect Obsidian to show. symlinks you expect Obsidian to show.
+113
View File
@@ -0,0 +1,113 @@
---
name: wiring-diagram
description: >-
Capability wiring diagram, "who holds what" — use when asked for a wiring diagram, a picture of who holds what, a capability diagram, or "a diagram like the cookie-rotation one"; when reviewing a design for POLA and wanting to see the reference graph. MarkM ERTP-slide style: actors are balls, makers pale regions, objects rounded squares, state ledger tables, references dot-to-arrowhead; HTML/SVG plus a headless-Chromium PNG. Not for message order over time: that's a sequence diagram (sequence-diagram-actor-sim in agoric-sdk).
---
# Capability wiring diagram
One picture that answers, for a design: **who holds a reference to what,
and therefore who can do what.** It complements a sequence diagram,
which shows *when* messages flow. This one shows the standing graph of
authority that makes those messages possible. For sequences, see
`~/projects/ymax-workspace/agoric-sdk/.agents/skills/sequence-diagram-actor-sim/SKILL.md`
(on agoric-sdk branches `connolly/ago-1264-agentic-planning` and
`connolly/ago-1299-agentic-planning-tdd`, not yet on master).
Exemplars:
- `~/oros-bots/capsite-cookie-rotation/docs/cookie-rotation-wiring.html`
(+ `.png`): the original.
- `~/qtrx/packages/umb-download/docs/wiring.html` (+ `.png`): three
makers, two outside parties, and an adversary holding a webkey.
Start from [`template.html`](template.html), next to this file. It has
the drawing helpers and a small example scene to replace.
## Read the graph from the code, not the story
Every arrow must correspond to something real: a constructor argument, a
stored id, a binding, a held token, or a file someone keeps. Before
drawing, list the references as `holder → target: where in the code`.
Read the factories (`make…`, `newX`, `configure`) and anything that
stores an id: a DO id held as a string *is* a reference. If you can't
cite where a reference comes from, it doesn't get an arrow.
Watch for references that are dropped on purpose (e.g. a setup route
that mints an instance, hands its id to one holder and forgets it).
That's often the point of the design. Say so in a caption, or just
leave the arrow out.
## Vocabulary
| element | helper | means |
|---|---|---|
| blue ball | `actor(cx, cy, label)` | a person or a program with its own agency |
| grey ball | `actor(cx, cy, label, "url(#ext)")` | outside the system: a browser extension, a third-party service |
| pale region | `maker(x, y, w, h, "makeFoo")` | a maker (factory, DO class) and the instances it made; the stacked inner panes say "there are many" |
| rounded square | `obj(x, y, w, h, label, stacked)` | an object or facet; `stacked` when one is made per instance |
| ledger table | `table(x, y, title, rows, [colW0, colW1])` | an object's state; rows are key/value; `rowY(i)` and `colX` give arrow anchors |
| dot → arrowhead | `ref(x1, y1, x2, y2, bend)` | holder references target; `bend` (±0.05–0.3) curves around things |
| speech bubble | the `path` + `code` block | the one call the picture is about, in `E(target).method(args)` form |
| caption | `cap(x, y, text, attrs)` | what an actor holds, and what it can't reach |
Facets are separate objects (`store` vs `readOnly`): drawing them apart
is how the picture shows attenuation.
## Include an adversary
Add a Mallory who holds whatever is most likely to leak (an instance
id, a webkey, a URL), with an arrow to exactly what it reaches. The
caption says what she *can't* reach. Absent arrows are the security
argument, and Mallory makes them visible.
## Layout
- Actors in a row along the top (y≈200), makers below (y≈320 onward),
captions along the bottom (y≈ height − 14).
- Put each maker under the actors that mostly use it, so arrows run
short and downward.
- Speech bubble at the top, its tail pointing at the speaker. Move the
bubble's path (`M x,y`) and the tail segment (`h… l-38,48 l6,-48`)
together.
- Captions: two or three short lines, never on top of an arrow or a
ball. Use `"text-anchor": "start"` to set one beside a ball.
- Long arrows that cross a maker: increase `|bend|` until the curve
clears tables and objects. The control point is the midpoint offset
by `bend × (−dy, dx)`.
## Accessibility
Keep `<title>` short. Write `<desc>` as prose that names every actor and
maker, every reference, the bubble's call, and what the adversary
can't reach. Someone who can't see the picture should get the same
argument from it.
## Render and check
The page is self-contained (no network). Render a PNG with headless
Chromium. The viewport comes out shorter than `--window-size`, so render
taller and crop to the SVG size:
```sh
chromium --headless=new --disable-gpu --no-sandbox --hide-scrollbars \
--window-size=W,$((H+100)) --screenshot=full.png "file://$PWD/NAME.html"
python3 -c "from PIL import Image; Image.open('full.png').crop((0,0,W,H)).save('NAME.png')" && rm full.png
```
Then **look at the PNG** (Read it) and fix overlaps: captions on arrows,
arrows through tables, text clipped at the bottom. Expect two or three
rounds. Commit the HTML (source) and the PNG separately, so the source
diff stays readable.
## Review checklist
- Does every arrow cite something in the code? Is any real reference
missing?
- Does each actor hold only what its job needs (POLA)? If the picture
shows an actor holding more, that's a finding about the design, not a
drawing problem.
- Are facets drawn apart where they differ in authority?
- Does the adversary's caption match what the arrows show?
- Does `<desc>` tell the same story in prose?
- Is any secret value drawn? Elide values (`session=…`, `{…}`): the
picture shows *that* a row exists, not its contents.
+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html><head><meta charset="utf-8">
<title>TITLE: who holds what</title>
<!-- drawn after the style of MarkM's ERTP slides; NAME.png is a headless-Chromium screenshot of this page -->
<style>
html, body { margin: 0; background: #fff; }
.sans { font-family: "Helvetica Neue", "DejaVu Sans", sans-serif; }
.obj { font-family: "DejaVu Serif", serif; font-style: italic; }
.mono { font-family: "DejaVu Sans Mono", monospace; }
</style></head>
<body>
<svg id="c" xmlns="http://www.w3.org/2000/svg" width="1120" height="820" viewBox="0 0 1120 820"
role="img" aria-labelledby="title desc">
<title id="title">TITLE: who holds what</title>
<desc id="desc">Prose for screen readers: name every actor and maker, every
reference (who holds what), the speech bubble's call, and what the
adversary can and cannot reach.</desc>
<defs>
<radialGradient id="ball" cx="40%" cy="35%" r="70%">
<stop offset="0%" stop-color="#7cc8f8"/>
<stop offset="100%" stop-color="#3aa6ee"/>
</radialGradient>
<radialGradient id="ext" cx="40%" cy="35%" r="70%">
<stop offset="0%" stop-color="#e3e3e3"/>
<stop offset="100%" stop-color="#b9b9b9"/>
</radialGradient>
<linearGradient id="objfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6cbcf4"/>
<stop offset="100%" stop-color="#4aa8ec"/>
</linearGradient>
<marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1d4f7a"/>
</marker>
</defs>
</svg>
<script>
const svg = document.getElementById("c");
const NS = "http://www.w3.org/2000/svg";
const el = (name, attrs, parent = svg) => {
const e = document.createElementNS(NS, name);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
parent.appendChild(e);
return e;
};
const text = (x, y, s, cls, attrs = {}) => {
const t = el("text", { x, y, class: cls, "text-anchor": "middle", fill: "#1d3550", ...attrs });
t.textContent = s;
return t;
};
const INK = "#1d4f7a";
const dot = (x, y) => el("circle", { cx: x, cy: y, r: 4.5, fill: INK });
/** a reference: dot at the holder, arrowhead at the target */
const ref = (x1, y1, x2, y2, bend = 0, extra = {}) => {
dot(x1, y1);
const mx = (x1 + x2) / 2 - (y2 - y1) * bend, my = (y1 + y2) / 2 + (x2 - x1) * bend;
el("path", { d: `M${x1},${y1} Q${mx},${my} ${x2},${y2}`, fill: "none", stroke: INK, "stroke-width": 2, "marker-end": "url(#head)", ...extra });
};
// maker box: a pale rounded region, with stacked instances inside
function maker(x, y, w, h, label) {
el("rect", { x, y, width: w, height: h, rx: 26, fill: "#d6ecfb", stroke: "#b5dbf5", "stroke-width": 1.5 });
text(x + 18, y + 30, label, "obj", { "text-anchor": "start", "font-size": 20 });
for (let i = 2; i >= 0; i -= 1) {
el("rect", {
x: x + 18 + i * 9, y: y + 48 + i * 9, width: w - 52, height: h - 82, rx: 22,
fill: "#c8e5fa", stroke: "#8ec9f2", "stroke-width": 1.5, "fill-opacity": 0.9,
});
}
}
// an object: rounded square, optionally stacked (several made)
function obj(x, y, w, h, label, stacked = false) {
const n = stacked ? 3 : 1;
for (let i = n - 1; i >= 0; i -= 1) {
el("rect", { x: x + i * 7, y: y - i * 7, width: w, height: h, rx: 12, fill: "url(#objfill)", stroke: "#2f8fd6", "stroke-width": 1.5 });
}
text(x + w / 2, y + h / 2 + 7, label, "obj", { "font-size": 19 });
}
function actor(cx, cy, label, fill = "url(#ball)") {
el("circle", { cx, cy, r: 52, fill, stroke: "#2f8fd6", "stroke-width": 1.5 });
text(cx, cy + 6, label, "sans", { "font-size": label.length > 8 ? 15 : 17 });
}
// a table like the ERTP ledger
function table(x, y, title, rows, colW) {
const rowH = 28, w = colW[0] + colW[1];
el("rect", { x, y, width: w, height: 36 + rowH * rows.length, fill: "#5fb5f2", stroke: "#2f8fd6", "stroke-width": 1.5 });
text(x + w / 2, y + 25, title, "obj", { "font-size": 18 });
rows.forEach(([k, v], i) => {
const ry = y + 36 + i * rowH;
el("line", { x1: x, y1: ry, x2: x + w, y2: ry, stroke: "#2f8fd6", "stroke-width": 1.2 });
text(x + 10, ry + 19, k, "mono", { "text-anchor": "start", "font-size": 14 });
if (v) text(x + colW[0] + 10, ry + 19, v, "mono", { "text-anchor": "start", "font-size": 14 });
});
el("line", { x1: x + colW[0], y1: y + 36, x2: x + colW[0], y2: y + 36 + rowH * rows.length, stroke: "#1d4f7a", "stroke-width": 1.5 });
return { rowY: (i) => y + 36 + i * rowH + rowH / 2, x, w, colX: x + colW[0] };
}
// ---- scene: replace from here down ----
// speech bubble: the one invocation the picture is about; tail toward the speaker
el("path", {
d: "M30,22 h470 a16,16 0 0 1 16,16 v34 a16,16 0 0 1 -16,16 h-330 l-38,48 l6,-48 h-102 a16,16 0 0 1 -16,-16 v-34 a16,16 0 0 1 16,-16 z",
fill: "#d9d9d9", stroke: "#555", "stroke-width": 2,
});
const code = el("text", { x: 46, y: 62, class: "mono", "font-size": 17, fill: "#222" });
code.innerHTML = '<tspan fill="#b0177b">await</tspan> E(store).set(<tspan fill="#1a7f37">"cookie"</tspan>, freshCookie);';
// actors: people, programs, outside services (grey: "url(#ext)")
actor(150, 200, "Alice");
actor(560, 200, "client");
actor(930, 200, "Mallory");
// makers, with what they made inside
maker(30, 320, 470, 470, "makeMapStore");
maker(560, 320, 530, 470, "makeWebApp");
obj(80, 420, 120, 80, "store");
obj(80, 600, 120, 80, "readOnly", true);
const kv = table(260, 430, "kv", [["cookie", "session=…"], ["", ""], ["", ""]], [90, 110]);
obj(620, 560, 140, 90, "access", true);
const init = table(830, 420, "init", [["url", "…"], ["headerStore", ""]], [130, 85]);
// references: holder first; one comment per arrow, in the form "holder → target"
ref(140, 228, 125, 418, 0.05); // Alice → store
ref(172, 224, 508, 210, 0.04); // Alice → client (she wired it)
ref(554, 218, 660, 553, 0.12); // client → access
ref(916, 220, 720, 553, -0.08); // Mallory → access
ref(160, 470, kv.x - 2, kv.rowY(0), 0.05); // store → kv
ref(150, 666, kv.x - 2, kv.rowY(0) + 6, -0.25); // readOnly → kv
ref(760, 600, init.x - 2, init.rowY(1), -0.1); // access → init
ref(init.colX + 40, init.rowY(1), 208, 640, -0.28); // init.headerStore → readOnly
// captions: what each actor holds, what the adversary can't reach
const cap = (x, y, s, attrs = {}) => text(x, y, s, "sans", { "font-size": 14, fill: "#33536f", ...attrs });
cap(930, 118, "holds the instance id:");
cap(930, 136, "uses the cookie, can't reach store");
cap(265, 806, "shapes: keyShape \"cookie\", valueShape M.string()");
</script>
</body></html>