yichus 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -16
- package/dist/cli.js +1328 -259
- package/dist/cli.js.map +1 -1
- package/dist/studio-page/dom.js +22 -0
- package/dist/studio-page/label-fit.js +7 -0
- package/dist/studio-page/layer-diagram.js +458 -0
- package/dist/studio-page/layer-groups.js +129 -0
- package/dist/studio-page/source-link.js +17 -0
- package/dist/studio-page/studio-views.js +256 -0
- package/dist/studio-page/system-map-record.js +122 -8
- package/dist/studio-page/system-map.js +461 -85
- package/dist/studio-page/what-happens-record.js +217 -0
- package/dist/studio-page/what-happens.js +752 -0
- package/dist/studio-reference/deploy-platforms.md +79 -0
- package/dist/studio-reference/portable-programs.md +58 -0
- package/dist/studio-reference/porting.md +32 -0
- package/dist/studio-reference/refund-desk-one-round.bosatsu +196 -0
- package/dist/studio-reference/refund-desk.bosatsu +194 -0
- package/dist/studio-reference/refund-policy.bosatsu +97 -0
- package/dist/studio-reference/refund-test.bosatsu +75 -0
- package/dist/studio-reference/the-page.md +107 -0
- package/dist/studio-reference/worker-authority.md +159 -0
- package/package.json +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Where the app runs: what to build for each platform
|
|
2
|
+
|
|
3
|
+
The studio gives you this file when the person has said where their app will run. It tells you what to build for that platform. Tell the person about the choices in product terms, such as "your app's server", "its database" or "the page people open". Never mention the programming language, 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. `mathstreams/deploy/store.mjs` and `mathstreams/deploy/server.mjs` in the Yichus repository show both parts. Transactions run at SERIALIZABLE isolation and retry a bounded number of times.
|
|
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`. The project must sign with asymmetric keys (RS256 or ES256), not its legacy shared secret.
|
|
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. `web/quickstart-app/render.yaml` and `mathstreams/deploy/render.yaml` are working examples. 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.
|
|
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,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,32 @@
|
|
|
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
|
+
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.
|
|
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
|
+
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
|
+
|
|
24
|
+
## Use what you found in the conversation
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
## Point at the pictures
|
|
31
|
+
|
|
32
|
+
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,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
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
package Demo/Portable/RefundDesk
|
|
2
|
+
|
|
3
|
+
# The refund engine: it holds no refund rule. A refund is kept per person
|
|
4
|
+
# and order. Each round reads the case, replays its record through
|
|
5
|
+
# Demo/Portable/RefundPolicy to learn what happens next, performs the one
|
|
6
|
+
# request that comes back (a model call), decodes the answer at the
|
|
7
|
+
# boundary, and appends the exchange in a transaction that re-reads the
|
|
8
|
+
# case and checks its version is the one the round read. The decision is
|
|
9
|
+
# saved the same way, and a round that lost either write reads the case
|
|
10
|
+
# again.
|
|
11
|
+
|
|
12
|
+
from Bosatsu/Predef import Int, Bool, String, List, Option, Some, None, True, False, Unit, GT, cmp_Int, eq_Int, eq_String, length_List, concat, int_to_String, range, add
|
|
13
|
+
from Yichus/IO import IO, flat_map, pure, fold_until
|
|
14
|
+
from Yichus/Access import Principal, AccessRule, AccessSpec, OwnerScoped, owner_key
|
|
15
|
+
from Yichus/Data import Db
|
|
16
|
+
from Yichus/Service import Reply, Done, Invalid, NotFound, Refused as Turned, TryAgain, handler, route, service_def, ReadPerm, WritePerm, CreatePerm
|
|
17
|
+
from Yichus/Transaction import Scope, TxTable, Txn, Committed, transaction, replied, tx_table, tx_bind, tx_pure, tx_read, tx_write, tx_insert, tx_new_token
|
|
18
|
+
from Yichus/OpenRouter import ProviderPolicy, ModelRequest, ModelResult, Completed, Refused, RetryableFailure, PermanentFailure, TimedOut, openrouter_call
|
|
19
|
+
from Yichus/Json import JsonObject, JsonString, JsonArray, JsonBool, Parsed, InvalidJson, Rendered, RenderError, Decoded, DecodeError, parse_json, render_json, decode_bind, required_field, as_bool
|
|
20
|
+
from Zafu/Control/IterState import IterState, Continue, Done as Finished
|
|
21
|
+
from Demo/Portable/RefundPolicy import (
|
|
22
|
+
Refund, Request, AskJudge, Answer, Judged, JudgeFailed, Exchange, Decision, Ruled, Perform, replay, same_refund, most_requests
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export (refund_desk, access_rules, answer_of, Case(), RefundView(), RefundError(), StatusRequest())
|
|
26
|
+
exposes (Demo/Portable/RefundPolicy, Yichus/Access, Yichus/OpenRouter, Yichus/Service)
|
|
27
|
+
|
|
28
|
+
# One refund: an opaque reference for outside services, a version every
|
|
29
|
+
# write raises, the request, every exchange so far, and the decision once
|
|
30
|
+
# there is one.
|
|
31
|
+
struct Case(reference: String, version: Int, refund: Refund, record: List[Exchange], decision: Option[Decision])
|
|
32
|
+
|
|
33
|
+
# What the person sees of a refund: the failure reasons outside services
|
|
34
|
+
# gave stay in the record.
|
|
35
|
+
struct RefundView(refund: Refund, decision: Option[Decision], attempts: Int)
|
|
36
|
+
|
|
37
|
+
struct StatusRequest(order: String)
|
|
38
|
+
|
|
39
|
+
enum RefundError:
|
|
40
|
+
NoSuchRefund
|
|
41
|
+
# The order already has a refund request for another amount or reason.
|
|
42
|
+
AlreadyRequested
|
|
43
|
+
# The stored record is not one the refund policy makes.
|
|
44
|
+
Unreadable
|
|
45
|
+
|
|
46
|
+
access_rules = AccessSpec([
|
|
47
|
+
AccessRule("refunds", OwnerScoped),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
def cases[s](scope: Scope[s]) -> TxTable[s, Case]: tx_table(scope, "refunds")
|
|
51
|
+
|
|
52
|
+
# Open the order's refund, unless it already has one.
|
|
53
|
+
def open_plan[s](scope: Scope[s], key: String, refund: Refund) -> Txn[s, RefundError, Unit]:
|
|
54
|
+
reference <- tx_bind(tx_new_token(scope))
|
|
55
|
+
tx_insert(cases(scope), key, Case(reference, 0, refund, [], None))
|
|
56
|
+
|
|
57
|
+
def read_plan[s](scope: Scope[s], key: String) -> Txn[s, RefundError, Option[Case]]:
|
|
58
|
+
tx_read(cases(scope), key)
|
|
59
|
+
|
|
60
|
+
# Append an exchange to an undecided case, or decide it, only if its
|
|
61
|
+
# version is still the one the round read; either raises the version.
|
|
62
|
+
def append_plan[s](scope: Scope[s], key: String, seen: Int, exchange: Exchange) -> Txn[s, RefundError, Bool]:
|
|
63
|
+
found <- tx_bind(tx_read(cases(scope), key))
|
|
64
|
+
match found:
|
|
65
|
+
case Some(Case(reference, version, refund, record, None)):
|
|
66
|
+
if eq_Int(version, seen):
|
|
67
|
+
_ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record.concat([exchange]), None)))
|
|
68
|
+
tx_pure(True)
|
|
69
|
+
else: tx_pure(False)
|
|
70
|
+
case _: tx_pure(False)
|
|
71
|
+
|
|
72
|
+
def decide_plan[s](scope: Scope[s], key: String, seen: Int, decision: Decision) -> Txn[s, RefundError, Bool]:
|
|
73
|
+
found <- tx_bind(tx_read(cases(scope), key))
|
|
74
|
+
match found:
|
|
75
|
+
case Some(Case(reference, version, refund, record, None)):
|
|
76
|
+
if eq_Int(version, seen):
|
|
77
|
+
_ <- tx_bind(tx_write(cases(scope), key, Case(reference, version.add(1), refund, record, Some(decision))))
|
|
78
|
+
tx_pure(True)
|
|
79
|
+
else: tx_pure(False)
|
|
80
|
+
case _: tx_pure(False)
|
|
81
|
+
|
|
82
|
+
# ---- The judge, behind the boundary ----
|
|
83
|
+
|
|
84
|
+
# A model that takes a temperature, through OpenAI only.
|
|
85
|
+
judge_model = "openai/gpt-4.1-mini"
|
|
86
|
+
judge_policy = ProviderPolicy(["openai"], False, True, False, False)
|
|
87
|
+
|
|
88
|
+
# {"covered": boolean}, and nothing else, as a strict schema needs.
|
|
89
|
+
judge_schema = JsonObject([
|
|
90
|
+
("type", JsonString("object")),
|
|
91
|
+
("properties", JsonObject([("covered", JsonObject([("type", JsonString("boolean"))]))])),
|
|
92
|
+
("required", JsonArray([JsonString("covered")])),
|
|
93
|
+
("additionalProperties", JsonBool(False)),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
# The request key names the case by its reference, never the person.
|
|
97
|
+
def judge_request(reference: String, attempt: Int, reason: String, schema: String) -> ModelRequest:
|
|
98
|
+
ModelRequest(
|
|
99
|
+
"refund:${reference}:${int_to_String(attempt)}", judge_model,
|
|
100
|
+
"Decide whether the customer's reason is covered by the refund policy: damaged, never arrived, or not as described. Answer JSON.",
|
|
101
|
+
reason, "refund_judgment", schema, judge_policy, 200, 0, 20000)
|
|
102
|
+
|
|
103
|
+
# The boundary: the model's text becomes a typed answer here, and anything
|
|
104
|
+
# else is the request failing, with why.
|
|
105
|
+
def answer_of(result: ModelResult) -> Answer:
|
|
106
|
+
match result:
|
|
107
|
+
case Completed(_, _, _, content, _, _):
|
|
108
|
+
match parse_json(content):
|
|
109
|
+
case Parsed(value):
|
|
110
|
+
match decode_bind(required_field("covered", value), field -> as_bool("covered", field)):
|
|
111
|
+
case Decoded(covered): Judged(covered)
|
|
112
|
+
case DecodeError(path, reason): JudgeFailed("its answer is not a judgment: ${path}: ${reason}")
|
|
113
|
+
case InvalidJson(reason): JudgeFailed("its answer is not JSON: ${reason}")
|
|
114
|
+
case Refused(reason): JudgeFailed("it refused: ${reason}")
|
|
115
|
+
case RetryableFailure(reason) | PermanentFailure(reason): JudgeFailed("the call failed: ${reason}")
|
|
116
|
+
case TimedOut(millis): JudgeFailed("it did not answer within ${int_to_String(millis)} ms")
|
|
117
|
+
|
|
118
|
+
def perform(reference: String, attempt: Int, request: Request) -> IO[Answer]:
|
|
119
|
+
match request:
|
|
120
|
+
case AskJudge(reason):
|
|
121
|
+
match render_json(judge_schema):
|
|
122
|
+
case Rendered(schema):
|
|
123
|
+
result <- flat_map(openrouter_call(judge_request(reference, attempt, reason, schema)))
|
|
124
|
+
pure(answer_of(result))
|
|
125
|
+
case RenderError(why): pure(JudgeFailed("its schema did not render: ${why}"))
|
|
126
|
+
|
|
127
|
+
# ---- Routes ----
|
|
128
|
+
|
|
129
|
+
# Enough rounds to ask the judge every time, save the decision, and lose
|
|
130
|
+
# one write to another round.
|
|
131
|
+
rounds = most_requests.add(2)
|
|
132
|
+
|
|
133
|
+
def request_refund(db: Db, p: Principal, refund: Refund) -> IO[Reply[RefundError, Decision]]:
|
|
134
|
+
Refund(order, amount, reason) = refund
|
|
135
|
+
key = owner_key(p, order)
|
|
136
|
+
# One round: what the policy says next, done. The same request again
|
|
137
|
+
# carries on with the stored refund; a different one for the same order
|
|
138
|
+
# is turned away.
|
|
139
|
+
def round(_: Unit, _: Int) -> IO[IterState[Unit, Reply[RefundError, Decision]]]:
|
|
140
|
+
found <- flat_map(transaction(db, scope -> read_plan(scope, key)))
|
|
141
|
+
match found:
|
|
142
|
+
case Committed(Some(Case(reference, seen, stored, record, decided))):
|
|
143
|
+
if same_refund(stored, refund):
|
|
144
|
+
match (decided, replay(stored, record)):
|
|
145
|
+
case (Some(decision), _): pure(Finished(Done(decision)))
|
|
146
|
+
case (None, Some(Ruled(decision))):
|
|
147
|
+
saved <- flat_map(transaction(db, scope -> decide_plan(scope, key, seen, decision)))
|
|
148
|
+
match saved:
|
|
149
|
+
case Committed(True): pure(Finished(Done(decision)))
|
|
150
|
+
case _: pure(Continue(()))
|
|
151
|
+
case (None, Some(Perform(request))):
|
|
152
|
+
answer <- flat_map(perform(reference, length_List(record), request))
|
|
153
|
+
_ <- flat_map(transaction(db, scope -> append_plan(scope, key, seen, Exchange(request, answer))))
|
|
154
|
+
pure(Continue(()))
|
|
155
|
+
case (None, None): pure(Finished(Turned("this refund's record cannot be read back", Unreadable)))
|
|
156
|
+
else: pure(Finished(Turned("this order already has a refund request for another amount or reason", AlreadyRequested)))
|
|
157
|
+
case Committed(None): pure(Finished(NotFound("no refund for this order", NoSuchRefund)))
|
|
158
|
+
# The store was busy: read again next round.
|
|
159
|
+
case _: pure(Continue(()))
|
|
160
|
+
def decide():
|
|
161
|
+
opened <- flat_map(transaction(db, scope -> open_plan(scope, key, refund)))
|
|
162
|
+
match opened:
|
|
163
|
+
case Committed(_):
|
|
164
|
+
ended <- flat_map(fold_until(range(rounds), (), round))
|
|
165
|
+
match ended:
|
|
166
|
+
case Finished(reply): pure(reply)
|
|
167
|
+
case Continue(_): pure(TryAgain("the refund is still being decided; ask for its status"))
|
|
168
|
+
case _: pure(TryAgain("the refund could not be opened; try again"))
|
|
169
|
+
if eq_String(order, ""): pure(Invalid("say which order to refund"))
|
|
170
|
+
elif eq_String(reason, ""): pure(Invalid("say why the order should be refunded"))
|
|
171
|
+
else:
|
|
172
|
+
match cmp_Int(amount, 0):
|
|
173
|
+
case GT: decide()
|
|
174
|
+
case _: pure(Invalid("a refund is for an amount above zero"))
|
|
175
|
+
|
|
176
|
+
def refund_status(db: Db, p: Principal, input: StatusRequest) -> IO[Reply[RefundError, RefundView]]:
|
|
177
|
+
StatusRequest(order) = input
|
|
178
|
+
found <- flat_map(transaction(db, scope -> read_plan(scope, owner_key(p, order))))
|
|
179
|
+
pure(replied(found,
|
|
180
|
+
c -> (
|
|
181
|
+
match c:
|
|
182
|
+
case Some(Case(_, _, refund, record, decision)): Done(RefundView(refund, decision, length_List(record)))
|
|
183
|
+
case None: NotFound("no refund for this order", NoSuchRefund)
|
|
184
|
+
),
|
|
185
|
+
reason -> Turned("the refund could not be read", reason),
|
|
186
|
+
TryAgain("the store was busy; try again")))
|
|
187
|
+
|
|
188
|
+
refund_desk = service_def(
|
|
189
|
+
"refunds",
|
|
190
|
+
[
|
|
191
|
+
route("/refunds/request", handler("request_refund", request_refund), [ReadPerm("refunds"), WritePerm("refunds"), CreatePerm("refunds")]),
|
|
192
|
+
route("/refunds/status", handler("refund_status", refund_status), [ReadPerm("refunds")]),
|
|
193
|
+
]
|
|
194
|
+
)
|