yichus 0.3.2 → 0.3.4

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.
package/dist/main.js CHANGED
@@ -301,15 +301,16 @@ var VOCABULARY = {
301
301
  modelRing: "violet ring: the entry reaches the model <- entries[].model",
302
302
  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",
303
303
  netRing: "orange ring: the entry reaches the network <- entries[].net",
304
- 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",
304
+ guard: 'red diamond on the path where the route can refuse, the check named above it (the program\u2019s definition it calls, "<table> row found", "<table> row\u2019s <field>" for a branch on a field of a read row, or "a test in <definition>" for a comparison or library answer that definition branches on), failure stem below; a branch no path refuses on is not drawn here <- entries[].guards (structure.scenarios[]: branches where one arm ends only in refusals), wrappers[].guards, entries[].passed[].guards, entries[].beside.guards',
305
+ policy: "under the db, each table with its access policy as the access check proves it: own = each caller reads and writes only their own row; no row rule = the access check holds no rule over its rows, so who reads or writes which row is decided only by the code drawn on the routes; guarded = rows pass the named guard; role \u2026 only = only callers with that role reach its rows; unchecked = the access check gave this table no verdict; a policy ending \u201Cbroken\u201D is one the code breaks, and \u201Cnot proven\u201D one the check could not settle; counted when the tables are too many to list <- resources[kind=db].policies",
305
306
  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[]",
306
307
  socket: "broken-rim ring: a function the caller hands in runs here; its centre fills like a station <- entries[].passed[]",
307
308
  token: "small grey word under a path: the value the caller fixes for the wrapper <- entries[].policy",
308
309
  afterDot: "dot in the after band: a call the entry makes after its wrapper returns <- entries[].beside.calls",
309
310
  builder: "plus-disc under a station with the call that builds the handle there <- uses[how=local].builtBy",
310
311
  unknown: 'a ? in a circle on the rail, with "partly traced" beside it and a sentence on hover: the engine traced the handle only on some paths <- uses[how=mixed]',
311
- fork: "three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it, else on the quiet line <- keyed[]",
312
- bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route; opening it draws each one <- bundles[]',
312
+ fork: 'three prongs and a type: a choice keyed on a plain String or Int; under the mark that makes it (under a list of checks, named by the check it is inside: "can_see on String"), else on the quiet line <- keyed[]',
313
+ bundle: 'one cable with how many routes it carries ("4 routes"): entries with the same wrapper and identical marks; its title names every route: by the one part of the path that differs, among at most six ("/{tags, tasks}/sync"), or each by name when there are at most four; else a pattern with *; opening it draws each one <- bundles[]',
313
314
  anchor: "where the rail starts: platform when no definition of the program can make the handle; ? when the trace stops and the program could <- uses[].anchor",
314
315
  step: 'loop arc with a type on the quiet line: functions of the package take that type and return it; gathered, a count in words ("8 steps"), as are choices, db uses and generic parts <- steps[]',
315
316
  loop: "loop arc with package names: packages whose types lead back to themselves <- phases.cycles",
@@ -330,6 +331,7 @@ var VOCABULARY = {
330
331
  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 \u2026 runs it" naming the routes that run the rounds <- programs[].engines[].replies, loop, starters`,
331
332
  replaced: 'a check named "(in place of \u2026)": 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[]'
332
333
  },
334
+ names: "every check, call and table name is a label the code's author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.",
333
335
  rule: "every guard, wrapper, socket, fork, handle, station, step and resource the engine records is drawn at its fixed place, in every view; where nothing is drawn, the engine checked and found none."
334
336
  };
335
337
  var bare = (id) => String(id).split("::").pop();
@@ -449,9 +451,51 @@ function programsOf(st, layers = null) {
449
451
  });
450
452
  }
451
453
  var outsideResourcesOf = (steps) => uniq((steps || []).map((e) => e.resource).filter((r) => r === "model" || r === "net"));
454
+ var leavesUnder = (t) => t.kind === "leaf" ? [t] : t.arms.flatMap((a) => leavesUnder(a.next));
455
+ function refusingForks(tree) {
456
+ const out = [];
457
+ const go = (t) => {
458
+ if (!t || t.kind !== "node") return;
459
+ const refused = t.arms.map((a) => leavesUnder(a.next).every((l) => l.outcome.kind === "refused"));
460
+ if (t.concern?.layer !== "platform" && refused.some(Boolean) && !refused.every(Boolean)) out.push(t);
461
+ t.arms.forEach((a) => go(a.next));
462
+ };
463
+ go(tree);
464
+ return out;
465
+ }
466
+ var forkId = (n, own) => n.from.kind === "call" && own.has(n.from.callee) ? n.from.callee : n.id;
467
+ var forkParts = (n, own) => n.from.kind === "call" && own.has(n.from.callee) ? { definition: n.from.callee } : n.from.kind === "read" && n.from.table ? { table: n.from.table, ...n.from.path?.length ? { field: n.from.path.join(".") } : {} } : n.from.site ? { definition: n.from.site } : {};
468
+ var forkKey = (own) => (n) => n.from.kind === "call" && own.has(n.from.callee) ? n.from.callee : `${forkLabel(n, own)} ${n.from.site}`;
469
+ var forkLabel = (n, own) => n.from.kind === "call" ? own.has(n.from.callee) ? bare(n.from.callee) : `a test in ${bare(n.from.site)}` : n.from.kind === "read" && n.from.table ? n.from.path?.length ? `${n.from.table} row\u2019s ${n.from.path.join(".")}` : `${n.from.table} row found` : n.from.site ? `a test in ${bare(n.from.site)}` : "a computed branch";
470
+ var policyWord = (r) => {
471
+ const rule = policyRule(r);
472
+ if (rule === "unchecked") return rule;
473
+ return r.status === "violated" ? `${rule}, broken` : r.proven ? rule : `${rule}, not proven`;
474
+ };
475
+ var policyRule = (r) => {
476
+ const roles = () => r.roles.join(", "), guards = () => r.guards.join(", ");
477
+ switch (r.policyKind) {
478
+ case "OwnerScoped":
479
+ return "own";
480
+ case "Public":
481
+ return "no row rule";
482
+ case "Guarded":
483
+ return `guarded by ${guards()}`;
484
+ // with no guard, only the roles reach its rows
485
+ case "GuardedOrRoles":
486
+ return r.guards.length ? `guarded by ${guards()} or role ${roles()}` : `role ${roles()} only`;
487
+ case "PublicReadRoleWrite":
488
+ return `anyone reads; role ${roles()} writes`;
489
+ default:
490
+ return "unchecked";
491
+ }
492
+ };
452
493
  function buildSystemMap(engine, { program }) {
453
494
  for (const [k, v] of Object.entries(engine)) if (!v.ok) throw new Error(`${k}: ${v.error}`);
454
495
  const report = engine.report, st = engine.structure, abs = engine.abstractions.abstractions, layers = engine.layers.layers;
496
+ const whole = (s) => s.tree && !s.truncated && !s.unsupported;
497
+ const trees = new Map((st.scenarios || []).filter(whole).map((s) => [s.entry, refusingForks(s.tree)]));
498
+ const ownDefs = new Set(layers.packages.flatMap((p) => p.definitions.map((d) => `${p.name}::${d.name}`)));
455
499
  const routes = (report.surface.routes || []).slice().sort((a, b) => a.path.localeCompare(b.path));
456
500
  const engEntry = new Map(st.entries.map((e) => [e.entry, e]));
457
501
  const shapes = new Map(abs.routeShapes.map((r) => [r.path, r]));
@@ -466,11 +510,17 @@ function buildSystemMap(engine, { program }) {
466
510
  resources: outsideResourcesOf(inst.besideEffectSteps),
467
511
  from: "abstractions.instantiations[].beside*"
468
512
  });
513
+ const resultOf = (sig) => sig.includes("->") ? sig.slice(sig.lastIndexOf("->") + 2).trim() : null;
514
+ const returnsCallers = (g) => {
515
+ const result = resultOf(g.signature);
516
+ return result !== null && (/^[a-z]\w*$/.test(result) || (g.parameterGroups || []).flat().some((sl) => sl.role === "function" && sl.returnType === result));
517
+ };
518
+ const wraps = (g) => !!g && (/\bIO\[/.test(g.signature) || g.envelope.effects.length > 0 || returnsCallers(g) && (g.envelope.guards.length > 0 || g.envelope.dispatchesOn.length > 0));
469
519
  const entries = routes.map((r) => {
470
520
  const key = `route:${r.path}`;
471
521
  const ee = engEntry.get(key);
472
522
  const rs = shapes.get(r.path);
473
- const wrapper = rs.shapedByGenerator;
523
+ const wrapper = wraps(gens.get(rs.shapedByGenerator)) ? rs.shapedByGenerator : null;
474
524
  const inst = wrapper ? insts.find((i) => i.callerId === rs.handlerId && i.generatorId === wrapper) : null;
475
525
  const passed = (inst ? inst.slotBindings : []).filter((sb) => ["function", "effect"].includes(sb.slotRole)).map((sb) => ({
476
526
  slot: sb.slotName,
@@ -507,8 +557,9 @@ function buildSystemMap(engine, { program }) {
507
557
  model: ee.resources.includes("model"),
508
558
  net: ee.resources.includes("net"),
509
559
  writes: reach.some((e) => isWrite(e.op)),
510
- guards: rs.handlerGuards.map(bare),
511
- guardIds: rs.handlerGuards,
560
+ // every branch of the entry's own where a path refuses (a wrapper's are
561
+ // its own, and a function handed to it keeps its own slot)
562
+ ...trees.has(key) ? ((forks) => ({ guards: forks.map((n) => forkLabel(n, ownDefs)), guardIds: forks.map((n) => forkId(n, ownDefs)), guardParts: forks.map((n) => forkParts(n, ownDefs)) }))(uniqBy(trees.get(key).filter((n) => n.concern?.layer !== "cross" && !(inst ? inst.slotBindings : []).some((sb) => (sb.guards || []).includes(forkId(n, ownDefs)))), forkKey(ownDefs))) : { guards: rs.handlerGuards.map(bare), guardIds: rs.handlerGuards },
512
563
  wrapper,
513
564
  via: rs.viaSpecialization ? bare(rs.viaSpecialization) : null,
514
565
  policy: (inst ? inst.slotBindings : []).filter((s) => s.slotRole === "semantic-object").map((s) => s.value).filter(Boolean),
@@ -598,7 +649,12 @@ function buildSystemMap(engine, { program }) {
598
649
  }
599
650
  const present = uniq(st.ioKinds.flatMap((b) => b.resources));
600
651
  const tables = uniq(entries.flatMap((e) => [...e.reach, ...e.beside ? e.beside.effects : []].map((r) => r.table))).sort();
601
- const resources = ["db", "model", "net"].filter((k) => present.includes(k)).map((k) => ({ kind: k, ...k === "db" ? { tables } : {}, from: "structure.ioKinds[].resources" }));
652
+ const policies = engine.access ? Object.fromEntries(engine.access.report.resources.filter((r) => tables.includes(r.resource)).map((r) => [r.resource, policyWord(r)])) : null;
653
+ const resources = ["db", "model", "net"].filter((k) => present.includes(k)).map((k) => ({
654
+ kind: k,
655
+ ...k === "db" ? { tables, ...policies ? { policies, policyFrom: "access.report.resources[].policyKind, guards, roles" } : {} } : {},
656
+ from: "structure.ioKinds[].resources"
657
+ }));
602
658
  const ghostResources = ["db", "model", "net"].filter((k) => !present.includes(k)).map((k) => ({ kind: k, from: "structure.ioKinds[] checked: no site of this kind" }));
603
659
  const transports = uniq(st.entries.map((e) => e.transport));
604
660
  const callers = [];
@@ -712,13 +768,22 @@ function bundlesOf(entries, uses, keyed) {
712
768
  }
713
769
  function pathPattern(paths) {
714
770
  if (paths.length === 1) return paths[0];
771
+ const sorted = [...paths].sort();
715
772
  const segs = paths.map((p) => p.split("/"));
716
773
  const n = Math.min(...segs.map((s) => s.length));
717
774
  const out = [];
775
+ const vary = [];
718
776
  for (let i = 0; i < n; i++) {
719
777
  const v = uniq(segs.map((s) => s[i]));
720
778
  out.push(v.length === 1 ? v[0] : "*");
779
+ if (v.length > 1) vary.push([i, v]);
780
+ }
781
+ if (segs.every((s) => s.length === n) && vary.length === 1 && vary[0][1].length <= 6) {
782
+ const [[i, v]] = vary;
783
+ out[i] = `{${[...v].sort().join(", ")}}`;
721
784
  }
785
+ if (segs.every((s) => s.length === n) && vary.length <= 1) return out.join("/");
786
+ if (paths.length <= 4) return sorted.join(", ");
722
787
  if (segs.every((s) => s.length === n)) return out.join("/");
723
788
  let k = 0;
724
789
  while (k < n && out[k] !== "*") k++;
@@ -750,6 +815,7 @@ var STRUCTURAL = /* @__PURE__ */ new Set([
750
815
  "vars",
751
816
  "id"
752
817
  ]);
818
+ var DESCRIBED = /* @__PURE__ */ new Set(["guards", "policies", "policyFrom"]);
753
819
  var WHAT = {
754
820
  path: "an action the app answers",
755
821
  routes: "an action the app answers",
@@ -758,10 +824,15 @@ var WHAT = {
758
824
  roles: "a permission an action requires, held by a person's role or by the app's own server",
759
825
  policy: "a setting an action passes to shared code",
760
826
  tokens: "a setting an action passes to shared code",
761
- fields: "an operation a pluggable part offers"
827
+ fields: "an operation a pluggable part offers",
828
+ field: "a field of a kind of record the app keeps"
762
829
  };
763
830
  var PACKAGE = "a section of the program";
764
831
  var DEFINITION = "a piece of the program";
832
+ var MADE = "a kind of value the program works out";
833
+ var OFFERED = "a part the program offers for others to use";
834
+ var DEFINED = { makes: MADE, exports: OFFERED, exported: OFFERED };
835
+ var PACKAGE_KEYS = /* @__PURE__ */ new Set(["package", "pkg", "instantiatingPackages"]);
765
836
  var VALUE = "a kind of value";
766
837
  var TYPES = /* @__PURE__ */ new Set(["type", "types", "signature"]);
767
838
  var WORD = /[A-Za-z_][\w/]*::\w+|[A-Za-z_]\w*/g;
@@ -781,7 +852,7 @@ var leaves = (record, visit) => {
781
852
  if (typeof v === "string") visit(v, key);
782
853
  else if (Array.isArray(v)) v.forEach((x) => walk3(x, key));
783
854
  else if (v && typeof v === "object") {
784
- for (const [k, x] of Object.entries(v)) if (!skipped(k, x)) walk3(x, k);
855
+ for (const [k, x] of Object.entries(v)) if (!skipped(k, x) && !DESCRIBED.has(k)) walk3(x, k);
785
856
  }
786
857
  };
787
858
  walk3(record, null);
@@ -789,16 +860,19 @@ var leaves = (record, visit) => {
789
860
  function nameableParts(map, own) {
790
861
  const found = /* @__PURE__ */ new Map();
791
862
  const theirs2 = (pkg) => !own || own.has(pkg);
863
+ const rank = (what) => [OFFERED, MADE, PACKAGE].indexOf(what);
792
864
  const add = (code, what) => {
793
- if (code && !found.has(code) && !(code in PREDEF_WORDS) && (what !== PACKAGE || theirs2(code))) found.set(code, what);
865
+ if (code && (!found.has(code) || rank(what) > rank(found.get(code))) && !(code in PREDEF_WORDS) && (what !== PACKAGE || theirs2(code))) found.set(code, what);
794
866
  };
795
- const idParts = (id) => {
867
+ const idParts = (id, key) => {
796
868
  const i = id.lastIndexOf("::");
797
869
  if (!theirs2(id.slice(0, i))) return;
798
870
  add(id.slice(0, i), PACKAGE);
799
- add(id.slice(i + 2), DEFINITION);
871
+ add(id.slice(i + 2), DEFINED[key] ?? DEFINITION);
800
872
  };
873
+ const isPackage = (s, key) => own?.has(s) || PACKAGE_KEYS.has(key) || s.includes("/");
801
874
  leaves(map, (s, key) => {
875
+ if (key === "guardIds" && s.startsWith("route:")) return;
802
876
  if (s.startsWith("route:")) return add(s.slice(6), WHAT.path);
803
877
  if (TYPES.has(key)) {
804
878
  for (const w of s.match(WORD) ?? []) if (w.includes("::")) idParts(w);
@@ -806,12 +880,12 @@ function nameableParts(map, own) {
806
880
  return;
807
881
  }
808
882
  if (s.includes("::")) {
809
- for (const w of s.match(WORD)) if (w.includes("::")) idParts(w);
883
+ for (const w of s.match(WORD)) if (w.includes("::")) idParts(w, key);
810
884
  return;
811
885
  }
812
886
  if (STRUCTURAL.has(key)) return;
813
- if (key === "name" || key === "package" || key === "pkg" || key === "instantiatingPackages" || key === "cycles" || key === "to") return add(s, s.includes("/") ? PACKAGE : DEFINITION);
814
- add(s, WHAT[key] ?? DEFINITION);
887
+ if (key === "name" || PACKAGE_KEYS.has(key) || key === "cycles" || key === "to" || own?.has(s)) return add(s, isPackage(s, key) ? PACKAGE : DEFINITION);
888
+ add(s, WHAT[key] ?? DEFINED[key] ?? DEFINITION);
815
889
  });
816
890
  for (const b of map.bundles ?? []) if (b.count > 1) found.delete(b.label);
817
891
  return [...found].map(([code, what]) => ({ code, what })).sort((a, b) => a.code.localeCompare(b.code));
@@ -863,19 +937,22 @@ var VOCABULARY2 = {
863
937
  version: MARKS_VERSION,
864
938
  added: ["outcome"],
865
939
  marks: {
866
- entryHead: "arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck <- entries[], ghosts[]",
940
+ entryHead: 'arrowhead where a request comes in; the rack above the tree has one per described entry, then one dashed line counting the entries no scenario describes, which opens to draw each of them struck (when no entry is described, that one line is all the rack draws, and it only counts them: "8 entries"; a scenario is a requirement someone wrote, so its absence says nothing about tests); under the head, "same steps as \u2026" names the entries whose trees take the same steps on their own tables and checks <- entries[], ghosts[], entries[].sameShape',
867
941
  guard: "red diamond: the declared sign-in or role check, the first branch of every entry that has one; its failure stem ends in a refusal <- tree node with from.kind=guard",
868
- fork: "three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result; the arm each line takes is written where it leaves <- tree nodes",
869
- station: "ring on a line: an effect on that path, its verb and table beside it; filled = it writes <- arms[].effects, entry.effects",
942
+ fork: 'three prongs: a branch the code takes on the request, the caller, a value read from a table, or a call result, with "\u2190 request", "\u2190 caller" or "\u2190 <table> as read" saying where what it tests comes from; the arm each line takes is written where it leaves <- tree nodes, nodes[].from.sources',
943
+ docs: "under the names line, in italic, the comment the author wrote above each named check, marked unchecked: what the author says it decides, which the engine does not confirm <- docs (structure.docs)",
944
+ rows: `under the tree, for each table its stations touch (and those of the entries named in "same steps as"), what one row holds, by its checked type: "posts holds List[Post(id: String, \u2026, edited: Bool)]"; a field that is a record gives only its type's name, a choice of bare cases its cases ("visibility: Open | Members") <- rows (structure.rows)`,
945
+ station: "ring on a line: an effect on that path, its verb and table beside it; filled = it writes; mint = the platform hands out a fresh row id. Under it, in grey, the row it touches (the caller's row; the row named by a request field; one fixed row; a new row; every row, and the guard that keeps them) and, for a write, whether the row is replaced whole or updated from what it read, then where what it writes comes from, field by field (\u2190 caller, \u2190 request.<field>, \u2190 a literal, \u2190 new id, \u2190 a call and what it was computed from) <- arms[].effects[].key, value, kept (access report.findings[operation=keep_visible].provenBy)",
870
946
  modelRing: "violet ring: the path reaches the model <- effects[resource=model]",
871
947
  netRing: "orange ring: the path reaches the network <- effects[resource=net]",
872
948
  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",
873
949
  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",
874
950
  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",
875
951
  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",
876
- compared: 'a line under the rack, always there: "compared with <base> \xB7 N fixed \xB7 M still failing", or "no base compared" in faint text <- diff',
952
+ compared: 'a line under the rack when a base was compared: "compared with <base> \xB7 N fixed \xB7 M still failing"; with no base, no line <- diff',
877
953
  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`
878
954
  },
955
+ names: "every check, call and table name is a label the code's author (often an AI) chose; the picture proves where a check runs, what it is given and what happens when it fails, never what it decides: a reader who doubts the author reads that rule itself.",
879
956
  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.",
880
957
  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."
881
958
  };
@@ -953,6 +1030,11 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
953
1030
  const claims = st.claims || { scenarios: [], undescribed: (st.scenarios || []).map((s) => s.entry) };
954
1031
  const byEntry = new Map(claims.scenarios.filter((s) => s.status === "matched").map((s) => [s.entry, s]));
955
1032
  const routes = new Map((st.entries || []).map((e) => [e.entry, e]));
1033
+ const kept = new Map((engine.access?.report?.findings || []).filter((f) => f.operation === "keep_visible" && f.kind === "GuardProven" && f.status === "holds").map((f) => [`${f.package}::${f.binding}\0${f.resource}`, `${(f.provenBy || []).join(" and ")} passes for the caller and the row`]));
1034
+ const withKept = (es) => es.map((e) => {
1035
+ const by = e.kind === "DbQuery" && e.tables.length === 1 ? kept.get(`${e.site}\0${e.tables[0]}`) : null;
1036
+ return by ? { ...e, kept: by } : e;
1037
+ });
956
1038
  const entries = st.scenarios.map((s) => {
957
1039
  const meta = routes.get(s.entry) || {};
958
1040
  const sc = byEntry.get(s.entry);
@@ -960,7 +1042,7 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
960
1042
  const copy = (t) => {
961
1043
  if (!t) return null;
962
1044
  if (t.kind === "leaf") return { ...t, words: names.get(t.id) || null, claims: [], witnesses: [], status: "grey" };
963
- return { ...t, words: names.get(t.id) || null, arms: t.arms.map((a) => ({ ...a, words: names.get(a.id) || null, next: copy(a.next) })) };
1045
+ return { ...t, words: names.get(t.id) || null, arms: t.arms.map((a) => ({ ...a, effects: withKept(a.effects), words: names.get(a.id) || null, next: copy(a.next) })) };
964
1046
  };
965
1047
  return {
966
1048
  entry: s.entry,
@@ -970,7 +1052,7 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
970
1052
  transport: meta.transport || null,
971
1053
  title: sc?.title || null,
972
1054
  described: !!sc,
973
- effects: s.effects,
1055
+ effects: withKept(s.effects),
974
1056
  tree: copy(s.tree),
975
1057
  leafCount: Number(s.leafCount),
976
1058
  truncated: s.truncated,
@@ -980,6 +1062,17 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
980
1062
  from: "structure.scenarios[]"
981
1063
  };
982
1064
  });
1065
+ const shapeOf = (e) => {
1066
+ const eff = (x) => `${x.op}${x.key ? `@${x.key.kind}` : ""}${x.value ? `<${x.value.kind}` : ""}${x.kept ? "|kept" : ""}`;
1067
+ const go = (t) => !t ? "" : t.kind === "leaf" ? `(${t.outcome.kind}:${(t.outcome.statuses || (t.outcome.status ? [t.outcome.status] : [])).join("/")})` : `[${t.from.kind}${t.arms.map((a) => `{${a.effects.map(eff).join(",")}${go(a.next)}}`).join("")}]`;
1068
+ return `${(e.effects || []).map(eff).join(",")}${go(e.tree)}`;
1069
+ };
1070
+ const byShape = /* @__PURE__ */ new Map();
1071
+ for (const e of entries) byShape.set(shapeOf(e), [...byShape.get(shapeOf(e)) || [], e.entry]);
1072
+ for (const e of entries) {
1073
+ const same = byShape.get(shapeOf(e)).filter((x) => x !== e.entry);
1074
+ if (same.length) e.sameShape = { entries: same, from: "derived: entries[] trees equal in node kinds, arms, effect verbs, row keys and value kinds, and outcomes" };
1075
+ }
983
1076
  const entryOf = new Map(entries.map((e) => [e.entry, e]));
984
1077
  const leafById = new Map(entries.flatMap((e) => leavesOf(e.tree).map((l) => [l.id, l])));
985
1078
  const checked = [], notes = [], refused = [], mismatches = [];
@@ -1085,6 +1178,18 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
1085
1178
  refused,
1086
1179
  mismatches,
1087
1180
  diff,
1181
+ // the comment above each definition, as its author wrote it (structure.docs); never checked
1182
+ docs: ((callees) => Object.fromEntries(Object.entries(st.docs || {}).filter(([id]) => callees.has(id))))(
1183
+ new Set(entries.flatMap((e) => {
1184
+ const ids = [];
1185
+ walk(e.tree, (n) => {
1186
+ if (n.kind === "node" && n.from?.kind === "call") ids.push(n.from.callee);
1187
+ });
1188
+ return ids;
1189
+ }))
1190
+ ),
1191
+ // what each table holds, by its checked row type (structure.rows)
1192
+ rows: Object.fromEntries(Object.entries(st.rows || {}).map(([table, schema]) => [table, rowShape(schema)])),
1088
1193
  counts: {
1089
1194
  entries: entries.length,
1090
1195
  described: entries.length - ghosts.length,
@@ -1098,6 +1203,14 @@ function buildWhatHappens(engine, { program, base = null, baseLabel = "base" } =
1098
1203
  evidence: { tools: ["api_organize:structure", ...engine.access ? ["api_access_check"] : []], derived: ["diff: base red leaves matched by id, their failed obligations by identity"] }
1099
1204
  };
1100
1205
  }
1206
+ var typeName = (s) => s.type ? s.type.split("::").pop() : null;
1207
+ var fieldType = (s) => ({ string: "String", int: "Int", float64: "Float64", bool: "Bool", unit: "Unit", json: "Json" })[s.kind] || (s.kind === "list" ? `List[${fieldType(s.element)}]` : s.kind === "unsupported" ? s.rendered : s.kind === "union" && s.variants.length && s.variants.every((v) => !v.fields.length) ? s.variants.map((v) => v.tag).join(" | ") : typeName(s));
1208
+ function rowShape(s) {
1209
+ if (s.kind === "list") return `List[${rowShape(s.element)}]`;
1210
+ if (s.kind === "struct") return `${typeName(s)}(${s.fields.map((f) => `${f.name}: ${fieldType(f)}`).join(", ")})`;
1211
+ if (s.kind === "union") return s.variants.map((v) => v.fields.length ? `${v.tag}(${v.fields.map((f) => `${f.name}: ${fieldType(f)}`).join(", ")})` : v.tag).join(" | ");
1212
+ return fieldType(s);
1213
+ }
1101
1214
  function stationLabel(e) {
1102
1215
  const verb = e.op === "mint id" ? "mint" : e.op;
1103
1216
  if (e.tables.length) return `${verb} \xB7 ${e.tables.join(", ")}`;
@@ -1152,6 +1265,7 @@ var Step = z3.looseObject({ op: z3.string(), tables: z3.array(z3.string()).defau
1152
1265
  var Route = z3.looseObject({
1153
1266
  path: z3.string(),
1154
1267
  handler: z3.string(),
1268
+ handlerBinding: z3.string(),
1155
1269
  authority: z3.string().optional(),
1156
1270
  tables: z3.array(z3.string()).default([]),
1157
1271
  inferredSteps: z3.array(Step).default([])
@@ -1170,8 +1284,10 @@ var Table = z3.looseObject({
1170
1284
  });
1171
1285
  var Report = z3.looseObject({
1172
1286
  surface: z3.looseObject({ routes: z3.array(Route).default([]) }).default({ routes: [] }),
1173
- dataModel: z3.looseObject({ tables: z3.array(Table).default([]) }).default({ tables: [] })
1287
+ dataModel: z3.looseObject({ tables: z3.array(Table).default([]) }).default({ tables: [] }),
1288
+ shape: z3.looseObject({ packages: z3.array(z3.looseObject({ name: z3.string() })).default([]) }).default({ packages: [] })
1174
1289
  });
1290
+ var handlerOf = (report, route) => route.handlerBinding.includes("::") ? route.handlerBinding : `${report.shape.packages[0]?.name}::${route.handlerBinding}`;
1175
1291
  var Entry = z3.looseObject({ entry: z3.string(), resources: z3.array(z3.string()).default([]) });
1176
1292
  var Structure = z3.looseObject({ entries: z3.array(Entry).default([]) });
1177
1293
  var ACCESS_RULES = {
@@ -1252,11 +1368,36 @@ function accessQuestion(table, names) {
1252
1368
  ask: `Your app saves "${shown(names, table.name)}"${table.name in names ? "" : " records"}. Who should be able to see one, and who should be able to change it?`,
1253
1369
  why: "Without a rule the app has no way to know a record is private. Yichus checks every place the app reads or changes these records against the rule you choose.",
1254
1370
  choices: [
1255
- { id: "owner", label: "Only the person who created it", means: "Every read and write is tied to the signed-in person, and the check proves no one reaches another person's records. No one else, not even a manager, sees or changes them, so anything someone else decides about them, such as an approval, belongs in records only the person who decides it may change." },
1256
- { id: "shared", label: "Only the people a rule lets in", means: "For example the members of a team, or the people a record was shared with. Every read and change goes through that rule, except work done under a role you name, such as an administrator or the app's own server, and the check proves nothing else skips it." },
1257
- { id: "server-only", label: "No person, only the app's own server", means: "Records the app keeps for itself, such as which number belongs to whom or which messages it has already seen. Only work the app's own server starts may read or change them, and the check proves nothing a person calls reaches them." },
1258
- { id: "everyone-reads", label: "Everyone can see it; only certain roles can change it", means: "Anyone can read; changes need a role the app gives out." },
1259
- { id: "public", label: "Anyone, even without signing in", means: "Nothing in these records is private." }
1371
+ {
1372
+ id: "owner",
1373
+ label: "Only the person who created it",
1374
+ means: "Every read and write is tied to the signed-in person, and the check proves no one reaches another person's records. No one else, not even a manager, sees or changes them, so anything someone else decides about them, such as an approval, belongs in records only the person who decides it may change.",
1375
+ unchecked: "Decisions someone else should make about these records, such as an approval: the person who creates one can change all of it."
1376
+ },
1377
+ {
1378
+ id: "shared",
1379
+ label: "Only the people a rule lets in",
1380
+ means: "For example the members of a team, or the people a record was shared with. Every read and change goes through that rule, except work done under a role you name, such as an administrator or the app's own server, and the check proves nothing else skips it.",
1381
+ unchecked: "Whether the rule lets in the right people. The check proves every read and change goes through it; who it lets in is what your agent wrote."
1382
+ },
1383
+ {
1384
+ id: "server-only",
1385
+ label: "No person, only the app's own server",
1386
+ means: "Records the app keeps for itself, such as which number belongs to whom or which messages it has already seen. Only work the app's own server starts may read or change them, and the check proves nothing a person calls reaches them.",
1387
+ unchecked: "What the app's own server does with these records."
1388
+ },
1389
+ {
1390
+ id: "everyone-reads",
1391
+ label: "Everyone can see it; only certain roles can change it",
1392
+ means: "Anyone can read; changes need a role the app gives out.",
1393
+ unchecked: "Who is given the role: that is up to sign-in."
1394
+ },
1395
+ {
1396
+ id: "public",
1397
+ label: "Anyone, even without signing in",
1398
+ means: "Nothing in these records is private.",
1399
+ unchecked: "Nothing protects these records: anyone, even without signing in, can read, change and delete every one."
1400
+ }
1260
1401
  ],
1261
1402
  fact: `The program stores rows in "${table.name}" (policy declared: ${table.policy ?? "none"}).`
1262
1403
  };
@@ -1270,8 +1411,18 @@ function togetherQuestion(table, handlers, names) {
1270
1411
  ask: `Two requests change the same record in "${shown(names, table.name)}" at the same moment (${who}). What should happen?`,
1271
1412
  why: "When a request reads a record and then writes it back, a second request in between can be silently lost, or both can pass a limit only one should, such as two people taking the last spot. People rarely notice until data goes missing.",
1272
1413
  choices: [
1273
- { id: "keep-both", label: "Both changes are kept", means: "Each change happens as one indivisible step that sees the one before it, so neither can overwrite the other unseen and a limit the app checks is never passed twice; the check proves no change is lost." },
1274
- { id: "refuse-second", label: "The second is refused and they try again", means: "A change made to an out-of-date copy is turned away; the check proves no change is lost." }
1414
+ {
1415
+ id: "keep-both",
1416
+ label: "Both changes are kept",
1417
+ means: "Each change happens as one indivisible step that sees the one before it, so neither can overwrite the other unseen and a limit the app checks is never passed twice; the check proves no change is lost.",
1418
+ unchecked: "A limit counted across many records, such as sign-ups against the spots on a shift. A rule you give can check that, with a scenario that runs every action that changes the records the rule is about."
1419
+ },
1420
+ {
1421
+ id: "refuse-second",
1422
+ label: "The second is refused and they try again",
1423
+ means: "A change made to an out-of-date copy is turned away; the check proves no change is lost.",
1424
+ unchecked: "A limit counted across many records, such as sign-ups against the spots on a shift. A rule you give can check that, with a scenario that runs every action that changes the records the rule is about."
1425
+ }
1275
1426
  ],
1276
1427
  fact: `${handlers.join(", ")} ${handlers.length === 1 ? "reads" : "read"} "${table.name}" and then ${handlers.length === 1 ? "writes" : "write"} it.`
1277
1428
  };
@@ -1284,7 +1435,12 @@ function partialQuestion(route, tables, names) {
1284
1435
  ask: `If the server stops halfway through "${shown(names, route.path)}" (a crash, a database error, a timeout), may part of its changes stay saved?`,
1285
1436
  why: `This action changes ${tables.map((t) => `"${shown(names, t)}"`).join(" and ")} in more than one step. A stop between steps can leave them disagreeing, for example money taken but no receipt.`,
1286
1437
  choices: [
1287
- { id: "all-or-nothing", label: "All or nothing", means: "Every change this action makes is saved together or not at all; Yichus stops it at each step and checks nothing half-done stays saved." },
1438
+ {
1439
+ id: "all-or-nothing",
1440
+ label: "All or nothing",
1441
+ means: "Every change this action makes is saved together or not at all; Yichus stops it at each step and checks nothing half-done stays saved.",
1442
+ unchecked: "Requests and starting records the failure scenario does not list."
1443
+ },
1288
1444
  { id: "partial-ok", label: "Partial is fine", means: "No check is added for this action." }
1289
1445
  ],
1290
1446
  fact: `${route.handler} writes ${tables.map((t) => `"${t}"`).join(", ")} in more than one step.`
@@ -1299,13 +1455,26 @@ function outsideQuestion(route, kinds, names) {
1299
1455
  ask: `"${shown(names, route.path)}" calls ${service}. If that service is down or slow, what should the person using your app see?`,
1300
1456
  why: "Outside services fail often. Without a plan, a failure can leave a half-finished record or charge for work that never happened.",
1301
1457
  choices: [
1302
- { id: "fail-clean", label: "An error, with nothing saved", means: "Yichus makes the service fail and checks that nothing from this action stays saved." },
1303
- { id: "finish-later", label: "Save the request and finish it later", means: "Yichus makes the service fail and checks the saved request is tried again and finished exactly once." },
1458
+ {
1459
+ id: "fail-clean",
1460
+ label: "An error, with nothing saved",
1461
+ means: "Yichus makes the service fail and checks that nothing from this action stays saved.",
1462
+ unchecked: "Requests, starting records and answers from the service that the failure scenario does not list."
1463
+ },
1464
+ {
1465
+ id: "finish-later",
1466
+ label: "Save the request and finish it later",
1467
+ means: "Yichus makes the service fail and checks the saved request is tried again and finished exactly once.",
1468
+ unchecked: "Requests, starting records and answers from the service that the failure scenario does not list."
1469
+ },
1304
1470
  { id: "no-plan", label: "No preference", means: "No check is added for this action." }
1305
1471
  ],
1306
1472
  fact: `${route.handler} reaches ${kinds.join(" and ")}.`
1307
1473
  };
1308
1474
  }
1475
+ var writeSteps = (route) => route.inferredSteps.filter((s) => s.resource === "db" && s.op !== "read" && s.op !== "query");
1476
+ var touchedBy = (route) => [.../* @__PURE__ */ new Set([...route.tables, ...route.inferredSteps.filter((s) => s.resource === "db").flatMap((s) => s.tables)])];
1477
+ var changedBy = (route) => [...new Set(writeSteps(route).flatMap((s) => s.tables))];
1309
1478
  var PERSON_SIGNS_IN = /* @__PURE__ */ new Set(["authenticated", "publishable", "requires-roles"]);
1310
1479
  var signIn = {
1311
1480
  id: "sign-in",
@@ -1344,9 +1513,7 @@ function questionsFor(report, structure, names = {}) {
1344
1513
  if (both.length) questions.push(togetherQuestion(table, both, names));
1345
1514
  }
1346
1515
  for (const route of report.surface.routes) {
1347
- const writes = route.inferredSteps.filter((s) => s.resource === "db" && s.op !== "read" && s.op !== "query");
1348
- const tables = [...new Set(writes.flatMap((s) => s.tables))];
1349
- if (writes.length >= 2) questions.push(partialQuestion(route, tables, names));
1516
+ if (writeSteps(route).length >= 2) questions.push(partialQuestion(route, changedBy(route), names));
1350
1517
  }
1351
1518
  for (const route of report.surface.routes) {
1352
1519
  const entry = structure.entries.find((e) => e.entry === `route:${route.path}`);
@@ -1574,7 +1741,7 @@ var LawCheck = z6.looseObject({
1574
1741
  notSampled: z6.array(z6.looseObject({ binding: z6.string() })).optional(),
1575
1742
  reach: z6.array(z6.looseObject({ binding: z6.string(), usedBy: z6.array(z6.string()) })).optional()
1576
1743
  });
1577
- var caseFile = (root, questionId) => path3.join(studioDir(root), "cases", `${questionId.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "")}.json`);
1744
+ var caseFile = (root, questionId) => path3.join(studioDir(root), "cases", `${questionId.replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "")}.json`);
1578
1745
  var answerOf = (requirements, id) => requirements.answers.find((a) => a.question === id) ?? null;
1579
1746
  function instancesFor(requirements) {
1580
1747
  const choice = answerOf(requirements, "topology")?.choice;
@@ -1627,6 +1794,7 @@ async function judge(root, engine, sources, question, saved, report, verify, tog
1627
1794
  evidence: personal.map((r) => `${r.path} reaches "${question.subject}" and is called ${r.authority === "requires-roles" ? "by a person with a role" : "by a person"}, not by the app's own server.`)
1628
1795
  };
1629
1796
  }
1797
+ if (answer.choice === "public") return { ...base, status: "no-check", check };
1630
1798
  const prop = byId(`access:${question.subject}`) ?? byId("access-coverage");
1631
1799
  if (!prop) return { ...base, status: "inconclusive", check: "api_verify", evidence: ["api_verify reported no access property for this table."] };
1632
1800
  return {
@@ -1652,35 +1820,47 @@ async function judge(root, engine, sources, question, saved, report, verify, tog
1652
1820
  case "partial":
1653
1821
  case "outside": {
1654
1822
  if (answer.choice === "partial-ok" || answer.choice === "no-plan") return { ...base, status: "no-check" };
1655
- const file = caseFile(root, question.id);
1656
- const rel = path3.relative(root, file).split(path3.sep).join("/");
1657
- if (!fs3.existsSync(file)) return { ...base, casePath: rel, status: "needs-case", check: "api_protocol_check", evidence: [`No protocol case at ${rel} yet.`] };
1658
- let protocolCase;
1659
- try {
1660
- protocolCase = JSON.parse(fs3.readFileSync(file, "utf8"));
1661
- } catch (error) {
1662
- return {
1663
- ...base,
1664
- casePath: rel,
1665
- check: "api_protocol_check",
1666
- status: "inconclusive",
1667
- evidence: [`${rel} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`]
1668
- };
1669
- }
1670
- const result = Protocol.parse(await engine.call("api_protocol_check", { sources: [...sources, ...caseSources(root)], case: protocolCase }));
1671
- const status = result.protocol?.status;
1672
- const check = `api_protocol_check ${rel}`;
1673
- if (!status) return { ...base, casePath: rel, check, status: "inconclusive", evidence: [result.error ?? "The protocol check gave no status."] };
1674
- return {
1675
- ...base,
1676
- casePath: rel,
1677
- check,
1678
- evidence: [`${result.protocol?.name ?? rel}: ${status}`],
1679
- status: status === "holds" ? "bounded" : status === "violated" ? "broken" : "inconclusive"
1680
- };
1823
+ const ran = await runCase(root, engine, sources, question.id);
1824
+ return { ...base, casePath: ran.casePath, check: ran.check, status: ran.status, evidence: ran.evidence };
1681
1825
  }
1682
1826
  }
1683
1827
  }
1828
+ var CaseRequests = z6.looseObject({ requests: z6.array(z6.looseObject({ call: z6.looseObject({ binding: z6.string() }) })) });
1829
+ async function runCase(root, engine, sources, id, runs = []) {
1830
+ const file = caseFile(root, id);
1831
+ const casePath = path3.relative(root, file).split(path3.sep).join("/");
1832
+ if (!fs3.existsSync(file)) return { casePath, status: "needs-case", check: "api_protocol_check", evidence: [`No protocol case at ${casePath} yet.`] };
1833
+ let protocolCase;
1834
+ try {
1835
+ protocolCase = JSON.parse(fs3.readFileSync(file, "utf8"));
1836
+ } catch (error) {
1837
+ return {
1838
+ casePath,
1839
+ check: "api_protocol_check",
1840
+ status: "inconclusive",
1841
+ evidence: [`${casePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`]
1842
+ };
1843
+ }
1844
+ const called = CaseRequests.safeParse(protocolCase);
1845
+ const bindings = called.success ? called.data.requests.map((r) => r.call.binding) : [];
1846
+ const missing = runs.filter((r) => !bindings.includes(r.binding));
1847
+ if (missing.length) return {
1848
+ casePath,
1849
+ check: "api_protocol_check",
1850
+ status: "needs-case",
1851
+ evidence: missing.map((r) => `${casePath} sends no request to ${r.path} (${r.binding}) yet.`)
1852
+ };
1853
+ const result = Protocol.parse(await engine.call("api_protocol_check", { sources: [...sources, ...caseSources(root)], case: protocolCase }));
1854
+ const status = result.protocol?.status;
1855
+ const check = `api_protocol_check ${casePath}`;
1856
+ if (!status) return { casePath, check, status: "inconclusive", evidence: [result.error ?? "The protocol check gave no status."] };
1857
+ return {
1858
+ casePath,
1859
+ check,
1860
+ evidence: [`${result.protocol?.name ?? casePath}: ${status}`],
1861
+ status: status === "holds" ? "bounded" : status === "violated" ? "broken" : "inconclusive"
1862
+ };
1863
+ }
1684
1864
  var reason = (result) => typeof result.error === "string" ? result.error : "no reason given";
1685
1865
  async function checkLaws(engine, sources) {
1686
1866
  const answer = async (mode) => {
@@ -1719,14 +1899,27 @@ async function checkLaws(engine, sources) {
1719
1899
  }
1720
1900
  var lawNamed = (laws, ref) => laws.find((l) => l.binding === ref || l.binding.endsWith(`::${ref}`) || l.name === ref);
1721
1901
  var unused = (law) => law.usedBy !== null && !law.usedBy.length;
1722
- function judgeRule(rule, laws) {
1902
+ async function judgeRule(rule, laws, report, root, engine, sources) {
1723
1903
  const found = rule.laws.map((ref) => [ref, lawNamed(laws, ref)]);
1724
1904
  const missing = found.filter(([, law]) => !law).map(([ref]) => ref);
1725
1905
  const checked = found.flatMap(([, law]) => law ? [law] : []);
1726
1906
  const evidence = [...missing.map((ref) => `No law named ${ref} in the program.`), ...checked.flatMap((l) => l.evidence.map((e) => `${l.name ?? l.binding}: ${e}`))];
1727
1907
  const status = missing.length || !checked.length ? "needs-law" : checked.some((l) => l.status === "broken") ? "broken" : checked.some((l) => l.status === "inconclusive") ? "inconclusive" : checked.some(unused) ? "unused" : checked.every((l) => l.status === "proven") ? "proven" : "bounded";
1728
- return { ...rule, status, evidence };
1729
- }
1908
+ if (status !== "proven" && status !== "bounded") return { ...rule, status, evidence };
1909
+ const reachKnown = checked.every((l) => l.usedBy !== null);
1910
+ const own = new Set(checked.flatMap((l) => l.usedBy ?? []).flatMap((entry) => entry.startsWith("route:") ? [entry.slice("route:".length)] : []));
1911
+ const routes = report.surface.routes;
1912
+ const records = new Set(reachKnown ? routes.filter((r) => own.has(r.path)).flatMap(touchedBy) : routes.flatMap(changedBy));
1913
+ const others = routes.filter((r) => !own.has(r.path) && changedBy(r).some((t) => records.has(t)));
1914
+ if (!others.length) return { ...rule, status, evidence };
1915
+ const runs = routes.filter((r) => own.has(r.path) || others.includes(r)).map((r) => ({ path: r.path, handler: r.handler, binding: handlerOf(report, r) }));
1916
+ const ran = await runCase(root, engine, sources, `rule:${rule.rule}`, runs);
1917
+ const across = { casePath: ran.casePath, actions: runs.map((r) => r.path), others: others.map((r) => r.path) };
1918
+ const changed = [...records].filter((t) => others.some((r) => changedBy(r).includes(t))).map((t) => `"${t}"`).join(", ");
1919
+ const changing = reachKnown ? `${others.map((r) => r.path).join(", ")} ${others.length === 1 ? "changes" : "change"} ${changed} without deciding with the rule's laws.` : `Which actions decide with the rule's laws is not known, so its scenario runs every action that changes ${changed}.`;
1920
+ return { ...rule, across, evidence: [...evidence, changing, ...ran.evidence], status: ran.status };
1921
+ }
1922
+ var keepRule = (actions) => `every one of ${actions.join(", ")} keep the rule, deciding with the same functions as its laws. Change the code, never the scenario's rule.`;
1730
1923
  var USE_THE_LAW = "If the function the law checks is in the program, make the action the rule is about decide with it. If it is defined next to the law, move it into the program, have the law check it there, and make the action decide with it. Never weaken what the law says.";
1731
1924
  var QUESTION_PROPERTIES = (id) => id.startsWith("access:") || id === "access-coverage" || id === "rmw-transactions" || id === "integrity";
1732
1925
  var GUIDE = {
@@ -1735,6 +1928,12 @@ var GUIDE = {
1735
1928
  partial: "Transaction protocols: finite cases, retries, and receipts",
1736
1929
  outside: "Transaction protocols: finite cases, retries, and receipts"
1737
1930
  };
1931
+ var DIRECTED_TOOLS = {
1932
+ addRule: "api_add_rule",
1933
+ deploy: "api_deploy",
1934
+ suggestProperties: "api_suggest_properties",
1935
+ protocolCheck: "api_protocol_check"
1936
+ };
1738
1937
  var SCAFFOLD_ARGS = {
1739
1938
  package_name: "My/App/Api",
1740
1939
  service_name: "my_app",
@@ -1831,7 +2030,7 @@ function nextStep(state, compileErrors, say2 = MCP) {
1831
2030
  instructions: [
1832
2031
  ...mismatched.map((p) => {
1833
2032
  const rule = ACCESS_RULES[p.answer.choice];
1834
- return `Make the AccessSpec rule for "${p.question.subject}" ${rule.write}${p.answer.choice === "server-only" ? ", and reach it only from worker routes" : ""} (${say2.api("api_add_rule")} for the snippet; ${say2.api("api_guide", { section: rule.guide })} for what it means).`;
2033
+ return `Make the AccessSpec rule for "${p.question.subject}" ${rule.write}${p.answer.choice === "server-only" ? ", and reach it only from worker routes" : ""} (${say2.api(DIRECTED_TOOLS.addRule)} for the snippet; ${say2.api("api_guide", { section: rule.guide })} for what it means).`;
1835
2034
  }),
1836
2035
  next
1837
2036
  ],
@@ -1840,7 +2039,7 @@ function nextStep(state, compileErrors, say2 = MCP) {
1840
2039
  const broken = state.properties.filter((p) => p.status === "broken" || p.status === "inconclusive");
1841
2040
  const failing = state.other.filter((o) => o.status !== "warning");
1842
2041
  const warnings = state.other.filter((o) => o.status === "warning");
1843
- const brokenRules = state.rules.filter((r) => r.status !== "proven" && r.status !== "bounded");
2042
+ const brokenRules = state.rules.filter((r) => r.status !== "proven" && r.status !== "bounded" && r.status !== "needs-case");
1844
2043
  const ruled = new Set(state.rules.flatMap((r) => r.laws.flatMap((ref) => lawNamed(state.laws, ref)?.binding ?? [])));
1845
2044
  const brokenLaws = state.laws.filter((l) => !ruled.has(l.binding) && (l.status === "broken" || l.status === "inconclusive" || unused(l)));
1846
2045
  const notHolding = broken.length + failing.length + brokenRules.length + brokenLaws.length + (state.lawsError ? 1 : 0);
@@ -1855,7 +2054,7 @@ function nextStep(state, compileErrors, say2 = MCP) {
1855
2054
  ...broken.map((p) => `"${p.question.ask}" The person chose "${p.question.choices.find((c) => c.id === p.answer?.choice)?.label}".${GUIDE[p.question.kind] ? ` ${capital(say2.api("api_guide", { section: GUIDE[p.question.kind] }))} for how.` : ""}`),
1856
2055
  ...failing.map((o) => `${o.title} (${o.id}) is ${o.status}.`),
1857
2056
  ...warnings.map((o) => `${o.title} (${o.id}) is a warning, not a failure: read its evidence. Change the code if it shows something the person did not choose, such as a stored field an action gives away; otherwise tell the person what it means.`),
1858
- ...brokenRules.map((r) => r.status === "needs-law" ? `The person's rule "${r.rule}" has no law in the program that checks it. Write one (${say2.reference("laws")} for how), then record the rules again with ${say2.name("yichus_rules")}.` : r.status === "unused" ? `The person's rule "${r.rule}" is checked by a law whose check calls nothing any action of the app reaches, so the law says nothing about what the app's actions do. ${USE_THE_LAW}` : `The person's rule "${r.rule}" is ${r.status === "broken" ? "broken: a law that states it fails" : "not decided by its laws"}. Change the code, never the law.`),
2057
+ ...brokenRules.map((r) => r.status === "needs-law" ? `The person's rule "${r.rule}" has no law in the program that checks it. Write one (${say2.reference("laws")} for how), then record the rules again with ${say2.name("yichus_rules")}.` : r.status === "unused" ? `The person's rule "${r.rule}" is checked by a law whose check calls nothing any action of the app reaches, so the law says nothing about what the app's actions do. ${USE_THE_LAW}` : r.across ? r.status === "broken" ? `The person's rule "${r.rule}" is broken: its laws pass, and the scenario that runs every action that changes its records fails. Make ${keepRule(r.across.actions)}` : `The person's rule "${r.rule}" is not decided by the scenario that runs every action that changes its records. If the evidence shows ${r.across.casePath} itself is refused, fix that file, never its rule; otherwise make ${keepRule(r.across.actions)}` : `The person's rule "${r.rule}" is ${r.status === "broken" ? "broken: a law that states it fails" : "not decided by its laws"}. Change the code, never the law.`),
1859
2058
  ...brokenLaws.map((l) => `The law ${l.name ? `"${l.name}" (${l.binding})` : l.binding} ${l.status === "broken" ? "is broken. Change the code, never the law." : l.status === "inconclusive" ? "is not decided. Change the code, never the law." : `checks code no action of the app reaches. ${USE_THE_LAW}`}`),
1860
2059
  ...state.lawsError ? ["The engine could not check the program's laws, so no rule the person stated is checked. If the error below points at the program, fix the program; otherwise tell the person their rules are not checked yet."] : [],
1861
2060
  `Never weaken a rule, delete a check, a law or a claim, or declare one server to make a check pass: change the code. ${next}`
@@ -1914,13 +2113,15 @@ function nextStep(state, compileErrors, say2 = MCP) {
1914
2113
  ]
1915
2114
  };
1916
2115
  const cases = state.properties.filter((p) => p.status === "needs-case");
1917
- if (cases.length) return {
2116
+ const ruleCases = state.rules.flatMap((r) => r.status === "needs-case" && r.across ? [{ rule: r.rule, ...r.across }] : []);
2117
+ if (cases.length || ruleCases.length) return {
1918
2118
  stage: "write-case",
1919
- summary: "Writing the failure scenarios that test your answers.",
2119
+ summary: cases.length ? "Writing the failure scenarios that test your answers." : "Writing a scenario that runs every action that changes the records your rules are about.",
1920
2120
  instructions: [
1921
2121
  `${capital(say2.reference("protocol-case"))}: it gives the case specification and a complete worked case with its program.`,
1922
2122
  "Put any Bosatsu observation predicates the case names in .yichus/cases/*.bosatsu. The studio adds those files to protocol checks only, so they never count as part of the app.",
1923
2123
  ...cases.map((p) => `Write ${p.casePath}: a protocol-case-1.0 for ${p.question.subject} whose invariants state the person's choice ("${p.question.choices.find((c) => c.id === p.answer?.choice)?.label}"), with crashes${p.question.kind === "outside" ? " and failed external results" : ""} at each step.`),
2124
+ ...ruleCases.map((c) => `Write ${c.casePath}: a protocol-case-1.0 for the person's rule "${c.rule}". ${c.others.join(", ")} ${c.others.length === 1 ? "changes" : "change"} the same records as the actions that decide with the rule, so the case sends a request to every one of ${c.actions.join(", ")}, from starting records where the rule matters (one spot left, say), and its invariant states the rule over the records after every step: for a limit, what each record counts matches the records it counts, and never passes the limit. Write the observation predicate from the rule, not from the code.`),
1924
2125
  `State the person's rule exactly; never weaken it to match the code. ${next}`
1925
2126
  ]
1926
2127
  };
@@ -1934,7 +2135,7 @@ function nextStep(state, compileErrors, say2 = MCP) {
1934
2135
  ...assumed.length ? ["Say plainly which results depend on running exactly one server."] : [],
1935
2136
  ...data === "move" || data === "same-database" ? [`Before any record moves, list for the person every kind of record the old place keeps that this app does not (for example timestamps or a message log), and ask about each: add it to the app, or agree to leave it behind. ${capital(say2.reference("porting"))} says how to bring the data over.`] : [],
1936
2137
  `When they describe a new feature, change the code and ${say2.use("yichus_next")} again: new facts can raise new questions.`,
1937
- sections.length ? `When they want the app online, follow the ${sectionsText(sections)} section${sections.length === 1 ? "" : "s"} of the deploy reference (${say2.reference("deploy")}), and ${say2.api("api_deploy")} to generate the server.` : `The program keeps no records and has no actions people call, so it needs no server. When they want it on a page, ${say2.reference("calculator")}.`
2138
+ sections.length ? `When they want the app online, follow the ${sectionsText(sections)} section${sections.length === 1 ? "" : "s"} of the deploy reference (${say2.reference("deploy")}), and ${say2.api(DIRECTED_TOOLS.deploy)} to generate the server.` : `The program keeps no records and has no actions people call, so it needs no server. When they want it on a page, ${say2.reference("calculator")}.`
1938
2139
  ]
1939
2140
  };
1940
2141
  }
@@ -2002,11 +2203,12 @@ async function direct({ root, engine, requirements, withMap = true, previous = n
2002
2203
  const checked = await checkLaws(engine, sources);
2003
2204
  base.laws = checked.laws;
2004
2205
  base.lawsError = checked.error;
2005
- base.rules = (requirements.rules ?? []).map((r) => judgeRule(r, base.laws));
2206
+ for (const rule of requirements.rules ?? []) base.rules.push(await judgeRule(rule, base.laws, report, root, engine, sources));
2006
2207
  base.hasActions = report.surface.routes.length > 0;
2208
+ let access = null;
2007
2209
  if (base.hasActions || requirements.scenarios?.length) {
2008
2210
  try {
2009
- const access = await call(engine, "api_access_check", { sources });
2211
+ access = await call(engine, "api_access_check", { sources });
2010
2212
  const record = WhatHappens.parse(buildWhatHappens({ structure: structureRaw, access }, { program: base.project }));
2011
2213
  base.whatHappens = base.hasActions ? record : { ...record, entries: [], ghosts: [], claims: [], witnesses: [], refused: [], notes: [] };
2012
2214
  } catch (error) {
@@ -2023,7 +2225,7 @@ async function direct({ root, engine, requirements, withMap = true, previous = n
2023
2225
  await call(engine, "api_organize", { sources, lens: "abstractions" }),
2024
2226
  await call(engine, "api_organize", { sources, lens: "layers" })
2025
2227
  ];
2026
- base.map = buildSystemMap({ report, structure: structureRaw, abstractions, layers }, { program: base.project });
2228
+ base.map = buildSystemMap({ report, structure: structureRaw, abstractions, layers, ...access ? { access } : {} }, { program: base.project });
2027
2229
  base.layers = layers;
2028
2230
  } catch (error) {
2029
2231
  base.mapError = error instanceof Error ? error.message : String(error);
@@ -2050,6 +2252,7 @@ function readState(root) {
2050
2252
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2051
2253
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2052
2254
  import { z as z10 } from "zod";
2255
+ import { OpenFamilies } from "@yichus/api-mcp";
2053
2256
 
2054
2257
  // src/studio/marks.ts
2055
2258
  import fs4 from "fs";
@@ -2202,6 +2405,7 @@ function recordMarks(root, args, now) {
2202
2405
  }
2203
2406
 
2204
2407
  // src/studio/page.ts
2408
+ var HOLDING = ["proven", "bounded", "assumed", "no-check"];
2205
2409
  var PAGE_STATUS = {
2206
2410
  unanswered: ["waiting for your answer", "quiet"],
2207
2411
  decided: ["decided", "good"],
@@ -2217,6 +2421,9 @@ var PAGE_STATUS = {
2217
2421
  "no-check": ["no check needed", "quiet"],
2218
2422
  pending: ["answered, waiting for your agent to check", "quiet"]
2219
2423
  };
2424
+ function ruleUnchecked(across) {
2425
+ return `Anything in your words that its laws do not state: your agent wrote the laws from your words.${across ? " Across actions, only the starting records and requests its scenario lists." : ""}`;
2426
+ }
2220
2427
  var PAGE_HEADINGS = {
2221
2428
  asked: "What you asked for",
2222
2429
  now: "What is happening now",
@@ -2366,6 +2573,7 @@ import { mount } from './system-map.js';
2366
2573
  import { mountSections, mountWhatHappens, reportViewing, showMarks } from './studio-views.js';
2367
2574
 
2368
2575
  const STATUS = ${JSON.stringify(PAGE_STATUS)};
2576
+ const HOLDING = ${JSON.stringify(HOLDING)};
2369
2577
  const NO_ACTIONS = ${JSON.stringify(NO_ACTIONS)};
2370
2578
  const el = (tag, props = {}, ...kids) => { const n = Object.assign(document.createElement(tag), props); n.append(...kids); return n; };
2371
2579
  let mapHash = null, drawn = '';
@@ -2400,7 +2608,9 @@ function questionCard(q, saved) {
2400
2608
  function ruleRow(r) {
2401
2609
  const [words, tone] = STATUS[r.status];
2402
2610
  const li = el('li', {}, el('span', { textContent: 'A rule you gave: ' }, el('em', { textContent: r.rule })), el('span', { className: 'chip ' + tone, textContent: words }));
2403
- li.append(el('span', { className: 'choice-said', textContent: 'Checked with ' + (r.laws === 1 ? '1 law' : r.laws + ' laws') + ' your agent wrote.' }));
2611
+ li.append(el('span', { className: 'choice-said', textContent: 'Checked with ' + (r.laws === 1 ? '1 law' : r.laws + ' laws') + ' your agent wrote'
2612
+ + (r.across ? ', and a scenario that runs every action that changes these records.' : '.') }));
2613
+ if (r.unchecked) li.append(el('span', { className: 'choice-said', textContent: 'Not checked: ' + r.unchecked }));
2404
2614
  return li;
2405
2615
  }
2406
2616
 
@@ -2412,6 +2622,8 @@ function propertyRow(p, saved) {
2412
2622
  const li = el('li', {}, el('span', { textContent: p.question.ask }), el('span', { className: 'chip ' + tone, textContent: words }));
2413
2623
  li.dataset.property = p.question.id;
2414
2624
  li.append(el('span', { className: 'choice-said', textContent: 'You chose: ' + label + (answer.words && answer.words !== label ? ' ("' + answer.words + '")' : '') }));
2625
+ const unchecked = !stale && HOLDING.includes(p.status) && p.question.choices.find(c => c.id === answer.choice)?.unchecked;
2626
+ if (unchecked) li.append(el('span', { className: 'choice-said', textContent: 'Not checked: ' + unchecked }));
2415
2627
  return li;
2416
2628
  }
2417
2629
 
@@ -2419,7 +2631,8 @@ async function refresh() {
2419
2631
  const { state, requirements } = await (await fetch('/state')).json();
2420
2632
  const savedOf = id => requirements.answers.find(a => a.question === id);
2421
2633
  if (requirements.description) document.getElementById('description').textContent = requirements.description;
2422
- if (!state) return;
2634
+ // Before the first check there is nothing drawn, but the person may already be looking.
2635
+ if (!state) { viewing(null); return; }
2423
2636
  document.getElementById('project').textContent = ': ' + state.project;
2424
2637
  document.getElementById('updated').textContent = 'Last checked ' + new Date(state.updatedAt).toLocaleTimeString() + '.';
2425
2638
  document.getElementById('summary').textContent = state.summary;
@@ -2488,7 +2701,14 @@ var Report2 = z9.object({
2488
2701
  /** The drawing the anchors belong to (person.ts), or null before one is drawn. */
2489
2702
  drawing: z9.string().max(64).nullable()
2490
2703
  }).strict();
2491
- var Viewing = Report2.extend({ schemaVersion: z9.literal("yichus-viewing-1"), at: z9.string() }).strict();
2704
+ var Viewing = Report2.extend({
2705
+ schemaVersion: z9.literal("yichus-viewing-1"),
2706
+ at: z9.string(),
2707
+ /** The page server the report came through: only it can tell that its page went. */
2708
+ server: z9.string().optional(),
2709
+ /** When the last copy of the page open on that server went away, until a page connects again. */
2710
+ closed: z9.string().optional()
2711
+ }).strict();
2492
2712
  var viewingPath = (root) => path5.join(studioDir(root), "viewing.json");
2493
2713
  function loadViewing(root) {
2494
2714
  const file = viewingPath(root);
@@ -2497,14 +2717,28 @@ function loadViewing(root) {
2497
2717
  if (!parsed.success) throw new Error(`${file} is not a yichus-viewing-1 file: ${z9.prettifyError(parsed.error)}`);
2498
2718
  return parsed.data;
2499
2719
  }
2500
- function recordViewing(root, report, now) {
2501
- const parsed = Report2.safeParse(report);
2502
- if (!parsed.success) return { ok: false, error: `The page's report is not one this studio reads. ${z9.prettifyError(parsed.error)}` };
2720
+ function writeViewing(root, viewing) {
2503
2721
  ensureStudioDir(root);
2504
2722
  const file = viewingPath(root), written = `${file}.${process.pid}.tmp`;
2505
- fs5.writeFileSync(written, `${JSON.stringify(Viewing.parse({ schemaVersion: "yichus-viewing-1", at: now.toISOString(), ...parsed.data }), null, 2)}
2723
+ fs5.writeFileSync(written, `${JSON.stringify(Viewing.parse(viewing), null, 2)}
2506
2724
  `);
2507
2725
  fs5.renameSync(written, file);
2726
+ }
2727
+ function recordClosed(root, server, now) {
2728
+ const last = loadViewing(root);
2729
+ if (last?.server === server) writeViewing(root, { ...last, closed: now.toISOString() });
2730
+ }
2731
+ function recordOpened(root, server) {
2732
+ const last = loadViewing(root);
2733
+ if (last?.server === server && last.closed) {
2734
+ const { closed: _, ...open } = last;
2735
+ writeViewing(root, open);
2736
+ }
2737
+ }
2738
+ function recordViewing(root, server, report, now) {
2739
+ const parsed = Report2.safeParse(report);
2740
+ if (!parsed.success) return { ok: false, error: `The page's report is not one this studio reads. ${z9.prettifyError(parsed.error)}` };
2741
+ writeViewing(root, { schemaVersion: "yichus-viewing-1", at: now.toISOString(), server, ...parsed.data });
2508
2742
  return { ok: true };
2509
2743
  }
2510
2744
 
@@ -2552,13 +2786,16 @@ function pageText(root, pageUrl, now, surface) {
2552
2786
  const chosen = (state?.properties ?? []).filter((p) => p.answer || savedOf(p.question.id));
2553
2787
  const rules = state?.rules ?? [];
2554
2788
  if (!chosen.length && !rules.length) out.push("No properties chosen yet.");
2555
- for (const r of rules) out.push(`A rule the person gave: "${r.rule}" Status: ${PAGE_STATUS[r.status][0]}. Checked with ${r.laws.length === 1 ? "1 law" : `${r.laws.length} laws`} you wrote.`);
2789
+ for (const r of rules) {
2790
+ out.push(`A rule the person gave: "${r.rule}" Status: ${PAGE_STATUS[r.status][0]}. Checked with ${r.laws.length === 1 ? "1 law" : `${r.laws.length} laws`} you wrote${r.across ? `, and a scenario that runs every action that changes these records (${r.across.actions.join(", ")})` : ""}.${r.status === "proven" || r.status === "bounded" ? ` Not checked: ${ruleUnchecked(r.across !== void 0)}` : ""}`);
2791
+ }
2556
2792
  for (const p of chosen) {
2557
2793
  const saved = savedOf(p.question.id);
2558
2794
  const stale = saved && (!p.answer || p.answer.answeredAt !== saved.answeredAt);
2559
2795
  const answer = stale ? saved : p.answer;
2560
2796
  const label2 = p.question.choices.find((c) => c.id === answer.choice)?.label ?? answer.choice;
2561
- out.push(`question ${p.question.id}: ${p.question.ask} Status: ${PAGE_STATUS[stale ? "pending" : p.status][0]}. They chose ${label2}${answer.words && answer.words !== label2 ? ` ("${answer.words}")` : ""}.`);
2797
+ const unchecked = !stale && HOLDING.includes(p.status) && p.question.choices.find((c) => c.id === answer.choice)?.unchecked;
2798
+ out.push(`question ${p.question.id}: ${p.question.ask} Status: ${PAGE_STATUS[stale ? "pending" : p.status][0]}. They chose ${label2}${answer.words && answer.words !== label2 ? ` ("${answer.words}")` : ""}.${unchecked ? ` Not checked: ${unchecked}` : ""}`);
2562
2799
  }
2563
2800
  heading("happens");
2564
2801
  const step = (s, pad) => {
@@ -2611,7 +2848,10 @@ function pageText(root, pageUrl, now, surface) {
2611
2848
  `;
2612
2849
  }
2613
2850
  function inViewLines(viewing, codeAt, said2, now, pageUrl) {
2614
- if (!viewing) return [`The page has not said what is on screen: it may not be open. ${pageUrl ? `Give the person its link: ${pageUrl}` : "The page is not running: start `yichus studio --page` in the background in this folder, then run this again for its link."}`];
2851
+ const link = pageUrl ? `Give the person its link: ${pageUrl}` : "The page is not running: start `yichus studio --page` in the background in this folder, then run this again for its link.";
2852
+ if (!viewing) return [`The page has not said what is on screen: it may not be open. ${link}`];
2853
+ if (viewing.closed) return [`The person closed the page ${ago(viewing.closed, now)}, so it is not open now. ${pageUrl ? `If you want them to look at it, give them its link again: ${pageUrl}` : link}`];
2854
+ if (!pageUrl) return [`The page is not open: its server stopped after it last said what was on screen. ${link}`];
2615
2855
  const parts = (anchors) => !viewing.current ? [] : anchors.flatMap((a) => codeAt.has(a) ? [`[${codeAt.get(a)}] ${said2.get(codeAt.get(a))}`] : []);
2616
2856
  const list = (what, items) => items.length ? [`${what}:`, ...items.map((i) => ` ${i}`)] : [];
2617
2857
  const { touched } = viewing;
@@ -2678,12 +2918,20 @@ var reference_default = {
2678
2918
  ]
2679
2919
  },
2680
2920
  "protocol-case": {
2681
- about: "The failure scenarios the studio checks for an action that must be all or nothing, or must survive an outside service failing: the case format and a complete worked case.",
2921
+ about: "The scenarios the studio checks for an action that must be all or nothing or must survive an outside service failing, and for a rule other actions could go around: the case format and complete worked cases.",
2682
2922
  files: [
2683
2923
  { name: "protocol-cases.md", from: "docs/studio/protocol-cases.md" },
2684
2924
  { name: "protocol-specification.md", from: "docs/protocol-verification.md", sections: ["Case format: protocol-case-1.0", "Observation predicates", "Trace assertions", "Reachability witnesses", "Interpretation and limits"] },
2685
2925
  { name: "withdrawal-case.json", from: "eval/corpus/webmcp-traps/iteration-003/programs/Q15/twin/case.json" },
2686
- { name: "withdrawal.bosatsu", from: "eval/corpus/webmcp-traps/iteration-003/programs/Q15/twin/ledger.bosatsu" }
2926
+ { name: "withdrawal.bosatsu", from: "eval/corpus/webmcp-traps/iteration-003/programs/Q15/twin/ledger.bosatsu" },
2927
+ { name: "signups.bosatsu", from: "docs/studio/laws-example/signups/signups.bosatsu" },
2928
+ { name: "signups-laws.bosatsu", from: "docs/studio/laws-example/signups/signups-laws.bosatsu" },
2929
+ { name: "signups-case.json", from: "docs/studio/laws-example/signups/cases/signups-case.json" },
2930
+ { name: "spots.bosatsu", from: "docs/studio/laws-example/signups/cases/spots.bosatsu" },
2931
+ { name: "near-miss-signups.bosatsu", from: "docs/studio/laws-example/signups/near-miss/signups.bosatsu" },
2932
+ { name: "near-miss-case.json", from: "docs/studio/laws-example/signups/cases/near-miss-case.json" },
2933
+ { name: "mutants/double-signup.bosatsu", from: "docs/studio/laws-example/signups/mutants/double-signup.bosatsu" },
2934
+ { name: "mutants/overfill.bosatsu", from: "docs/studio/laws-example/signups/mutants/overfill.bosatsu" }
2687
2935
  ]
2688
2936
  },
2689
2937
  calculator: {
@@ -2761,7 +3009,7 @@ function stepText(state, pageUrl) {
2761
3009
  }
2762
3010
  if (state.rules.length) {
2763
3011
  lines.push("", "Rules the person stated:");
2764
- for (const r of state.rules) lines.push(` "${r.rule}" (${r.laws.join(", ")}): ${STATUS_WORDS[r.status]}`);
3012
+ for (const r of state.rules) lines.push(` "${r.rule}" (${[...r.laws, ...r.across ? [r.across.casePath] : []].join(", ")}): ${STATUS_WORDS[r.status]}`);
2765
3013
  }
2766
3014
  if (pageUrl) lines.push("", `Studio page: ${pageUrl}`);
2767
3015
  return `${lines.join("\n")}
@@ -2784,7 +3032,7 @@ The loop:
2784
3032
  8. While you talk with the person, point at what you mean on the studio page with yichus_point: a section of their app, an action, or a step in one, with a sentence or two as you would say it in the chat. The page shows each note over the pictures as yours, not checked, so never use a note to say something holds; the checks say that. Point again as the conversation moves on, and clear the marks when they no longer help.
2785
3033
  9. You drive the conversation, and the person reads the studio page while you talk. Call yichus_page to see what the page shows them, in words: what they asked for, the questions, each property with its status, what happens in each action, the sections of their app, your notes, and what they have on screen and last clicked. Call it whenever they refer to something on the page ("this one", "the red one", "what does that mean"), and after they answer on the page.
2786
3034
 
2787
- Every api_* tool is available too. Leave out sources and the studio supplies the project's files. Pages on yich.us, such as calculators and the playground, come through a separate connection (@yichus/mcp); building needs only this one.`;
3035
+ The core api_* tools are listed too, and api_tools opens a specialist family (access, concurrency, proofs and more): its tools join the list. Leave out sources and the studio supplies the project's files. Pages on yich.us, such as data apps and the playground, come through a separate connection (@yichus/mcp); building needs only this one.`;
2788
3036
  var START = {
2789
3037
  name: "yichus_start",
2790
3038
  description: "Record what the person wants built, in their own words, and get the studio page link to give them. Call this first.",
@@ -2953,7 +3201,7 @@ function pageNote(pageUrl, say2) {
2953
3201
  ].join("\n");
2954
3202
  }
2955
3203
  function createStudioMcpServer({ root, engine, catalog, pageUrl, now = () => /* @__PURE__ */ new Date(), locate = builtCopy }) {
2956
- const server = new Server({ name: "yichus-studio", version: "0.3.2" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
3204
+ const server = new Server({ name: "yichus-studio", version: "0.3.4" }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
2957
3205
  const active = /* @__PURE__ */ new Set();
2958
3206
  const track = (task) => {
2959
3207
  active.add(task);
@@ -2961,10 +3209,12 @@ function createStudioMcpServer({ root, engine, catalog, pageUrl, now = () => /*
2961
3209
  });
2962
3210
  return task;
2963
3211
  };
2964
- let tools = null;
2965
- const catalogTools2 = async () => tools ??= (await catalog()).map(studioTool);
3212
+ let entries = null;
3213
+ const catalogEntries2 = async () => entries ??= (await catalog()).map((e) => ({ ...e, tool: studioTool(e.tool) }));
3214
+ const catalogTools = async () => (await catalogEntries2()).map((e) => e.tool);
3215
+ const families = new OpenFamilies(false, Object.values(DIRECTED_TOOLS));
2966
3216
  let last = readState(root);
2967
- server.setRequestHandler(ListToolsRequestSchema, () => track((async () => ({ tools: [START, NEXT, NAME, ANSWER, RULES, SCENARIO, POINT, PAGE, REFERENCE_TOOL, ...await catalogTools2()] }))()));
3217
+ server.setRequestHandler(ListToolsRequestSchema, () => track((async () => ({ tools: [START, NEXT, NAME, ANSWER, RULES, SCENARIO, POINT, PAGE, REFERENCE_TOOL, ...families.listed(await catalogEntries2())] }))()));
2968
3218
  server.setRequestHandler(CallToolRequestSchema, (request) => track(callTool(request.params)));
2969
3219
  async function callTool(params) {
2970
3220
  const { name, arguments: args = {} } = params;
@@ -3009,7 +3259,7 @@ Next: call yichus_next.`, { pageUrl });
3009
3259
  return said2 ? text(said2) : text(`No reference topic ${String(args.topic)}.
3010
3260
  ${topicList((topic) => `topic "${topic}"`)}`, void 0, true);
3011
3261
  }
3012
- const tool = (await catalogTools2()).find((t) => t.name === name);
3262
+ const tool = (await catalogTools()).find((t) => t.name === name);
3013
3263
  if (!tool) return text(`Unknown tool ${name}.`, void 0, true);
3014
3264
  const filled = { ...args };
3015
3265
  const props = tool.inputSchema.properties ?? {};
@@ -3019,15 +3269,7 @@ ${topicList((topic) => `topic "${topic}"`)}`, void 0, true);
3019
3269
  const topology2 = loadRequirements(root).answers.find((a) => a.question === "topology")?.choice;
3020
3270
  if ("instances" in props && filled.instances === void 0 && topology2) filled.instances = topology2 === "one" ? "1" : "many";
3021
3271
  const result = await engine.call(name, filled);
3022
- if (name === "api_guide" && filled.section === void 0 && Array.isArray(result.sections)) {
3023
- const titles = {
3024
- ok: true,
3025
- tool: "api_guide",
3026
- titles: result.sections.map((s) => s.title),
3027
- note: "The whole guide is long. Call api_guide with section set to one of these titles, or to part of one."
3028
- };
3029
- return text(JSON.stringify(titles), titles);
3030
- }
3272
+ if (families.record(name, result)) await server.sendToolListChanged();
3031
3273
  return text(JSON.stringify(result), result, result.ok !== true);
3032
3274
  }
3033
3275
  return {
@@ -3222,7 +3464,7 @@ ${said2}
3222
3464
  var HELP = `yichus \u2014 the Yichus WebMCP analysis engine, locally
3223
3465
 
3224
3466
  yichus tools
3225
- yichus call TOOL [--args JSON_FILE|-] [--source FILE ... | --project DIR]
3467
+ yichus call TOOL [--args JSON_FILE|-] [--arg-file NAME=JSON_FILE ...] [--source FILE ... | --project DIR]
3226
3468
  yichus verify FILE ... [--instances 1|many] [--block-on-warning ID ...]
3227
3469
  yichus lean FILE ... --lean-wasm-root DIRECTORY [--timeout-ms N]
3228
3470
  ${STUDIO_HELP}
@@ -3234,6 +3476,8 @@ Options:
3234
3476
  (default the current folder), as the studio reads
3235
3477
  it (call only).
3236
3478
  --args FILE|- Tool arguments as a JSON object; - reads stdin.
3479
+ --arg-file NAME=FILE Set one argument to the JSON in FILE, such as a
3480
+ saved snapshot (repeatable; call only).
3237
3481
  --block-on-warning ID A property id whose warning blocks the verdict, as a
3238
3482
  violation would (repeatable; all names every property;
3239
3483
  verify only). An id the verdict lacks exits 2.
@@ -3297,6 +3541,7 @@ var objectJson = (raw, label2) => objectValue(JSON.parse(raw), label2);
3297
3541
  var FLAGS = {
3298
3542
  source: { type: "string", multiple: true },
3299
3543
  args: { type: "string" },
3544
+ "arg-file": { type: "string", multiple: true },
3300
3545
  project: { type: "string" },
3301
3546
  instances: { type: "string" },
3302
3547
  "block-on-warning": { type: "string", multiple: true },
@@ -3317,7 +3562,7 @@ function parse(argv2) {
3317
3562
  if (command !== "tools" && command !== "call" && command !== "verify" && command !== "lean") {
3318
3563
  throw new Error("Expected tools, call TOOL, verify FILE, or lean FILE; use --help for usage.");
3319
3564
  }
3320
- const opts = { command, sources: [], expects: [], blockOnWarnings: values["block-on-warning"] ?? [] };
3565
+ const opts = { command, sources: [], argFiles: [], expects: [], blockOnWarnings: values["block-on-warning"] ?? [] };
3321
3566
  if (command === "call") {
3322
3567
  opts.tool = rest.shift();
3323
3568
  if (!opts.tool) throw new Error("call requires a tool name");
@@ -3325,6 +3570,14 @@ function parse(argv2) {
3325
3570
  if ((command === "tools" || command === "call") && rest.length) throw new Error(`Unexpected argument: ${rest[0]}`);
3326
3571
  opts.sources = [...rest, ...values.source ?? []];
3327
3572
  opts.args = values.args;
3573
+ for (const value of values["arg-file"] ?? []) {
3574
+ const equal = value.indexOf("=");
3575
+ if (equal <= 0 || equal === value.length - 1) throw new Error("--arg-file requires NAME=FILE");
3576
+ const name = value.slice(0, equal);
3577
+ if (opts.argFiles.some((a) => a.name === name)) throw new Error(`--arg-file names ${name} twice`);
3578
+ opts.argFiles.push({ name, file: value.slice(equal + 1) });
3579
+ }
3580
+ if (opts.argFiles.length && command !== "call") throw new Error("--arg-file is for call");
3328
3581
  opts.project = values.project;
3329
3582
  if (opts.project !== void 0 && command !== "call") throw new Error("--project is for call");
3330
3583
  if (opts.project !== void 0 && opts.sources.length) throw new Error("Use either --source files or --project, not both");
@@ -3430,6 +3683,15 @@ function sourceNames(paths) {
3430
3683
  }
3431
3684
  async function argumentsFor(opts, io) {
3432
3685
  const args = opts.args ? objectJson(opts.args === "-" ? await io.readStdin() : readInput(opts.args), "Arguments") : {};
3686
+ for (const { name, file } of opts.argFiles) {
3687
+ if (Object.hasOwn(args, name)) throw new Error(`Use either --arg-file ${name}=... or args.${name}, not both`);
3688
+ try {
3689
+ args[name] = JSON.parse(readInput(file));
3690
+ } catch (error) {
3691
+ if (error instanceof SyntaxError) throw new Error(`--arg-file ${name}: ${file} is not JSON (${error.message})`);
3692
+ throw error;
3693
+ }
3694
+ }
3433
3695
  if (opts.sources.length) {
3434
3696
  if (Object.hasOwn(args, "sources")) throw new Error("Use either --source files or args.sources, not both");
3435
3697
  const names = sourceNames(opts.sources);
@@ -3457,7 +3719,7 @@ function clearOutput(opts) {
3457
3719
  if (!opts.output) return;
3458
3720
  const output = path9.resolve(opts.output);
3459
3721
  const outputStat = fs9.existsSync(output) ? fs9.statSync(output) : void 0;
3460
- for (const input of [...opts.sources, opts.args, opts.bundle].filter((p) => !!p && p !== "-")) {
3722
+ for (const input of [...opts.sources, ...opts.argFiles.map((a) => a.file), opts.args, opts.bundle].filter((p) => !!p && p !== "-")) {
3461
3723
  const inputStat = fs9.existsSync(input) ? fs9.statSync(input) : void 0;
3462
3724
  if (path9.resolve(input) === output || outputStat && inputStat && inputStat.dev === outputStat.dev && inputStat.ino === outputStat.ino) {
3463
3725
  throw new Error("--output must not overwrite an input file");
@@ -3492,16 +3754,22 @@ async function runCli(argv2, io) {
3492
3754
  timeoutMs
3493
3755
  ), "Lean result");
3494
3756
  } else {
3495
- if (opts.command === "call") {
3496
- const files = Array.isArray(args.sources) ? args.sources.length : void 0;
3497
- const progress = callProgress(opts.tool, files);
3498
- if (progress) io.stderr(progress);
3499
- }
3757
+ const progress = opts.command === "call" ? callProgress(opts.tool, Array.isArray(args.sources) ? args.sources.length : void 0) : void 0;
3758
+ let stageStart = io.now();
3759
+ const finished = (stage) => {
3760
+ const now = io.now();
3761
+ io.stderr(` ${stage} in ${((now - stageStart) / 1e3).toFixed(1)} s.
3762
+ `);
3763
+ stageStart = now;
3764
+ };
3765
+ if (progress) io.stderr(progress);
3500
3766
  const engine = await io.loadEngine(opts.bundle);
3767
+ if (progress) finished("Loaded the engine");
3501
3768
  if (opts.command === "call" && !Object.hasOwn(args, "sources") && readsProgram(engine, opts.tool)) {
3502
3769
  args.sources = folderSources(opts.tool, opts.project);
3503
3770
  }
3504
- const raw = opts.command === "tools" ? engine.listTools() : engine.callTool(opts.command === "verify" ? "api_verify" : opts.tool, JSON.stringify(args));
3771
+ const tool = opts.command === "verify" ? "api_verify" : opts.tool;
3772
+ const raw = opts.command === "tools" ? engine.listTools() : progress ? engine.callTool(tool, JSON.stringify(args), finished) : engine.callTool(tool, JSON.stringify(args));
3505
3773
  result = objectJson(raw, "Engine result");
3506
3774
  }
3507
3775
  if (typeof result.ok !== "boolean") throw new Error("Engine result must explicitly contain boolean ok");
@@ -3547,7 +3815,7 @@ async function runCli(argv2, io) {
3547
3815
  import { once } from "events";
3548
3816
  import { fileURLToPath as fileURLToPath2 } from "url";
3549
3817
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3550
- import { EngineWorkerClient, catalogTools } from "@yichus/api-mcp";
3818
+ import { EngineWorkerClient, catalogEntries } from "@yichus/api-mcp";
3551
3819
 
3552
3820
  // src/studio/page-server.ts
3553
3821
  import fs10 from "fs";
@@ -3573,7 +3841,13 @@ function personView(state, names, marks = null) {
3573
3841
  status: p.status
3574
3842
  })),
3575
3843
  // A state written before rules were kept has none.
3576
- rules: (state.rules ?? []).map((r) => ({ rule: r.rule, status: r.status, laws: r.laws.length })),
3844
+ rules: (state.rules ?? []).map((r) => ({
3845
+ rule: r.rule,
3846
+ status: r.status,
3847
+ laws: r.laws.length,
3848
+ across: r.across !== void 0,
3849
+ unchecked: r.status === "proven" || r.status === "bounded" ? ruleUnchecked(r.across !== void 0) : null
3850
+ })),
3577
3851
  map: state.map ? relabelSystemMap(state.map, names) : null,
3578
3852
  mapError: state.mapError,
3579
3853
  sections,
@@ -3601,9 +3875,10 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3601
3875
  };
3602
3876
  for (const file of watched) fs10.watchFile(file, { interval: 250 }, changed);
3603
3877
  const hosts = /* @__PURE__ */ new Set();
3878
+ let self = "", closing = false;
3604
3879
  const takes = /* @__PURE__ */ new Map([
3605
3880
  ["/answer", (args) => recordAnswer(root, args, now())],
3606
- ["/viewing", (args) => recordViewing(root, args, now())]
3881
+ ["/viewing", (args) => recordViewing(root, self, args, now())]
3607
3882
  ]);
3608
3883
  const server = http.createServer((req, res) => {
3609
3884
  const send = (status, type, body) => {
@@ -3624,7 +3899,11 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3624
3899
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
3625
3900
  res.write(": connected\n\n");
3626
3901
  clients.add(res);
3627
- req.on("close", () => clients.delete(res));
3902
+ recordOpened(root, self);
3903
+ req.on("close", () => {
3904
+ clients.delete(res);
3905
+ if (!clients.size && !closing) recordClosed(root, self, now());
3906
+ });
3628
3907
  return;
3629
3908
  }
3630
3909
  const asset = PAGE_ASSETS.find((a) => url.pathname === `/${a}`);
@@ -3667,9 +3946,11 @@ async function startPageServer(root, assetsDir, port = null, now = () => /* @__P
3667
3946
  }
3668
3947
  const bound = server.address().port;
3669
3948
  hosts.add(`127.0.0.1:${bound}`).add(`localhost:${bound}`);
3949
+ self = `http://127.0.0.1:${bound}/`;
3670
3950
  return {
3671
- url: `http://127.0.0.1:${bound}/`,
3951
+ url: self,
3672
3952
  async close() {
3953
+ closing = true;
3673
3954
  for (const file of watched) fs10.unwatchFile(file, changed);
3674
3955
  const closed = new Promise((resolve) => server.close(() => resolve()));
3675
3956
  server.closeAllConnections();
@@ -3697,7 +3978,7 @@ function workerEngine(bundle) {
3697
3978
  return objectResult(await worker.callTool(tool, JSON.stringify(args), `studio-${++id}`), tool);
3698
3979
  },
3699
3980
  async catalog() {
3700
- return catalogTools(await worker.listTools(`studio-${++id}`));
3981
+ return catalogEntries(await worker.listTools(`studio-${++id}`));
3701
3982
  },
3702
3983
  close: () => worker.close()
3703
3984
  };
@@ -3757,6 +4038,7 @@ process.exitCode = STUDIO_COMMANDS.has(argv[0] ?? "") ? await runStudioCommand(a
3757
4038
  // fails a synchronous read with EAGAIN on macOS.
3758
4039
  readStdin: () => text2(process.stdin),
3759
4040
  stdout: (text3) => process.stdout.write(text3),
3760
- stderr: (text3) => process.stderr.write(text3)
4041
+ stderr: (text3) => process.stderr.write(text3),
4042
+ now: () => performance.now()
3761
4043
  });
3762
4044
  //# sourceMappingURL=main.js.map