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.
@@ -5,6 +5,7 @@ The studio gives you this file when the program has no actions people call and k
5
5
  ## What the studio still does for it
6
6
 
7
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
+ - `api_suggest_properties` drafts a law for each answer the calculation offers: a floor every answer stays at or above, for the inputs the person means. Fill in the floor and which inputs are in use from the person's words; the draft checks inconclusive until then, never broken.
8
9
  - The page shows the shape of the program in the person's words.
9
10
 
10
11
  ## Putting it on a page
@@ -13,9 +13,12 @@ The studio gives you this file when the person has said where their app will run
13
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
14
  - `clerk-api-key` is for API keys.
15
15
  - `proxy-header` is only for running behind a proxy you control that sets the caller's id.
16
+ - `public` is for an app where everyone may do everything and nobody signs in. The engine starts in this mode only when every action is open to everyone, and otherwise it names the ones that need to know the caller.
16
17
  - `dev` is never used in production.
17
18
  - **HTTPS in front.** Every platform below terminates TLS for you.
18
19
 
20
+ An app that keeps no records and lets everyone use every action, such as a calculator served as an API, needs less than this. It needs no database, because the memory store has nothing to lose. It needs no identity provider either: run it with `YICHUS_AUTH=public`. It still needs a Node.js process and HTTPS. A calculator that only runs in the page people open needs no server at all (`yichus_reference` topic `calculator`).
21
+
19
22
  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
23
 
21
24
  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.
@@ -0,0 +1,149 @@
1
+ package Example/Signups
2
+
3
+ # A mutant of ../signups.bosatsu, kept so cases/signups-case.json is shown
4
+ # to fail on it: the same app without the check that the volunteer already
5
+ # holds a spot on the shift, so one volunteer takes two spots, and
6
+ # cancelling removes both sign-ups but gives back only one spot.
7
+
8
+ from Yichus/IO import IO, pure, flat_map
9
+ from Yichus/Transaction import TxTable, Txn, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_abort, transaction, replied
10
+ from Yichus/Access import Principal, AccessRule, AccessSpec, Public, OwnerScoped
11
+ from Yichus/Data import Db, Table, table, db_read, db_query
12
+ from Yichus/Service import Reply, Done, Refused, TryAgain, handler, route, service_def, ReadPerm, WritePerm, QueryPerm
13
+
14
+ export (signups_api, access_rules, Shift(), Signup(), take_spot)
15
+ exposes (Yichus/Access, Yichus/Service)
16
+
17
+ struct Shift(title: String, starts_at: String, spots: Int, taken: Int)
18
+
19
+ struct Signup(shift_id: String, volunteer_name: String)
20
+
21
+ # The decision: a shift with `spots` places, `taken` already taken,
22
+ # has room for one more only while taken is below spots.
23
+ def has_room(spots: Int, taken: Int) -> Bool:
24
+ cmp_Int(taken, spots) matches LT
25
+
26
+ # The shift after one more volunteer takes a spot, or None when full.
27
+ def take_spot(shift: Shift) -> Option[Shift]:
28
+ Shift(title, starts_at, spots, taken) = shift
29
+ if has_room(spots, taken):
30
+ Some(Shift(title, starts_at, spots, taken.add(1)))
31
+ else:
32
+ None
33
+
34
+ # The declared access surface. `api_verify` proves each rule against
35
+ # the handlers below — and keeps proving it as this file is edited.
36
+ access_rules = AccessSpec([
37
+ AccessRule("shifts", Public),
38
+ AccessRule("signups", OwnerScoped),
39
+ ])
40
+
41
+ # Why a change was refused; the error body's code is its name in
42
+ # snake_case. maybe_saved: the commit was not confirmed, so sending
43
+ # the change again could repeat it. The others were refused before
44
+ # anything was saved, and sending the same change again is refused again.
45
+ enum ChangeRefused:
46
+ MaybeSaved
47
+ ShiftFull
48
+ NoSuchShift
49
+ AlreadySignedUp
50
+
51
+ def unsaved[a](reason: ChangeRefused) -> Reply[ChangeRefused, a]:
52
+ match reason:
53
+ case ShiftFull: Refused("the shift is full", reason)
54
+ case NoSuchShift: Refused("there is no such shift", reason)
55
+ case AlreadySignedUp: Refused("the volunteer is already signed up for this shift", reason)
56
+ case MaybeSaved: Refused("the change may already be saved; list the signups before sending it again", reason)
57
+
58
+ # Shifts are public: shared rows, no key discipline claimed.
59
+ def shifts_table(db: Db) -> Table[Shift]:
60
+ table(db, "shifts")
61
+
62
+ def get_shift(db: Db, id: String) -> IO[Shift]:
63
+ db_read(shifts_table(db), id)
64
+
65
+ def list_shifts(db: Db) -> IO[List[Shift]]:
66
+ db_query(shifts_table(db))
67
+
68
+ # Signups are owner-scoped: one row per user, keyed by exactly
69
+ # Principal.user_id, holding that user's list. The analyzer proves no
70
+ # handler can reach another caller's row.
71
+ def signups_table(db: Db) -> Table[List[Signup]]:
72
+ table(db, "signups")
73
+
74
+ # The same table inside a transaction. Every change to a caller's row
75
+ # goes through one, so changes made at the same moment, on one server
76
+ # or several, never lose one another.
77
+ signups_rows: TxTable[s, List[Signup]] = stored_table("signups")
78
+
79
+ def list_signups(db: Db, p: Principal) -> IO[List[Signup]]:
80
+ Principal(uid, _) = p
81
+ db_read(signups_table(db), uid)
82
+
83
+ shifts_rows: TxTable[s, Shift] = stored_table("shifts")
84
+
85
+ def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
86
+ Signup(shift_id, _) = item
87
+ found <- tx_bind(tx_read(signups_rows, uid))
88
+ saved = match found:
89
+ case Some(saved): saved
90
+ case None: []
91
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
92
+ match shift_found:
93
+ case None: tx_abort(NoSuchShift)
94
+ case Some(shift):
95
+ match take_spot(shift):
96
+ case None: tx_abort(ShiftFull)
97
+ case Some(updated):
98
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
99
+ items = [item, *saved]
100
+ _ <- tx_bind(tx_write(signups_rows, uid, items))
101
+ tx_pure(items)
102
+
103
+ def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
104
+ Principal(uid, _) = p
105
+ outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
106
+ pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
107
+
108
+ def without(items: List[Signup], shift_id: String) -> List[Signup]:
109
+ items.foldr_List((item, kept) -> (
110
+ Signup(id, _) = item
111
+ kept if eq_String(id, shift_id) else [item, *kept]
112
+ ), [])
113
+
114
+ def give_back(shift: Shift) -> Shift:
115
+ Shift(title, starts_at, spots, taken) = shift
116
+ Shift(title, starts_at, spots, taken.sub(1))
117
+
118
+ def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
119
+ found <- tx_bind(tx_read(signups_rows, uid))
120
+ items = match found:
121
+ case Some(saved): saved
122
+ case None: []
123
+ kept = without(items, shift_id)
124
+ if eq_Int(length_List(kept), length_List(items)):
125
+ tx_pure(items)
126
+ else:
127
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
128
+ match shift_found:
129
+ case None: tx_abort(NoSuchShift)
130
+ case Some(shift):
131
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
132
+ _ <- tx_bind(tx_write(signups_rows, uid, kept))
133
+ tx_pure(kept)
134
+
135
+ def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
136
+ Principal(uid, _) = p
137
+ outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
138
+ pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
139
+
140
+ signups_api = service_def(
141
+ "signups",
142
+ [
143
+ route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
144
+ route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
145
+ route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
146
+ route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
147
+ route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
148
+ ]
149
+ )
@@ -0,0 +1,153 @@
1
+ package Example/Signups
2
+
3
+ # A mutant of ../signups.bosatsu, kept so cases/signups-case.json is shown
4
+ # to fail on it: sign-up takes a spot without asking take_spot whether one
5
+ # is left, so a full shift takes one more volunteer.
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
+ Shift(title, starts_at, spots, taken) = shift
102
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, Shift(title, starts_at, spots, taken.add(1))))
103
+ items = [item, *saved]
104
+ _ <- tx_bind(tx_write(signups_rows, uid, items))
105
+ tx_pure(items)
106
+
107
+ def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
108
+ Principal(uid, _) = p
109
+ outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
110
+ pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
111
+
112
+ def without(items: List[Signup], shift_id: String) -> List[Signup]:
113
+ items.foldr_List((item, kept) -> (
114
+ Signup(id, _) = item
115
+ kept if eq_String(id, shift_id) else [item, *kept]
116
+ ), [])
117
+
118
+ def give_back(shift: Shift) -> Shift:
119
+ Shift(title, starts_at, spots, taken) = shift
120
+ Shift(title, starts_at, spots, taken.sub(1))
121
+
122
+ def cancel_signup_plan[s](uid: String, shift_id: String) -> Txn[s, ChangeRefused, List[Signup]]:
123
+ found <- tx_bind(tx_read(signups_rows, uid))
124
+ items = match found:
125
+ case Some(saved): saved
126
+ case None: []
127
+ kept = without(items, shift_id)
128
+ if eq_Int(length_List(kept), length_List(items)):
129
+ tx_pure(items)
130
+ else:
131
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
132
+ match shift_found:
133
+ case None: tx_abort(NoSuchShift)
134
+ case Some(shift):
135
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, give_back(shift)))
136
+ _ <- tx_bind(tx_write(signups_rows, uid, kept))
137
+ tx_pure(kept)
138
+
139
+ def cancel_signup(db: Db, p: Principal, shift_id: String) -> IO[Reply[ChangeRefused, List[Signup]]]:
140
+ Principal(uid, _) = p
141
+ outcome <- flat_map(transaction(db, _ -> cancel_signup_plan(uid, shift_id)))
142
+ pure(replied(outcome, Done, unsaved, TryAgain("the sign-up may not have been cancelled; send the request again")))
143
+
144
+ signups_api = service_def(
145
+ "signups",
146
+ [
147
+ route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
148
+ route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
149
+ route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
150
+ route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
151
+ route("/signups/cancel", handler("cancel_signup", cancel_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
152
+ ]
153
+ )
@@ -0,0 +1,104 @@
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": 1,
18
+ "taken": 0
19
+ }
20
+ },
21
+ "signups": {}
22
+ },
23
+ "initialize": [],
24
+ "requests": [
25
+ {
26
+ "instance": "a",
27
+ "delivery": "a",
28
+ "call": {
29
+ "binding": "Example/Signups::add_signup",
30
+ "arguments": {
31
+ "p": {
32
+ "user_id": "ann",
33
+ "roles": []
34
+ },
35
+ "item": {
36
+ "shift_id": "s1",
37
+ "volunteer_name": "Ann"
38
+ }
39
+ }
40
+ }
41
+ },
42
+ {
43
+ "instance": "b",
44
+ "delivery": "b",
45
+ "call": {
46
+ "binding": "Example/Signups::put_signups",
47
+ "arguments": {
48
+ "p": {
49
+ "user_id": "bo",
50
+ "roles": []
51
+ },
52
+ "items": [
53
+ {
54
+ "shift_id": "s1",
55
+ "volunteer_name": "Bo"
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ },
61
+ {
62
+ "instance": "c",
63
+ "delivery": "c",
64
+ "call": {
65
+ "binding": "Example/Signups::clear_signups",
66
+ "arguments": {
67
+ "p": {
68
+ "user_id": "ann",
69
+ "roles": []
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ],
75
+ "times": [
76
+ 1000
77
+ ],
78
+ "externalResults": {},
79
+ "invariants": [
80
+ {
81
+ "name": "spots-match-signups",
82
+ "binding": "Example/SignupsCase::spots_match_signups",
83
+ "arguments": {
84
+ "after": "after"
85
+ },
86
+ "when": "always"
87
+ }
88
+ ],
89
+ "traceInvariants": [],
90
+ "witnesses": [
91
+ {
92
+ "name": "someone-signed-up",
93
+ "kind": "table-nonempty",
94
+ "table": "signups"
95
+ }
96
+ ],
97
+ "bounds": {
98
+ "maxStates": 100000,
99
+ "maxDepth": 30,
100
+ "maxOperations": 60,
101
+ "maxFaults": 0,
102
+ "maxCrashes": 0
103
+ }
104
+ }
@@ -0,0 +1,139 @@
1
+ package Example/Signups
2
+
3
+ # The near miss: the same sign-up with room checked, beside the starter
4
+ # actions that replace or clear a volunteer's list without the count.
5
+ # The law over the room check still passes; a scenario that runs every
6
+ # action that changes sign-ups does not.
7
+
8
+ from Yichus/IO import IO, pure, flat_map
9
+ from Yichus/Transaction import TxTable, Txn, stored_table, tx_bind, tx_pure, tx_read, tx_write, tx_delete, tx_abort, transaction, replied
10
+ from Yichus/Access import Principal, AccessRule, AccessSpec, Public, OwnerScoped
11
+ from Yichus/Data import Db, Table, table, db_read, db_query
12
+ from Yichus/Service import Reply, Done, Refused, TryAgain, handler, route, service_def, ReadPerm, WritePerm, DeletePerm, QueryPerm
13
+
14
+ export (signups_api, access_rules, Shift(), Signup(), take_spot)
15
+ exposes (Yichus/Access, Yichus/Service)
16
+
17
+ struct Shift(title: String, starts_at: String, spots: Int, taken: Int)
18
+
19
+ struct Signup(shift_id: String, volunteer_name: String)
20
+
21
+ # The decision: a shift with `spots` places, `taken` already taken,
22
+ # has room for one more only while taken is below spots.
23
+ def has_room(spots: Int, taken: Int) -> Bool:
24
+ cmp_Int(taken, spots) matches LT
25
+
26
+ # The shift after one more volunteer takes a spot, or None when full.
27
+ def take_spot(shift: Shift) -> Option[Shift]:
28
+ Shift(title, starts_at, spots, taken) = shift
29
+ if has_room(spots, taken):
30
+ Some(Shift(title, starts_at, spots, taken.add(1)))
31
+ else:
32
+ None
33
+
34
+ # The declared access surface. `api_verify` proves each rule against
35
+ # the handlers below — and keeps proving it as this file is edited.
36
+ access_rules = AccessSpec([
37
+ AccessRule("shifts", Public),
38
+ AccessRule("signups", OwnerScoped),
39
+ ])
40
+
41
+ # Why a change was refused; the error body's code is its name in
42
+ # snake_case. maybe_saved: the commit was not confirmed, so sending
43
+ # the change again could repeat it. The others were refused before
44
+ # anything was saved, and sending the same change again is refused again.
45
+ enum ChangeRefused:
46
+ MaybeSaved
47
+ ShiftFull
48
+ NoSuchShift
49
+ AlreadySignedUp
50
+
51
+ def unsaved[a](reason: ChangeRefused) -> Reply[ChangeRefused, a]:
52
+ match reason:
53
+ case ShiftFull: Refused("the shift is full", reason)
54
+ case NoSuchShift: Refused("there is no such shift", reason)
55
+ case AlreadySignedUp: Refused("the volunteer is already signed up for this shift", reason)
56
+ case MaybeSaved: Refused("the change may already be saved; list the signups before sending it again", reason)
57
+
58
+ # Shifts are public: shared rows, no key discipline claimed.
59
+ def shifts_table(db: Db) -> Table[Shift]:
60
+ table(db, "shifts")
61
+
62
+ def get_shift(db: Db, id: String) -> IO[Shift]:
63
+ db_read(shifts_table(db), id)
64
+
65
+ def list_shifts(db: Db) -> IO[List[Shift]]:
66
+ db_query(shifts_table(db))
67
+
68
+ # Signups are owner-scoped: one row per user, keyed by exactly
69
+ # Principal.user_id, holding that user's list. The analyzer proves no
70
+ # handler can reach another caller's row.
71
+ def signups_table(db: Db) -> Table[List[Signup]]:
72
+ table(db, "signups")
73
+
74
+ # The same table inside a transaction. Every change to a caller's row
75
+ # goes through one, so changes made at the same moment, on one server
76
+ # or several, never lose one another.
77
+ signups_rows: TxTable[s, List[Signup]] = stored_table("signups")
78
+
79
+ def list_signups(db: Db, p: Principal) -> IO[List[Signup]]:
80
+ Principal(uid, _) = p
81
+ db_read(signups_table(db), uid)
82
+
83
+ # A change that may or may not have been saved (the commit was not
84
+ # confirmed) is answered so the caller knows whether sending it again
85
+ # is safe: replacing or clearing the row again is; adding again could
86
+ # add the item twice.
87
+ def put_signups(db: Db, p: Principal, items: List[Signup]) -> IO[Reply[ChangeRefused, List[Signup]]]:
88
+ Principal(uid, _) = p
89
+ outcome <- flat_map(transaction(db, _ -> tx_bind(tx_write(signups_rows, uid, items), _ -> tx_pure(items))))
90
+ pure(replied(outcome, Done, unsaved, TryAgain("the signups may not have been saved; send them again")))
91
+
92
+ shifts_rows: TxTable[s, Shift] = stored_table("shifts")
93
+
94
+ # A volunteer takes at most one spot on a shift.
95
+ def signed_up(items: List[Signup], shift_id: String) -> Bool:
96
+ items.foldl_List(False, (seen, Signup(id, _)) -> seen.or(eq_String(id, shift_id)))
97
+
98
+ def add_signup_plan[s](uid: String, item: Signup) -> Txn[s, ChangeRefused, List[Signup]]:
99
+ Signup(shift_id, _) = item
100
+ found <- tx_bind(tx_read(signups_rows, uid))
101
+ saved = match found:
102
+ case Some(saved): saved
103
+ case None: []
104
+ if signed_up(saved, shift_id):
105
+ tx_abort(AlreadySignedUp)
106
+ else:
107
+ shift_found <- tx_bind(tx_read(shifts_rows, shift_id))
108
+ match shift_found:
109
+ case None: tx_abort(NoSuchShift)
110
+ case Some(shift):
111
+ match take_spot(shift):
112
+ case None: tx_abort(ShiftFull)
113
+ case Some(updated):
114
+ _ <- tx_bind(tx_write(shifts_rows, shift_id, updated))
115
+ items = [item, *saved]
116
+ _ <- tx_bind(tx_write(signups_rows, uid, items))
117
+ tx_pure(items)
118
+
119
+ def add_signup(db: Db, p: Principal, item: Signup) -> IO[Reply[ChangeRefused, List[Signup]]]:
120
+ Principal(uid, _) = p
121
+ outcome <- flat_map(transaction(db, _ -> add_signup_plan(uid, item)))
122
+ pure(replied(outcome, Done, unsaved, Refused("the signup may already be saved; list the signups before adding it again", MaybeSaved)))
123
+
124
+ def clear_signups(db: Db, p: Principal) -> IO[Reply[ChangeRefused, Unit]]:
125
+ Principal(uid, _) = p
126
+ outcome <- flat_map(transaction(db, _ -> tx_delete(signups_rows, uid)))
127
+ pure(replied(outcome, Done, unsaved, TryAgain("the signups may not have been cleared; send the request again")))
128
+
129
+ signups_api = service_def(
130
+ "signups",
131
+ [
132
+ route("/shifts/get", handler("get_shift", get_shift), [ReadPerm("shifts")]),
133
+ route("/shifts", handler("list_shifts", list_shifts), [QueryPerm("shifts")]),
134
+ route("/signups", handler("list_signups", list_signups), [ReadPerm("signups")]),
135
+ route("/signups/put", handler("put_signups", put_signups), [WritePerm("signups")]),
136
+ route("/signups/add", handler("add_signup", add_signup), [ReadPerm("shifts"), WritePerm("shifts"), ReadPerm("signups"), WritePerm("signups")]),
137
+ route("/signups/clear", handler("clear_signups", clear_signups), [DeletePerm("signups")]),
138
+ ]
139
+ )
@@ -1,6 +1,6 @@
1
1
  # Failure scenarios for an action
2
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.
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. It also asks for one for a rule the person gave when other actions change the same records as the actions that decide with the rule's laws (see below). You write the case as JSON at the path `yichus_next` names (`.yichus/cases/<question>.json`, or `.yichus/cases/rule-<the rule's words>.json`). Each `yichus_next` runs it with `api_protocol_check` against the program in the folder. You never run a command for it.
4
4
 
5
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
6
 
@@ -8,4 +8,10 @@ Observation predicates the case names are Bosatsu functions. Put them in `.yichu
8
8
 
9
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
10
 
11
+ ## A rule across actions
12
+
13
+ A law checks the function it calls, so it speaks only for the actions that decide with that function. When another action changes the same records without it (a starter action that replaces or clears a list, beside the sign-up that checks the spots left), the law still passes while that action goes around the rule. So the studio asks for a scenario that sends a request to every one of those actions, from starting records where the rule matters, with the rule as an invariant over the records after every step. A case that leaves one of the actions out is not run.
14
+
15
+ For a limit, the invariant says that what a record counts matches the records it counts, and never passes the limit, and it fails when a record it names is missing, so it never passes by finding nothing. `signups.bosatsu`, `signups-laws.bosatsu`, `signups-case.json` and `spots.bosatsu` are a complete example: signing up checks the spots left and turns away a second sign-up for the same shift, cancelling gives the spot back, and the case holds. The case starts from one shift with a spot left and one that is full, so both halves of the invariant can fail: it fails on `mutants/double-signup.bosatsu` (one volunteer takes two spots) and on `mutants/overfill.bosatsu` (a sign-up that never checks the spots left). `near-miss-signups.bosatsu` (`near-miss/signups.bosatsu` in the repository) with `near-miss-case.json` is the same app with the starter actions that replace or clear a volunteer's list, and the case fails there.
16
+
11
17
  `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.