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.
Files changed (36) hide show
  1. package/README.md +45 -16
  2. package/dist/cli.js +17 -2123
  3. package/dist/cli.js.map +1 -1
  4. package/dist/main.js +3762 -0
  5. package/dist/main.js.map +1 -0
  6. package/dist/studio-page/dom.js +22 -0
  7. package/dist/studio-page/label-fit.js +7 -0
  8. package/dist/studio-page/layer-diagram.js +458 -0
  9. package/dist/studio-page/layer-groups.js +129 -0
  10. package/dist/studio-page/source-link.js +17 -0
  11. package/dist/studio-page/studio-views.js +256 -0
  12. package/dist/studio-page/system-map-record.js +124 -10
  13. package/dist/studio-page/system-map.js +476 -87
  14. package/dist/studio-page/what-happens-record.js +224 -0
  15. package/dist/studio-page/what-happens.js +771 -0
  16. package/dist/studio-reference/balance-listed-laws.bosatsu +29 -0
  17. package/dist/studio-reference/balance.bosatsu +7 -0
  18. package/dist/studio-reference/calculator.md +20 -0
  19. package/dist/studio-reference/consent-laws.bosatsu +37 -0
  20. package/dist/studio-reference/consent.bosatsu +15 -0
  21. package/dist/studio-reference/deploy-platforms.md +79 -0
  22. package/dist/studio-reference/laws.md +62 -0
  23. package/dist/studio-reference/portable-programs.md +58 -0
  24. package/dist/studio-reference/porting.md +34 -0
  25. package/dist/studio-reference/protocol-cases.md +11 -0
  26. package/dist/studio-reference/{protocol-specification.txt → protocol-specification.md} +16 -18
  27. package/dist/studio-reference/refund-desk-one-round.bosatsu +196 -0
  28. package/dist/studio-reference/refund-desk.bosatsu +194 -0
  29. package/dist/studio-reference/refund-policy.bosatsu +97 -0
  30. package/dist/studio-reference/refund-test.bosatsu +75 -0
  31. package/dist/studio-reference/render.yaml +42 -0
  32. package/dist/studio-reference/server.mjs +33 -0
  33. package/dist/studio-reference/store.mjs +16 -0
  34. package/dist/studio-reference/the-page.md +130 -0
  35. package/dist/studio-reference/worker-authority.md +159 -0
  36. package/package.json +2 -2
@@ -0,0 +1,29 @@
1
+ package Example/BalanceListedLaws
2
+
3
+ from Example/Balance import opening_balance
4
+ from Yichus/Law import Domain, Law, Obligation, FiniteObligation, Witness
5
+
6
+ export (never_negative,)
7
+ exposes (Yichus/Law)
8
+
9
+ # The person's rule, "a balance never goes below zero", checked only on
10
+ # the deposits listed here. Every one passes, yet a deposit of -1 breaks
11
+ # the rule: a listed check is bounded, never a proof for every input.
12
+
13
+ def show(deposit: Int) -> String: int_to_String(deposit)
14
+
15
+ def stays_above_zero(deposit: Int) -> Bool:
16
+ match cmp_Int(opening_balance(deposit), 0):
17
+ case LT: False
18
+ case _: True
19
+
20
+ def empty_account(deposit: Int) -> Bool:
21
+ match deposit:
22
+ case 0: True
23
+ case _: False
24
+
25
+ never_negative = FiniteObligation(
26
+ Obligation(Law("a balance never goes below zero", Domain("a first deposit", show, None, None), stays_above_zero),
27
+ [Witness("an account opened with nothing", empty_account)]),
28
+ [0, 5, 10]
29
+ )
@@ -0,0 +1,7 @@
1
+ package Example/Balance
2
+
3
+ export (opening_balance,)
4
+
5
+ # A new account opens with the first deposit as its balance. Nothing stops
6
+ # a negative deposit, so a balance can go below zero.
7
+ def opening_balance(deposit: Int) -> Int: deposit
@@ -0,0 +1,20 @@
1
+ # A calculation with no server
2
+
3
+ The studio gives you this file when the program has no actions people call and keeps no records: a calculator, a quote, a planning tool. Such a program needs no server, no database and no sign-in, so the studio asks the person nothing about where it runs, who may see records or what happens when two people act at once.
4
+
5
+ ## What the studio still does for it
6
+
7
+ - Every rule the person states about the numbers is a law the studio runs after each change: "more debt never raises what they can borrow", "the payment is never negative". Write each one (`yichus_reference` topic `laws`) and record it with `yichus_rules`. A law checked over every input is proven; one checked on sample inputs is bounded, and the page says which.
8
+ - The page shows the shape of the program in the person's words.
9
+
10
+ ## Putting it on a page
11
+
12
+ Yichus's calculator path turns the calculation into a page with sliders, results, charts and a Why button beside every number. Why shows the formula, the values that went into it and the source line it came from. The path runs in the person's browser, through a separate connection to the agent (`@yichus/mcp`, set up from the QuickStart at https://yich.us/start/quickstart.html, with the calculator walkthrough at https://yich.us/start/calculator.html). Keep the same calculation file: the laws the studio checked stay true of the page's numbers.
13
+
14
+ Tell the person, in product terms:
15
+
16
+ - The result is one page they can host anywhere and link to. Yichus does not document embedding it inside a site builder such as Squarespace or Wix.
17
+ - The saved page carries its own scripts and styles, so it keeps working wherever it is hosted, whatever happens to yich.us. It leaves out the formula editor the workspace has.
18
+ - Why explains what was calculated, not whether the model's assumptions suit their situation.
19
+
20
+ If the person needs their own branded page instead, build it the usual way around the same pure calculation, and say plainly that the Why buttons then are not there.
@@ -0,0 +1,37 @@
1
+ package Example/ConsentLaws
2
+
3
+ from Example/Consent import Consent, OptedIn, OptedOut, merge_consent
4
+ from Yichus/Law import Domain, Law, Obligation, Witness
5
+
6
+ export (opt_out_is_sticky,)
7
+ exposes (Example/Consent, Yichus/Law)
8
+
9
+ # The person's rule: "a fan who opts out stays opted out". The input is
10
+ # what the fan said before (nothing yet, in or out) and a new sign-up.
11
+
12
+ def said(choice: Consent) -> String:
13
+ match choice:
14
+ case OptedIn: "opted in"
15
+ case OptedOut: "opted out"
16
+
17
+ def show(input: (Option[Consent], Consent)) -> String:
18
+ (before, signup) = input
19
+ earlier = match before:
20
+ case Some(choice): said(choice)
21
+ case None: "new"
22
+ concat_String([earlier, ", then signs up ", said(signup)])
23
+
24
+ def stays_out(input: (Option[Consent], Consent)) -> Bool:
25
+ (before, signup) = input
26
+ match before:
27
+ case Some(OptedOut): merge_consent(before, signup) matches OptedOut
28
+ case _: True
29
+
30
+ def had_opted_out(input: (Option[Consent], Consent)) -> Bool:
31
+ (before, _) = input
32
+ before matches Some(OptedOut)
33
+
34
+ opt_out_is_sticky = Obligation(
35
+ Law("a fan who opted out stays opted out", Domain("an earlier choice and a new sign-up", show, None, None), stays_out),
36
+ [Witness("a fan who had opted out signs up again", had_opted_out)]
37
+ )
@@ -0,0 +1,15 @@
1
+ package Example/Consent
2
+
3
+ export (Consent(), merge_consent)
4
+
5
+ # What a fan said last about texts from an artist.
6
+ enum Consent:
7
+ OptedIn
8
+ OptedOut
9
+
10
+ # A fan who signs up again keeps an opt-out: only the fan's own "start"
11
+ # can undo it, never a form.
12
+ def merge_consent(before: Option[Consent], signup: Consent) -> Consent:
13
+ match before:
14
+ case Some(OptedOut): OptedOut
15
+ case _: signup
@@ -0,0 +1,79 @@
1
+ # Where the app runs: how to put it online on each platform
2
+
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
+
5
+ ## What every platform has to provide
6
+
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
+
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. `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
+ - **An identity mode.** The engine refuses to listen without `YICHUS_AUTH`:
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`. 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
+ - `clerk-api-key` is for API keys.
15
+ - `proxy-header` is only for running behind a proxy you control that sets the caller's id.
16
+ - `dev` is never used in production.
17
+ - **HTTPS in front.** Every platform below terminates TLS for you.
18
+
19
+ Where the person chose "one server", configure the platform so that exactly one copy runs. Each platform below says how. Several platforms add copies on their own when traffic grows, and then the one-server assumption is false. If the person can't accept that limit, go back and answer the topology question "several copies".
20
+
21
+ Work that takes longer than one request, waits on an outside service, or must finish later should never be one long request. Write it as a portable step program (`yichus_reference` topic `portable-programs`). Then have a scheduled job or a worker call the route that advances it. Every platform below can run that job.
22
+
23
+ ## Render
24
+
25
+ Render runs the whole app in one place, and it is the simplest choice.
26
+
27
+ - **Server:** a Web Service with `runtime: node`. The start command runs the migration, then starts the engine. Set `healthCheckPath: /health`. For "one server", set `numInstances: 1` and turn off autoscaling.
28
+ - **Database:** Render Postgres. Connect it to the service's `DATABASE_URL` with `fromDatabase`.
29
+ - **Page people open:** a Static Site in the same Blueprint, or the API alone if the person's users are other programs.
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. 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
+
33
+ ## Vercel
34
+
35
+ Vercel is the place for the page people open, not for the API engine. Vercel Functions start per request with time limits and share no memory, so the engine can't run there.
36
+
37
+ - **Page people open:** a Next.js app on Vercel. It calls the API from the server side (route handlers or server actions), so secret keys never reach the browser. A publishable key is the only kind that may sit in browser code.
38
+ - **Server and database:** host the engine on Render, Google Cloud Run, AWS App Runner or Azure Container Apps (see their sections). Set `YICHUS_CORS_ORIGIN` on the engine to the Vercel site's origin if the browser calls the API directly.
39
+ - **Background work:** a Vercel Cron Job runs a function on a schedule, and that function calls the advance route on the engine's host. A cron job on the engine's host works too.
40
+
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
+
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
+
45
+ ## Netlify
46
+
47
+ The same shape as Vercel. Netlify Functions are per request and can't hold the engine.
48
+
49
+ - **Page people open:** a static site or a framework site on Netlify that calls the API.
50
+ - **Server and database:** on Render, Cloud Run, App Runner or Azure Container Apps. Set `YICHUS_CORS_ORIGIN` to the Netlify site's origin.
51
+ - **Background work:** a Netlify scheduled function can call the advance route.
52
+
53
+ ## AWS
54
+
55
+ - **Server:** AWS App Runner from a container image or from source with the Node runtime, or Amazon ECS on Fargate behind an Application Load Balancer. Either runs the engine as a long-lived process. Lambda doesn't: the engine expects to listen on a port. For "one server", set App Runner's maximum instances (or the ECS service's desired and maximum task count) to 1.
56
+ - **Database:** Amazon RDS for PostgreSQL, or Aurora PostgreSQL. Keep `DATABASE_URL` in AWS Secrets Manager.
57
+ - **Page people open:** AWS Amplify Hosting, or S3 with CloudFront.
58
+ - **Identity:** Amazon Cognito user pools publish a JWKS, so use `YICHUS_AUTH=jwt` with that URL.
59
+ - **Background work:** EventBridge Scheduler, running a task that calls the advance route.
60
+
61
+ ## Google Cloud
62
+
63
+ - **Server:** Cloud Run, from a container image or from source with the Node runtime. Cloud Run adds instances under load. For "one server", set maximum instances to 1. Minimum instances of 1 avoids a cold start on the first request.
64
+ - **Database:** Cloud SQL for PostgreSQL, reached through the Cloud SQL connection Cloud Run provides. Keep the connection string in Secret Manager.
65
+ - **Page people open:** Firebase Hosting, or a second Cloud Run service.
66
+ - **Identity:** Google Identity Platform or Firebase Auth ID tokens, with `YICHUS_AUTH=jwt` and the provider's JWKS URL.
67
+ - **Background work:** Cloud Scheduler calling the advance route, or a Cloud Run job.
68
+
69
+ ## Microsoft Azure
70
+
71
+ - **Server:** Azure Container Apps, or Azure App Service with the Node runtime. Container Apps scales by rules. For "one server", set minimum and maximum replicas to 1. On App Service, keep the instance count at 1.
72
+ - **Database:** Azure Database for PostgreSQL (flexible server). Keep the connection string in Key Vault or in the app's secrets.
73
+ - **Page people open:** Azure Static Web Apps.
74
+ - **Identity:** Microsoft Entra ID (or Entra External ID for customers) issues JWTs. Use `YICHUS_AUTH=jwt` with its JWKS URL.
75
+ - **Background work:** a scheduled Container Apps job that calls the advance route.
76
+
77
+ ## Not decided yet
78
+
79
+ Recommend Render unless the person already pays for one of the others. It holds the server, the database, the page and background jobs in one Blueprint. Moving later is a configuration change, not a rewrite, because the engine and its store are the same everywhere.
@@ -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.
@@ -0,0 +1,58 @@
1
+ # Portable programs: decisions in one pure step, effects in a rule-free engine
2
+
3
+ The studio gives you this file before you write work that takes more than one step or waits on something outside the app, such as a model, a web page, a payment or another person. Write that work in this shape. The person never sees this file or the words in it. To them it is "the rules your app follows" and "the part that does the work".
4
+
5
+ ## The shape
6
+
7
+ Split the work into two parts:
8
+
9
+ - **The program** is a pure function. Given the input and the record of every request and reply so far, it answers either `Ruled(result)` or `Perform(request)`, where the request is the one thing the outside world must do next. It reads, writes and calls nothing.
10
+ - **The engine** is the effectful code around it. It reads the record, asks the program what happens next, performs the one request, decodes the answer into a typed value, appends the exchange to the record, and asks again. It never branches on what the business wants.
11
+
12
+ Name the program's two answers for what they mean, such as `Ruled` and `Perform`. `Done` already names a route's reply and a loop's end, and `Decided` a decision call's result, and an engine imports them.
13
+
14
+ Because the program is pure:
15
+
16
+ - Each rule lives in one place, not spread across handlers, workers and labels.
17
+ - Replaying a stored record through the program rebuilds the same result with no effects, so any outcome can be explained after the fact. Give the program a `replay` that checks each exchange answers the request it made at that point, and refuses a record it would not have made.
18
+ - The decision tree and the checks read the rules once, in one package.
19
+ - Any engine (an API route, a scheduled job, a worker) can drive the same program.
20
+
21
+ ## Rules
22
+
23
+ 1. **Put the decision in the program and the doing in the engine.** The program says what happens next as a typed request. The engine checks it, performs it, and feeds the reply back.
24
+ 2. **Make requests a closed enum.** A program can ask only for what its request type names. A new kind of request is a change to that enum and to the engine, never a rule an engine adds on its own.
25
+ 3. **Carry the record with the work.** Store the input and every exchange in one row, keyed by the work itself (`owner_key(principal, order)`), never only by the person, and create it with `tx_insert` so a new request never overwrites old work. Any engine can resume from it, and any reader can replay it.
26
+ 4. **Keep engines free of rules.** An engine validates, authorises, performs effects, retries and leases. If engine code branches on a business condition, move that branch into the program.
27
+ 5. **Don't route on labels.** Never tag work "needs-review" for a later stage to interpret. The program's next request is the instruction.
28
+ 6. **Validate at the boundary.** Decode replies from models and the network with real parsers into typed values before the program sees them. A reply that doesn't decode is that request's failure arm, not a string the program inspects.
29
+ 7. **Count a retry as a new attempt.** A reply that may differ on a retry, such as a model call, is appended as a new exchange, never written over an old one.
30
+ 8. **Bound the loop.** The engine drives the program with `Yichus/IO::fold_until(range(n), start, round)`, or with `Yichus/Transaction::tx_fold_until` inside a transaction. The list of items is the bound. The analyses read the round once as the body every iteration runs, and they refuse recursive effect code.
31
+ 9. **Write each exchange in a transaction that re-reads the record.** Keep a version on the row that every write raises with `add(1)`, and append only if `eq_Int(version, seen)` says it is still the one the round read, so two rounds can never both land the same step. A round whose write did not land reads the record again; it never reports a result it did not save.
32
+ 10. **Write the round as a two-argument `def`**, local or top level, as `def round(state, item)`. That is the shape the checks read; a round returned from another function hides its reads and writes from them.
33
+ 11. **Keep the person out of outside requests.** A request key or session id names the work by an opaque reference stored on its row (`tx_new_token`), never the person's id.
34
+
35
+ ## Work that outlives one request
36
+
37
+ When the work must finish later (the person chose "save the request and finish it later", or a reply can take minutes), the route records the input and returns. A scheduled job or a worker then runs rounds until the program answers with its result (`Ruled` in the example). `yichus_reference` topic `deploy` says how each platform starts that job. For several copies of the server, claim each round with a `Yichus/Job` lease (api_guide section "Durable jobs with Yichus/Job"), so exactly one copy performs a request.
38
+
39
+ ## Worked example: refunds
40
+
41
+ `refund-policy.bosatsu` is the program. Small refunds are approved. A larger one asks a model judge whether the reason is covered, approves or denies on the judge's typed answer, asks again when the judge fails, and leaves the decision to a person (`Escalated`) after `most_requests` attempts. `replay` reads a stored record back.
42
+
43
+ `refund-desk.bosatsu` is the engine. It has two routes and no refund rule:
44
+
45
+ - A refund is kept per person and order, created once with `tx_insert` with version 0.
46
+ - The route turns away an empty order, an empty reason and an amount of zero or less before anything is stored.
47
+ - Each round, a local `def round(_, _)`, reads the case. If the stored refund is not the same one (`same_refund`: order, amount and reason), it turns the request away; asking again for the same refund carries on with the stored one. Otherwise it replays the record, and either saves the decision or performs the one request, decoding the model's answer with the JSON parser at the boundary (`answer_of`). A failed call keeps its reason.
48
+ - The exchange and the decision are each written in a transaction that re-reads the case, checks its version is the one the round read, and raises it. A round whose write did not land, or that found the store busy, reads again.
49
+ - `fold_until(range(most_requests + 2), ...)` bounds the rounds. A refund still open when they run out answers `TryAgain`, and `/refunds/status` shows the refund, its decision and how many attempts it took.
50
+
51
+ `refund-test.bosatsu` holds its laws as native tests: what each record leads to, which records replay, and which model answers decode.
52
+
53
+ On one server (`instances: 1`) the example verifies, with two warnings that one server accepts. With several copies of the server they are violations:
54
+
55
+ - `rmw-transactions`: the round reads the case in one transaction and writes it in a later one, around the model call. The writing transaction re-reads the case and checks its version, which is the remedy the warning names. The checks recognise a version compared with `eq_Int` and raised with `add` on every write, but not yet inside a `fold_until` round, a known limit of the checks. `refund-desk-one-round.bosatsu` is the same desk run as one round, and it proves on several copies. It is not an engine to copy: one round never asks the judge twice.
56
+ - `integrity` (`race-free:refunds`): its counterexample orders one request's `tx_insert` before or after another request's round. The race check reads the same version fence and has the same limit: the one-round desk holds `race-free:refunds` on several copies.
57
+
58
+ Two copies can also both ask the judge for the same step. The version check keeps one answer, so the cost is a repeated call, and the checks do not report it.
@@ -0,0 +1,34 @@
1
+ # Replacing an app the person already has
2
+
3
+ Read this file when the new app replaces one the person already has. Reading the old app is your job. The studio never sees the old code and never records what you found there. It checks only the new program and the answers the person gives.
4
+
5
+ ## Read the old app first
6
+
7
+ Before you write the new program, read the old app's code and find:
8
+
9
+ - the records it keeps and the actions people take,
10
+ - who may see and change each record,
11
+ - what happens when two people act on the same record at once,
12
+ - what it does when a server or an outside service fails.
13
+
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
+
16
+ ## Where the old app's entry points go
17
+
18
+ Public routes on the new program only read. Anything the old app saves from outside a signed-in page goes through the page host instead: a webhook from a payment or messaging provider, or a public sign-up or contact form. A route handler on the page host (on Vercel, a Next.js route handler) checks the provider's signature or the bot check. It then calls a worker route on the new program, and that route holds the rules. worker-authority.md, which comes with this topic, says how to declare a worker route and how its caller signs in.
19
+
20
+ ## Bring the existing data over
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
+
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.
25
+
26
+ ## Use what you found in the conversation
27
+
28
+ When `yichus_next` lists a question, you may tell the person what their old app does about it, in product terms, and say where you saw it. For example: "Today each artist can see only their own fans. Do you want to keep that?" Say that this is your reading of the old code, not something the studio checked.
29
+
30
+ The person still picks the answer. Record only what they decide, with `yichus_answer`. What the old app does is never their answer, and it is never a default. If the old app's behaviour is one the person would not choose, such as two people being able to overwrite each other's changes, say so plainly and let them decide.
31
+
32
+ ## Point at the pictures
33
+
34
+ Once the new program's pictures are on the studio page, `yichus_point` can show the person which part matches which piece of their old app. The notes appear above the pictures and are labelled as yours.
@@ -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.
@@ -0,0 +1,196 @@
1
+ package Demo/Portable/Limits/RefundDeskOneRound
2
+
3
+ # A known limit of the checks, pinned (studio-engine.test.ts): this is
4
+ # Demo/Portable/RefundDesk with one round in place of the fold_until loop.
5
+ # Its version fence proves rmw-transactions and race-free:refunds on
6
+ # several copies; the same fence inside the loop does not yet (register
7
+ # D-34). Not an example to copy: one round never asks the judge twice.
8
+
9
+ # The refund engine: it holds no refund rule. A refund is kept per person
10
+ # and order. The one round reads the case, replays its record through
11
+ # Demo/Portable/RefundPolicy to learn what happens next, and either saves
12
+ # the decision or performs the one request that comes back (a model call),
13
+ # decodes the answer at the boundary, and appends the exchange. Each write
14
+ # runs in a transaction that re-reads the case and checks its version is
15
+ # the one the round read. A lost write or a busy store ends the request
16
+ # with TryAgain.
17
+
18
+ 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, add
19
+ from Yichus/IO import IO, flat_map, pure
20
+ from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
21
+ from Yichus/Data import Db
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
24
+ from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
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
+ from Zafu/Control/IterState import IterState, Continue, Done as Finished
27
+ from Demo/Portable/RefundPolicy import (
28
+ Refund, Request, AskJudge, Answer, Judged, JudgeFailed, Exchange, Decision, Ruled, Perform, replay, same_refund
29
+ )
30
+
31
+ export (refund_desk, access_rules, answer_of, Case(), RefundView(), RefundError(), StatusRequest())
32
+ exposes (Demo/Portable/RefundPolicy, Yichus/Access, Yichus/OpenRouter, Yichus/Service)
33
+
34
+ # One refund: an opaque reference for outside services, a version every
35
+ # write raises, the request, every exchange so far, and the decision once
36
+ # there is one.
37
+ struct Case(reference: String, version: Int, refund: Refund, record: List[Exchange], decision: Option[Decision])
38
+
39
+ # What the person sees of a refund: the failure reasons outside services
40
+ # gave stay in the record.
41
+ struct RefundView(refund: Refund, decision: Option[Decision], attempts: Int)
42
+
43
+ struct StatusRequest(order: String)
44
+
45
+ enum RefundError:
46
+ NoSuchRefund
47
+ # The order already has a refund request for another amount or reason.
48
+ AlreadyRequested
49
+ # The stored record is not one the refund policy makes.
50
+ Unreadable
51
+
52
+ access_rules = AccessSpec([
53
+ AccessRule("refunds", OwnerScoped),
54
+ ])
55
+
56
+ def cases[s](scope: Scope[s]) -> TxTable[s, Case]: tx_table(scope, "refunds")
57
+
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))
62
+
63
+ def read_plan[s](scope: Scope[s], key: String) -> Txn[s, RefundError, Option[Case]]:
64
+ tx_read(cases(scope), key)
65
+
66
+ # Append an exchange to an undecided case, or decide it, only if its
67
+ # 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))
70
+ match found:
71
+ case Some(Case(reference, version, refund, record, None)):
72
+ if eq_Int(version, seen):
73
+ _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
74
+ tx_pure(True)
75
+ else: tx_pure(False)
76
+ case _: tx_pure(False)
77
+
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))
80
+ match found:
81
+ case Some(Case(reference, version, refund, record, None)):
82
+ if eq_Int(version, seen):
83
+ _ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record, Some(decision))))
84
+ tx_pure(True)
85
+ else: tx_pure(False)
86
+ case _: tx_pure(False)
87
+
88
+ # ---- The judge, behind the boundary ----
89
+
90
+ # A model that takes a temperature, through OpenAI only.
91
+ judge_model = "openai/gpt-4.1-mini"
92
+ judge_policy = ProviderPolicy(["openai"], False, True, False, False)
93
+
94
+ # {"covered": boolean}, and nothing else, as a strict schema needs.
95
+ judge_schema = JsonObject([
96
+ ("type", JsonString("object")),
97
+ ("properties", JsonObject([("covered", JsonObject([("type", JsonString("boolean"))]))])),
98
+ ("required", JsonArray([JsonString("covered")])),
99
+ ("additionalProperties", JsonBool(False)),
100
+ ])
101
+
102
+ # The request key names the case by its reference, never the person.
103
+ def judge_request(reference: String, attempt: Int, reason: String, schema: String) -> ModelRequest:
104
+ ModelRequest(
105
+ "refund:${reference}:${int_to_String(attempt)}", judge_model,
106
+ "Decide whether the customer's reason is covered by the refund policy: damaged, never arrived, or not as described. Answer JSON.",
107
+ reason, "refund_judgment", schema, judge_policy, 200, 0, 20000)
108
+
109
+ # The boundary: the model's text becomes a typed answer here, and anything
110
+ # else is the request failing, with why.
111
+ def answer_of(result: ModelResult) -> Answer:
112
+ match result:
113
+ case Completed(_, _, _, content, _, _):
114
+ match parse_json(content):
115
+ case Parsed(value):
116
+ match decode_bind(required_field("covered", value), field -> as_bool("covered", field)):
117
+ case Decoded(covered): Judged(covered)
118
+ case DecodeError(path, reason): JudgeFailed("its answer is not a judgment: ${path}: ${reason}")
119
+ case InvalidJson(reason): JudgeFailed("its answer is not JSON: ${reason}")
120
+ case Refused(reason): JudgeFailed("it refused: ${reason}")
121
+ case RetryableFailure(reason) | PermanentFailure(reason): JudgeFailed("the call failed: ${reason}")
122
+ case TimedOut(millis): JudgeFailed("it did not answer within ${int_to_String(millis)} ms")
123
+
124
+ def perform(reference: String, attempt: Int, request: Request) -> IO[Answer]:
125
+ match request:
126
+ case AskJudge(reason):
127
+ match render_json(judge_schema):
128
+ case Rendered(schema):
129
+ result <- flat_map(openrouter_call(judge_request(reference, attempt, reason, schema)))
130
+ pure(answer_of(result))
131
+ case RenderError(why): pure(JudgeFailed("its schema did not render: ${why}"))
132
+
133
+ # ---- Routes ----
134
+
135
+ def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError, Decision]]:
136
+ Refund(order, amount, reason) = refund
137
+ key = owner_key(p, order)
138
+ # One round: what the policy says next, done. The same request again
139
+ # carries on with the stored refund; a different one for the same order
140
+ # is turned away.
141
+ def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
142
+ found <- flat_map(transaction(db, scope -> read_plan(scope, key)))
143
+ match found:
144
+ case Committed(Some(Case(reference, seen, stored, record, decided))):
145
+ if same_refund(stored, refund):
146
+ match (decided, replay(stored, record)):
147
+ case (Some(decision), _): pure(Finished(Done(decision)))
148
+ case (None, Some(Ruled(decision))):
149
+ saved <- flat_map(transaction(db, scope -> decide_plan(scope, key, seen, decision)))
150
+ match saved:
151
+ case Committed(True): pure(Finished(Done(decision)))
152
+ case _: pure(Continue(()))
153
+ case (None, Some(Perform(request))):
154
+ answer <- flat_map(perform(reference, length_List(record), request))
155
+ _ <- flat_map(transaction(db, scope -> append_plan(scope, key, seen, Exchange(request, answer))))
156
+ pure(Continue(()))
157
+ case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
158
+ else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
159
+ case Committed(None): pure(Finished(NotFound("no refund for this order", NoSuchRefund)))
160
+ # The store was busy: read again next round.
161
+ case _: pure(Continue(()))
162
+ def decide():
163
+ opened <- flat_map(transaction(db, scope -> open_plan(scope, key, refund)))
164
+ match opened:
165
+ case Committed(_):
166
+ ended <- flat_map(round((), 0))
167
+ match ended:
168
+ case Finished(reply): pure(reply)
169
+ case Continue(_): pure(TryAgain("the refund is still being decided; ask for its status"))
170
+ case _: pure(TryAgain("the refund could not be opened; try again"))
171
+ if eq_String(order, ""): pure(Invalid("say which order to refund"))
172
+ elif eq_String(reason, ""): pure(Invalid("say why the order should be refunded"))
173
+ else:
174
+ match cmp_Int(amount, 0):
175
+ case GT: decide()
176
+ case _: pure(Invalid("a refund is for an amount above zero"))
177
+
178
+ def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
179
+ StatusRequest(order) = input
180
+ found <- flat_map(transaction(db, scope -> read_plan(scope, owner_key(p, order))))
181
+ pure(replied(found,
182
+ c -> (
183
+ match c:
184
+ case Some(Case(_, _, refund, record, decision)): Done(RefundView(refund, decision, length_List(record)))
185
+ case None: NotFound("no refund for this order", NoSuchRefund)
186
+ ),
187
+ reason -> Turned("the refund could not be read", reason),
188
+ TryAgain("the store was busy; try again")))
189
+
190
+ refund_desk = service_def(
191
+ "refunds",
192
+ [
193
+ route("/refunds/request", handler("request_refund", request_refund), [ReadPerm("refunds"), WritePerm("refunds"), CreatePerm("refunds")]),
194
+ route("/refunds/status", handler("refund_status", refund_status), [ReadPerm("refunds")]),
195
+ ]
196
+ )