yichus 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ export const VOCABULARY = {
22
22
  callers: 'left of the wall: one glyph per transport kind, HTTP above, importer below, each with a bracket over the openings it calls; the kind the program lacks is the same glyph struck through <- callers[], ghostCallers[]',
23
23
  openings: 'the left wall: one opening per entry <- entries[]',
24
24
  hullBand: 'the middle band of the wall: wrappers sit here and nowhere else; empty band = no entry goes through a shared wrapper <- wrappers[]',
25
- afterBand: 'between the hull band and the reply bars: work an entry does after its wrapper returns, with its stations and checks; empty = none <- entries[].beside',
25
+ afterBand: 'between a hull\'s right wall and the reply bars: work an entry does after its wrapper returns, with its stations and checks; empty = none <- entries[].beside',
26
26
  replyBars: 'one bar per entry at the same x, past the hull band <- entries[]',
27
27
  rail: 'the bottom of the wall: a handle handed in from outside, with a riser to each use; a ? on the rail where a use is handed in on some paths only <- uses[how=handed|mixed]',
28
28
  rack: 'right of the wall: db, model, net, always in this order; struck = the engine checked and found none <- resources[], ghostResources[]',
@@ -39,7 +39,7 @@ export const VOCABULARY = {
39
39
  reachedBy: 'under each resource in the rack: how many routes reach it, the resource drawn at that share of the routes (never smaller than its name) <- entries[].db, model, net, beside.resources',
40
40
  netRing: 'orange ring: the entry reaches the network <- entries[].net',
41
41
  guard: 'red diamond on the path where the check happens, its name above, failure stem below <- entries[].guards, wrappers[].guards, entries[].passed[].guards, entries[].beside.guards',
42
- hull: 'translucent region in the hull band: paths merge into it, run one shared stretch, and split; everything inside it runs inside the wrapper <- wrappers[]',
42
+ hull: 'translucent region in the hull band: paths merge into it, run one shared stretch, and split; everything inside it runs inside the wrapper; its right wall, drawn in the hull ink, is where the wrapper returns <- wrappers[]',
43
43
  socket: 'broken-rim ring: a function the caller hands in runs here; its centre fills like a station <- entries[].passed[]',
44
44
  token: 'small grey word under a path: the value the caller fixes for the wrapper <- entries[].policy',
45
45
  afterDot: 'dot in the after band: a call the entry makes after its wrapper returns <- entries[].beside.calls',
@@ -62,7 +62,7 @@ export const VOCABULARY = {
62
62
  starter: 'dashed line from a route\'s reply bar into the program top: the route calls the step with its values and acts on the answer, found by value flow; a route that runs an engine is named under its loop instead <- programs[].starters[advances=false]',
63
63
  gap: 'struck opening: a request constructor no engine handles <- programs[].gaps[]',
64
64
  wire: 'line from an opening to the lane that handles it, ending in the names of the resources the arm reaches, or a dot each where they do not fit <- programs[].requests[].handledBy',
65
- lane: 'thin vertical line on the resource side: an engine that performs the program\'s requests, its package above it and what it does written from its type (its own parameters → what its effect yields), its code name and whole type on hover; it ends in the resources its arms reach, then dashed pills under "also" for resources it reaches for no request <- programs[].engines[], pkgLabel, type, resources, also',
65
+ lane: 'thin vertical line on the resource side: an engine that performs the program\'s requests, its package above it (package.definition where lanes share one) and what it does written from its type (its own parameters → what its effect yields), its code name and whole type on hover; it ends in the resources its arms reach, then dashed pills under "also" for resources it reaches for no request <- programs[].engines[], pkgLabel, type, resources, also',
66
66
  laneFork: 'bar and number on a lane: the engine\'s decisions, on the program\'s scale; 0 = a rule-free engine <- programs[].engines[].decisions',
67
67
  replyLoop: 'one solid arc from a lane\'s foot back into the program, with one loop node naming what repeats the step within one call, and "each call of … runs it" naming the routes that run the rounds <- programs[].engines[].replies, loop, starters',
68
68
  replaced: 'a check named "(in place of …)": in the compared base record the same route had that other check at the same place, and it is on that route no longer; says nothing about whether the two do the same <- compare.replaced[]',
@@ -269,6 +269,11 @@ const afterRoom = (b, from, xr) => {
269
269
  return b.guards.length ? xr - 2 - (from + (xr - from) / (n + 1)) - 6 : xr - from - 8;
270
270
  };
271
271
 
272
+ // A hull's right wall on each path that leaves it, where the wrapper returns:
273
+ // whatever the path passes after it runs after the wrapper.
274
+ const hullWall = (x, ys, phone) =>
275
+ `<path d="${ys.map(y => `M${f1(x)} ${f1(y - (phone ? 8 : 10))} V${f1(y + (phone ? 8 : 10))}`).join(' ')}" stroke="${C.hullInk}" stroke-width="3"/>`;
276
+
272
277
  export function renderSystemMap(map, opts = {}) {
273
278
  const W = opts.width || 1100;
274
279
  return inFrame(W, () => map.kind === 'service' ? renderService(map, W, opts) : renderLibrary(map, W, opts));
@@ -435,7 +440,7 @@ function renderService(map, W, opts) {
435
440
  // path that takes two lines lifts the row.
436
441
  const roleText = e => [...new Set(e.members.flatMap(m => m.roles))].map(r => dense && rolePrefix ? r.slice(rolePrefix.length) : r).join(', ');
437
442
  // the lines a fork's types take from x to end (none when not even a letter fits: then they are its tooltip)
438
- const forkLines = (types, room) => (!types.length || dense ? [] : room < tw(`${types.join(',')[0]}…`, fsS) ? [''] : wrapped(types.join(','), room, { size: fsS }));
443
+ const forkLines = (types, room) => (dense ? [] : forkTypeLines(types, room, fsS));
439
444
  const gapL = lineGap(fs);
440
445
  const titleSz = phone ? 9.5 : 10.5;
441
446
  // a hull's name over its first row, on as many lines as the gap between risers takes
@@ -678,7 +683,8 @@ function renderService(map, W, opts) {
678
683
  const first = members[0], last = members[members.length - 1];
679
684
  const hy0 = Math.min(...ys) - (phone ? 20 : 24) - first._lay.lift, hy1 = Math.max(...ys) + (phone ? 18 : 22) + lead * Math.max(0, last._lay.lines - 1);
680
685
  const hull = `hull:${w.id}`;
681
- under.push(tg([`wrapper:${w.id}`], `<g data-mark="hull"${opener(w.id, `Open ${w.label}`)}><rect x="${f1(hx0)}" y="${f1(hy0)}" width="${f1(hx1 - hx0)}" height="${f1(hy1 - hy0)}" rx="${phone ? 9 : 13}" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1.2"${boxAttr(hull)}/></g>`));
686
+ under.push(tg([`wrapper:${w.id}`], `<g data-mark="hull"${opener(w.id, `Open ${w.label}`)}><rect x="${f1(hx0)}" y="${f1(hy0)}" width="${f1(hx1 - hx0)}" height="${f1(hy1 - hy0)}" rx="${phone ? 9 : 13}" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1.2"${boxAttr(hull)}/>` +
687
+ hullWall(hx1, ys, phone) + '</g>'));
682
688
  { const { ha, lines } = hullTitle.get(w.id);
683
689
  out.push(Tw(ha, hy0 - (phone ? 4 : 5), lines, { size: titleSz, weight: 700, fill: C.hullInk }, w.label, lineGap(titleSz), 'up')); }
684
690
  for (const e of members) {
@@ -893,6 +899,10 @@ export const wrapAt = (text, width, size, mono = true) => {
893
899
  return lines;
894
900
  };
895
901
  const RES_WORD = { db: 'db', model: 'model', net: 'net', compile: 'compile', run: 'run', io: 'io' };
902
+ // A lane is named by its package, and by its own definition where another lane shares that package.
903
+ export const laneName = (program, engine) =>
904
+ (program.engines.some(x => x !== engine && x.pkg === engine.pkg) ? `${engine.pkgLabel}.${engine.label}` : engine.pkgLabel);
905
+
896
906
  export function programBand(programs, x0, x1, y0, phone, rows = null) {
897
907
  const under = [], out = [], over = [];
898
908
  // a label that fits the room it is given: shrunk a little, then cut, with
@@ -997,10 +1007,11 @@ export function programBand(programs, x0, x1, y0, phone, rows = null) {
997
1007
  // what an engine does, from its type: what it takes of the program's own and what its effect yields; its code name on hover
998
1008
  // (the arrow stays with what it gives, so a wrapped line never ends on it)
999
1009
  const sigOf = e => `${e.type.plainTakes.join(', ') || '()'} →\u00a0${e.type.yields}`;
1010
+ const nameOf = e => laneName(p, e);
1000
1011
  const codeOf = e => `${e.label}: ${e.type.takes.join(', ')} → ${e.type.returns}`;
1001
1012
  // above each lane: its package, then what it does on at most two lines, when every lane's words fit there
1002
1013
  const sigLines = e => wrapAt(sigOf(e), spacing - 6, fsS);
1003
- const wide = p.engines.every(x => tw(x.pkgLabel, fsS) <= spacing - 6 && sigLines(x).length <= 2 && sigLines(x).every(l => tw(l, fsS) <= spacing - 6));
1014
+ const wide = p.engines.every(x => tw(nameOf(x), fsS) <= spacing - 6 && sigLines(x).length <= 2 && sigLines(x).every(l => tw(l, fsS) <= spacing - 6));
1004
1015
  const laneTop = top + headH - (phone ? 6 : 8);
1005
1016
  const resH = phone ? 15 : 18, resGap = phone ? 4 : 5;
1006
1017
  const laneBot = Math.max(bot, openY.length ? openY[openY.length - 1] + 14 : bot, ...p.engines.map(e => laneTop + barH(e.decisions) + 8));
@@ -1017,7 +1028,9 @@ export function programBand(programs, x0, x1, y0, phone, rows = null) {
1017
1028
  const how = e.loop.kind === 'repeat' ? `${e.loop.label} repeats the round` : e.loop.kind === 'recursion' ? `${e.loop.label} calls itself, a round each time` : 'rounds written out one after another';
1018
1029
  const reply = `replies ${e.replies.map(rs => RES_WORD[rs]).join(', ')}`;
1019
1030
  const lines = [];
1020
- if (!wide) lines.push([`${i + 1} ${e.pkgLabel}: ${sigOf(e)}`]);
1031
+ // a name too long for its room breaks after its package, so the definition that tells lanes apart stays whole
1032
+ if (!wide && nameOf(e) !== e.pkgLabel && tw(`${i + 1} ${nameOf(e)}:`, fsS) > capRoom(i)) lines.push([`${i + 1} ${e.pkgLabel}.`], [`${e.label}: ${sigOf(e)}`]);
1033
+ else if (!wide) lines.push([`${i + 1} ${nameOf(e)}: ${sigOf(e)}`]);
1021
1034
  lines.push(phone ? [how] : [`${how} · ${reply}`]);
1022
1035
  if (phone && e.replies.length) lines.push([reply]);
1023
1036
  if (e.starters.length) lines.push([`each call of ${e.starters.map(k => k.replace(/^route:/, '')).join(', ')} runs ${e.loop.kind === 'sequence' ? 'them' : 'it'}`, e.starters.map(k => `starter:${p.id}:${k}`)]);
@@ -1106,7 +1119,7 @@ export function programBand(programs, x0, x1, y0, phone, rows = null) {
1106
1119
  // its package and name above the lane, or a number when the lanes are close
1107
1120
  if (wide) {
1108
1121
  const sl = sigLines(e), ly0 = laneTop - (phone ? 5 : 6) - (sl.length - 1) * lead;
1109
- out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, ly0 - lead, e.pkgLabel, { anchor: 'middle', size: fsS, fill: C.muted }) +
1122
+ out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, ly0 - lead, nameOf(e), { anchor: 'middle', size: fsS, fill: C.muted }) +
1110
1123
  sl.map((l, j) => T(lx, ly0 + j * lead, l, { anchor: 'middle', size: fsS, weight: 700, fill: C.ink })).join('') + '</g>'));
1111
1124
  } else out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, laneTop - (phone ? 5 : 6), String(i + 1), { anchor: 'middle', size: fsS, weight: 700, fill: C.ink }) + '</g>'));
1112
1125
  // the engine's own decisions, on the program's scale; its bar gives up width so that the count fits before the next lane
@@ -1214,9 +1227,26 @@ function wallPathGaps(x0, x1, yTop, yBot, openings, r = 14) {
1214
1227
  // line; each package is a stretch starting with a tick and its name; the
1215
1228
  // parts' labels alternate above and below the line so it never wraps at
1216
1229
  // phone width. A package with many forks draws one fork with a count.
1230
+ // The lines a fork's types take in `room` at `size`: none for no types, one
1231
+ // empty line when not even a letter fits (the types are then its tooltip).
1232
+ // Lines break between types, so a long list keeps its lines, a type too
1233
+ // long for a line clipped where the line ends. Only a type broken mid-word
1234
+ // across lines (a line that ends inside a type) with the rest clipped puts
1235
+ // the types on one line, clipped.
1236
+ export function forkTypeLines(types, room, size) {
1237
+ const s = types.join(',');
1238
+ if (!types.length) return [];
1239
+ if (room < tw(`${s[0]}…`, size)) return [''];
1240
+ const ls = wrapped(s, room, { size });
1241
+ const brokenMidWord = ls.slice(0, -1).some(l => !l.endsWith(',')) && ls.slice(1).some(l => l.endsWith('…'));
1242
+ return brokenMidWord ? [clip(s, size, room)] : ls;
1243
+ }
1244
+
1217
1245
  // A gathered mark's count, said in words: what it counts, never a bare ×n.
1218
1246
  const SET_WORDS = { forkset: ['choice', 'choices'], dbset: ['db use', 'db uses'], genset: ['generic part', 'generic parts'], stepset: ['step', 'steps'] };
1219
1247
  export const setWord = (kind, n) => `${n} ${SET_WORDS[kind][n === 1 ? 0 : 1]}`;
1248
+ // a db use off every path whose handle is handed in on some paths only: a ? beside its station
1249
+ const strayUnknown = (x, y, phone) => mark.unknown(x + (phone ? 6 : 7.5), y - (phone ? 6 : 7.5), phone ? 3.6 : 4.4);
1220
1250
 
1221
1251
  export function quietItems(map, placed, { services = false, dense = false, phone = false, drawn = new Set(), socketFns = new Map(), genericById = new Map(), exportsLeft = [], strays = [] } = {}) {
1222
1252
  const pkgOf = id => String(id).split('::')[0];
@@ -1237,7 +1267,8 @@ export function quietItems(map, placed, { services = false, dense = false, phone
1237
1267
  for (const u of strays) { const p = pkgOf(u.definition); pk(p); if (!strayBy.has(p)) strayBy.set(p, []); strayBy.get(p).push(u); }
1238
1268
  for (const [p, us] of strayBy) {
1239
1269
  const defs = [...new Set(us.map(u => u.definition))];
1240
- P.get(p).parts.push({ kind: 'dbset', label: defs.length === 1 && !dense ? bare(defs[0]) : setWord('dbset', defs.length), n: defs.length, writes: us.some(u => u.writes), keys: us.map(u => `use:${u.i}`) });
1270
+ const mixed = us.filter(u => u.how === 'mixed');
1271
+ P.get(p).parts.push({ kind: 'dbset', label: defs.length === 1 && !dense ? bare(defs[0]) : setWord('dbset', defs.length), n: defs.length, writes: us.some(u => u.writes), mixed: mixed.length > 0, keys: [...us.map(u => `use:${u.i}`), ...mixed.map(u => `unknown:${u.i}`)] });
1241
1272
  }
1242
1273
  // forks (and bare generic parts) of one package gathered under one mark with a count when the line would be long
1243
1274
  const total = [...list.values()].reduce((n, l) => n + l.length, 0);
@@ -1264,7 +1295,7 @@ export function quietItems(map, placed, { services = false, dense = false, phone
1264
1295
  const forks = of('forkset'), dbs = of('dbset'), others = all.filter(it => !forks.includes(it) && !dbs.includes(it));
1265
1296
  const n = list => list.reduce((c, it) => c + (it.n || 1), 0);
1266
1297
  if (forks.length) parts.push({ kind: 'forkset', label: setWord('forkset', n(forks)), n: n(forks), fork: [...new Set(forks.flatMap(it => it.fork))], keys: forks.flatMap(it => it.keys) });
1267
- if (dbs.length) parts.push({ kind: 'dbset', label: setWord('dbset', n(dbs)), n: n(dbs), writes: dbs.some(it => it.writes), keys: dbs.flatMap(it => it.keys) });
1298
+ if (dbs.length) parts.push({ kind: 'dbset', label: setWord('dbset', n(dbs)), n: n(dbs), writes: dbs.some(it => it.writes), mixed: dbs.some(it => it.mixed), keys: dbs.flatMap(it => it.keys) });
1268
1299
  parts.push(...others);
1269
1300
  if (steps.length) parts.push({ kind: 'stepset', label: setWord('stepset', steps.length), n: steps.length, keys: steps.map(st => `step:${st.pkg}:${st.type}`) });
1270
1301
  return [{ pkg: '', exports: [], steps: [], parts, gathered: groups.length }];
@@ -1377,6 +1408,7 @@ function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
1377
1408
  g.parts.forEach((it, i) => {
1378
1409
  const x = xs[i];
1379
1410
  let gg = it.kind === 'forkset' ? mark.fork(x - (phone ? 3 : 4), ry, phone ? 0.7 : 0.85) : it.kind === 'dbset' ? mark.station(x, ry, phone ? 3.6 : 4.6, it.writes) : it.socket ? mark.socket(x, ry, phone ? 4.2 : 5.4) : mark.dot(x, ry, phone ? 2.8 : 3.4);
1411
+ if (it.mixed) gg += strayUnknown(x, ry, phone);
1380
1412
  if (it.fork) gg += mark.fork(x + 1, ry - (phone ? 7 : 9), phone ? 0.5 : 0.6);
1381
1413
  gg += rot(x + 2, ry - (phone ? 12 : 15), -ang, ups.get(it));
1382
1414
  over.push(tg(it.keys, gg));
@@ -1455,7 +1487,7 @@ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
1455
1487
  // a step set's count starts past its loop's arrowhead
1456
1488
  let gg = '', tx = it.kind === 'stepset' ? x + (phone ? 6 : 8) : x - 3;
1457
1489
  if (it.kind === 'forkset') gg += mark.fork(x - (phone ? 3 : 4), ry, phone ? 0.75 : 0.9);
1458
- else if (it.kind === 'dbset') gg += mark.station(x, ry, phone ? 3.6 : 4.6, it.writes);
1490
+ else if (it.kind === 'dbset') gg += mark.station(x, ry, phone ? 3.6 : 4.6, it.writes) + (it.mixed ? strayUnknown(x, ry, phone) : '');
1459
1491
  else if (it.kind === 'stepset') gg += mark.loop(x, ry - 0.5, phone ? 4.2 : 5.4);
1460
1492
  else if (it.socket) gg += mark.socket(x, ry, phone ? 4.6 : 6);
1461
1493
  else gg += mark.dot(x, ry, phone ? 3 : 3.8);
@@ -1986,7 +2018,7 @@ function renderPart(map, partId, opts) {
1986
2018
  const yBot = railUses.length ? busY + (phone ? 18 : 22) : rowsBottom + (phone ? 46 : 52);
1987
2019
  const wall = wallPath(x0, x1, yTop, yBot, ys.concat(railUses.length ? [busY] : []), phone ? 6 : 7);
1988
2020
  const casing = part.station ? 'canvas' : 'casing';
1989
- if (!part.station) under.push(shape('hull', `<rect x="${x0 + 8}" y="${yTop + 8}" width="${f1(caseEnd - x0 - 8)}" height="${yBot - yTop - 16}" rx="12" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1"${boxAttr(casing)}/>`));
2021
+ if (!part.station) under.push(shape('hull', `<rect x="${x0 + 8}" y="${yTop + 8}" width="${f1(caseEnd - x0 - 8)}" height="${yBot - yTop - 16}" rx="12" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1"${boxAttr(casing)}/>` + hullWall(caseEnd, ys, phone)));
1990
2022
  // a fork under a mark, its types from line k ending by `end`
1991
2023
  const forkAt = (ids, x, y, k, end) => {
1992
2024
  const ks = keyed(ids);
@@ -28,10 +28,10 @@ export const VOCABULARY = {
28
28
  netRing: 'orange ring: the path reaches the network <- effects[resource=net]',
29
29
  outcome: 'the end of a path: a cap with what the request gets back (a constructor, or a refusal with its status and code); red when a claim fails there or a check reports a witness that ends there, green when every obligation that failed there at the base is checked here and holds, hollow and dashed (unchecked) when a claim there is inconclusive or an obligation that failed at the base is no longer checked, grey otherwise <- leaves',
30
30
  unchecked: 'a hollow dashed cap and a ? tick: the engine cannot say whether the claim holds on this path (a step it could not open, a table it could not name, an answer it could not decide), or what failed here at the base is not checked here; never counted as holding <- claims[verdict=inconclusive], leaves[].gaps, diff.unresolved',
31
- struck: 'a struck ghost: something checked and found absent (an entry nobody described, a write a claim says a path must not make, "no base compared" when no earlier version was compared) <- ghosts[], claims holding with not',
31
+ struck: 'a struck ghost: something checked and found absent (an entry nobody described, a write a claim says a path must not make) <- ghosts[], claims holding with not',
32
32
  bundle: 'a cable with a count: a subtree too large for this view; opening it draws that part with a breadcrumb <- derived from the tree',
33
- compared: 'a line under the rack, always there: "compared with <base> · N fixed · M still failing", or "no base compared", struck <- diff',
34
- hull: 'a shaded box on the line: a step that is not the handler\'s own work, folded (a wrapper\'s guard or short-circuit, the platform\'s declared check or the transaction it runs the plan in, work beside the wrapper, a wrapper\'s work after the handler); it keeps what the step answers instead of running the handler, one row per arm that skips the handler, read as a side stub (the arm, its struck writes, what the request gets, ticks), then "continue" (a transaction: "runs"), and its effects; opening it draws the step\'s own tree; drawn inline, the same hull is a light band behind the step and its exits, with its role and where it comes from above its test, so either way it reads as not the handler\'s own work <- tree nodes[concern.role], entries[].steps',
33
+ compared: 'a line under the rack, always there: "compared with <base> · N fixed · M still failing", or "no base compared" in faint text <- diff',
34
+ hull: 'a shaded box on the line: a step that is not the handler\'s own work, folded (a wrapper\'s guard or short-circuit, the platform\'s declared check or the transaction it runs the plan in, work beside the wrapper, a wrapper\'s work after the handler); it keeps what the step answers instead of running the handler: the effects it makes before its test above the arms, then one row per arm read as a side stub (the arm, the effects that arm makes, its struck writes, what the request gets, ticks), then "continue"; opening it draws the step\'s own tree; drawn inline, the same hull is a light band behind the step and its exits, with its role and where it comes from above its test, so either way it reads as not the handler\'s own work <- tree nodes[concern.role], entries[].steps',
35
35
  },
36
36
  rule: 'every entry, branch, arm, effect and outcome the engine computes is drawn, a folded step\'s inside its box and an undescribed entry behind the rack\'s one line; the author\'s words appear only on ids the engine computed, in italic, with the code name beside them.',
37
37
  concerns: 'where each branch and effect comes from in value flow (concern.layer, effects[].layer): core = the handler\'s own work, a function it hands its wrapper or its body when it has none; cross = the wrapper its route shape names, shared with other entries; beside = the handler\'s code outside the wrapper call; platform = the route\'s declared authority, or the transaction the platform runs a plan in (its arms that give up or cannot confirm skip the handler\'s work). Roles come from which arms reach the handler, never from names.',
@@ -210,8 +210,15 @@ export function stationLabel(e) {
210
210
  if (e.resource === 'model' || e.resource === 'net') return verb;
211
211
  return `${verb} · ${bare(e.primitive)}`;
212
212
  }
213
+ // an unknown answer a station gave back (outcome.from), or a field of it,
214
+ // said in the station's words; any other answer keeps its key
215
+ const said = f => {
216
+ if (f.kind !== 'read') return null;
217
+ const gives = `what ${f.op} · ${f.table || bare(f.primitive)} gives back`;
218
+ return f.path && f.path.length ? `the ${f.path.join('.')} of ${gives}` : gives;
219
+ };
213
220
  export function outcomeLabel(o) {
214
221
  if (o.status && !o.constructors.length) return o.shown;
215
- const head = o.constructors.length ? o.constructors.join(' | ') : (o.value ? `the ${o.value}` : o.shown);
222
+ const head = o.constructors.length ? o.constructors.join(' | ') : ((o.from && said(o.from)) || (o.value ? `the ${o.value}` : o.shown));
216
223
  return head;
217
224
  }
@@ -3,7 +3,7 @@
3
3
  // copies from the engine. It never measures code.
4
4
  //
5
5
  // renderWhatHappens(record, { width, entry, open, cross, ghosts }) any width; 390 and 1100 are tuned
6
- // renderNotes(record) the author's text that is not a claim (HTML, outside the frame)
6
+ // renderNotes(record, entry) the author's text that is not a claim about that entry (HTML, outside the frame)
7
7
  //
8
8
  // The tree is the journey view's metro line turned on its side: a request
9
9
  // comes in at the top and runs down. The path that reaches the main outcome
@@ -41,7 +41,7 @@
41
41
  // (poteto) TLA+ diagrams.
42
42
 
43
43
  import { mark, tg, MIN_LABEL, COLORS as C } from './system-map.js';
44
- import { leavesOf, pathEffects, allEffects, stationLabel, outcomeLabel, isWriteOp } from './what-happens-record.js';
44
+ import { walk, leavesOf, pathEffects, allEffects, stationLabel, outcomeLabel, isWriteOp } from './what-happens-record.js';
45
45
  import { SANS, MONO, textWidth, clipText, boxAttr, fitAttr, labelAttr, labelled } from './label-fit.js';
46
46
 
47
47
  const RED = C.guard, GREEN = '#1f7a45', GREEN_SOFT = '#e4f4ea', RED_SOFT = '#fbe9e7';
@@ -109,32 +109,45 @@ export function foldOf(t) {
109
109
  const go = t.arms.filter(a => a.handler), skip = t.arms.filter(a => !a.handler);
110
110
  return go.length === 1 && skip.length ? { go: go[0], skip } : null;
111
111
  }
112
- // the status and codes, less what the label already says
112
+ // the statuses a path can be refused with: one, several, or none
113
+ const statusesOf = o => o.statuses || (o.status ? [o.status] : []);
114
+ // the status and codes, less what the label already says; an answer that
115
+ // may instead be refused says with what
113
116
  const shortDetail = o => {
114
- const head = outcomeLabel(o);
115
- return [o.status && !head.includes(String(o.status)) ? String(o.status) : '', ...o.codes.filter(c => !head.split(' ').includes(c))].filter(Boolean).join(' ');
117
+ const head = outcomeLabel(o), statuses = statusesOf(o).filter(s => !head.includes(String(s)));
118
+ const detail = [statuses.join(' or '), ...o.codes.filter(c => !head.split(' ').includes(c))].filter(Boolean).join(' ');
119
+ return o.kind !== 'refused' && detail ? `or refused ${detail}` : detail;
116
120
  };
121
+ // A refusal with no status is a transaction that did not commit: its writes
122
+ // are undone and the value goes back as the request's answer, not as an
123
+ // error status.
124
+ const refusedWord = o => (o.kind !== 'refused' ? '' : statusesOf(o).length ? 'refused ' : 'undone · answers ');
117
125
  const outcomeText = o => {
118
126
  const head = outcomeLabel(o), detail = shortDetail(o);
119
- return `${o.kind === 'refused' ? 'refused ' : ''}${head}${detail ? ` ${detail}` : ''}`;
127
+ return `${refusedWord(o)}${head}${detail ? ` ${detail}` : ''}`;
120
128
  };
121
129
  // What a folded step can answer instead of running the handler: one row per
122
- // arm that skips it, read as a side stub reads (the arm, the writes a claim
123
- // says it does not make, struck, then what the request gets, its witness
124
- // number and the claims' ticks), then "continue" (a transaction: "runs").
130
+ // arm that skips it, read as a side stub reads (the arm, the effects it runs,
131
+ // the writes a claim says it does not make, struck, then what the request
132
+ // gets, its witness number and the claims' ticks), then "continue".
125
133
  // A skip that is not a refusal still ends the request there: "ends Denied".
126
134
  const humane = p => p.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
127
135
  const absentOf = l => l.claims.filter(holdsAbsent).map(c => {
128
136
  const { is: verb, subject: table } = c.property;
129
137
  return `${verb === 'writes' ? 'write' : 'read'} · ${table}`;
130
138
  });
131
- const endText = o => (o.kind === 'refused' ? outcomeText(o) : `ends ${outcomeLabel(o)}`);
139
+ const endText = o => (o.kind === 'refused' ? outcomeText(o) : `ends ${outcomeText(o)}`);
140
+ // a step's own effects: not the handler's work, and not a wrapper's work after it
141
+ const notCore = e => !isCore(e) && e.phase !== 'after';
142
+ // the effects an arm runs that are not the handler's own work, on it and under it
143
+ const skippedEffects = a => { const out = [...a.effects]; walk(a.next, x => { if (!x.kind) out.push(...x.effects); }); return out.filter(notCore); };
132
144
  export function foldArms(t, witnessNo = new Map()) {
133
145
  const f = foldOf(t);
134
146
  if (!f) return null;
135
147
  const rows = f.skip.map(a => {
136
148
  const ls = leavesOf(a.next);
137
149
  const segs = [{ text: a.words || (a.pattern === '_' ? 'otherwise' : t.from && t.from.kind === 'platform' ? humane(a.pattern) : a.pattern), italic: !!a.words, mono: !a.words, fill: a.words ? C.ink : C.muted }];
150
+ for (const w of [...new Set(skippedEffects(a).map(stationLabel))]) segs.push({ text: w, fill: C.hullInk });
138
151
  for (const w of [...new Set(ls.flatMap(absentOf))]) segs.push({ text: w, struck: true, fill: C.faint });
139
152
  segs.push({ text: '→', mono: false, fill: C.faint });
140
153
  ls.forEach((l, i) => {
@@ -145,7 +158,7 @@ export function foldArms(t, witnessNo = new Map()) {
145
158
  });
146
159
  return segs;
147
160
  });
148
- return [...rows, [{ text: t.from && t.from.kind === 'platform' ? f.go.arm : 'continue', fill: C.ink2 }]];
161
+ return [...rows, [{ text: 'continue', fill: C.ink2 }]];
149
162
  }
150
163
  export function foldOutcomes(t) {
151
164
  const rows = foldArms(t);
@@ -157,6 +170,8 @@ function armInto(tree, node) {
157
170
  go(tree);
158
171
  return hit;
159
172
  }
173
+ // the effects a step makes before its test: on the way into it
174
+ const incomingOf = (entry, node) => ((armInto(entry.tree, node) || entry).effects || []).filter(notCore);
160
175
  const effectKey = e => `${e.primitive}|${e.site}|${e.line}|${e.tables.join(',')}|${e.phase || ''}`;
161
176
  function findNode(tree, id) {
162
177
  let hit = null;
@@ -220,8 +235,12 @@ export function renderWhatHappens(record, opts = {}) {
220
235
  // the folded steps: each node step's effects go in its box, and so do a
221
236
  // wrapper's effects after the handler and the work beside it
222
237
  P.paths = pathEffects(entry);
223
- P.steps = new Map((entry.steps || []).filter(st => st.node).map(st => [st.node, st]));
224
- P.hidden = new Set((entry.steps || []).filter(st => !st.node || foldOf(findNode(entry.tree, st.node))).flatMap(st => st.effects.map(effectKey)));
238
+ // a folded step draws the effects on its way in; its skip arms are not drawn
239
+ P.hidden = new Set((entry.steps || []).flatMap(st => {
240
+ if (!st.node) return st.effects;
241
+ const n = findNode(entry.tree, st.node);
242
+ return foldOf(n) ? incomingOf(entry, n) : [];
243
+ }).map(effectKey));
225
244
  const x0 = P.pad + (phone ? 6 : 12);
226
245
  y = entryHead(entry, root || stepNode, record, P, x0, y, out, opts.open);
227
246
  const subject = root ? root.next : stepNode || entry.tree;
@@ -297,8 +316,7 @@ function rack(record, selected, P, y, out, expanded) {
297
316
  function compared(record, P, y, out) {
298
317
  const x = P.pad + (P.phone ? 6 : 12), d = record.diff;
299
318
  if (!d) {
300
- const text = 'no base compared', w = width(text, P.fsS, false);
301
- out.push(tg(['compared:none'], `<g${labelAttr('compared')}>${title('no earlier version of the program was compared, so nothing here says what was fixed')}${T(x, y, text, { size: P.fsS, fill: C.faint, mono: false })}${shape('struck', `<path d="M${f1(x - 1)} ${f1(y - 3.5)} H${f1(x + w + 1)}" stroke="${C.faint}" stroke-width="1.1"/>`)}</g>`));
319
+ out.push(tg(['compared:none'], `<g${labelAttr('compared')}>${title('no earlier version of the program was compared, so nothing here says what was fixed')}${T(x, y, 'no base compared', { size: P.fsS, fill: C.faint, mono: false })}</g>`));
302
320
  return y + 4;
303
321
  }
304
322
  const parts = [[`compared with ${d.base}`, C.muted, null], [`${d.fixed.length} fixed`, d.fixed.length ? GREEN : C.muted, 600], [`${d.stillRed.length} still failing`, d.stillRed.length ? RED : C.muted, 600]];
@@ -362,15 +380,16 @@ function stations(effects, x, y, P, out, key) {
362
380
  // One subtree from (x, y) down. Returns the y where it ends.
363
381
  // A folded step: one shaded box on the line (the map's hull), its role,
364
382
  // what it answers instead of running the handler, and its effects.
365
- function foldBox(x, y, P, out, { key, role, head, outs, effects, open }) {
383
+ function foldBox(x, y, P, out, { key, role, head, before = [], outs, effects = [], open }) {
366
384
  const lx = x + (P.phone ? 13 : 16), room = P.W - P.pad - lx - 10;
367
385
  const lh = P.row * 0.82, fs = P.fsS;
368
386
  // each row is a list of pieces laid out left to right; a piece that does
369
387
  // not fit starts the next line, indented
370
388
  const rows = [];
371
389
  for (const l of lines(head, fs, room - 8, false, 3, 700)) rows.push([{ text: l, fill: C.hullInk, weight: 700, mono: false }]);
390
+ const pre = [...new Set(before.map(stationLabel))], eff = [...new Set(effects.map(stationLabel))];
391
+ if (pre.length) for (const l of lines(pre.join(', '), fs, room, true, 2)) rows.push([{ text: l, fill: C.hullInk }]);
372
392
  for (const o of outs) rows.push(typeof o === 'string' ? [{ text: o, fill: C.ink2 }] : o);
373
- const eff = [...new Set((effects || []).map(stationLabel))];
374
393
  if (eff.length) for (const l of lines(eff.join(', '), fs, room, true, 2)) rows.push([{ text: l, fill: C.hullInk }]);
375
394
  const placed = [];
376
395
  let line_ = 0, wMax = 0;
@@ -398,7 +417,7 @@ function foldBox(x, y, P, out, { key, role, head, outs, effects, open }) {
398
417
  if (strike) g += shape('struck', `<path d="M${f1(px)} ${f1(py - 3.5)} H${f1(px + p.w)}" stroke="${C.faint}" stroke-width="1"${labelAttr(strike)}/>`);
399
418
  }
400
419
  const said = outs.map(o => (typeof o === 'string' ? o : o.map(x => (x.struck ? `no ${x.text}` : x.text)).join(' ')));
401
- const what = `${head}: ${said.join('; ')}${eff.length ? `; ${eff.join(', ')}` : ''}`;
420
+ const what = `${head}: ${pre.length ? `${pre.join(', ')}; ` : ''}${said.join('; ')}${eff.length ? `; ${eff.join(', ')}` : ''}`;
402
421
  out.push(tg([`fold:${key}`], `<g data-fold="${esc(key)}" data-role="${esc(role)}"${open ? opener(open, `Open ${what}`) : ''}>${title(what)}${g}</g>`));
403
422
  return top + h + P.row * 0.35;
404
423
  }
@@ -418,9 +437,9 @@ function tree(t, depth, x, y, P, out, entry, top) {
418
437
  const f = P.cross === 'folded' && P.stepRoot !== t.id ? foldOf(t) : null;
419
438
  if (f) {
420
439
  const [role, where] = stepParts(t);
421
- const effects = P.steps.get(t.id)?.effects || [];
440
+ const before = incomingOf(entry, t);
422
441
  y = foldBox(x, y, P, out, { key: t.id, role: t.concern.role, head: `${role} · ${t.words || t.test} · ${where}`,
423
- outs: foldArms(t, P.witnessNo), effects, open: `step:${t.id}` });
442
+ before, outs: foldArms(t, P.witnessNo), open: `step:${t.id}` });
424
443
  y = stations(f.go.effects, x, y, P, out, f.go.id);
425
444
  return sub(f.go, depth + 1, x, y, P, out, entry);
426
445
  }
@@ -530,7 +549,7 @@ function stub(a, x, y, P, out, guard) {
530
549
  const head = outcomeLabel(l.outcome), detail = shortDetail(l.outcome);
531
550
  const no = P.witnessNo.get(l.id), badge = no ? ` [${no}]` : '';
532
551
  const color = statusInk(l.status);
533
- const tt = clip(`${refused ? 'refused ' : ''}${head}${badge}`, P.fsS, right - lx - 30);
552
+ const tt = clip(`${refusedWord(l.outcome)}${head}${badge}`, P.fsS, right - lx - 30);
534
553
  const ox = place(18 + width(tt, P.fsS, true));
535
554
  let g = outcomeMark(ox + 7, cy, l.status, refused, s) + T(ox + 18, cy + 3.5, tt, { size: P.fsS, fill: color, weight: 600 });
536
555
  if (detail) g += T(place(width(detail, P.fsS, true)), cy + 3.5, detail, { size: P.fsS, fill: C.muted });
@@ -541,7 +560,7 @@ function stub(a, x, y, P, out, guard) {
541
560
  if (l.status === 'green' && l.base) {
542
561
  cy += P.row * 0.85;
543
562
  const extra = l.base.effects.filter(e => !(P.paths.get(l.id) || []).some(h => h.op === e.op && h.tables.join() === e.tables.join())).map(stationLabel);
544
- const btxt = clip(`base: ${l.base.outcome.kind === 'refused' ? 'refused ' : ''}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, right - lx - 14);
563
+ const btxt = clip(`base: ${refusedWord(l.base.outcome)}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, right - lx - 14);
545
564
  g += `<g data-f="${encodeURIComponent(`base:${l.id}`)}">${title('the same leaf at the base, where it was red')}${outcomeMark(lx + 4, cy, 'red', l.base.outcome.kind === 'refused', s * 0.8, true)}${T(lx + 13, cy + 3.5, btxt, { size: P.fsS, fill: RED, opacity: 0.6 })}</g>`;
546
565
  }
547
566
  out.push(line(x, y - 2, x, cy + P.row * 0.45));
@@ -608,7 +627,7 @@ function leaf(l, x, y, P, out) {
608
627
  const badge = no ? ` [${no}]` : '';
609
628
  const claimsText = claimsTextOf(l);
610
629
  let g = outcomeMark(x, cy, l.status, refused, s);
611
- const t1 = clip(`${refused ? 'refused ' : ''}${head}${badge}`, P.fs, room);
630
+ const t1 = clip(`${refusedWord(l.outcome)}${head}${badge}`, P.fs, room);
612
631
  g += T(lx, cy + 4, t1, { size: P.fs, fill: color, weight: 600 });
613
632
  let after = width(t1, P.fs, true) + 8;
614
633
  const ticks = ticksOf(l);
@@ -621,7 +640,7 @@ function leaf(l, x, y, P, out) {
621
640
  yy += P.row * 0.95;
622
641
  const bx = lx + 6;
623
642
  const extra = l.base.effects.filter(e => !(P.paths.get(l.id) || []).some(h => h.op === e.op && h.tables.join() === e.tables.join())).map(stationLabel);
624
- const btxt = clip(`base: ${l.base.outcome.kind === 'refused' ? 'refused ' : ''}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, room - 14);
643
+ const btxt = clip(`base: ${refusedWord(l.base.outcome)}${outcomeLabel(l.base.outcome)}${extra.length ? ` after ${extra.join(', ')}` : ''}`, P.fsS, room - 14);
625
644
  g += `<g data-f="${encodeURIComponent(`base:${l.id}`)}">${title('the same leaf at the base, where it was red')}${outcomeMark(bx, yy - 3, 'red', l.base.outcome.kind === 'refused', s * 0.8, true)}${T(bx + 11, yy, btxt, { size: P.fsS, fill: RED, opacity: 0.6 })}</g>`;
626
645
  }
627
646
  const what = `${l.id}: ${l.outcome.shown}${claimsText ? ` · ${claimsText}` : ''}`;
@@ -658,10 +677,10 @@ function witnessList(record, entry, subject, P, x0, y, out) {
658
677
  }
659
678
 
660
679
  // The author's text the engine could not check, outside the frame.
661
- export function renderNotes(record) {
662
- const items = [];
663
- for (const n of record.notes) items.push(`<li><span class="wh-note-kind">not a claim</span> <q>${esc(n.text)}</q> <span class="wh-note-at">${esc(n.at)}</span></li>`);
664
- for (const r of record.refused) items.push(`<li><span class="wh-note-kind">refused</span> <q>${esc(r.words)}</q> names <code>${esc(r.id)}</code>, which the engine did not compute</li>`);
680
+ export function renderNotes(record, entry) {
681
+ const items = [], about = x => x.entry === entry;
682
+ for (const n of record.notes.filter(about)) items.push(`<li><span class="wh-note-kind">not a claim</span> <q>${esc(n.text)}</q> <span class="wh-note-at">${esc(n.at)}</span></li>`);
683
+ for (const r of record.refused.filter(about)) items.push(`<li><span class="wh-note-kind">refused</span> <q>${esc(r.words)}</q> names <code>${esc(r.id)}</code>, which the engine did not compute</li>`);
665
684
  for (const m of record.mismatches) items.push(`<li><span class="wh-note-kind">mismatch</span> a scenario names <code>${esc(m.entry)}</code>, which is not an entry of this program</li>`);
666
685
  if (!items.length) return '';
667
686
  return `<aside class="wh-notes" aria-label="Unverified notes"><h3>Unverified notes from the author</h3><p>Outside the frame: text that is not a checkable claim, and names for ids the engine did not compute. Nothing here is checked or drawn.</p><ul>${items.join('')}</ul></aside>`;
@@ -686,7 +705,6 @@ export function mount(root, record) {
686
705
  const drawing = document.createElement('div');
687
706
  drawing.className = 'system-map-drawing';
688
707
  const notes = document.createElement('div');
689
- notes.innerHTML = renderNotes(record);
690
708
  root.replaceChildren(crumb, toggle, drawing, notes);
691
709
  let w = 0;
692
710
  const draw = () => {
@@ -712,6 +730,7 @@ export function mount(root, record) {
712
730
  toggle.setAttribute('aria-pressed', String(cross === 'shown'));
713
731
  toggle.textContent = cross === 'folded' ? `Show the ${steps} wrapper and platform step${steps === 1 ? '' : 's'} inline` : 'Fold the wrapper and platform steps';
714
732
  drawing.innerHTML = renderWhatHappens(record, { width: w, entry, open, cross, ghosts });
733
+ notes.innerHTML = renderNotes(record, entry);
715
734
  };
716
735
  const choose = target => {
717
736
  const g = target.closest('[data-open]');
@@ -0,0 +1,29 @@
1
+ package Example/BalanceListedLaws
2
+
3
+ from Example/Balance import opening_balance
4
+ from Yichus/Law import Domain, Law, Obligation, FiniteObligation, Witness
5
+
6
+ export (never_negative,)
7
+ exposes (Yichus/Law)
8
+
9
+ # The person's rule, "a balance never goes below zero", checked only on
10
+ # the deposits listed here. Every one passes, yet a deposit of -1 breaks
11
+ # the rule: a listed check is bounded, never a proof for every input.
12
+
13
+ def show(deposit: Int) -> String: int_to_String(deposit)
14
+
15
+ def stays_above_zero(deposit: Int) -> Bool:
16
+ match cmp_Int(opening_balance(deposit), 0):
17
+ case LT: False
18
+ case _: True
19
+
20
+ def empty_account(deposit: Int) -> Bool:
21
+ match deposit:
22
+ case 0: True
23
+ case _: False
24
+
25
+ never_negative = FiniteObligation(
26
+ Obligation(Law("a balance never goes below zero", Domain("a first deposit", show, None, None), stays_above_zero),
27
+ [Witness("an account opened with nothing", empty_account)]),
28
+ [0, 5, 10]
29
+ )
@@ -0,0 +1,7 @@
1
+ package Example/Balance
2
+
3
+ export (opening_balance,)
4
+
5
+ # A new account opens with the first deposit as its balance. Nothing stops
6
+ # a negative deposit, so a balance can go below zero.
7
+ def opening_balance(deposit: Int) -> Int: deposit
@@ -0,0 +1,20 @@
1
+ # A calculation with no server
2
+
3
+ The studio gives you this file when the program has no actions people call and keeps no records: a calculator, a quote, a planning tool. Such a program needs no server, no database and no sign-in, so the studio asks the person nothing about where it runs, who may see records or what happens when two people act at once.
4
+
5
+ ## What the studio still does for it
6
+
7
+ - Every rule the person states about the numbers is a law the studio runs after each change: "more debt never raises what they can borrow", "the payment is never negative". Write each one (`yichus_reference` topic `laws`) and record it with `yichus_rules`. A law checked over every input is proven; one checked on sample inputs is bounded, and the page says which.
8
+ - The page shows the shape of the program in the person's words.
9
+
10
+ ## Putting it on a page
11
+
12
+ Yichus's calculator path turns the calculation into a page with sliders, results, charts and a Why button beside every number. Why shows the formula, the values that went into it and the source line it came from. The path runs in the person's browser, through a separate connection to the agent (`@yichus/mcp`, set up from the QuickStart at https://yich.us/start/quickstart.html, with the calculator walkthrough at https://yich.us/start/calculator.html). Keep the same calculation file: the laws the studio checked stay true of the page's numbers.
13
+
14
+ Tell the person, in product terms:
15
+
16
+ - The result is one page they can host anywhere and link to. Yichus does not document embedding it inside a site builder such as Squarespace or Wix.
17
+ - The saved page carries its own scripts and styles, so it keeps working wherever it is hosted, whatever happens to yich.us. It leaves out the formula editor the workspace has.
18
+ - Why explains what was calculated, not whether the model's assumptions suit their situation.
19
+
20
+ If the person needs their own branded page instead, build it the usual way around the same pure calculation, and say plainly that the Why buttons then are not there.
@@ -0,0 +1,37 @@
1
+ package Example/ConsentLaws
2
+
3
+ from Example/Consent import Consent, OptedIn, OptedOut, merge_consent
4
+ from Yichus/Law import Domain, Law, Obligation, Witness
5
+
6
+ export (opt_out_is_sticky,)
7
+ exposes (Example/Consent, Yichus/Law)
8
+
9
+ # The person's rule: "a fan who opts out stays opted out". The input is
10
+ # what the fan said before (nothing yet, in or out) and a new sign-up.
11
+
12
+ def said(choice: Consent) -> String:
13
+ match choice:
14
+ case OptedIn: "opted in"
15
+ case OptedOut: "opted out"
16
+
17
+ def show(input: (Option[Consent], Consent)) -> String:
18
+ (before, signup) = input
19
+ earlier = match before:
20
+ case Some(choice): said(choice)
21
+ case None: "new"
22
+ concat_String([earlier, ", then signs up ", said(signup)])
23
+
24
+ def stays_out(input: (Option[Consent], Consent)) -> Bool:
25
+ (before, signup) = input
26
+ match before:
27
+ case Some(OptedOut): merge_consent(before, signup) matches OptedOut
28
+ case _: True
29
+
30
+ def had_opted_out(input: (Option[Consent], Consent)) -> Bool:
31
+ (before, _) = input
32
+ before matches Some(OptedOut)
33
+
34
+ opt_out_is_sticky = Obligation(
35
+ Law("a fan who opted out stays opted out", Domain("an earlier choice and a new sign-up", show, None, None), stays_out),
36
+ [Witness("a fan who had opted out signs up again", had_opted_out)]
37
+ )
@@ -0,0 +1,15 @@
1
+ package Example/Consent
2
+
3
+ export (Consent(), merge_consent)
4
+
5
+ # What a fan said last about texts from an artist.
6
+ enum Consent:
7
+ OptedIn
8
+ OptedOut
9
+
10
+ # A fan who signs up again keeps an opt-out: only the fan's own "start"
11
+ # can undo it, never a form.
12
+ def merge_consent(before: Option[Consent], signup: Consent) -> Consent:
13
+ match before:
14
+ case Some(OptedOut): OptedOut
15
+ case _: signup
@@ -1,16 +1,16 @@
1
- # Where the app runs: what to build for each platform
1
+ # Where the app runs: how to put it online on each platform
2
2
 
3
- The studio gives you this file when the person has said where their app will run. It tells you what to build for that platform. Tell the person about the choices in product terms, such as "your app's server", "its database" or "the page people open". Never mention the programming language, file names or tool names.
3
+ The studio gives you this file when the person has said where their app will run. The program is the same on every platform. This file says how to put it online on each one: which pieces run where, the database, and how background work starts. Tell the person about the choices in product terms, such as "your app's server", "its database" or "the page people open". Never name the programming language, and never mention file names or tool names.
4
4
 
5
5
  ## What every platform has to provide
6
6
 
7
7
  `api_deploy` produces one JavaScript module, the API engine. You don't write a server of your own. The engine needs four things:
8
8
 
9
9
  - **A Node.js process that keeps running.** `startYichusApiEngine(port)` listens on `PORT` and answers every route as a POST of JSON. It also serves `GET /health`, `GET /openapi.json` and the MCP endpoint at `/mcp`. The process holds state between requests (the idempotency cache, the JWKS cache), so it has to be a server process. A platform that starts a fresh function for each request is not enough.
10
- - **A PostgreSQL database.** The default store, `YICHUS_STORE=memory`, loses everything on restart. In production, use `@yichus/postgres-store` through `YICHUS_STORE=adapter:<module>`. The module reads `DATABASE_URL` and a namespace from the environment, and the start script runs `migrateYichusPostgres` first. `mathstreams/deploy/store.mjs` and `mathstreams/deploy/server.mjs` in the Yichus repository show both parts. Transactions run at SERIALIZABLE isolation and retry a bounded number of times.
10
+ - **A PostgreSQL database.** The default store, `YICHUS_STORE=memory`, loses everything on restart. In production, use `@yichus/postgres-store` through `YICHUS_STORE=adapter:<module>`. The module reads `DATABASE_URL` and a namespace from the environment, and the start script runs `migrateYichusPostgres` first. `store.mjs` and `server.mjs`, which come with this file, are both parts, ready to copy. Transactions run at SERIALIZABLE isolation and retry a bounded number of times. The database can be one the person already has, such as their Supabase project's PostgreSQL: the store keeps its rows in a schema of its own (`yichus_runtime`), apart from the old tables, and its migration needs a role that may create that schema.
11
11
  - **An identity mode.** The engine refuses to listen without `YICHUS_AUTH`:
12
12
  - `jwt` takes `YICHUS_JWKS_URL` from any provider that publishes a JWKS, such as Auth0, Clerk, Amazon Cognito, Google Identity Platform or Firebase Auth, or Microsoft Entra ID.
13
- An app that already signs people in with Supabase Auth keeps it: set `YICHUS_JWT_PROFILE=supabase-session`, `YICHUS_JWT_ISSUER=https://<project>.supabase.co/auth/v1` and `YICHUS_JWKS_URL` to that issuer plus `/.well-known/jwks.json`. The project must sign with asymmetric keys (RS256 or ES256), not its legacy shared secret.
13
+ An app that already signs people in with Supabase Auth keeps it: set `YICHUS_JWT_PROFILE=supabase-session`, `YICHUS_JWT_ISSUER=https://<project>.supabase.co/auth/v1` and `YICHUS_JWKS_URL` to that issuer plus `/.well-known/jwks.json`. A project still on its legacy JWT secret (HS256) sets `YICHUS_JWT_SECRET` to that secret as well as, or instead of, `YICHUS_JWKS_URL`; ask the person which their project uses (Supabase's JWT settings show it), and keep the secret in the engine host's environment only.
14
14
  - `clerk-api-key` is for API keys.
15
15
  - `proxy-header` is only for running behind a proxy you control that sets the caller's id.
16
16
  - `dev` is never used in production.
@@ -28,7 +28,7 @@ Render runs the whole app in one place, and it is the simplest choice.
28
28
  - **Database:** Render Postgres. Connect it to the service's `DATABASE_URL` with `fromDatabase`.
29
29
  - **Page people open:** a Static Site in the same Blueprint, or the API alone if the person's users are other programs.
30
30
  - **Background work:** a Cron Job or a Background Worker that calls the advance route.
31
- - **Config:** one `render.yaml` Blueprint holds all of these. `web/quickstart-app/render.yaml` and `mathstreams/deploy/render.yaml` are working examples. Keep secrets (the auth keys and model keys) as `sync: false` environment variables set in the dashboard.
31
+ - **Config:** one `render.yaml` Blueprint holds all of these. The `render.yaml` that comes with this file is a working start for the server and its database. Keep secrets (the auth keys and model keys) as `sync: false` environment variables set in the dashboard.
32
32
 
33
33
  ## Vercel
34
34
 
@@ -40,7 +40,7 @@ Vercel is the place for the page people open, not for the API engine. Vercel Fun
40
40
 
41
41
  - **Webhooks and public forms that save something:** a public route only reads, so a provider's webhook (Twilio, Stripe, Meta) or a sign-up form a stranger submits goes to a Vercel route handler first. It checks the provider's signature against the exact request bytes, or the form's bot check, and then calls a `worker_route` on the engine with a worker secret (`yichusSecretWorkerVerifier`, kept in both hosts' environments). The engine's rules still decide what is saved.
42
42
 
43
- Tell the person their app lives in two places, the page on Vercel and the server elsewhere, and why.
43
+ Tell the person their app lives in two places, the page on Vercel and the server elsewhere, and why. The second place is a second account and a second monthly bill; the studio asks them where it goes.
44
44
 
45
45
  ## Netlify
46
46