yichus 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -0,0 +1,11 @@
1
+ # Failure scenarios for an action
2
+
3
+ The studio asks for one of these when the person chose "all or nothing" for an action that changes records in several steps, or chose a plan for an action that calls an outside service. You write the case as JSON at the path `yichus_next` names (`.yichus/cases/<question>.json`). Each `yichus_next` runs it with `api_protocol_check` against the program in the folder. You never run a command for it.
4
+
5
+ A case lists the starting rows, the requests (two deliveries of one request on two servers, for example), the clock values and outside results to try, and the rules that must hold in every order the steps can run, with crashes between them. The checker tries every order within those bounds. "Holds" means it holds for every order in the case, not for every input. The page says so.
6
+
7
+ Observation predicates the case names are Bosatsu functions. Put them in `.yichus/cases/*.bosatsu`: the studio passes those files to protocol checks only, so they never count as part of the app.
8
+
9
+ Write the rules from the person's choice ("a withdrawal is taken once, or not at all"), never from what the code does. Then test the case on a deliberately broken copy of the program: the check must fail there with a named counterexample. A case that passes against broken code checks nothing.
10
+
11
+ `withdrawal-case.json` and `withdrawal.bosatsu`, which come with this topic, are a complete case and the program it checks. Two servers deliver the same withdrawal, and the case requires exactly one durable receipt. The specification follows.
@@ -38,9 +38,11 @@ section does not change the report's universal proof result.
38
38
  ## Case format: protocol-case-1.0
39
39
 
40
40
  Every top-level field below is required. Unknown fields and duplicate JSON keys
41
- are rejected recursively. The complete runnable example is
42
- [article-retrieval.protocol.json](../icetakes/probes/article-retrieval.protocol.json).
43
- The replacement-worker and final-attempt cases sit beside it.
41
+ are rejected recursively. A complete runnable example is the withdrawal case,
42
+ `withdrawal-case.json`, with the program it checks, `withdrawal.bosatsu`. Both
43
+ come with the studio's `protocol-case` reference; in this repository they are
44
+ `eval/corpus/webmcp-traps/iteration-003/programs/Q15/twin/case.json` and
45
+ `ledger.bosatsu` beside it.
44
46
 
45
47
  | Field | Contract |
46
48
  | --- | --- |
@@ -109,8 +111,8 @@ An invariant entry names a compiled `IO[Bool]` function:
109
111
 
110
112
  ```json
111
113
  {
112
- "name": "current-lease-and-input",
113
- "binding": "Icetakes/RetrievalInvariants::completion_has_current_lease",
114
+ "name": "balance-never-negative",
115
+ "binding": "Ledger/Invariants::balance_never_negative",
114
116
  "arguments": { "before": "before", "after": "after", "now": "time" },
115
117
  "when": "always"
116
118
  }
@@ -124,16 +126,14 @@ named argument. Selector types must equal parameter types. Predicates may read
124
126
  these databases; writes and other IO are rejected. Return predicates automatically
125
127
  require a return witness, preventing success from a wholly unobserved return path.
126
128
 
127
- Use `explore --agenda` and `organize` on the predicate's complete source set,
128
- then read their findings alongside the protocol case. The explorer does not
129
- see entry points selected by a separate case JSON, and generic samples of
130
- unrelated records may never satisfy an identity comparison. An unexecuted
131
- reference or constant-result observation is therefore a prompt to investigate,
132
- not evidence that a predicate is dead or correct. Require witnesses for the
133
- states the invariant describes, and test a deliberately broken implementation
134
- that must produce a named counterexample. The
135
- [Icetakes completion cases](../icetakes/probes/2026-09-25-completion-coherence.md)
136
- demonstrate this workflow with typed source relationships and durable receipts.
129
+ Exploring the predicate's complete source set (`api_explore`, or the CLI's
130
+ `explore --agenda` and `organize`) helps, but the explorer does not see entry
131
+ points selected by a separate case JSON, and generic samples of unrelated
132
+ records may never satisfy an identity comparison. An unexecuted reference or
133
+ constant-result observation is therefore a prompt to investigate, not evidence
134
+ that a predicate is dead or correct. Require witnesses for the states the
135
+ invariant describes, and test a deliberately broken implementation that must
136
+ produce a named counterexample.
137
137
 
138
138
  ## Trace assertions
139
139
 
@@ -191,6 +191,4 @@ history, not an exploration of every possible five-attempt history.
191
191
  The conformance suites replay actual generated JavaScript with a controlled
192
192
  adapter and compare JVM transaction and model-call behavior with model transitions. They test
193
193
  that boundary under the declared adapter contract; they do not certify a deployed
194
- PostgreSQL adapter, JWT implementation, external fetch service, or model provider. The retained
195
- [earlier retrieval report](../icetakes/probes/article-retrieval-report.json) remains
196
- a separate artifact and is not replaced by a runtime-test success label.
194
+ PostgreSQL adapter, JWT implementation, external fetch service, or model provider.
@@ -20,7 +20,7 @@ from Yichus/IO import IO, flat_map, pure
20
20
  from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
21
21
  from Yichus/Data import Db
22
22
  from Yichus/Service import Reply, Done, Invalid, NotFound, Refused as Turned, TryAgain, handler, route, service_def, ReadPerm, WritePerm, CreatePerm
23
- 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
23
+ from Yichus/Transaction import TxTable, Txn, Committed, transaction, replied, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
24
24
  from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
25
25
  from Yichus/Json import JsonObject, JsonString, JsonArray, JsonBool, Parsed, InvalidJson, Rendered, RenderError, Decoded, DecodeError, parse_json, render_json, decode_bind, required_field, as_bool
26
26
  from Zafu/Control/IterState import IterState, Continue, Done as Finished
@@ -53,34 +53,33 @@ access_rules = AccessSpec([
53
53
  AccessRule("refunds", OwnerScoped),
54
54
  ])
55
55
 
56
- def cases[s](scope: Scope[s]) -> TxTable[s, Case]: tx_table(scope, "refunds")
56
+ cases: TxTable[s, Case] = stored_table("refunds")
57
57
 
58
58
  # Open the order's refund, unless it already has one.
59
- def open_plan[s](scope: Scope[s], key: String, refund: Refund) -> Txn[s, RefundError, Unit]:
60
- reference <- tx_bind(tx_new_token(scope))
61
- tx_insert(cases(scope), key, Case(reference, 0, refund, [], None))
59
+ def open_plan[s](key: String, refund: Refund, reference: String) -> Txn[s, RefundError, Unit]:
60
+ tx_insert(cases, key, Case(reference, 0, refund, [], None))
62
61
 
63
- def read_plan[s](scope: Scope[s], key: String) -> Txn[s, RefundError, Option[Case]]:
64
- tx_read(cases(scope), key)
62
+ def read_plan[s](key: String) -> Txn[s, RefundError, Option[Case]]:
63
+ tx_read(cases, key)
65
64
 
66
65
  # Append an exchange to an undecided case, or decide it, only if its
67
66
  # version is still the one the round read; either raises the version.
68
- def append_plan[s](scope: Scope[s], key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
69
- found <- tx_bind(tx_read(cases(scope), key))
67
+ def append_plan[s](key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
68
+ found <- tx_bind(tx_read(cases, key))
70
69
  match found:
71
70
  case Some(Case(reference, version, refund, record, None)):
72
71
  if eq_Int(version, seen):
73
- _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
72
+ _ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
74
73
  tx_pure(True)
75
74
  else: tx_pure(False)
76
75
  case _: tx_pure(False)
77
76
 
78
- def decide_plan[s](scope: Scope[s], key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
79
- found <- tx_bind(tx_read(cases(scope), key))
77
+ def decide_plan[s](key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
78
+ found <- tx_bind(tx_read(cases, key))
80
79
  match found:
81
80
  case Some(Case(reference, version, refund, record, None)):
82
81
  if eq_Int(version, seen):
83
- _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record, Some(decision))))
82
+ _ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record, Some(decision))))
84
83
  tx_pure(True)
85
84
  else: tx_pure(False)
86
85
  case _: tx_pure(False)
@@ -139,20 +138,20 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
139
138
  # carries on with the stored refund; a different one for the same order
140
139
  # is turned away.
141
140
  def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
142
- found <- flat_map(transaction(db, scope -> read_plan(scope, key)))
141
+ found <- flat_map(transaction(db, _ -> read_plan(key)))
143
142
  match found:
144
143
  case Committed(Some(Case(reference, seen, stored, record, decided))):
145
144
  if same_refund(stored, refund):
146
145
  match (decided, replay(stored, record)):
147
146
  case (Some(decision), _): pure(Finished(Done(decision)))
148
147
  case (None, Some(Ruled(decision))):
149
- saved <- flat_map(transaction(db, scope -> decide_plan(scope, key, seen, decision)))
148
+ saved <- flat_map(transaction(db, _ -> decide_plan(key, seen, decision)))
150
149
  match saved:
151
150
  case Committed(True): pure(Finished(Done(decision)))
152
151
  case _: pure(Continue(()))
153
152
  case (None, Some(Perform(request))):
154
153
  answer <- flat_map(perform(reference, length_List(record), request))
155
- _ <- flat_map(transaction(db, scope -> append_plan(scope, key, seen, Exchange(request, answer))))
154
+ _ <- flat_map(transaction(db, _ -> append_plan(key, seen, Exchange(request, answer))))
156
155
  pure(Continue(()))
157
156
  case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
158
157
  else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
@@ -160,7 +159,11 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
160
159
  # The store was busy: read again next round.
161
160
  case _: pure(Continue(()))
162
161
  def decide():
163
- opened <- flat_map(transaction(db, scope -> open_plan(scope, key, refund)))
162
+ def open_case():
163
+ scope <- transaction(db)
164
+ reference <- tx_bind(tx_new_token(scope))
165
+ open_plan(key, refund, reference)
166
+ opened <- flat_map(open_case())
164
167
  match opened:
165
168
  case Committed(_):
166
169
  ended <- flat_map(round((), 0))
@@ -177,7 +180,7 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
177
180
 
178
181
  def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
179
182
  StatusRequest(order) = input
180
- found <- flat_map(transaction(db, scope -> read_plan(scope, owner_key(p, order))))
183
+ found <- flat_map(transaction(db, _ -> read_plan(owner_key(p, order))))
181
184
  pure(replied(found,
182
185
  c -> (
183
186
  match c:
@@ -14,7 +14,7 @@ from Yichus/IO import IO, flat_map, pure, fold_until
14
14
  from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
15
15
  from Yichus/Data import Db
16
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
17
+ from Yichus/Transaction import TxTable, Txn, Committed, transaction, replied, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
18
18
  from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
19
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
20
  from Zafu/Control/IterState import IterState, Continue, Done as Finished
@@ -47,34 +47,33 @@ access_rules = AccessSpec([
47
47
  AccessRule("refunds", OwnerScoped),
48
48
  ])
49
49
 
50
- def cases[s](scope: Scope[s]) -> TxTable[s, Case]: tx_table(scope, "refunds")
50
+ cases: TxTable[s, Case] = stored_table("refunds")
51
51
 
52
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))
53
+ def open_plan[s](key: String, refund: Refund, reference: String) -> Txn[s, RefundError, Unit]:
54
+ tx_insert(cases, key, Case(reference, 0, refund, [], None))
56
55
 
57
- def read_plan[s](scope: Scope[s], key: String) -> Txn[s, RefundError, Option[Case]]:
58
- tx_read(cases(scope), key)
56
+ def read_plan[s](key: String) -> Txn[s, RefundError, Option[Case]]:
57
+ tx_read(cases, key)
59
58
 
60
59
  # Append an exchange to an undecided case, or decide it, only if its
61
60
  # 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))
61
+ def append_plan[s](key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
62
+ found <- tx_bind(tx_read(cases, key))
64
63
  match found:
65
64
  case Some(Case(reference, version, refund, record, None)):
66
65
  if eq_Int(version, seen):
67
- _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
66
+ _ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
68
67
  tx_pure(True)
69
68
  else: tx_pure(False)
70
69
  case _: tx_pure(False)
71
70
 
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))
71
+ def decide_plan[s](key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
72
+ found <- tx_bind(tx_read(cases, key))
74
73
  match found:
75
74
  case Some(Case(reference, version, refund, record, None)):
76
75
  if eq_Int(version, seen):
77
- _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record, Some(decision))))
76
+ _ <- tx_bind(tx_write(cases, key, Case(reference, version.add(1), refund, record, Some(decision))))
78
77
  tx_pure(True)
79
78
  else: tx_pure(False)
80
79
  case _: tx_pure(False)
@@ -137,20 +136,20 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
137
136
  # carries on with the stored refund; a different one for the same order
138
137
  # is turned away.
139
138
  def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
140
- found <- flat_map(transaction(db, scope -> read_plan(scope, key)))
139
+ found <- flat_map(transaction(db, _ -> read_plan(key)))
141
140
  match found:
142
141
  case Committed(Some(Case(reference, seen, stored, record, decided))):
143
142
  if same_refund(stored, refund):
144
143
  match (decided, replay(stored, record)):
145
144
  case (Some(decision), _): pure(Finished(Done(decision)))
146
145
  case (None, Some(Ruled(decision))):
147
- saved <- flat_map(transaction(db, scope -> decide_plan(scope, key, seen, decision)))
146
+ saved <- flat_map(transaction(db, _ -> decide_plan(key, seen, decision)))
148
147
  match saved:
149
148
  case Committed(True): pure(Finished(Done(decision)))
150
149
  case _: pure(Continue(()))
151
150
  case (None, Some(Perform(request))):
152
151
  answer <- flat_map(perform(reference, length_List(record), request))
153
- _ <- flat_map(transaction(db, scope -> append_plan(scope, key, seen, Exchange(request, answer))))
152
+ _ <- flat_map(transaction(db, _ -> append_plan(key, seen, Exchange(request, answer))))
154
153
  pure(Continue(()))
155
154
  case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
156
155
  else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
@@ -158,7 +157,11 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
158
157
  # The store was busy: read again next round.
159
158
  case _: pure(Continue(()))
160
159
  def decide():
161
- opened <- flat_map(transaction(db, scope -> open_plan(scope, key, refund)))
160
+ def open_case():
161
+ scope <- transaction(db)
162
+ reference <- tx_bind(tx_new_token(scope))
163
+ open_plan(key, refund, reference)
164
+ opened <- flat_map(open_case())
162
165
  match opened:
163
166
  case Committed(_):
164
167
  ended <- flat_map(fold_until(range(rounds), (), round))
@@ -175,7 +178,7 @@ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError
175
178
 
176
179
  def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
177
180
  StatusRequest(order) = input
178
- found <- flat_map(transaction(db, scope -> read_plan(scope, owner_key(p, order))))
181
+ found <- flat_map(transaction(db, _ -> read_plan(owner_key(p, order))))
179
182
  pure(replied(found,
180
183
  c -> (
181
184
  match c:
@@ -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
+ }
@@ -4,11 +4,30 @@ The person does not read documentation. The yich.us studio page only tells them
4
4
 
5
5
  `yichus_page` shows you what the page shows them right now, and what they have on screen and last clicked.
6
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
+
7
26
  ## When you give them the link
8
27
 
9
28
  Tell them three things, briefly:
10
29
 
11
- - The page runs on their computer only. Nobody else can open it, and it keeps the same address when you restart the studio.
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.
12
31
  - It updates by itself after every check, so they can leave it open in a tab.
13
32
  - They can ask you about anything on it.
14
33
 
@@ -19,7 +38,7 @@ Do not explain the sections until something appears in them.
19
38
  - What you asked for: their description, exactly as recorded.
20
39
  - What is happening now: the step you are on, in one sentence.
21
40
  - Questions for you: shown while a question is open. Saving an answer there and answering you in the chat do the same thing.
22
- - What your program must do: each choice they made, with its status.
41
+ - What your program must do: each rule they gave and each choice they made, with its status.
23
42
  - What happens when: each thing the app does, followed to every way it can end.
24
43
  - How your app is built: the parts of the app, stacked on the parts they are built from.
25
44
  - The shape of your program: a picture of the whole program as it is now.
@@ -29,14 +48,14 @@ Do not explain the sections until something appears in them.
29
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.
30
49
 
31
50
  1. Describe: their description is recorded word for word.
32
- 2. Where it runs: Render, Vercel, Netlify, Amazon Web Services, Google Cloud, Microsoft Azure, or not sure yet. You build the pieces that place can run, so nothing has to be rebuilt later.
33
- 3. Write the first version.
34
- 4. Make it run: fix the mistakes that stop it from running.
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.
35
54
  5. Name the parts in their words, so the questions and the pictures use their words instead of the code's.
36
- 6. Ask them the questions the studio found.
37
- 7. 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.
38
- 8. Match their answers: write the answers into the program as rules the checks hold it to.
39
- 9. Fix whatever does not hold yet.
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.
40
59
  10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it.
41
60
  11. Done: everything they chose holds.
42
61
 
@@ -49,7 +68,9 @@ Questions come from what the program does, never from what things are called. A
49
68
  | Asked when | The question | The choices |
50
69
  | --- | --- | --- |
51
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) |
52
- | The app saves a kind of record | Who may see one of these records, and who may change it? | Only its creator; the creator and people they share with; everyone reads, certain roles change; anyone |
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 |
53
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 |
54
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 |
55
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 |
@@ -63,13 +84,15 @@ Each choice they made shows on the page under "What your program must do", with
63
84
  | On the page | Meaning |
64
85
  | --- | --- |
65
86
  | waiting for your answer | A question they have not answered yet. Ask it. |
66
- | proven | Checked against every path through the program. No input, user or timing can break 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. |
67
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. |
68
89
  | holds, under an assumption | Proven only while the assumption is true, usually that exactly one server runs. A second copy can break it. |
69
90
  | decided | A choice that sets up the other checks, such as several copies of the server. |
70
91
  | broken | The check found a way it fails. You have the evidence; say in their terms what goes wrong, then fix it. |
71
92
  | the code says otherwise | The rule in the program does not match their answer yet. You update it. |
72
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. |
73
96
  | not decided yet | The check ran but could not decide. You find out why. |
74
97
  | no check needed | They chose an option that adds no check, such as "partial is fine". |
75
98
  | answered, waiting for your agent to check | They answered on the page; the result appears after your next check. |