yichus 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ {
2
+ "schemaVersion": "protocol-case-1.0",
3
+ "name": "Every action that changes sign-ups keeps the shift count",
4
+ "assumptions": [
5
+ "serializable-atomic-transactions",
6
+ "rollback-on-abort-and-retry-exhaustion",
7
+ "commit-unknown-may-commit",
8
+ "stable-time-within-attempt",
9
+ "no-unmodeled-writers",
10
+ "preauthenticated-principals"
11
+ ],
12
+ "initialRows": {
13
+ "shifts": {
14
+ "s1": {
15
+ "title": "Sorting",
16
+ "starts_at": "9am",
17
+ "spots": 2,
18
+ "taken": 1
19
+ },
20
+ "s2": {
21
+ "title": "Packing",
22
+ "starts_at": "1pm",
23
+ "spots": 1,
24
+ "taken": 1
25
+ }
26
+ },
27
+ "signups": {
28
+ "ann": [
29
+ {
30
+ "shift_id": "s1",
31
+ "volunteer_name": "Ann"
32
+ }
33
+ ],
34
+ "bo": [
35
+ {
36
+ "shift_id": "s2",
37
+ "volunteer_name": "Bo"
38
+ }
39
+ ]
40
+ }
41
+ },
42
+ "initialize": [],
43
+ "requests": [
44
+ {
45
+ "instance": "ann-again",
46
+ "delivery": "ann-again",
47
+ "call": {
48
+ "binding": "Example/Signups::add_signup",
49
+ "arguments": {
50
+ "p": {
51
+ "user_id": "ann",
52
+ "roles": []
53
+ },
54
+ "item": {
55
+ "shift_id": "s1",
56
+ "volunteer_name": "Ann"
57
+ }
58
+ }
59
+ }
60
+ },
61
+ {
62
+ "instance": "ann-cancel",
63
+ "delivery": "ann-cancel",
64
+ "call": {
65
+ "binding": "Example/Signups::cancel_signup",
66
+ "arguments": {
67
+ "p": {
68
+ "user_id": "ann",
69
+ "roles": []
70
+ },
71
+ "shift_id": "s1"
72
+ }
73
+ }
74
+ },
75
+ {
76
+ "instance": "cy",
77
+ "delivery": "cy",
78
+ "call": {
79
+ "binding": "Example/Signups::add_signup",
80
+ "arguments": {
81
+ "p": {
82
+ "user_id": "cy",
83
+ "roles": []
84
+ },
85
+ "item": {
86
+ "shift_id": "s2",
87
+ "volunteer_name": "Cy"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ],
93
+ "times": [
94
+ 1000
95
+ ],
96
+ "externalResults": {},
97
+ "invariants": [
98
+ {
99
+ "name": "spots-match-signups",
100
+ "binding": "Example/SignupsCase::spots_match_signups",
101
+ "arguments": {
102
+ "after": "after"
103
+ },
104
+ "when": "always"
105
+ }
106
+ ],
107
+ "traceInvariants": [],
108
+ "witnesses": [
109
+ {
110
+ "name": "someone-signed-up",
111
+ "kind": "table-nonempty",
112
+ "table": "signups"
113
+ }
114
+ ],
115
+ "bounds": {
116
+ "maxStates": 100000,
117
+ "maxDepth": 30,
118
+ "maxOperations": 60,
119
+ "maxFaults": 0,
120
+ "maxCrashes": 0
121
+ }
122
+ }
@@ -0,0 +1,32 @@
1
+ package Example/SignupsLaws
2
+
3
+ from Example/Signups import Shift, take_spot
4
+ from Yichus/Law import Domain, Law, Obligation, Witness
5
+
6
+ export (never_overbooked,)
7
+ exposes Yichus/Law
8
+
9
+ def show(input: (Int, Int)) -> String:
10
+ (spots, taken) = input
11
+ concat_String(["spots ", int_to_String(spots), ", taken ", int_to_String(taken)])
12
+
13
+ # "each shift has a limited number of spots": a sign-up never takes a
14
+ # shift past its spots; a full shift turns the volunteer away.
15
+ def never_over(input: (Int, Int)) -> Bool:
16
+ (spots, taken) = input
17
+ match take_spot(Shift("s", "t", spots, taken)):
18
+ case Some(Shift(_, _, _, after)): cmp_Int(after, spots) matches (LT | EQ)
19
+ case None: cmp_Int(taken, spots) matches (GT | EQ)
20
+
21
+ def has_space(input: (Int, Int)) -> Bool:
22
+ (spots, taken) = input
23
+ cmp_Int(taken, spots) matches LT
24
+
25
+ def full(input: (Int, Int)) -> Bool:
26
+ (spots, taken) = input
27
+ eq_Int(taken, spots)
28
+
29
+ never_overbooked = Obligation(
30
+ Law("a shift never has more volunteers than spots", Domain("a shift's spots and spots taken", show, None, None), never_over),
31
+ [Witness("a volunteer signs up for a shift with room", has_space), Witness("a volunteer tries a full shift", full)]
32
+ )
@@ -0,0 +1,155 @@
1
+ package Example/Signups
2
+
3
+ # A food bank's shifts: a volunteer signs up while a spot is left, and
4
+ # cancelling gives the spot back. Every action that changes sign-ups
5
+ # changes the shift's count in the same transaction.
6
+
7
+ from Yichus/IO import IO, pure, flat_map
8
+ from Yichus/Transaction import TxTable, Txn, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_abort, transaction, replied
9
+ from Yichus/Access import Principal, AccessRule, AccessSpec, Public, OwnerScoped
10
+ from Yichus/Data import Db, Table, table, db_read, db_query
11
+ from Yichus/Service import Reply, Done, Refused, TryAgain, handler, route, service_def, ReadPerm, WritePerm, QueryPerm
12
+
13
+ export (signups_api, access_rules, Shift(), Signup(), take_spot)
14
+ exposes (Yichus/Access, Yichus/Service)
15
+
16
+ struct Shift(title: String, starts_at: String, spots: Int, taken: Int)
17
+
18
+ struct Signup(shift_id: String, volunteer_name: String)
19
+
20
+ # The decision: a shift with `spots` places, `taken` already taken,
21
+ # has room for one more only while taken is below spots.
22
+ def has_room(spots: Int, taken: Int) -> Bool:
23
+ cmp_Int(taken, spots) matches LT
24
+
25
+ # The shift after one more volunteer takes a spot, or None when full.
26
+ def take_spot(shift: Shift) -> Option[Shift]:
27
+ Shift(title, starts_at, spots, taken) = shift
28
+ if has_room(spots, taken):
29
+ Some(Shift(title, starts_at, spots, taken.add(1)))
30
+ else:
31
+ None
32
+
33
+ # The declared access surface. `api_verify` proves each rule against
34
+ # the handlers below — and keeps proving it as this file is edited.
35
+ access_rules = AccessSpec([
36
+ AccessRule("shifts", Public),
37
+ AccessRule("signups", OwnerScoped),
38
+ ])
39
+
40
+ # Why a change was refused; the error body's code is its name in
41
+ # snake_case. maybe_saved: the commit was not confirmed, so sending
42
+ # the change again could repeat it. The others were refused before
43
+ # anything was saved, and sending the same change again is refused again.
44
+ enum ChangeRefused:
45
+ MaybeSaved
46
+ ShiftFull
47
+ NoSuchShift
48
+ AlreadySignedUp
49
+
50
+ def unsaved[a](reason: ChangeRefused) -> Reply[ChangeRefused, a]:
51
+ match reason:
52
+ case ShiftFull: Refused("the shift is full", reason)
53
+ case NoSuchShift: Refused("there is no such shift", reason)
54
+ case AlreadySignedUp: Refused("the volunteer is already signed up for this shift", reason)
55
+ case MaybeSaved: Refused("the change may already be saved; list the signups before sending it again", reason)
56
+
57
+ # Shifts are public: shared rows, no key discipline claimed.
58
+ def shifts_table(db: Db) -> Table[Shift]:
59
+ table(db, "shifts")
60
+
61
+ def get_shift(db: Db, id: String) -> IO[Shift]:
62
+ db_read(shifts_table(db), id)
63
+
64
+ def list_shifts(db: Db) -> IO[List[Shift]]:
65
+ db_query(shifts_table(db))
66
+
67
+ # Signups are owner-scoped: one row per user, keyed by exactly
68
+ # Principal.user_id, holding that user's list. The analyzer proves no
69
+ # handler can reach another caller's row.
70
+ def signups_table(db: Db) -> Table[List[Signup]]:
71
+ table(db, "signups")
72
+
73
+ # The same table inside a transaction. Every change to a caller's row
74
+ # goes through one, so changes made at the same moment, on one server
75
+ # or several, never lose one another.
76
+ signups_rows: TxTable[s, List[Signup]] = stored_table("signups")
77
+
78
+ def list_signups(db: Db, p: Principal) -> IO[List[Signup]]:
79
+ Principal(uid, _) = p
80
+ db_read(signups_table(db), uid)
81
+
82
+ shifts_rows: TxTable[s, Shift] = stored_table("shifts")
83
+
84
+ # A volunteer takes at most one spot on a shift.
85
+ def signed_up(items: List[Signup], shift_id: String) -> Bool:
86
+ items.foldl_List(False, (seen, Signup(id, _)) -> seen.or(eq_String(id, shift_id)))
87
+
88
+ def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
89
+ Signup(shift_id, _) = item
90
+ found <- tx_bind(tx_read(signups_rows, uid))
91
+ saved = match found:
92
+ case Some(saved): saved
93
+ case None: []
94
+ if signed_up(saved, shift_id):
95
+ tx_abort(AlreadySignedUp)
96
+ else:
97
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
98
+ match shift_found:
99
+ case None: tx_abort(NoSuchShift)
100
+ case Some(shift):
101
+ match take_spot(shift):
102
+ case None: tx_abort(ShiftFull)
103
+ case Some(updated):
104
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
105
+ items = [item, *saved]
106
+ _ <- tx_bind(tx_write(signups_rows, uid, items))
107
+ tx_pure(items)
108
+
109
+ def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
110
+ Principal(uid, _) = p
111
+ outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
112
+ pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
113
+
114
+ def without(items: List[Signup], shift_id: String) -> List[Signup]:
115
+ items.foldr_List((item, kept) -> (
116
+ Signup(id, _) = item
117
+ kept if eq_String(id, shift_id) else [item, *kept]
118
+ ), [])
119
+
120
+ def give_back(shift: Shift) -> Shift:
121
+ Shift(title, starts_at, spots, taken) = shift
122
+ Shift(title, starts_at, spots, taken.sub(1))
123
+
124
+ def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
125
+ found <- tx_bind(tx_read(signups_rows, uid))
126
+ items = match found:
127
+ case Some(saved): saved
128
+ case None: []
129
+ kept = without(items, shift_id)
130
+ if eq_Int(length_List(kept), length_List(items)):
131
+ tx_pure(items)
132
+ else:
133
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
134
+ match shift_found:
135
+ case None: tx_abort(NoSuchShift)
136
+ case Some(shift):
137
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
138
+ _ <- tx_bind(tx_write(signups_rows, uid, kept))
139
+ tx_pure(kept)
140
+
141
+ def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
142
+ Principal(uid, _) = p
143
+ outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
144
+ pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
145
+
146
+ signups_api = service_def(
147
+ "signups",
148
+ [
149
+ route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
150
+ route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
151
+ route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
152
+ route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
153
+ route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
154
+ ]
155
+ )
@@ -0,0 +1,27 @@
1
+ package Example/SignupsCase
2
+
3
+ from Example/Signups import Shift, Signup
4
+ from Yichus/Data import Db, table, db_query, db_read_opt
5
+ from Yichus/IO import IO, pure, flat_map
6
+
7
+ export (spots_match_signups,)
8
+ exposes (Yichus/Data, Yichus/IO)
9
+
10
+ def signed_up_for(lists: List[List[Signup]], shift_id: String) -> Int:
11
+ lists.foldl_List(0, (n, list) -> list.foldl_List(n, (m, Signup(id, _)) -> m.add(1) if eq_String(id, shift_id) else m))
12
+
13
+ # The case's shifts, "s1" and "s2": each one's taken count is the number
14
+ # of sign-ups for it across every volunteer, and never more than its spots.
15
+ def shift_matches(after: Db, lists: List[List[Signup]], shift_id: String) -> IO[Bool]:
16
+ shift <- flat_map(db_read_opt(table(after, "shifts"), shift_id))
17
+ match shift:
18
+ case None: pure(False)
19
+ case Some(Shift(_, _, spots, taken)):
20
+ within = cmp_Int(taken, spots) matches (LT | EQ)
21
+ pure(within.and(eq_Int(taken, signed_up_for(lists, shift_id))))
22
+
23
+ def spots_match_signups(after: Db) -> IO[Bool]:
24
+ lists <- flat_map(db_query(table(after, "signups")))
25
+ first <- flat_map(shift_matches(after, lists, "s1"))
26
+ second <- flat_map(shift_matches(after, lists, "s2"))
27
+ pure(first.and(second))
@@ -1,6 +1,6 @@
1
1
  # Guiding the person through the studio
2
2
 
3
- The person does not read documentation. The yich.us studio page only tells them what the studio is and how to connect you. Everything they need to know while building comes from you, a little at a time, when it becomes relevant. This file is what you need to know to do that. Do not read it out. Bring in one piece when the conversation reaches it, in their words, and point at the part of the page you mean with `yichus_point`.
3
+ The person does not read documentation. The yich.us studio page only tells them what the studio is, that the QuickStart is the route for browser data apps, and how to connect you. Everything they need to know while building comes from you, a little at a time, when it becomes relevant. This file is what you need to know to do that. Do not read it out. Bring in one piece when the conversation reaches it, in their words, and point at the part of the page you mean with `yichus_point`.
4
4
 
5
5
  `yichus_page` shows you what the page shows them right now, and what they have on screen and last clicked.
6
6
 
@@ -19,7 +19,7 @@ What it costs them, with what limits each cost:
19
19
  - It is slower to start than a quick build: a few questions first, and fixes until the rules hold.
20
20
  - The checks cover the rules as written. Read each rule back to them in plain words, because a wrong rule passes. Payments, email, file uploads and scheduled jobs are not asked about yet. They can be built beside the checked part, unchecked; a download they click, such as a monthly export, is an ordinary action and is checked.
21
21
 
22
- Recommend it when the app keeps records several people touch and a leak, a double booking or a half-saved change would hurt: bookings, sign-ups, approvals, anything with customers, patients or money. When some parts are not covered, recommend it for the part with the records and the people. For a live app, the port is a side copy: the old app keeps running untouched while the checks run on the new one. With you doing the port it is a much smaller job than it sounds: in one rehearsal a fresh agent ported a copy of a live Next.js and Supabase app in about 45 minutes, keeping its pages and its sign-in, with every property proven (locally, on test data). For a large app you can start with the part they worry about most. If the app keeps no records at all, such as a calculator, the studio's checks have nothing to hold; Yichus's calculator path (Why on every number, checked rules about the numbers) fits better, through the separate @yichus/mcp connection and the QuickStart at https://yich.us/start/quickstart.html. Its exported page carries its own scripts, so the person can host it anywhere. It is usually not worth it for a throwaway prototype, or for code they want to keep in its current language.
22
+ Recommend it when the app keeps records several people touch and a leak, a double booking or a half-saved change would hurt: bookings, sign-ups, approvals, anything with customers, patients or money. When some parts are not covered, recommend it for the part with the records and the people. For a live app, the port is a side copy: the old app keeps running untouched while the checks run on the new one. With you doing the port it is a much smaller job than it sounds: in one unrecorded rehearsal a fresh agent ported a copy of a live Next.js and Supabase app in about 45 minutes, keeping its pages and its sign-in, with every property proven (locally, on test data). For a large app you can start with the part they worry about most. If the app keeps no records at all, such as a calculator, the studio's checks have nothing to hold; Yichus's calculator path (Why on every number, checked rules about the numbers) fits better, through the separate @yichus/mcp connection and the QuickStart at https://yich.us/start/quickstart.html. Its exported page carries its own scripts, so the person can host it anywhere. It is usually not worth it for a throwaway prototype, or for code they want to keep in its current language.
23
23
 
24
24
  A scaffold is a starting point: shape its records and who may see them to the person's roles (a coordinator who sees everyone's sign-ups, a manager who approves) before you show them results.
25
25
 
@@ -56,7 +56,7 @@ The studio moves you through these steps in order, after every change. Tell the
56
56
  7. Match their answers: write the answers into the program as rules the checks hold it to.
57
57
  8. Fix whatever does not hold yet. A broken rule comes first, before describing what happens.
58
58
  9. What happens when: for each thing the app does, write down in their words every way it can go and what they should get at the end. The studio checks each one.
59
- 10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it.
59
+ 10. Test failures: for answers about crashes and outside services, you write a failure scenario and the studio runs it. For a rule that other actions could go around (a sign-up checks the spots left, and replacing or cancelling a sign-up does not), you write a scenario that runs every one of those actions.
60
60
  11. Done: everything they chose holds.
61
61
 
62
62
  When a check fails, the code changes, never the rule. If they ask why something is taking a while, this is usually the answer.
@@ -90,13 +90,15 @@ Each choice they made shows on the page under "What your program must do", with
90
90
  | decided | A choice that sets up the other checks, such as several copies of the server. |
91
91
  | broken | The check found a way it fails. You have the evidence; say in their terms what goes wrong, then fix it. |
92
92
  | the code says otherwise | The rule in the program does not match their answer yet. You update it. |
93
- | scenario being written | You are writing the failure scenario this answer needs. |
93
+ | scenario being written | You are writing the failure scenario this answer needs, or the scenario that runs every action that changes the records a rule is about. |
94
94
  | check being written | A rule they gave has no law that checks it yet. You write one. |
95
95
  | checked, but your app does not use it yet | Its law passes, but no action of the app reaches what the law checks, so the check says nothing about what the actions do. You make the action decide with that function. |
96
96
  | not decided yet | The check ran but could not decide. You find out why. |
97
- | no check needed | They chose an option that adds no check, such as "partial is fine". |
97
+ | no check needed | They chose an option that adds no check, such as "partial is fine", or records anyone may change, which nothing protects. |
98
98
  | answered, waiting for your agent to check | They answered on the page; the result appears after your next check. |
99
99
 
100
+ Under each property and each rule that holds, the page says what its check does not cover ("Not checked: ..."). When the person reads a green status as "safe", read that line with them: a status says the program does what they chose, never more.
101
+
100
102
  The evidence for a status comes to you, never to the page. When they ask why, explain it in product terms.
101
103
 
102
104
  ## Reading the pictures with them
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yichus",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Local Bosatsu compilation, Yichus analysis, and generated apps using the WebMCP engine",
5
5
  "license": "MIT",
6
6
  "homepage": "https://yich.us",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "zod": "^4.3.6",
22
22
  "@modelcontextprotocol/sdk": "^1.26.0",
23
- "@yichus/api-mcp": "0.1.5"
23
+ "@yichus/api-mcp": "0.1.6"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^22.0.0",