yichus 0.2.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 +45 -16
- package/dist/cli.js +17 -2123
- package/dist/cli.js.map +1 -1
- package/dist/main.js +3762 -0
- package/dist/main.js.map +1 -0
- package/dist/studio-page/dom.js +22 -0
- package/dist/studio-page/label-fit.js +7 -0
- package/dist/studio-page/layer-diagram.js +458 -0
- package/dist/studio-page/layer-groups.js +129 -0
- package/dist/studio-page/source-link.js +17 -0
- package/dist/studio-page/studio-views.js +256 -0
- package/dist/studio-page/system-map-record.js +124 -10
- package/dist/studio-page/system-map.js +476 -87
- package/dist/studio-page/what-happens-record.js +224 -0
- package/dist/studio-page/what-happens.js +771 -0
- 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 +79 -0
- package/dist/studio-reference/laws.md +62 -0
- package/dist/studio-reference/portable-programs.md +58 -0
- package/dist/studio-reference/porting.md +34 -0
- 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/refund-desk-one-round.bosatsu +196 -0
- package/dist/studio-reference/refund-desk.bosatsu +194 -0
- package/dist/studio-reference/refund-policy.bosatsu +97 -0
- package/dist/studio-reference/refund-test.bosatsu +75 -0
- 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 +130 -0
- package/dist/studio-reference/worker-authority.md +159 -0
- package/package.json +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
package Demo/Portable/RefundDesk
|
|
2
|
+
|
|
3
|
+
# The refund engine: it holds no refund rule. A refund is kept per person
|
|
4
|
+
# and order. Each round reads the case, replays its record through
|
|
5
|
+
# Demo/Portable/RefundPolicy to learn what happens next, performs the one
|
|
6
|
+
# request that comes back (a model call), decodes the answer at the
|
|
7
|
+
# boundary, and appends the exchange in a transaction that re-reads the
|
|
8
|
+
# case and checks its version is the one the round read. The decision is
|
|
9
|
+
# saved the same way, and a round that lost either write reads the case
|
|
10
|
+
# again.
|
|
11
|
+
|
|
12
|
+
from Bosatsu/Predef import Int, Bool, String, List, Option, Some, None, True, False, Unit, GT, cmp_Int, eq_Int, eq_String, length_List, concat, int_to_String, range, add
|
|
13
|
+
from Yichus/IO import IO, flat_map, pure, fold_until
|
|
14
|
+
from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
|
|
15
|
+
from Yichus/Data import Db
|
|
16
|
+
from Yichus/Service import Reply, Done, Invalid, NotFound, Refused as Turned, TryAgain, handler, route, service_def, ReadPerm, WritePerm, CreatePerm
|
|
17
|
+
from Yichus/Transaction import Scope, TxTable, Txn, Committed, transaction, replied, tx_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
|
|
18
|
+
from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
|
|
19
|
+
from Yichus/Json import JsonObject, JsonString, JsonArray, JsonBool, Parsed, InvalidJson, Rendered, RenderError, Decoded, DecodeError, parse_json, render_json, decode_bind, required_field, as_bool
|
|
20
|
+
from Zafu/Control/IterState import IterState, Continue, Done as Finished
|
|
21
|
+
from Demo/Portable/RefundPolicy import (
|
|
22
|
+
Refund, Request, AskJudge, Answer, Judged, JudgeFailed, Exchange, Decision, Ruled, Perform, replay, same_refund, most_requests
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export (refund_desk, access_rules, answer_of, Case(), RefundView(), RefundError(), StatusRequest())
|
|
26
|
+
exposes (Demo/Portable/RefundPolicy, Yichus/Access, Yichus/OpenRouter, Yichus/Service)
|
|
27
|
+
|
|
28
|
+
# One refund: an opaque reference for outside services, a version every
|
|
29
|
+
# write raises, the request, every exchange so far, and the decision once
|
|
30
|
+
# there is one.
|
|
31
|
+
struct Case(reference: String, version: Int, refund: Refund, record: List[Exchange], decision: Option[Decision])
|
|
32
|
+
|
|
33
|
+
# What the person sees of a refund: the failure reasons outside services
|
|
34
|
+
# gave stay in the record.
|
|
35
|
+
struct RefundView(refund: Refund, decision: Option[Decision], attempts: Int)
|
|
36
|
+
|
|
37
|
+
struct StatusRequest(order: String)
|
|
38
|
+
|
|
39
|
+
enum RefundError:
|
|
40
|
+
NoSuchRefund
|
|
41
|
+
# The order already has a refund request for another amount or reason.
|
|
42
|
+
AlreadyRequested
|
|
43
|
+
# The stored record is not one the refund policy makes.
|
|
44
|
+
Unreadable
|
|
45
|
+
|
|
46
|
+
access_rules = AccessSpec([
|
|
47
|
+
AccessRule("refunds", OwnerScoped),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
def cases[s](scope: Scope[s]) -> TxTable[s, Case]: tx_table(scope, "refunds")
|
|
51
|
+
|
|
52
|
+
# Open the order's refund, unless it already has one.
|
|
53
|
+
def open_plan[s](scope: Scope[s], key: String, refund: Refund) -> Txn[s, RefundError, Unit]:
|
|
54
|
+
reference <- tx_bind(tx_new_token(scope))
|
|
55
|
+
tx_insert(cases(scope), key, Case(reference, 0, refund, [], None))
|
|
56
|
+
|
|
57
|
+
def read_plan[s](scope: Scope[s], key: String) -> Txn[s, RefundError, Option[Case]]:
|
|
58
|
+
tx_read(cases(scope), key)
|
|
59
|
+
|
|
60
|
+
# Append an exchange to an undecided case, or decide it, only if its
|
|
61
|
+
# version is still the one the round read; either raises the version.
|
|
62
|
+
def append_plan[s](scope: Scope[s], key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
|
|
63
|
+
found <- tx_bind(tx_read(cases(scope), key))
|
|
64
|
+
match found:
|
|
65
|
+
case Some(Case(reference, version, refund, record, None)):
|
|
66
|
+
if eq_Int(version, seen):
|
|
67
|
+
_ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
|
|
68
|
+
tx_pure(True)
|
|
69
|
+
else: tx_pure(False)
|
|
70
|
+
case _: tx_pure(False)
|
|
71
|
+
|
|
72
|
+
def decide_plan[s](scope: Scope[s], key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
|
|
73
|
+
found <- tx_bind(tx_read(cases(scope), key))
|
|
74
|
+
match found:
|
|
75
|
+
case Some(Case(reference, version, refund, record, None)):
|
|
76
|
+
if eq_Int(version, seen):
|
|
77
|
+
_ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record, Some(decision))))
|
|
78
|
+
tx_pure(True)
|
|
79
|
+
else: tx_pure(False)
|
|
80
|
+
case _: tx_pure(False)
|
|
81
|
+
|
|
82
|
+
# ---- The judge, behind the boundary ----
|
|
83
|
+
|
|
84
|
+
# A model that takes a temperature, through OpenAI only.
|
|
85
|
+
judge_model = "openai/gpt-4.1-mini"
|
|
86
|
+
judge_policy = ProviderPolicy(["openai"], False, True, False, False)
|
|
87
|
+
|
|
88
|
+
# {"covered": boolean}, and nothing else, as a strict schema needs.
|
|
89
|
+
judge_schema = JsonObject([
|
|
90
|
+
("type", JsonString("object")),
|
|
91
|
+
("properties", JsonObject([("covered", JsonObject([("type", JsonString("boolean"))]))])),
|
|
92
|
+
("required", JsonArray([JsonString("covered")])),
|
|
93
|
+
("additionalProperties", JsonBool(False)),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
# The request key names the case by its reference, never the person.
|
|
97
|
+
def judge_request(reference: String, attempt: Int, reason: String, schema: String) -> ModelRequest:
|
|
98
|
+
ModelRequest(
|
|
99
|
+
"refund:${reference}:${int_to_String(attempt)}", judge_model,
|
|
100
|
+
"Decide whether the customer's reason is covered by the refund policy: damaged, never arrived, or not as described. Answer JSON.",
|
|
101
|
+
reason, "refund_judgment", schema, judge_policy, 200, 0, 20000)
|
|
102
|
+
|
|
103
|
+
# The boundary: the model's text becomes a typed answer here, and anything
|
|
104
|
+
# else is the request failing, with why.
|
|
105
|
+
def answer_of(result: ModelResult) -> Answer:
|
|
106
|
+
match result:
|
|
107
|
+
case Completed(_, _, _, content, _, _):
|
|
108
|
+
match parse_json(content):
|
|
109
|
+
case Parsed(value):
|
|
110
|
+
match decode_bind(required_field("covered", value), field -> as_bool("covered", field)):
|
|
111
|
+
case Decoded(covered): Judged(covered)
|
|
112
|
+
case DecodeError(path, reason): JudgeFailed("its answer is not a judgment: ${path}: ${reason}")
|
|
113
|
+
case InvalidJson(reason): JudgeFailed("its answer is not JSON: ${reason}")
|
|
114
|
+
case Refused(reason): JudgeFailed("it refused: ${reason}")
|
|
115
|
+
case RetryableFailure(reason) | PermanentFailure(reason): JudgeFailed("the call failed: ${reason}")
|
|
116
|
+
case TimedOut(millis): JudgeFailed("it did not answer within ${int_to_String(millis)} ms")
|
|
117
|
+
|
|
118
|
+
def perform(reference: String, attempt: Int, request: Request) -> IO[Answer]:
|
|
119
|
+
match request:
|
|
120
|
+
case AskJudge(reason):
|
|
121
|
+
match render_json(judge_schema):
|
|
122
|
+
case Rendered(schema):
|
|
123
|
+
result <- flat_map(openrouter_call(judge_request(reference, attempt, reason, schema)))
|
|
124
|
+
pure(answer_of(result))
|
|
125
|
+
case RenderError(why): pure(JudgeFailed("its schema did not render: ${why}"))
|
|
126
|
+
|
|
127
|
+
# ---- Routes ----
|
|
128
|
+
|
|
129
|
+
# Enough rounds to ask the judge every time, save the decision, and lose
|
|
130
|
+
# one write to another round.
|
|
131
|
+
rounds = most_requests.add(2)
|
|
132
|
+
|
|
133
|
+
def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError, Decision]]:
|
|
134
|
+
Refund(order, amount, reason) = refund
|
|
135
|
+
key = owner_key(p, order)
|
|
136
|
+
# One round: what the policy says next, done. The same request again
|
|
137
|
+
# carries on with the stored refund; a different one for the same order
|
|
138
|
+
# is turned away.
|
|
139
|
+
def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
|
|
140
|
+
found <- flat_map(transaction(db, scope -> read_plan(scope, key)))
|
|
141
|
+
match found:
|
|
142
|
+
case Committed(Some(Case(reference, seen, stored, record, decided))):
|
|
143
|
+
if same_refund(stored, refund):
|
|
144
|
+
match (decided, replay(stored, record)):
|
|
145
|
+
case (Some(decision), _): pure(Finished(Done(decision)))
|
|
146
|
+
case (None, Some(Ruled(decision))):
|
|
147
|
+
saved <- flat_map(transaction(db, scope -> decide_plan(scope, key, seen, decision)))
|
|
148
|
+
match saved:
|
|
149
|
+
case Committed(True): pure(Finished(Done(decision)))
|
|
150
|
+
case _: pure(Continue(()))
|
|
151
|
+
case (None, Some(Perform(request))):
|
|
152
|
+
answer <- flat_map(perform(reference, length_List(record), request))
|
|
153
|
+
_ <- flat_map(transaction(db, scope -> append_plan(scope, key, seen, Exchange(request, answer))))
|
|
154
|
+
pure(Continue(()))
|
|
155
|
+
case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
|
|
156
|
+
else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
|
|
157
|
+
case Committed(None): pure(Finished(NotFound("no refund for this order", NoSuchRefund)))
|
|
158
|
+
# The store was busy: read again next round.
|
|
159
|
+
case _: pure(Continue(()))
|
|
160
|
+
def decide():
|
|
161
|
+
opened <- flat_map(transaction(db, scope -> open_plan(scope, key, refund)))
|
|
162
|
+
match opened:
|
|
163
|
+
case Committed(_):
|
|
164
|
+
ended <- flat_map(fold_until(range(rounds), (), round))
|
|
165
|
+
match ended:
|
|
166
|
+
case Finished(reply): pure(reply)
|
|
167
|
+
case Continue(_): pure(TryAgain("the refund is still being decided; ask for its status"))
|
|
168
|
+
case _: pure(TryAgain("the refund could not be opened; try again"))
|
|
169
|
+
if eq_String(order, ""): pure(Invalid("say which order to refund"))
|
|
170
|
+
elif eq_String(reason, ""): pure(Invalid("say why the order should be refunded"))
|
|
171
|
+
else:
|
|
172
|
+
match cmp_Int(amount, 0):
|
|
173
|
+
case GT: decide()
|
|
174
|
+
case _: pure(Invalid("a refund is for an amount above zero"))
|
|
175
|
+
|
|
176
|
+
def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
|
|
177
|
+
StatusRequest(order) = input
|
|
178
|
+
found <- flat_map(transaction(db, scope -> read_plan(scope, owner_key(p, order))))
|
|
179
|
+
pure(replied(found,
|
|
180
|
+
c -> (
|
|
181
|
+
match c:
|
|
182
|
+
case Some(Case(_, _, refund, record, decision)): Done(RefundView(refund, decision, length_List(record)))
|
|
183
|
+
case None: NotFound("no refund for this order", NoSuchRefund)
|
|
184
|
+
),
|
|
185
|
+
reason -> Turned("the refund could not be read", reason),
|
|
186
|
+
TryAgain("the store was busy; try again")))
|
|
187
|
+
|
|
188
|
+
refund_desk = service_def(
|
|
189
|
+
"refunds",
|
|
190
|
+
[
|
|
191
|
+
route("/refunds/request", handler("request_refund", request_refund), [ReadPerm("refunds"), WritePerm("refunds"), CreatePerm("refunds")]),
|
|
192
|
+
route("/refunds/status", handler("refund_status", refund_status), [ReadPerm("refunds")]),
|
|
193
|
+
]
|
|
194
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package Demo/Portable/RefundPolicy
|
|
2
|
+
|
|
3
|
+
# Every refund decision, as one pure step. Given the refund and the record
|
|
4
|
+
# of every request and answer so far, `step` says what happens next: the
|
|
5
|
+
# one request the engine must perform, or the decision. The engine holds
|
|
6
|
+
# no refund rule of its own. `replay` reads a stored record back through
|
|
7
|
+
# `step`, with no effects, and refuses a record this program would not
|
|
8
|
+
# have made.
|
|
9
|
+
|
|
10
|
+
from Bosatsu/Predef import Int, Bool, String, List, Option, Some, None, True, False, LT, cmp_Int, eq_Int, eq_String, length_List, foldl_List
|
|
11
|
+
|
|
12
|
+
export (Refund(), Request(), Answer(), Exchange(), Decision(), Next(), step, replay, same_request, same_refund, small_refund_cents, most_requests)
|
|
13
|
+
|
|
14
|
+
struct Refund(order: String, amount_cents: Int, reason: String)
|
|
15
|
+
|
|
16
|
+
# The only things a refund can ask the outside world for. A new kind of
|
|
17
|
+
# request is a change to this enum and to every engine, never a rule an
|
|
18
|
+
# engine adds by itself.
|
|
19
|
+
enum Request:
|
|
20
|
+
AskJudge(reason: String)
|
|
21
|
+
|
|
22
|
+
# An answer, decoded at the boundary into a typed value before the program
|
|
23
|
+
# sees it.
|
|
24
|
+
enum Answer:
|
|
25
|
+
Judged(covered: Bool)
|
|
26
|
+
JudgeFailed(why: String)
|
|
27
|
+
|
|
28
|
+
struct Exchange(request: Request, answer: Answer)
|
|
29
|
+
|
|
30
|
+
enum Decision:
|
|
31
|
+
Approved
|
|
32
|
+
Denied
|
|
33
|
+
# The judge failed every time it was asked: a person decides.
|
|
34
|
+
Escalated
|
|
35
|
+
|
|
36
|
+
# Named so as to clash with no constructor an engine imports: `Done` is a
|
|
37
|
+
# route's reply and a loop's end, and `Decided` a decision call's result.
|
|
38
|
+
enum Next:
|
|
39
|
+
Ruled(decision: Decision)
|
|
40
|
+
Perform(request: Request)
|
|
41
|
+
|
|
42
|
+
# Refunds below this are approved without asking.
|
|
43
|
+
small_refund_cents = 2000
|
|
44
|
+
|
|
45
|
+
# A refund asks the judge at most this many times, then a person decides.
|
|
46
|
+
most_requests = 3
|
|
47
|
+
|
|
48
|
+
def last_answer(record: List[Exchange]) -> Option[Answer]:
|
|
49
|
+
foldl_List(record, None, (_, exchange) -> (
|
|
50
|
+
Exchange(_, answer) = exchange
|
|
51
|
+
Some(answer)
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
def step(refund: Refund, record: List[Exchange]) -> Next:
|
|
55
|
+
Refund(_, amount, reason) = refund
|
|
56
|
+
match cmp_Int(amount, small_refund_cents):
|
|
57
|
+
case LT: Ruled(Approved)
|
|
58
|
+
case _:
|
|
59
|
+
match last_answer(record):
|
|
60
|
+
case Some(Judged(True)): Ruled(Approved)
|
|
61
|
+
case Some(Judged(False)): Ruled(Denied)
|
|
62
|
+
case _:
|
|
63
|
+
# No answer yet, or the judge failed: ask again until the limit.
|
|
64
|
+
match cmp_Int(length_List(record), most_requests):
|
|
65
|
+
case LT: Perform(AskJudge(reason))
|
|
66
|
+
case _: Ruled(Escalated)
|
|
67
|
+
|
|
68
|
+
def same_request(left: Request, right: Request) -> Bool:
|
|
69
|
+
match (left, right):
|
|
70
|
+
case (AskJudge(l), AskJudge(r)): eq_String(l, r)
|
|
71
|
+
|
|
72
|
+
# Two requests for one order ask for the same refund.
|
|
73
|
+
def same_refund(left: Refund, right: Refund) -> Bool:
|
|
74
|
+
Refund(lo, la, lr) = left
|
|
75
|
+
Refund(ro, ra, rr) = right
|
|
76
|
+
if eq_String(lo, ro):
|
|
77
|
+
if eq_Int(la, ra): eq_String(lr, rr)
|
|
78
|
+
else: False
|
|
79
|
+
else: False
|
|
80
|
+
|
|
81
|
+
# A record replays when each exchange answers the request `step` made at
|
|
82
|
+
# that point. The result is the step the record leads to; None means the
|
|
83
|
+
# record is not one this program makes.
|
|
84
|
+
def replay(refund: Refund, record: List[Exchange]) -> Option[Next]:
|
|
85
|
+
def answered(prefix: Option[List[Exchange]], exchange: Exchange) -> Option[List[Exchange]]:
|
|
86
|
+
match prefix:
|
|
87
|
+
case None: None
|
|
88
|
+
case Some(made):
|
|
89
|
+
Exchange(asked, _) = exchange
|
|
90
|
+
match step(refund, made):
|
|
91
|
+
case Perform(expected):
|
|
92
|
+
if same_request(expected, asked): Some([*made, exchange])
|
|
93
|
+
else: None
|
|
94
|
+
case Ruled(_): None
|
|
95
|
+
match foldl_List(record, Some([]), answered):
|
|
96
|
+
case Some(made): Some(step(refund, made))
|
|
97
|
+
case None: None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package Demo/Portable/RefundTest
|
|
2
|
+
|
|
3
|
+
# Native Bosatsu tests for the refund example: the laws of `step` and
|
|
4
|
+
# `replay`, and the boundary that turns the model's text into an answer.
|
|
5
|
+
# They evaluate (PortableRefundExampleTest.scala), so they pin behaviour, not just
|
|
6
|
+
# types.
|
|
7
|
+
|
|
8
|
+
# Test, TestSuite and Assertion come from the implicit Predef import.
|
|
9
|
+
from Bosatsu/Predef import String, True, False, Bool, eq_String
|
|
10
|
+
from Yichus/OpenRouter import ModelResult, Completed, TimedOut
|
|
11
|
+
from Demo/Portable/RefundPolicy import (
|
|
12
|
+
Refund, AskJudge, Judged, JudgeFailed, Exchange, Approved, Denied, Escalated, Ruled, Perform, step, replay
|
|
13
|
+
)
|
|
14
|
+
from Demo/Portable/RefundDesk import answer_of
|
|
15
|
+
|
|
16
|
+
export (tests)
|
|
17
|
+
|
|
18
|
+
small = Refund("order-1", 1500, "it arrived broken")
|
|
19
|
+
large = Refund("order-2", 9000, "it never arrived")
|
|
20
|
+
asked = AskJudge("it never arrived")
|
|
21
|
+
failed = Exchange(asked, JudgeFailed("the call failed: busy"))
|
|
22
|
+
|
|
23
|
+
def completed(content: String) -> ModelResult: Completed("id", "openai/gpt-4.1-mini", "openai", content, 10, 5)
|
|
24
|
+
|
|
25
|
+
def is_judged(result: ModelResult, covered: Bool, said: String) -> Test:
|
|
26
|
+
match answer_of(result):
|
|
27
|
+
case Judged(got):
|
|
28
|
+
match (covered, got):
|
|
29
|
+
case (True, True) | (False, False): Assertion(True, said)
|
|
30
|
+
case _: Assertion(False, said)
|
|
31
|
+
case JudgeFailed(_): Assertion(False, said)
|
|
32
|
+
|
|
33
|
+
def is_failed(result: ModelResult, said: String) -> Test:
|
|
34
|
+
match answer_of(result):
|
|
35
|
+
case JudgeFailed(_): Assertion(True, said)
|
|
36
|
+
case Judged(_): Assertion(False, said)
|
|
37
|
+
|
|
38
|
+
tests = TestSuite("Refund example", [
|
|
39
|
+
match step(small, []):
|
|
40
|
+
case Ruled(Approved): Assertion(True, "a small refund is approved without asking")
|
|
41
|
+
case _: Assertion(False, "a small refund is approved without asking"),
|
|
42
|
+
match step(large, []):
|
|
43
|
+
case Perform(AskJudge(reason)): Assertion(eq_String(reason, "it never arrived"), "a large refund asks the judge about its reason")
|
|
44
|
+
case _: Assertion(False, "a large refund asks the judge about its reason"),
|
|
45
|
+
match step(Refund("order-3", 2000, "it never arrived"), []):
|
|
46
|
+
case Perform(_): Assertion(True, "a refund of exactly the limit asks the judge")
|
|
47
|
+
case _: Assertion(False, "a refund of exactly the limit asks the judge"),
|
|
48
|
+
match step(large, [Exchange(asked, Judged(True))]):
|
|
49
|
+
case Ruled(Approved): Assertion(True, "a covered reason is approved")
|
|
50
|
+
case _: Assertion(False, "a covered reason is approved"),
|
|
51
|
+
match step(large, [Exchange(asked, Judged(False))]):
|
|
52
|
+
case Ruled(Denied): Assertion(True, "a reason not covered is denied")
|
|
53
|
+
case _: Assertion(False, "a reason not covered is denied"),
|
|
54
|
+
match step(large, [failed, failed]):
|
|
55
|
+
case Perform(_): Assertion(True, "a failed judge is asked again")
|
|
56
|
+
case _: Assertion(False, "a failed judge is asked again"),
|
|
57
|
+
match step(large, [failed, failed, failed]):
|
|
58
|
+
case Ruled(Escalated): Assertion(True, "after three failures a person decides")
|
|
59
|
+
case _: Assertion(False, "after three failures a person decides"),
|
|
60
|
+
match replay(large, [Exchange(AskJudge("something else"), Judged(True))]):
|
|
61
|
+
case None: Assertion(True, "a record answering a request the policy did not make does not replay")
|
|
62
|
+
case Some(_): Assertion(False, "a record answering a request the policy did not make does not replay"),
|
|
63
|
+
match replay(small, [Exchange(asked, Judged(True))]):
|
|
64
|
+
case None: Assertion(True, "a record past the decision does not replay")
|
|
65
|
+
case Some(_): Assertion(False, "a record past the decision does not replay"),
|
|
66
|
+
match replay(large, [failed, Exchange(asked, Judged(False))]):
|
|
67
|
+
case Some(Ruled(Denied)): Assertion(True, "a stored record replays to its decision")
|
|
68
|
+
case _: Assertion(False, "a stored record replays to its decision"),
|
|
69
|
+
is_judged(completed("{ \"covered\" : true }"), True, "any valid JSON spelling of a judgment is read"),
|
|
70
|
+
is_judged(completed("{\"covered\":false}"), False, "a judgment that the reason is not covered is read"),
|
|
71
|
+
is_failed(completed("{\"covered\":\"yes\"}"), "a judgment of the wrong type is a failure"),
|
|
72
|
+
is_failed(completed("covered"), "an answer that is not JSON is a failure"),
|
|
73
|
+
is_failed(completed("{\"covered\":true,\"covered\":false}"), "an answer that says covered twice is a failure"),
|
|
74
|
+
is_failed(TimedOut(20000), "a call that timed out is a failure"),
|
|
75
|
+
])
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# A Render Blueprint for the engine and its database. Commit it at the root
|
|
2
|
+
# of the folder Render deploys, with engine.mjs, server.mjs, store.mjs, a
|
|
3
|
+
# package.json that depends on @yichus/postgres-store and pg, and the
|
|
4
|
+
# package-lock.json npm writes (npm ci installs from it).
|
|
5
|
+
databases:
|
|
6
|
+
- name: app-db
|
|
7
|
+
plan: basic-256mb
|
|
8
|
+
services:
|
|
9
|
+
- type: web
|
|
10
|
+
name: app-api
|
|
11
|
+
runtime: node
|
|
12
|
+
plan: starter
|
|
13
|
+
# One server: keep it at 1 and leave autoscaling off. Raise it only when
|
|
14
|
+
# the person chose several copies and the studio's checks hold for that.
|
|
15
|
+
numInstances: 1
|
|
16
|
+
buildCommand: npm ci --omit=dev
|
|
17
|
+
startCommand: node server.mjs
|
|
18
|
+
# Render sends traffic to a new deploy only once this answers.
|
|
19
|
+
healthCheckPath: /health
|
|
20
|
+
envVars:
|
|
21
|
+
- key: NODE_VERSION
|
|
22
|
+
value: "22"
|
|
23
|
+
- key: DATABASE_URL
|
|
24
|
+
fromDatabase:
|
|
25
|
+
name: app-db
|
|
26
|
+
property: connectionString
|
|
27
|
+
- key: YICHUS_STORE
|
|
28
|
+
value: adapter:./store.mjs
|
|
29
|
+
- key: YICHUS_POSTGRES_NAMESPACE
|
|
30
|
+
value: app-1
|
|
31
|
+
# How the engine checks who is calling (see "An identity mode" in
|
|
32
|
+
# deploy-platforms.md). For sign-in with a provider's sessions:
|
|
33
|
+
- key: YICHUS_AUTH
|
|
34
|
+
value: jwt
|
|
35
|
+
- key: YICHUS_JWKS_URL
|
|
36
|
+
sync: false
|
|
37
|
+
# The page's origin, when a browser calls the API directly.
|
|
38
|
+
- key: YICHUS_CORS_ORIGIN
|
|
39
|
+
sync: false
|
|
40
|
+
# Only with worker routes; set it here and on the page host.
|
|
41
|
+
- key: INGEST_WORKER_SECRET
|
|
42
|
+
sync: false
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The start command. Applies the store's schema (it is safe to run on every
|
|
2
|
+
// start), then starts the engine api_deploy generated as engine.mjs, which
|
|
3
|
+
// reads YICHUS_STORE, YICHUS_AUTH and PORT from the environment and refuses
|
|
4
|
+
// to listen when sign-in is not configured.
|
|
5
|
+
import pg from 'pg';
|
|
6
|
+
import { migrateYichusPostgres } from '@yichus/postgres-store';
|
|
7
|
+
|
|
8
|
+
import { attachYichusWorkerVerifier, startYichusApiEngine, yichusSecretWorkerVerifier } from './engine.mjs';
|
|
9
|
+
|
|
10
|
+
const connectionString = process.env.DATABASE_URL;
|
|
11
|
+
if (!connectionString) {
|
|
12
|
+
console.error('refusing to start: DATABASE_URL is not set');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const pool = new pg.Pool({ connectionString });
|
|
16
|
+
try {
|
|
17
|
+
await migrateYichusPostgres(pool);
|
|
18
|
+
} finally {
|
|
19
|
+
await pool.end();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Only for an app with worker routes: the page host's route handlers call
|
|
23
|
+
// them with this secret after checking a webhook's signature or a form's
|
|
24
|
+
// bot check. The roles are the grants the program's worker_route calls
|
|
25
|
+
// name ("ingest" here). Leave it out when the program declares no worker_route.
|
|
26
|
+
if (process.env.INGEST_WORKER_SECRET) {
|
|
27
|
+
attachYichusWorkerVerifier(yichusSecretWorkerVerifier([
|
|
28
|
+
{ name: 'ingest', secret: process.env.INGEST_WORKER_SECRET, roles: ['ingest'] },
|
|
29
|
+
]));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const server = await startYichusApiEngine();
|
|
33
|
+
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The engine's store: PostgreSQL through @yichus/postgres-store, which runs
|
|
2
|
+
// every transaction at SERIALIZABLE isolation and retries a bounded number
|
|
3
|
+
// of times. The engine loads this module from YICHUS_STORE=adapter:./store.mjs
|
|
4
|
+
// and calls the factory with no arguments, so the connection comes from the
|
|
5
|
+
// environment.
|
|
6
|
+
import { createYichusPostgresAdapter } from '@yichus/postgres-store';
|
|
7
|
+
|
|
8
|
+
// The namespace has no default: rows of one namespace are never served
|
|
9
|
+
// under another, so a missing value stops the engine.
|
|
10
|
+
export function createYichusStoreAdapter() {
|
|
11
|
+
const connectionString = process.env.DATABASE_URL;
|
|
12
|
+
if (!connectionString) throw new Error('DATABASE_URL is not set');
|
|
13
|
+
const namespace = process.env.YICHUS_POSTGRES_NAMESPACE;
|
|
14
|
+
if (!namespace) throw new Error('YICHUS_POSTGRES_NAMESPACE is not set');
|
|
15
|
+
return createYichusPostgresAdapter({ connectionString, namespace, maxAttempts: 5 });
|
|
16
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Guiding the person through the studio
|
|
2
|
+
|
|
3
|
+
The person does not read documentation. The yich.us studio page only tells them what the studio is and how to connect you. Everything they need to know while building comes from you, a little at a time, when it becomes relevant. This file is what you need to know to do that. Do not read it out. Bring in one piece when the conversation reaches it, in their words, and point at the part of the page you mean with `yichus_point`.
|
|
4
|
+
|
|
5
|
+
`yichus_page` shows you what the page shows them right now, and what they have on screen and last clicked.
|
|
6
|
+
|
|
7
|
+
## Is this worth it?
|
|
8
|
+
|
|
9
|
+
When the person asks whether the studio is worth it, compared with you building the app the usual way, answer for their app, honestly, and give a clear recommendation.
|
|
10
|
+
|
|
11
|
+
You build the app either way. The difference is what they can know about it. You could often avoid these bugs yourself with careful code and tests, but tests cover the cases you thought of, and they cannot read the code or the tests. With the studio they choose the rules, a check that does not depend on you says whether each one holds, on every path through the program, and it says so again after every change you make later. Use what the checks found so far: a bug they caught in your own first draft is the clearest answer to the question.
|
|
12
|
+
|
|
13
|
+
What else they get: the product decisions behind those bugs come to them, so nothing is settled by your default, and a page shows their app in their words.
|
|
14
|
+
|
|
15
|
+
What it costs them, with what limits each cost:
|
|
16
|
+
|
|
17
|
+
- The code is in a less common typed language. You write and maintain it, and they read no code either way, so it matters mostly if a developer will join later and have to learn it. They are not stuck with it: the rules are written down in one place, so an agent can port the app to a mainstream stack later, as cheaply as you can port one in today.
|
|
18
|
+
- It runs on Yichus's engine: MIT-licensed npm packages on a host they pick, with their data in their own PostgreSQL. A built app runs nothing on Yichus's servers.
|
|
19
|
+
- It is slower to start than a quick build: a few questions first, and fixes until the rules hold.
|
|
20
|
+
- The checks cover the rules as written. Read each rule back to them in plain words, because a wrong rule passes. Payments, email, file uploads and scheduled jobs are not asked about yet. They can be built beside the checked part, unchecked; a download they click, such as a monthly export, is an ordinary action and is checked.
|
|
21
|
+
|
|
22
|
+
Recommend it when the app keeps records several people touch and a leak, a double booking or a half-saved change would hurt: bookings, sign-ups, approvals, anything with customers, patients or money. When some parts are not covered, recommend it for the part with the records and the people. For a live app, the port is a side copy: the old app keeps running untouched while the checks run on the new one. With you doing the port it is a much smaller job than it sounds: in one rehearsal a fresh agent ported a copy of a live Next.js and Supabase app in about 45 minutes, keeping its pages and its sign-in, with every property proven (locally, on test data). For a large app you can start with the part they worry about most. If the app keeps no records at all, such as a calculator, the studio's checks have nothing to hold; Yichus's calculator path (Why on every number, checked rules about the numbers) fits better, through the separate @yichus/mcp connection and the QuickStart at https://yich.us/start/quickstart.html. Its exported page carries its own scripts, so the person can host it anywhere. It is usually not worth it for a throwaway prototype, or for code they want to keep in its current language.
|
|
23
|
+
|
|
24
|
+
A scaffold is a starting point: shape its records and who may see them to the person's roles (a coordinator who sees everyone's sign-ups, a manager who approves) before you show them results.
|
|
25
|
+
|
|
26
|
+
## When you give them the link
|
|
27
|
+
|
|
28
|
+
Tell them three things, briefly:
|
|
29
|
+
|
|
30
|
+
- The page runs on the computer the studio runs on. Nobody else can open it, and it keeps the same address when you restart the studio. If that is not their computer (you run in a cloud container, say), they cannot open it: tell them, and describe what it shows from `yichus_page` instead.
|
|
31
|
+
- It updates by itself after every check, so they can leave it open in a tab.
|
|
32
|
+
- They can ask you about anything on it.
|
|
33
|
+
|
|
34
|
+
Do not explain the sections until something appears in them.
|
|
35
|
+
|
|
36
|
+
## The page, top to bottom
|
|
37
|
+
|
|
38
|
+
- What you asked for: their description, exactly as recorded.
|
|
39
|
+
- What is happening now: the step you are on, in one sentence.
|
|
40
|
+
- Questions for you: shown while a question is open. Saving an answer there and answering you in the chat do the same thing.
|
|
41
|
+
- What your program must do: each rule they gave and each choice they made, with its status.
|
|
42
|
+
- What happens when: each thing the app does, followed to every way it can end.
|
|
43
|
+
- How your app is built: the parts of the app, stacked on the parts they are built from.
|
|
44
|
+
- The shape of your program: a picture of the whole program as it is now.
|
|
45
|
+
|
|
46
|
+
## The steps you go through
|
|
47
|
+
|
|
48
|
+
The studio moves you through these steps in order, after every change. Tell the person which one you are on when it changes, in one sentence. The page's "What is happening now" says the same.
|
|
49
|
+
|
|
50
|
+
1. Describe: their description is recorded word for word.
|
|
51
|
+
2. Write the first version.
|
|
52
|
+
3. Make it run: fix the mistakes that stop it from running.
|
|
53
|
+
4. Their rules: each rule they gave about what the app must always or never do becomes a law the studio runs on every check.
|
|
54
|
+
5. Name the parts in their words, so the questions and the pictures use their words instead of the code's.
|
|
55
|
+
6. Ask them the questions the studio found. For an app with a server, one of them is where it runs: Render, Vercel, Netlify, Amazon Web Services, Google Cloud, Microsoft Azure, or not sure yet. The app is the same wherever it runs, so this only decides how you put it online. Vercel and Netlify hold only the pages, so there they also pick where the server goes: a second service with its own bill. A calculation with no actions people call and no records has no server, and the studio does not ask.
|
|
56
|
+
7. Match their answers: write the answers into the program as rules the checks hold it to.
|
|
57
|
+
8. Fix whatever does not hold yet. A broken rule comes first, before describing what happens.
|
|
58
|
+
9. What happens when: for each thing the app does, write down in their words every way it can go and what they should get at the end. The studio checks each one.
|
|
59
|
+
10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it.
|
|
60
|
+
11. Done: everything they chose holds.
|
|
61
|
+
|
|
62
|
+
When a check fails, the code changes, never the rule. If they ask why something is taking a while, this is usually the answer.
|
|
63
|
+
|
|
64
|
+
## The questions
|
|
65
|
+
|
|
66
|
+
Questions come from what the program does, never from what things are called. A new feature can bring new questions. When you ask one, say why it matters, using the question's own "why", and give every choice.
|
|
67
|
+
|
|
68
|
+
| Asked when | The question | The choices |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| The app answers requests | One server, or several copies sharing one database? | Several copies (safer, the default); one server (shown as an assumption on every result) |
|
|
71
|
+
| Some actions are for signed-in people | How do people sign in? | Not yet; Clerk; Supabase; another provider; only programs, with keys |
|
|
72
|
+
| The app saves records | Are there records somewhere today that this app must start with? | No, it starts empty; copy them in; keep the database they are in |
|
|
73
|
+
| The app saves a kind of record | Who may see one of these records, and who may change it? | Only its creator; only the people a rule lets in (a team, say); no person, only the app's own server; everyone reads, certain roles change; anyone |
|
|
74
|
+
| An action reads a record and then changes it | Two people change the same record at the same moment. What should happen? | Both changes are kept; the second is refused and they try again |
|
|
75
|
+
| An action saves in more than one step | If the server stops halfway, may part of the change stay saved? | All or nothing; partial is fine |
|
|
76
|
+
| An action calls an outside service or an AI model | If that service is down or slow, what should the person see? | An error with nothing saved; save the request and finish it later; no preference |
|
|
77
|
+
|
|
78
|
+
They can answer in the chat or on the page; both are the same. The page keeps their words next to the choice, and a later answer replaces an earlier one. The answers live in their project folder, so a new session starts from the same decisions. Tell them that once, the first time they answer.
|
|
79
|
+
|
|
80
|
+
## What each status means
|
|
81
|
+
|
|
82
|
+
Each choice they made shows on the page under "What your program must do", with one of these statuses. When a status changes, say what it means if they have not seen it before.
|
|
83
|
+
|
|
84
|
+
| On the page | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| waiting for your answer | A question they have not answered yet. Ask it. |
|
|
87
|
+
| proven | Checked against every path through the program: no input, user or timing breaks the rule as written. Whether the rule is the right one is theirs to judge. |
|
|
88
|
+
| holds in every scenario checked | The failure scenario ran and stopped or failed the app at every step it covers. Strong, but limited to that scenario. |
|
|
89
|
+
| holds, under an assumption | Proven only while the assumption is true, usually that exactly one server runs. A second copy can break it. |
|
|
90
|
+
| decided | A choice that sets up the other checks, such as several copies of the server. |
|
|
91
|
+
| broken | The check found a way it fails. You have the evidence; say in their terms what goes wrong, then fix it. |
|
|
92
|
+
| the code says otherwise | The rule in the program does not match their answer yet. You update it. |
|
|
93
|
+
| scenario being written | You are writing the failure scenario this answer needs. |
|
|
94
|
+
| check being written | A rule they gave has no law that checks it yet. You write one. |
|
|
95
|
+
| checked, but your app does not use it yet | Its law passes, but no action of the app reaches what the law checks, so the check says nothing about what the actions do. You make the action decide with that function. |
|
|
96
|
+
| not decided yet | The check ran but could not decide. You find out why. |
|
|
97
|
+
| no check needed | They chose an option that adds no check, such as "partial is fine". |
|
|
98
|
+
| answered, waiting for your agent to check | They answered on the page; the result appears after your next check. |
|
|
99
|
+
|
|
100
|
+
The evidence for a status comes to you, never to the page. When they ask why, explain it in product terms.
|
|
101
|
+
|
|
102
|
+
## Reading the pictures with them
|
|
103
|
+
|
|
104
|
+
Everything in a box marked "Yichus output" comes straight from the checks. The pictures are drawn by the checks too, but their words in italics are the names and descriptions you chose, and nothing checks those words. Never let a picture's italic words or your notes stand in for a check.
|
|
105
|
+
|
|
106
|
+
"What happens when" follows each thing the app does to every way it can end.
|
|
107
|
+
|
|
108
|
+
- Each question the app asks itself, such as "are you signed in?", splits into the ways it can go. A way that ends at once sits just under its question. The way that leads on furthest continues straight down after an arrow. Any other way that leads on is folded until they open it.
|
|
109
|
+
- Each way ends in a mark: a bar when the request is turned away, a ring with a dot when it is answered.
|
|
110
|
+
- Under the mark are the things they said should be true there. A tick means the check finds it holds, a cross means it does not, and a question mark means the check cannot say.
|
|
111
|
+
- A red ending is one where something they expect is false, or where someone could reach another person's records. Green means an ending that was broken is now fixed. A dashed outline means the check cannot say.
|
|
112
|
+
- "Not described yet" marks a part you have not put into words. It is still checked.
|
|
113
|
+
|
|
114
|
+
"How your app is built" stacks the parts of the app. Each box is one part, in their words, with the records it changes or only looks at. Parts in a higher band are built from parts in the bands below, and requests come in at the top. A wider box is one more of the app is built on.
|
|
115
|
+
|
|
116
|
+
"The shape of your program" is the system map. Requests come in on the left, and the records and outside services sit on the right. Tapping a part shows it on its own. A label that looks like code is a part you have not named yet. For a person who wants to learn the marks, https://yich.us/system-map-blocks.html explains each one, and https://yich.us/lessons/index.html has short lessons with practice.
|
|
117
|
+
|
|
118
|
+
## Your notes and what you can see
|
|
119
|
+
|
|
120
|
+
Your `yichus_point` notes appear as bubbles marked "Your agent · not checked", each beside the part it is about. Tell the person once that the bubbles are you pointing, like a finger on the page, and that the statuses and checks say whether something holds. They can close a bubble with its ×.
|
|
121
|
+
|
|
122
|
+
You can see what the page shows and what they have in view. When they say "this one" or "the red one", call `yichus_page` before you answer. If they ask what you can see, it is only which parts of the studio page are on screen, which folds they opened, and what they last clicked, nothing else from their browser.
|
|
123
|
+
|
|
124
|
+
## What the studio does not check
|
|
125
|
+
|
|
126
|
+
Say these plainly when they come up, and before the person treats "done" as a guarantee:
|
|
127
|
+
|
|
128
|
+
- Whether they chose well. The studio checks that the program does what they chose, not that the choice suits their users.
|
|
129
|
+
- Questions it does not ask yet: payments, email, file uploads and scheduled jobs.
|
|
130
|
+
- Mathematical proofs and whole-system model checking. A failure scenario covers only the steps it names.
|