yichus 0.3.0 → 0.3.1
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/README.md +2 -2
- package/dist/cli.js +17 -3192
- package/dist/cli.js.map +1 -1
- package/dist/main.js +3762 -0
- package/dist/main.js.map +1 -0
- package/dist/studio-page/system-map-record.js +3 -3
- package/dist/studio-page/system-map.js +18 -5
- package/dist/studio-page/what-happens-record.js +11 -4
- package/dist/studio-page/what-happens.js +48 -29
- package/dist/studio-reference/balance-listed-laws.bosatsu +29 -0
- package/dist/studio-reference/balance.bosatsu +7 -0
- package/dist/studio-reference/calculator.md +20 -0
- package/dist/studio-reference/consent-laws.bosatsu +37 -0
- package/dist/studio-reference/consent.bosatsu +15 -0
- package/dist/studio-reference/deploy-platforms.md +6 -6
- package/dist/studio-reference/laws.md +62 -0
- package/dist/studio-reference/porting.md +3 -1
- package/dist/studio-reference/protocol-cases.md +11 -0
- package/dist/studio-reference/{protocol-specification.txt → protocol-specification.md} +16 -18
- package/dist/studio-reference/render.yaml +42 -0
- package/dist/studio-reference/server.mjs +33 -0
- package/dist/studio-reference/store.mjs +16 -0
- package/dist/studio-reference/the-page.md +34 -11
- package/package.json +2 -2
|
@@ -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
|
|
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));
|
|
@@ -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)}
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -1986,7 +1999,7 @@ function renderPart(map, partId, opts) {
|
|
|
1986
1999
|
const yBot = railUses.length ? busY + (phone ? 18 : 22) : rowsBottom + (phone ? 46 : 52);
|
|
1987
2000
|
const wall = wallPath(x0, x1, yTop, yBot, ys.concat(railUses.length ? [busY] : []), phone ? 6 : 7);
|
|
1988
2001
|
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)}/>`));
|
|
2002
|
+
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
2003
|
// a fork under a mark, its types from line k ending by `end`
|
|
1991
2004
|
const forkAt = (ids, x, y, k, end) => {
|
|
1992
2005
|
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
|
|
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"
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
123
|
-
// says it does not make, struck, then what the request
|
|
124
|
-
// number and the claims' ticks), then "continue"
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
224
|
-
P.hidden = new Set((entry.steps || []).
|
|
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
|
-
|
|
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
|
|
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),
|
|
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(`${
|
|
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
|
|
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(`${
|
|
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
|
|
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,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:
|
|
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.
|
|
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. `
|
|
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`.
|
|
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. `
|
|
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
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Rules the person states, written as laws
|
|
2
|
+
|
|
3
|
+
When the person says what their app must always or never do ("a fan who opts out stays opted out", "a balance never goes below zero"), write the rule as a law in a `.bosatsu` file of the app. The studio runs every law on each `yichus_next`. A law that fails keeps the app from being done, and the page shows each rule with its status.
|
|
4
|
+
|
|
5
|
+
The two files that come with this topic are a complete example: `consent.bosatsu` holds the rule the app follows, and `consent-laws.bosatsu` holds the law that checks it.
|
|
6
|
+
|
|
7
|
+
## Writing a law
|
|
8
|
+
|
|
9
|
+
Put the decision in a pure function, then state the rule about that function:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
opt_out_is_sticky = Obligation(
|
|
13
|
+
Law("a fan who opted out stays opted out", Domain("an earlier choice and a new sign-up", show, None, None), stays_out),
|
|
14
|
+
[Witness("a fan who had opted out signs up again", had_opted_out)]
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `Law(name, domain, check)`: `check` takes one input and is `True` when the rule holds for it. Pack several values into a tuple, as the example does.
|
|
19
|
+
- `Domain(name, show, gen, shrink)`: `show` turns an input into text for a counterexample. With `gen` and `shrink` set to `None`, inputs come from the input's type.
|
|
20
|
+
- `Witness(name, check)`: an input the rule is about must occur among the inputs checked. Without one, a law that holds because nothing ever happens would look like it holds. Name every case the rule is about.
|
|
21
|
+
- Export the obligation, and list the packages its type mentions in `exposes`: `exposes (Example/Consent, Yichus/Law)`.
|
|
22
|
+
- `FiniteObligation(obligation, [values])` checks exactly the listed inputs and nothing else. A pass covers only those inputs, so the rule shows as holding where checked, never as proven. Use it when the inputs that matter can truly be listed. `balance.bosatsu` and `balance-listed-laws.bosatsu`, which come with this topic, show one: the law passes on each listed deposit, while a deposit of -1 breaks the rule.
|
|
23
|
+
|
|
24
|
+
Import `Domain`, `Law`, `Obligation`, `Witness` and `FiniteObligation` from `Yichus/Law`.
|
|
25
|
+
|
|
26
|
+
State the person's rule, never what the code does. When a law fails, change the code. Never loosen the law.
|
|
27
|
+
|
|
28
|
+
## How the studio checks it
|
|
29
|
+
|
|
30
|
+
Each law is checked in two ways:
|
|
31
|
+
|
|
32
|
+
- **Every input**, when the input's type has finitely many values (`Bool`, enums, `Option` and tuples of them). A pass here covers every value of the type, and the rule shows as proven.
|
|
33
|
+
- **The listed inputs**, when the law is a `FiniteObligation`. A pass covers only the listed values, and the rule shows as holding where checked, not proven.
|
|
34
|
+
- **200 generated inputs**, for types such as `Int` and `String`. A pass here is a bounded observation, not a proof, and the page says so.
|
|
35
|
+
|
|
36
|
+
When a law cannot be decided (for example, a fold it cannot finish over every input), the rule shows as not decided. Simplify the function the law calls, give it an input type with finitely many values, or list the inputs that matter with `FiniteObligation` (a bounded check, as above).
|
|
37
|
+
|
|
38
|
+
Once the laws are written, record each rule with the laws that check it: `yichus_rules` with `{"rules": [{"rule": "a fan who opts out stays opted out", "laws": ["opt_out_is_sticky"]}]}`. A law is named by its binding (`opt_out_is_sticky`) or by the name in its `Law`.
|
|
39
|
+
|
|
40
|
+
A rule about what a page shows, or about something outside the app, can't be a law. Tell the person it is not checked, and leave it out.
|
|
41
|
+
|
|
42
|
+
## Standard functions
|
|
43
|
+
|
|
44
|
+
Every package can use these without an import (`Bosatsu/Predef`). There is no `trim`, lower-casing, `any` or `filter` here. The libraries below have some of those. Write the rest from `uncons_String`, `partition_String` and the folds.
|
|
45
|
+
|
|
46
|
+
- **Booleans:** `and`, `or`, `not`, `xor`, `implies`, `eq_Bool`, `cmp_Bool`. These are functions, never infix words: write `a.and(b)` or `and(a, b)`, not `a and b`.
|
|
47
|
+
- **Whole numbers (`Int`):** `add`, `sub`, `mul`, `div`, `div_mod`, `mod_Int`, `gcd_Int`, `eq_Int`, `cmp_Int`, `shift_left_Int`, `shift_right_Int`, `and_Int`, `or_Int`, `xor_Int`, `not_Int`, `popcount_Int`, `int_to_String`, `string_to_Int`, `int_to_Char`, `range`
|
|
48
|
+
- **Decimals (`Float64`):** `addf`, `subf`, `mulf`, `divf`, `eq_Float64`, `cmp_Float64`
|
|
49
|
+
- **Text (`String`, `Char`):** `concat_String`, `length_String`, `eq_String`, `cmp_String`, `string_Order`, `partition_String`, `rpartition_String`, `uncons_String`, `tail_or_empty_String`, `char_to_String`, `char_to_Int`, `char_List_to_String`, `eq_Char`, `cmp_Char`
|
|
50
|
+
- **Lists:** `map_List`, `flat_map_List`, `foldl_List`, `foldr_List`, `length_List`, `reverse`, `reverse_concat`, `concat`, `replicate_List`, `build_List`
|
|
51
|
+
- **Lookup tables (`Dict`):** `empty_Dict`, `add_key`, `get_key`, `remove_key`, `items`, `clear_Dict`
|
|
52
|
+
- **Comparisons:** `eq_Comparison`, `cmp_Comparison`
|
|
53
|
+
- **Debugging:** `trace`
|
|
54
|
+
|
|
55
|
+
`foldl_List(list, start, fn)` takes its accumulator before the function: `foldl_List(items, 0, (total, item) -> total.add(item))`.
|
|
56
|
+
|
|
57
|
+
These libraries come with every program, by import:
|
|
58
|
+
|
|
59
|
+
- `Bosatsu/Char`: `string_to_Char_List`, `to_ascii_lower_case`, `to_ascii_upper_case`, `eq_ignore_ascii_case`, `is_ascii_whitespace`, `is_ascii_digit`, `is_ascii_alpha`, `to_digit` and more character tests.
|
|
60
|
+
- `Yichus/Num/Float64`: `int_to_Float64`, `float64_to_Int`, `powf`, `minf`, `maxf`, and the operators `+.`, `-.`, `*.`, `/.`, for money and rates worked out in decimals. Its `Float64` is its own type, apart from the one above: import every decimal function and operator you use from it, and build constants with `int_to_Float64` or `divf` rather than writing decimal literals.
|
|
61
|
+
- `Yichus/Law`: `exists_List`, `filter_List`, `size_List`, `contains_String`, besides the law types.
|
|
62
|
+
- `Zafu/Control/IterState`: `foldl_List` with `done` and `continue`, for a scan that stops at the first match.
|
|
@@ -11,7 +11,7 @@ Before you write the new program, read the old app's code and find:
|
|
|
11
11
|
- what happens when two people act on the same record at once,
|
|
12
12
|
- what it does when a server or an outside service fails.
|
|
13
13
|
|
|
14
|
-
Then build the new program so that it does the same, unless the person asks for something different. Move every rule into it, wherever the old app kept it (database policies and triggers, server routes, code in the pages): a rule left behind is one no check sees. The old pages can stay as the screens people use, calling the new program.
|
|
14
|
+
Write the new program in a folder of its own, beside the old app rather than inside it: the studio reads every `.bosatsu` file in its folder. Then build the new program so that it does the same, unless the person asks for something different. Move every rule into it, wherever the old app kept it (database policies and triggers, server routes, code in the pages): a rule left behind is one no check sees. A rule the old app enforces, or the person states ("a fan who opts out stays opted out"), becomes a law the studio runs (reference topic `laws`). Records only the old app's server touched, such as which phone number belongs to whom or which webhook deliveries it has already seen, are the app's own: the person can choose "No person, only the app's own server" for them. The old pages can stay as the screens people use, calling the new program.
|
|
15
15
|
|
|
16
16
|
## Where the old app's entry points go
|
|
17
17
|
|
|
@@ -19,6 +19,8 @@ Public routes on the new program only read. Anything the old app saves from outs
|
|
|
19
19
|
|
|
20
20
|
## Bring the existing data over
|
|
21
21
|
|
|
22
|
+
Before anything moves, list for the person every kind of record the old app keeps that the new program does not: timestamps such as first seen or consent time, a log of messages, raw payloads. Ask about each one: add it to the new program, or agree to leave it behind. A record dropped without their word is data lost.
|
|
23
|
+
|
|
22
24
|
Write a one-off worker route that takes a batch of the old app's records and inserts them as the new program's typed rows in one transaction. Then write a script that posts the old database's export to it in batches. The imported rows pass the program's own types and rules, just like any other write, and a row the rules reject shows up as an error rather than as bad data. Map the old app's user ids to the ids the new sign-in gives the same people before you insert, or rows that belong to someone will not match the new program's ownership checks. Remove the route once the import is done and the person has checked the result.
|
|
23
25
|
|
|
24
26
|
## Use what you found in the conversation
|