@marktiderman/genesis-forms 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/conformance/after-class-back-after-a-jump.scenario.json +217 -0
  4. package/conformance/after-class-contact-yes-completed.scenario.json +230 -0
  5. package/conformance/after-class-contact-yes-requires-name-and-email.scenario.json +218 -0
  6. package/conformance/after-class-happy-path.scenario.json +237 -0
  7. package/conformance/after-class-invalid-email-rejected.scenario.json +227 -0
  8. package/conformance/after-class-low-rating-skips-went-well.scenario.json +223 -0
  9. package/conformance/after-class-server-drops-unknown-and-hidden-answers.scenario.json +236 -0
  10. package/conformance/after-class-server-ignores-a-client-sent-coach.scenario.json +219 -0
  11. package/conformance/contact-details-anonymous-form-skips-identifying-fields.scenario.json +125 -0
  12. package/conformance/contact-details-named-form-asks-for-identifying-fields.scenario.json +129 -0
  13. package/conformance/monthly-pulse-default-ending.scenario.json +148 -0
  14. package/conformance/monthly-pulse-promoter-ending-via-jump.scenario.json +128 -0
  15. package/dist/engine-Cp5t2_Am.d.ts +75 -0
  16. package/dist/engine-LsPRTzt3.d.cts +75 -0
  17. package/dist/index.cjs +1205 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.cts +374 -0
  20. package/dist/index.d.ts +374 -0
  21. package/dist/index.js +1174 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/server.cjs +988 -0
  24. package/dist/server.cjs.map +1 -0
  25. package/dist/server.d.cts +52 -0
  26. package/dist/server.d.ts +52 -0
  27. package/dist/server.js +984 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/testing.cjs +1299 -0
  30. package/dist/testing.cjs.map +1 -0
  31. package/dist/testing.d.cts +129 -0
  32. package/dist/testing.d.ts +129 -0
  33. package/dist/testing.js +1293 -0
  34. package/dist/testing.js.map +1 -0
  35. package/dist/validate-j8Z188mF.d.cts +175 -0
  36. package/dist/validate-j8Z188mF.d.ts +175 -0
  37. package/package.json +79 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-forms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Genesis Forms engine. A form is plain JSON: blocks, logic, endings and typed hidden inputs. It
4
+ can be stored, diffed, versioned, and evaluated identically in a browser, in React Native and on a
5
+ server. Zero dependencies.
6
+
7
+ **Status: 0.1, release R1.** Field types: `short-text`, `long-text`, `email`, `phone`, `rating`,
8
+ `nps`, `single-choice`, `multi-choice`, `yes-no`. Logic: show, hide, jump to a field or an ending. Layouts
9
+ (rendered by `genesis-forms-web`): focus (one question at a time) and classic. The full plan is in
10
+ [docs/FORMS.md](../../docs/FORMS.md).
11
+
12
+ ```ts
13
+ import { defineForm, f, when } from "@marktiderman/genesis-forms";
14
+
15
+ export const afterClass = defineForm({
16
+ id: "after-class",
17
+ title: "How was class?",
18
+ layout: { mode: "focus" },
19
+ hidden: { coach: f.hidden.string({ source: "server" }) },
20
+ blocks: [
21
+ f.rating("overall", { label: "How was today's class?", required: true }),
22
+ f.longText("improve", { label: "Anything we could do better?" }),
23
+ f.yesNo("contact_me", { label: "Want {{hidden:coach|a coach}} to follow up?" }),
24
+ f.email("email", { label: "Best email to reach you", required: true }),
25
+ ],
26
+ logic: [when("contact_me").isNot(true).hide("email")],
27
+ endings: [{ id: "default", title: "Thanks!" }],
28
+ });
29
+
30
+ type Answers = typeof afterClass.$answers; // { overall?: number; improve?: string; … }
31
+ const json = afterClass.toJSON(); // the canonical definition
32
+ const version = afterClass.hash(); // content fingerprint for publishing
33
+ ```
34
+
35
+ ## The rules it keeps
36
+
37
+ - **Answers are keyed by field id**, never by label or position. `id` defaults to the key; set it
38
+ explicitly before renaming a key on a live form.
39
+ - **The path is derived from the answers on the path**, so the client, the server and a replay
40
+ always agree. It is built in one forward pass: a block's rules, and the jumps from it, see only the
41
+ fields already on the path before it. A field that is hidden, jumped over or later in the form
42
+ reads as unanswered, whatever value it still holds. A field jumped over counts as hidden: it is
43
+ not validated, and its value is cleared on submit.
44
+ - **Empty is unanswered.** An empty value (unanswered, `null`, blank text, an empty list) fails
45
+ `eq`, `in` and `contains` and passes `neq`: `eq null` is never true for a cleared field.
46
+ - **Hidden inputs are typed and sourced.** `url` and `host` inputs are untrusted; `server` inputs are
47
+ never read from the client. `coerceHidden(definition, raw)` types them, in `createForm` and on the
48
+ server alike: only string, number and boolean primitives, `""` is not a number, strings at most
49
+ 1000 characters.
50
+ - **Everything is in the event log.** `initialValues` are logged as leading `answer` events with
51
+ `source: "prefill"`, so `replay` and the server see them.
52
+ - **Anonymous forms withhold identifying fields.** `settings.anonymous` is a per-form admin
53
+ setting, off by default. When it is true, fields marked `identifying: true` are treated as hidden
54
+ (off the path, not validated, cleared on submit), the server drops client-sent values for them and
55
+ lists them in `ignored`, and events carry no `at` timestamps whatever clock is passed. Fields not
56
+ marked `identifying` are unaffected.
57
+ - **Definitions are checked.** `defineForm` runs `parseForm` and throws with every issue;
58
+ `validateSubmission` refuses a definition that does not parse. Ids, keys, hidden names and ending
59
+ ids may not be `Object.prototype` names (`constructor`, `__proto__`…).
60
+ - **Unanswered recall renders its fallback** (`{{field:name|there}}`); the text is never dropped.
61
+
62
+ ## Pieces
63
+
64
+ | Export | What it does |
65
+ | --- | --- |
66
+ | `defineForm`, `f`, `when` | Author a form in code, with inferred answer types. |
67
+ | `parseForm(json)` | Check a stored or built definition: structure, text fields, references, operators, recall. Never throws. |
68
+ | `createForm(def)` | The engine: values, visibility, path, Next/Back with history, validation, submit, an event log. Framework-agnostic `subscribe` / `getState`. |
69
+ | `replay(def, events)` | Rebuild a response from its event log. |
70
+ | `registerFieldType` | Add a field type. Non-JSON values must provide `serialize` and `deserialize`; `normalize` sets the stored form (email trims). A field whose type is not registered accepts no answer. |
71
+ | `coerceHidden(def, raw, { sources })` | Type hidden inputs by their declarations; reports dropped names in `ignored`. |
72
+ | `isFormEvent(value)` | Guard for one well-formed event-log entry. |
73
+ | `isSensitive(block)` | `sensitive: true` on a field, or the `props.sensitive` alias. Hidden inputs take `sensitive: true` too (`f.hidden.string({ source: "server", sensitive: true })`); devtools masks both. |
74
+ | `/server` → `validateSubmission`, `publicDefinition` | Re-check a submission against the published definition: drops unknown and logic-hidden answers, ignores client values for server inputs, checks drafts by format only. Never throws on a malformed body. No IO. |
75
+ | `explain(engine, blockId)` | "Why is this hidden?": the path, the jump that skipped a block, each show/hide rule with its result in plain English, and the field's validation. |
76
+ | `/testing` → `simulate`, `coverage`, `runScenario`, `recordScenario`, `compactEvents` | Walk a form by key, enumerate its paths, and run JSON scenarios. See [Testing](#testing). |
77
+
78
+ `f.phone(key, opts)` stores a string: trimmed, an optional leading `+`, digits and the separators
79
+ space, `-`, `(`, `)` and `.`, 7 to 15 digits, at most 32 characters. Operators: `eq`, `neq`,
80
+ `answered`, `empty`. Its manifest carries `autocomplete: "tel"` and `inputMode: "tel"` (email:
81
+ `"email"` for both).
82
+
83
+ ## On the server
84
+
85
+ ```ts
86
+ import { validateSubmission } from "@marktiderman/genesis-forms/server";
87
+
88
+ const result = validateSubmission(publishedDefinition, await request.json(), {
89
+ context: { coach: coachForNow() }, // server inputs, resolved here
90
+ });
91
+ if (!result.ok) return Response.json({ errors: result.errors }, { status: 422 });
92
+ await store(result.submission); // answers, hidden, context, ending
93
+ ```
94
+
95
+ Errors are keyed by field id. The `""` key is for the whole submission: `"Malformed submission."`
96
+ (a body of the wrong shape), a definition that fails `parseForm`, or more than `maxEvents` events
97
+ (default 10 000, counted after `compactEvents`). Events of an unknown type are dropped, so a newer
98
+ client's log still replays. With `settings.clearHiddenValues: false`, a hidden field's value is kept
99
+ only if its format is valid.
100
+
101
+ ## Testing
102
+
103
+ ```ts
104
+ import { coverage, recordScenario, runScenario, simulate } from "@marktiderman/genesis-forms/testing";
105
+
106
+ // Walk a form by key.
107
+ expect(simulate(afterClass).answer({ overall: 2 }).pathKeys()).toEqual(["overall", "improve", "contact_me"]);
108
+
109
+ // Every block reachable, every rule able to fire, every ending reached.
110
+ const report = coverage(afterClass);
111
+ expect(report.unreachableBlocks).toEqual([]);
112
+ expect(report.rulesNeverFired).toEqual([]);
113
+ expect(report.endings.default.reached).toBe(true); // witness: answers that reach it
114
+ ```
115
+
116
+ `coverage` enumerates paths symbolically: each field gets a small domain (rating `1..max`, NPS
117
+ `0..10`, yes/no, each choice option, a sample text, "unanswered" unless required) plus the values
118
+ the logic compares it with, and a depth-first search answers fields in path order. It stops at
119
+ `maxStates` (default 5000) and then reports `truncated: true`. A completed response with no jump to
120
+ an ending reaches the default ending. A plugin type gives coverage its answers through the manifest's
121
+ `samples(field)`. A field with no valid sample answer is listed in `unsampledFields`; coverage
122
+ still steers the logic down each branch on it (unanswered, the compared values, some answer), so its
123
+ blocks and rules count, but an ending found only that way stays `reached: false` with `needsSamples`.
124
+
125
+ **Scenarios** are versioned JSON (`scenarioVersion: 1`): a definition, hidden inputs, server
126
+ `context`, an event log and the expected `path`, `visible`, `current`, `status`, `ending`,
127
+ `answers`, client `errors` and server verdict (all by key). `runScenario(s)` replays the events
128
+ with the clock off, compares, and runs `validateSubmission` (as completed when the replay
129
+ completed, or as `expect.server.complete` says). `recordScenario(engine, name)` freezes a live
130
+ engine as a scenario with its expectations filled in.
131
+
132
+ Text inputs log one answer event per keystroke. `compactEvents(events)` merges consecutive answers
133
+ to the same field, keeping the last value; the result replays to the same state because the path
134
+ depends on values alone, not on how they were typed. `recordScenario` compacts by default (`compact: false` keeps every event).
135
+
136
+ The **conformance suite** ships in the package: `@marktiderman/genesis-forms/conformance/*.scenario.json`
137
+ (the R1 sample forms: after-class and monthly-pulse). Run each file through `runScenario` to hold
138
+ another runtime, a renderer or an app's CI to the same behaviour. The fixtures are generated by
139
+ `pnpm --filter @marktiderman/genesis-forms conformance:build`; review the diff before committing.
140
+
141
+ Two engine rules the suite pins down: failed `next` and `submit` attempts are logged, so a replay
142
+ shows the same errors; and a completed response is final, so edits after submit are ignored by the
143
+ engine and by the server's replay.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,217 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "after-class: back after a jump",
4
+ "description": "Back from a jump target returns to the block the jump came from; raising the rating puts went_well back on the path.",
5
+ "tags": [
6
+ "after-class",
7
+ "jump",
8
+ "navigation"
9
+ ],
10
+ "definition": {
11
+ "schemaVersion": 1,
12
+ "id": "after-class",
13
+ "title": "How was class?",
14
+ "blocks": [
15
+ {
16
+ "kind": "field",
17
+ "id": "overall",
18
+ "key": "overall",
19
+ "type": "rating",
20
+ "label": "How was today's class?",
21
+ "required": true,
22
+ "props": {
23
+ "max": 5
24
+ }
25
+ },
26
+ {
27
+ "kind": "field",
28
+ "id": "went_well",
29
+ "key": "went_well",
30
+ "type": "multi-choice",
31
+ "label": "What went well?",
32
+ "props": {
33
+ "options": [
34
+ {
35
+ "value": "coaching",
36
+ "label": "Coaching"
37
+ },
38
+ {
39
+ "value": "workout",
40
+ "label": "The workout"
41
+ },
42
+ {
43
+ "value": "community",
44
+ "label": "Community"
45
+ },
46
+ {
47
+ "value": "scaling",
48
+ "label": "Scaling options"
49
+ },
50
+ {
51
+ "value": "equipment",
52
+ "label": "Equipment"
53
+ },
54
+ {
55
+ "value": "cleanliness",
56
+ "label": "Cleanliness"
57
+ }
58
+ ]
59
+ }
60
+ },
61
+ {
62
+ "kind": "field",
63
+ "id": "improve",
64
+ "key": "improve",
65
+ "type": "long-text",
66
+ "label": "Anything we could do better?"
67
+ },
68
+ {
69
+ "kind": "field",
70
+ "id": "contact_me",
71
+ "key": "contact_me",
72
+ "type": "yes-no",
73
+ "label": "Want {{hidden:coach|a coach}} to follow up with you?"
74
+ },
75
+ {
76
+ "kind": "field",
77
+ "id": "name",
78
+ "key": "name",
79
+ "type": "short-text",
80
+ "label": "Your name",
81
+ "required": true
82
+ },
83
+ {
84
+ "kind": "field",
85
+ "id": "email",
86
+ "key": "email",
87
+ "type": "email",
88
+ "label": "Best email to reach you",
89
+ "required": true
90
+ }
91
+ ],
92
+ "layout": {
93
+ "mode": "focus"
94
+ },
95
+ "hidden": [
96
+ {
97
+ "name": "class",
98
+ "type": "string",
99
+ "source": "server"
100
+ },
101
+ {
102
+ "name": "coach",
103
+ "type": "string",
104
+ "source": "server"
105
+ }
106
+ ],
107
+ "logic": [
108
+ {
109
+ "id": "r1",
110
+ "when": {
111
+ "op": "neq",
112
+ "left": {
113
+ "field": "contact_me"
114
+ },
115
+ "right": {
116
+ "value": true
117
+ }
118
+ },
119
+ "effect": "hide",
120
+ "target": "name"
121
+ },
122
+ {
123
+ "id": "r2",
124
+ "when": {
125
+ "op": "neq",
126
+ "left": {
127
+ "field": "contact_me"
128
+ },
129
+ "right": {
130
+ "value": true
131
+ }
132
+ },
133
+ "effect": "hide",
134
+ "target": "email"
135
+ },
136
+ {
137
+ "id": "r3",
138
+ "when": {
139
+ "op": "lt",
140
+ "left": {
141
+ "field": "overall"
142
+ },
143
+ "right": {
144
+ "value": 4
145
+ }
146
+ },
147
+ "effect": "jump",
148
+ "from": "overall",
149
+ "to": "improve"
150
+ }
151
+ ],
152
+ "endings": [
153
+ {
154
+ "id": "default",
155
+ "title": "Thanks! See you at the next class."
156
+ }
157
+ ],
158
+ "settings": {
159
+ "anonymous": true
160
+ }
161
+ },
162
+ "context": {
163
+ "class": "6:00 AM CrossFit",
164
+ "coach": "Sam"
165
+ },
166
+ "events": [
167
+ {
168
+ "type": "answer",
169
+ "field": "overall",
170
+ "value": 2
171
+ },
172
+ {
173
+ "type": "next"
174
+ },
175
+ {
176
+ "type": "back"
177
+ },
178
+ {
179
+ "type": "answer",
180
+ "field": "overall",
181
+ "value": 5
182
+ },
183
+ {
184
+ "type": "next"
185
+ }
186
+ ],
187
+ "expect": {
188
+ "path": [
189
+ "overall",
190
+ "went_well",
191
+ "improve",
192
+ "contact_me"
193
+ ],
194
+ "visible": [
195
+ "overall",
196
+ "went_well",
197
+ "improve",
198
+ "contact_me"
199
+ ],
200
+ "current": "went_well",
201
+ "status": "in_progress",
202
+ "ending": null,
203
+ "answers": {
204
+ "overall": 5
205
+ },
206
+ "server": {
207
+ "ok": true,
208
+ "answers": {
209
+ "overall": 5
210
+ },
211
+ "context": {
212
+ "class": "6:00 AM CrossFit",
213
+ "coach": "Sam"
214
+ }
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,230 @@
1
+ {
2
+ "scenarioVersion": 1,
3
+ "name": "after-class: contact yes, completed",
4
+ "description": "With name and email filled in, the follow-up request completes and both are stored.",
5
+ "tags": [
6
+ "after-class",
7
+ "show-hide"
8
+ ],
9
+ "definition": {
10
+ "schemaVersion": 1,
11
+ "id": "after-class",
12
+ "title": "How was class?",
13
+ "blocks": [
14
+ {
15
+ "kind": "field",
16
+ "id": "overall",
17
+ "key": "overall",
18
+ "type": "rating",
19
+ "label": "How was today's class?",
20
+ "required": true,
21
+ "props": {
22
+ "max": 5
23
+ }
24
+ },
25
+ {
26
+ "kind": "field",
27
+ "id": "went_well",
28
+ "key": "went_well",
29
+ "type": "multi-choice",
30
+ "label": "What went well?",
31
+ "props": {
32
+ "options": [
33
+ {
34
+ "value": "coaching",
35
+ "label": "Coaching"
36
+ },
37
+ {
38
+ "value": "workout",
39
+ "label": "The workout"
40
+ },
41
+ {
42
+ "value": "community",
43
+ "label": "Community"
44
+ },
45
+ {
46
+ "value": "scaling",
47
+ "label": "Scaling options"
48
+ },
49
+ {
50
+ "value": "equipment",
51
+ "label": "Equipment"
52
+ },
53
+ {
54
+ "value": "cleanliness",
55
+ "label": "Cleanliness"
56
+ }
57
+ ]
58
+ }
59
+ },
60
+ {
61
+ "kind": "field",
62
+ "id": "improve",
63
+ "key": "improve",
64
+ "type": "long-text",
65
+ "label": "Anything we could do better?"
66
+ },
67
+ {
68
+ "kind": "field",
69
+ "id": "contact_me",
70
+ "key": "contact_me",
71
+ "type": "yes-no",
72
+ "label": "Want {{hidden:coach|a coach}} to follow up with you?"
73
+ },
74
+ {
75
+ "kind": "field",
76
+ "id": "name",
77
+ "key": "name",
78
+ "type": "short-text",
79
+ "label": "Your name",
80
+ "required": true
81
+ },
82
+ {
83
+ "kind": "field",
84
+ "id": "email",
85
+ "key": "email",
86
+ "type": "email",
87
+ "label": "Best email to reach you",
88
+ "required": true
89
+ }
90
+ ],
91
+ "layout": {
92
+ "mode": "focus"
93
+ },
94
+ "hidden": [
95
+ {
96
+ "name": "class",
97
+ "type": "string",
98
+ "source": "server"
99
+ },
100
+ {
101
+ "name": "coach",
102
+ "type": "string",
103
+ "source": "server"
104
+ }
105
+ ],
106
+ "logic": [
107
+ {
108
+ "id": "r1",
109
+ "when": {
110
+ "op": "neq",
111
+ "left": {
112
+ "field": "contact_me"
113
+ },
114
+ "right": {
115
+ "value": true
116
+ }
117
+ },
118
+ "effect": "hide",
119
+ "target": "name"
120
+ },
121
+ {
122
+ "id": "r2",
123
+ "when": {
124
+ "op": "neq",
125
+ "left": {
126
+ "field": "contact_me"
127
+ },
128
+ "right": {
129
+ "value": true
130
+ }
131
+ },
132
+ "effect": "hide",
133
+ "target": "email"
134
+ },
135
+ {
136
+ "id": "r3",
137
+ "when": {
138
+ "op": "lt",
139
+ "left": {
140
+ "field": "overall"
141
+ },
142
+ "right": {
143
+ "value": 4
144
+ }
145
+ },
146
+ "effect": "jump",
147
+ "from": "overall",
148
+ "to": "improve"
149
+ }
150
+ ],
151
+ "endings": [
152
+ {
153
+ "id": "default",
154
+ "title": "Thanks! See you at the next class."
155
+ }
156
+ ],
157
+ "settings": {
158
+ "anonymous": true
159
+ }
160
+ },
161
+ "context": {
162
+ "class": "6:00 AM CrossFit",
163
+ "coach": "Sam"
164
+ },
165
+ "events": [
166
+ {
167
+ "type": "answer",
168
+ "field": "overall",
169
+ "value": 5
170
+ },
171
+ {
172
+ "type": "answer",
173
+ "field": "contact_me",
174
+ "value": true
175
+ },
176
+ {
177
+ "type": "answer",
178
+ "field": "name",
179
+ "value": "Ada"
180
+ },
181
+ {
182
+ "type": "answer",
183
+ "field": "email",
184
+ "value": "ada@example.com"
185
+ },
186
+ {
187
+ "type": "submit"
188
+ }
189
+ ],
190
+ "expect": {
191
+ "path": [
192
+ "overall",
193
+ "went_well",
194
+ "improve",
195
+ "contact_me",
196
+ "name",
197
+ "email"
198
+ ],
199
+ "visible": [
200
+ "overall",
201
+ "went_well",
202
+ "improve",
203
+ "contact_me",
204
+ "name",
205
+ "email"
206
+ ],
207
+ "current": "overall",
208
+ "status": "completed",
209
+ "ending": "default",
210
+ "answers": {
211
+ "overall": 5,
212
+ "contact_me": true,
213
+ "name": "Ada",
214
+ "email": "ada@example.com"
215
+ },
216
+ "server": {
217
+ "ok": true,
218
+ "answers": {
219
+ "overall": 5,
220
+ "contact_me": true,
221
+ "name": "Ada",
222
+ "email": "ada@example.com"
223
+ },
224
+ "context": {
225
+ "class": "6:00 AM CrossFit",
226
+ "coach": "Sam"
227
+ }
228
+ }
229
+ }
230
+ }