@superbuilders/primer-tives 0.4.0 → 0.6.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 CHANGED
@@ -1,8 +1,19 @@
1
1
  # @superbuilders/primer-tives
2
2
 
3
- Client SDK for the Primer adaptive learning engine. Drives a state machine over HTTP POST — the client renders frames, the server controls all routing and assessment.
3
+ Client SDK for the Primer adaptive learning engine.
4
4
 
5
- The SDK has no concept of routines, curricula, or backend storage structure. It advances a student through whatever the server decides to serve next.
5
+ > **Breaking change**: Auth is a server-minted Primer JWT access token. `create()` takes `accessToken` (not `publishableKey`), and `client.start()` is parameterless the student identity lives in the token's `sub` claim. Mint tokens on your backend via `POST /api/v0/auth/exchange` with your `sk_` and either a Primer-minted `student_id` (provider `native`) or a Timeback OneRoster `sourcedId` (provider `timeback`). The exchange body now uses `student_id` in place of the old `sourced_id`.
6
+
7
+ This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
8
+
9
+ The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
10
+
11
+ - start a student session
12
+ - submit answers or timeouts
13
+ - normalize transport failures into sentinel errors plus `retriable` semantics
14
+ - expose the current step as an ergonomic TypeScript union
15
+ - expose interaction-native review data for feedback rendering
16
+ - preserve full type safety for portable custom interactions (PCIs)
6
17
 
7
18
  ## Install
8
19
 
@@ -12,19 +23,41 @@ bun add @superbuilders/primer-tives
12
23
 
13
24
  Dependency: `@superbuilders/errors` is installed automatically.
14
25
 
26
+ ## What you get
27
+
28
+ - **Single entry point**: `create(config)`
29
+ - **Single wire endpoint**: `POST ${origin}/api/v0/advance`
30
+ - **Single runtime model**: `PrimerState`
31
+ - **Six phases/kinds to handle**:
32
+ - phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
33
+ - interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
34
+ - **Typed PCI submissions** driven by the `PciRegistry`
35
+ - **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
36
+
15
37
  ## Quick start
16
38
 
17
39
  ```ts
18
- import { create, ErrRateLimited } from "@superbuilders/primer-tives"
19
40
  import * as errors from "@superbuilders/errors"
20
-
41
+ import {
42
+ create,
43
+ ErrRateLimited,
44
+ type PrimerState,
45
+ } from "@superbuilders/primer-tives"
46
+
47
+ // Mint accessToken on your backend via POST /api/v0/auth/exchange
48
+ // (sk_ + { provider, student_id }) then pass it here. Tokens are short-lived
49
+ // (15 min default).
21
50
  const client = create({
22
- publishableKey: "pk_live_abc123",
51
+ accessToken: "<primer JWS minted by /auth/exchange>",
23
52
  origin: "https://sb-primer.vercel.app",
24
- supportedPcis: ["urn:primer:pci:division-remainder"],
53
+ supportedPcis: [
54
+ "urn:primer:pci:division-remainder",
55
+ "urn:primer:pci:fraction-addition",
56
+ ],
57
+ subject: "math",
25
58
  })
26
59
 
27
- let state = await client.start("student-uuid")
60
+ let state: PrimerState = await client.start()
28
61
 
29
62
  while (state.phase !== "completed" && state.phase !== "fatal") {
30
63
  switch (state.phase) {
@@ -34,32 +67,64 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
34
67
  break
35
68
 
36
69
  case "interaction":
70
+ renderStimulus(state.stimulus)
71
+
37
72
  switch (state.kind) {
38
73
  case "choice":
39
74
  state = await state.submitChoice(["option-a"])
40
75
  break
76
+
41
77
  case "text-entry":
42
78
  state = await state.submitText("42")
43
79
  break
80
+
44
81
  case "extended-text":
45
82
  if (state.cardinality === "single") {
46
83
  state = await state.submitText("answer")
47
84
  } else {
48
- state = await state.submitTexts(["a", "b"])
85
+ state = await state.submitTexts(["first", "second"])
49
86
  }
50
87
  break
88
+
89
+ case "order":
90
+ state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
91
+ break
92
+
93
+ case "match":
94
+ state = await state.submitMatch([
95
+ { source: "left-1", target: "right-2" },
96
+ { source: "left-2", target: "right-1" },
97
+ ])
98
+ break
99
+
51
100
  case "portable-custom":
52
- state = await state.submit({ quotient: "3", remainder: "1" })
101
+ switch (state.pciId) {
102
+ case "urn:primer:pci:division-remainder":
103
+ state = await state.submit({ quotient: "3", remainder: "1" })
104
+ break
105
+ case "urn:primer:pci:fraction-addition":
106
+ state = await state.submit({ numerator: "5", denominator: "6" })
107
+ break
108
+ }
53
109
  break
54
110
  }
55
111
  break
56
112
 
57
113
  case "feedback":
58
- renderFeedback(state.isCorrect, state.feedbackContent, state.correctAnswer)
114
+ renderFeedback({
115
+ correct: state.isCorrect,
116
+ content: state.feedbackContent,
117
+ review: state.review,
118
+ interaction: state.interaction,
119
+ submission: state.submission,
120
+ })
59
121
  state = await state.advance()
60
122
  break
61
123
 
62
124
  case "errored":
125
+ if (!state.retriable) {
126
+ throw state.error
127
+ }
63
128
  if (errors.is(state.error, ErrRateLimited)) {
64
129
  await delay(1000)
65
130
  }
@@ -69,229 +134,499 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
69
134
  }
70
135
  ```
71
136
 
72
- Both `state.phase` and `state.kind` are discriminated unions TypeScript narrows the type in each `case` branch automatically. The outer switch on `phase` is exhaustive over the 6 states (`observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`). The inner switch on `kind` is exhaustive over the 4 interaction types (`choice`, `text-entry`, `extended-text`, `portable-custom`).
137
+ Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
73
138
 
74
- ## Wire protocol
139
+ ## Mental model
75
140
 
76
- Every request is a POST to `https://${origin}/api/v0/advance`:
141
+ Primer is **server-authored**.
77
142
 
78
- ```
79
- POST /api/v0/advance
80
- Header: Authorization: Bearer pk_...
81
- Body: { studentId, supportedPcis, intent }
82
- ```
143
+ - The client sends an **intent**: advance, submit, or timeout.
144
+ - The server evaluates that intent and returns the next frame.
145
+ - The SDK wraps that frame in a typed state object.
146
+ - Your UI renders the state and calls the next valid method.
83
147
 
84
- The `origin` field is required and must be the full Primer API base URL (e.g., `https://sb-primer.vercel.app`). All requests are made as absolute URLs so the browser sends the `Origin` header for server-side origin validation. The path constant `ADVANCE_PATH` is exported for reference.
85
-
86
- The server identifies the student, determines which content to serve based on the frontend's assigned courses, evaluates submissions, and returns the next frame. The client sends:
87
-
88
- - `studentId` — who is being advanced
89
- - `supportedPcis` — which custom interaction types the renderer can handle
90
- - `intent` — `{ kind: "observation" }`, `{ kind: "interaction", submission: ... }`, or `{ kind: "timeout" }`
91
-
92
- Course resolution is entirely server-side. The publishable key identifies the frontend, and the server looks up which courses are assigned to that frontend. The client has no knowledge of courses, subjects, or form factors.
148
+ In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
93
149
 
94
150
  ## Configuration
95
151
 
96
152
  ```ts
97
153
  interface Config<Pcis extends PciId = PciId> {
98
- readonly publishableKey: string
154
+ readonly accessToken: string
99
155
  readonly supportedPcis: readonly Pcis[]
100
156
  readonly origin: string
157
+ readonly subject: Subject | "all"
101
158
  readonly fetch?: typeof globalThis.fetch
102
159
  readonly abort?: AbortController
103
160
  readonly logger?: PrimerLogger
104
161
  }
162
+
163
+ type Subject = "math" | "vocabulary"
105
164
  ```
106
165
 
107
166
  | Field | Required | Description |
108
167
  |---|---|---|
109
- | `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` header |
110
- | `supportedPcis` | yes | PCI URNs the renderer handles. `[]` if none |
111
- | `origin` | yes | Base URL for the Primer API (e.g., `https://sb-primer.vercel.app`). All requests are cross-origin to ensure the browser sends the Origin header for server-side validation |
112
- | `fetch` | no | Custom fetch. Defaults to `globalThis.fetch` |
113
- | `abort` | no | AbortController for cancelling in-flight requests |
114
- | `logger` | no | Structured logger (`debug`, `info`, `warn`, `error`) |
168
+ | `accessToken` | yes | Primer JWT minted by your backend via `POST /api/v0/auth/exchange`. Sent as `Authorization: Bearer <jwt>`. Short-lived (15 min default). |
169
+ | `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
170
+ | `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
171
+ | `subject` | yes | Subject scope for this session. `"math"` or `"vocabulary"` restricts drill selection to courses of that subject; `"all"` disables the filter |
172
+ | `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
173
+ | `abort` | no | `AbortController` whose `signal` is passed to every request |
174
+ | `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
175
+
176
+ `create()` does a cheap structural check on the token (must start with `eyJ` and contain exactly two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
177
+
178
+ ### What `subject` controls
115
179
 
116
- `create()` validates the key prefix immediately throws `ErrMalformedPublishableKey` if invalid.
180
+ `subject` is sent on every `/advance` request and narrows the set of drill courses the server can serve:
181
+
182
+ - `"math"` / `"vocabulary"` — only drills whose `drill_courses.subject` matches are eligible. The bootstrap router, trigger-driven re-routes, and active-frame lookups all respect the scope.
183
+ - `"all"` — no filter; the server treats the session as federated and may serve drills from any subject the frontend is bound to.
184
+
185
+ The scope is **per-session**. To switch subjects, construct a new client with a different `subject`; you cannot mutate it on an existing client. Student placements are isolated per subject: a student with an in-progress math placement resumes it when a math-scoped client reconnects, and a vocabulary-scoped client will bootstrap a new placement in that subject without disturbing the math one. If a student submits or times out under a scope that does not match their current placement, the server reports no active frame and the client's next observation bootstraps a fresh placement in the declared subject.
186
+
187
+ Curriculum routing is not affected by `subject` in this release — only drill selection is scoped.
188
+
189
+ ### Credentials
190
+
191
+ Each integration has one credential pair:
192
+
193
+ - **`client_id`** — your Primer frontend ID (a UUIDv7). Not currently required in request bodies; it's embedded in the `sk_` record server-side and surfaced as the `tenant_id` claim on minted JWTs.
194
+ - **`client_secret`** — your `sk_` secret key. Sent as `Authorization: Bearer sk_…` on both `/api/v0/students` and `/api/v0/auth/exchange`. Stored only as a SHA-256 hash at rest — the raw value is returned exactly once at generation time.
195
+
196
+ Never expose `sk_` keys to the browser. Use them only from your backend.
197
+
198
+ ### Minting a student (native provider)
199
+
200
+ If your integration does not front a Timeback OneRoster tenant, mint a Primer-native student for each of your users and persist the returned `student_id`. Your users exist in your system; Primer just needs a stable UUID to attach session state to.
201
+
202
+ ```sh
203
+ curl -X POST "$ORIGIN/api/v0/students" \
204
+ -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
205
+ -H "Content-Type: application/json" \
206
+ -d '{"grade_level":"9"}'
207
+ ```
208
+
209
+ Response:
210
+
211
+ ```json
212
+ { "student_id": "019d3e6f-5f6d-7000-8000-..." }
213
+ ```
214
+
215
+ - `grade_level` is required. Valid values: `"K"`, `"1"`, `"2"`, …, `"12"`. Primer routes lessons by grade, so it must be set at mint time.
216
+ - The returned `student_id` is owned by the frontend the `sk_` belongs to. Another frontend's `sk_` cannot exchange for it.
217
+ - Store the `student_id` in your own database keyed by your user. It does not change.
218
+
219
+ ### Minting an access token
220
+
221
+ `POST /api/v0/auth/exchange` takes your `sk_` bearer and a body describing which student you want a token for.
222
+
223
+ **Native** (you minted the student via `POST /api/v0/students` above):
224
+
225
+ ```sh
226
+ curl -X POST "$ORIGIN/api/v0/auth/exchange" \
227
+ -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
228
+ -H "Content-Type: application/json" \
229
+ -d '{"provider":"native","student_id":"<primer-uuid-you-stored>"}'
230
+ ```
231
+
232
+ **Timeback** (`student_id` carries a OneRoster `sourcedId`):
233
+
234
+ ```sh
235
+ curl -X POST "$ORIGIN/api/v0/auth/exchange" \
236
+ -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
237
+ -H "Content-Type: application/json" \
238
+ -d '{"provider":"timeback","student_id":"<oneroster-sourced-id>"}'
239
+ ```
240
+
241
+ The request body is `.strict()` — both `provider` and `student_id` are required and the field names are snake_case. A successful response is:
242
+
243
+ ```ts
244
+ {
245
+ access_token: string, // HS256 JWS, use as Bearer for /api/v0/advance
246
+ token_type: "Bearer",
247
+ expires_in: 900, // seconds (15 min default)
248
+ scope: "frame:advance",
249
+ }
250
+ ```
251
+
252
+ Server-side flow:
253
+
254
+ 1. **Authenticate the `sk_`.** SHA-256 the bearer, look up `iam_frontend_secret_keys.key_hash` with `status = 'active'`. The matching `frontend_id` + `key_id` are captured for the token claims.
255
+ 2. **Resolve the student.**
256
+ - `provider: "native"` — join `iam_frontend_students` on `(frontend_id, student_id)`. If no row exists, return `404 student_not_found`. This stops one frontend from minting tokens against another frontend's UUIDs.
257
+ - `provider: "timeback"` — look up `iam_student_timeback_identities` by `sourced_id`. If a row exists, reuse the linked `iam_students.id`. Otherwise call Timeback OneRoster, map the grade, and insert `iam_students` + `iam_student_timeback_identities` rows in a single transaction. If OneRoster returns 404, return `404 student_not_found`.
258
+ 3. **Mint the JWS.** HS256 with claims `{ sub: <iam_students.id>, tenant_id: <frontend_id>, client_id: <key_id>, scope: "frame:advance", iss: "primer", aud: "primer-api", iat, exp, jti }`. Signed but not encrypted — tamper-proof, not opaque.
259
+
260
+ Error responses:
261
+
262
+ | Status | Body | Cause |
263
+ | ------ | ---------------------------- | --------------------------------------------------------------------- |
264
+ | 401 | `invalid_request` | Missing/malformed `sk_` bearer |
265
+ | 400 | `invalid_request` | Body failed schema validation |
266
+ | 404 | `student_not_found` | Native: no junction row. Timeback: OneRoster returned 404. |
267
+ | 400 | `unsupported_grade` | Timeback: OneRoster returned a grade we don't route. |
268
+ | 502 | `timeback_unavailable` | Timeback OneRoster / Cognito failure. |
269
+
270
+ ### Local dev (seeded fixtures)
271
+
272
+ The Primer repo ships a seed script that populates a self-contained dev tenant so SDK integrators can develop against a real `/api/v0/auth/exchange` without any Timeback/OneRoster credentials. The seed is idempotent: every run tears down and re-creates the `Primer Dev` frontend, re-hashes the current `env.PRIMER_SECRET_KEY`, deletes any previous dev students owned by this frontend, and mints a fresh one with a random UUIDv7.
273
+
274
+ Run the seed:
275
+
276
+ ```sh
277
+ bun db:seed
278
+ ```
279
+
280
+ This invokes `src/db/scripts/seed/index.ts`, which chains:
281
+
282
+ - `seedFrontend()` — inserts `iam_frontends { name: "Primer Dev" }` and `iam_frontend_secret_keys { key_hash: sha256(env.PRIMER_SECRET_KEY), key_preview, name: "dev-seed", status: "active" }`.
283
+ - `seedTestCourse(frontendId)` — re-inserts `iam_students { id: HARDCODED_USER.studentId }` (stable dev UUID `019d0000-0000-7000-8000-000000000001`) and an `iam_frontend_students { frontend_id, student_id }` native-ownership row, then binds the frontend to the dev curriculum course.
284
+
285
+ The dev environment is native-first; the seed does **not** create a `iam_student_timeback_identities` row. If you want to exercise the timeback exchange path against the sandbox, hit it with a real OneRoster `sourcedId` (the first call will provision the junction).
286
+
287
+ Three fixture values a client needs:
288
+
289
+ | Fixture | Value | Source |
290
+ | ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------- |
291
+ | Frontend name | `Primer Dev` | `DEV_FRONTEND_NAME` in `src/db/scripts/seed/constants.ts` |
292
+ | Secret key (`sk_`) | whatever `env.PRIMER_SECRET_KEY` is at seed time | hashed at rest; the raw value is unrecoverable after seeding, so keep it in `.env` |
293
+ | Dev student id | `019d0000-0000-7000-8000-000000000001` | `HARDCODED_USER.studentId` in `src/lib/auth/hardcoded-user.ts` |
294
+
295
+ Mint a native access token off the seeded student:
296
+
297
+ ```sh
298
+ curl -X POST "$ORIGIN/api/v0/auth/exchange" \
299
+ -H "Authorization: Bearer $PRIMER_SECRET_KEY" \
300
+ -H "Content-Type: application/json" \
301
+ -d '{"provider":"native","student_id":"019d0000-0000-7000-8000-000000000001"}'
302
+ ```
303
+
304
+ Expected response:
305
+
306
+ ```json
307
+ {
308
+ "access_token": "eyJ...",
309
+ "token_type": "Bearer",
310
+ "expires_in": 900,
311
+ "scope": "frame:advance"
312
+ }
313
+ ```
314
+
315
+ Hand `access_token` to `create({ accessToken, origin, supportedPcis })` and you're up. The repo's root `/` page does this automatically — opening it in the browser after `bun db:seed` renders the SDK against the seeded student.
316
+
317
+ **Integration contract.** The seed script is the contract. As long as it runs cleanly, nothing on the client side has to change when the Primer auth model evolves — drop the DB, re-run `bun db:seed`, and the same `sk_` keeps working. If a future schema change breaks this guarantee, it breaks the seed first; fix the seed and the contract holds.
318
+
319
+ ### Logger interface
320
+
321
+ ```ts
322
+ interface PrimerLogger {
323
+ debug(message: string, attributes?: Record<string, unknown>): void
324
+ info(message: string, attributes?: Record<string, unknown>): void
325
+ warn(message: string, attributes?: Record<string, unknown>): void
326
+ error(message: string, attributes?: Record<string, unknown>): void
327
+ }
328
+ ```
329
+
330
+ ## Wire protocol
331
+
332
+ Every request is a `POST` to:
333
+
334
+ ```txt
335
+ ${origin}/api/v0/advance
336
+ ```
337
+
338
+ The path constant is exported as:
339
+
340
+ ```ts
341
+ const ADVANCE_PATH = "/api/v0/advance"
342
+ ```
343
+
344
+ ### Headers
345
+
346
+ ```txt
347
+ Authorization: Bearer <primer jwt>
348
+ Content-Type: application/json
349
+ ```
350
+
351
+ ### Request body
352
+
353
+ ```ts
354
+ interface WireRequestBody<Pcis extends PciId = PciId> {
355
+ supportedPcis: readonly PciId[]
356
+ intent: WireIntent<Pcis>
357
+ subject: Subject | "all"
358
+ }
359
+ ```
360
+
361
+ The student identity is **not** in the body. The server reads it from the verified JWT's `sub` claim.
362
+
363
+ The `subject` field is required on every request and validated server-side. A missing or invalid value is rejected with `400 invalid_request`.
364
+
365
+ ```ts
366
+ type WireIntent<Pcis extends PciId = PciId> =
367
+ | { kind: "observation" }
368
+ | { kind: "interaction"; submission: RendererSubmission<Pcis> }
369
+ | { kind: "timeout" }
370
+ ```
371
+
372
+ ### Response outcomes
373
+
374
+ Conceptually, the server returns one of three outcomes:
375
+
376
+ ```ts
377
+ type WireResult<Pcis extends PciId = PciId> =
378
+ | {
379
+ outcome: "advanced"
380
+ stimulus: RendererStimulus | null
381
+ interaction: RendererInteraction<Pcis> | null
382
+ }
383
+ | {
384
+ outcome: "submitted"
385
+ stimulus: RendererStimulus | null
386
+ interaction: RendererInteraction<Pcis>
387
+ submission: RendererSubmission<Pcis>
388
+ isCorrect: boolean
389
+ feedbackContent: ContentInline[]
390
+ review: InteractionReview<Pcis> | null
391
+ }
392
+ | { outcome: "completed" }
393
+ ```
394
+
395
+ ### Important notes
396
+
397
+ - `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
398
+ - `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
399
+ - The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
117
400
 
118
401
  ## State machine
119
402
 
120
403
  `PrimerState` is a discriminated union on `phase`:
121
404
 
122
- ```
123
- observation interaction feedback observation ... completed
405
+ ```txt
406
+ observation -> interaction -> feedback -> observation -> ... -> completed
124
407
  | \ | \ | \
125
-
408
+ v \ v \ v \
126
409
  errored fatal errored fatal errored fatal
127
410
 
128
- errored (retry) replays failed action
129
- interaction timeout same as submit (server evaluates)
411
+ errored -> retry() -> replays exact failed action
412
+ interaction timeout -> same transport path, intent kind "timeout"
130
413
  ```
131
414
 
132
- Any action (`advance()`, `submitChoice()`, `timeout()`, etc.) can fail. Transient failures (network, timeout, 5xx) become `errored` with `retry()`. Permanent failures (401, 403, 404) become `fatal` — session is dead, no recovery.
133
-
134
- Calling an action twice (e.g., double-clicking `advance()`) returns the same promise — the SDK memoizes in-flight requests internally. No special handling needed.
135
-
136
415
  ### `observation`
137
416
 
138
- Display-only frame. Show the stimulus, call `advance()`.
417
+ Display-only frame. Render `state.stimulus`, then call `advance()`.
139
418
 
140
419
  ```ts
420
+ state.phase === "observation"
141
421
  state.stimulus // RendererStimulus | null
142
422
  state.advance() // Promise<PrimerState>
143
423
  ```
144
424
 
145
- Stimulus is a discriminated union on `type`:
146
- - `BodyStimulus` — `{ type: "body", body: ContentBlock[] }` — text-only
147
- - `ImageStimulus` — `{ type: "image", description: ContentInline[], src: string }` — image-backed
148
- - `null` — no stimulus
149
-
150
425
  ### `interaction`
151
426
 
152
- Student must respond. Discriminate on `state.kind`:
427
+ Student must respond. Discriminate on `state.kind`.
153
428
 
154
- | `state.kind` | Submit | Key fields |
155
- |---|---|---|
156
- | `"choice"` | `submitChoice(keys: string[])` | `options`, `maxChoices`, `minChoices` |
157
- | `"text-entry"` | `submitText(value: string)` | — |
158
- | `"extended-text"` (single) | `submitText(value: string)` | `cardinality: "single"` |
159
- | `"extended-text"` (multiple) | `submitTexts(values: string[])` | `cardinality: "multiple"`, `maxStrings`, `minStrings` |
160
- | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
429
+ All interaction states also expose:
161
430
 
162
- All interaction states also have a `timeout()` method that advances the state machine as if the student ran out of time.
431
+ - `state.stimulus`
432
+ - `state.interaction`
433
+ - `timeout()`
163
434
 
164
- All submit and timeout methods return `Promise<PrimerState>`.
435
+ #### Interaction kinds
165
436
 
166
- Every interaction state carries `interaction.prompt` (`ContentInline[]`) and `interaction.type` (the discriminant matching `state.kind`).
437
+ | `state.kind` | Submit method | Key fields |
438
+ |---|---|---|
439
+ | `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
440
+ | `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
441
+ | `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
442
+ | `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
443
+ | `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
444
+ | `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
445
+ | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
167
446
 
168
447
  ### `feedback`
169
448
 
170
- Server evaluated the submission. Show result, call `advance()`.
449
+ The server has evaluated the submission. Render feedback, then call `advance()`.
171
450
 
172
451
  ```ts
452
+ state.phase === "feedback"
173
453
  state.stimulus // RendererStimulus | null
174
- state.interaction // the original interaction
175
- state.submission // the student's submission
454
+ state.interaction // RendererInteraction<Pcis>
455
+ state.submission // RendererSubmission<Pcis>
176
456
  state.isCorrect // boolean
177
457
  state.feedbackContent // ContentInline[]
178
- state.correctAnswer // RendererCorrectAnswer | null
458
+ state.review // InteractionReview<Pcis> | null
179
459
  state.advance() // Promise<PrimerState>
180
460
  ```
181
461
 
182
- `correctAnswer` carries the canonical correct response, or `null` when unavailable:
462
+ `review` is interaction-native feedback data when available:
183
463
 
184
464
  ```ts
185
- type RendererCorrectScalarValue =
186
- | { kind: "identifier"; value: string }
187
- | { kind: "string"; value: string }
188
- | { kind: "integer"; value: number }
189
- | { kind: "float"; value: number }
190
-
191
- type RendererCorrectAnswer =
192
- | { kind: "single"; value: RendererCorrectScalarValue | null }
193
- | { kind: "multiple"; values: RendererCorrectScalarValue[] }
465
+ type InteractionReview<Pcis extends PciId = PciId> =
466
+ | { type: "choice"; correctKeys: string[] }
467
+ | { type: "text-entry"; correctValue: ReviewScalarValue | null }
468
+ | { type: "extended-text"; correctValues: ReviewScalarValue[] }
469
+ | { type: "order"; correctOrder: string[] }
470
+ | { type: "match"; correctPairs: Array<{ source: string; target: string }> }
194
471
  | {
195
- kind: "record"
472
+ type: "portable-custom"
473
+ pciId: Pcis
196
474
  fields: Array<{
197
475
  fieldIdentifier: string
198
- baseType: "identifier" | "string" | "integer" | "float"
199
- value: RendererCorrectScalarValue | null
476
+ baseType: "identifier" | "string" | "integer" | "float" | "pair"
477
+ value: ReviewScalarValue | null
200
478
  }>
201
479
  }
480
+
481
+ type ReviewScalarValue =
482
+ | { kind: "identifier"; value: string }
483
+ | { kind: "string"; value: string }
484
+ | { kind: "integer"; value: number }
485
+ | { kind: "float"; value: number }
486
+ | { kind: "pair"; source: string; target: string }
202
487
  ```
203
488
 
204
- For choice interactions, extract correct option identifiers from `kind: "identifier"` scalars. For text-entry, check `kind: "string"` or `kind: "integer"`. For PCI record responses, iterate `fields`. `null` means the server could not determine a correct response.
489
+ Notes:
490
+
491
+ - `null` means the server did not provide review data.
492
+ - order review preserves ordering directly.
493
+ - match review preserves directional `{ source, target }` pairs directly.
494
+ - PCI review uses record fields and can carry `pair` values without flattening them into strings.
495
+ - `feedback.advance()` uses the same observation intent as `observation.advance()`.
205
496
 
206
497
  ### `completed`
207
498
 
208
- Session finished. No further actions.
499
+ Session finished successfully. No further actions.
209
500
 
210
501
  ### `errored`
211
502
 
212
- Transient failure. The request might succeed on retry.
503
+ Recoverable or user-correctable failure.
213
504
 
214
505
  ```ts
215
- state.error // Error sentinel (use errors.is())
216
- state.failedPhase // "observation" | "interaction" | "timeout"
217
- state.retry() // Promise<PrimerState> — replays exact failed intent
506
+ state.phase === "errored"
507
+ state.error // Error sentinel
508
+ state.retriable // boolean
509
+ state.retry() // Promise<PrimerState>
218
510
  ```
219
511
 
220
- Retryable sentinels: `ErrNetwork`, `ErrTimeout`, `ErrRateLimited`, `ErrServerError`, `ErrServiceUnavailable`, `ErrJsonParse`, `ErrConflict`.
512
+ Caller guidance:
513
+
514
+ - switch on sentinel identity with `errors.is(...)`
515
+ - check `state.retriable` before calling `retry()`
516
+ - non-retriable errored states keep the original sentinel but do not replay the failed action
221
517
 
222
518
  ### `fatal`
223
519
 
224
- Permanent failure. Session cannot recover. No `retry()`.
520
+ Permanent failure. Session cannot recover.
225
521
 
226
522
  ```ts
227
- state.error // Error sentinel (use errors.is())
523
+ state.phase === "fatal"
524
+ state.error // Error sentinel
228
525
  ```
229
526
 
230
- Causes:
231
- - Invalid publishable key (401 — `ErrInvalidPublishableKey`)
232
- - Origin not allowed (403 — `ErrForbidden`)
233
- - Resource not found (404 — `ErrNotFound`)
234
- - Malformed request (400 — `ErrBadRequest`)
235
- - Unsupported PCI (422 — `ErrUnsupportedPci`)
527
+ Fatal states can come from:
236
528
 
237
- ## Client-side validation
529
+ - malformed request semantics on the server (`400`)
530
+ - missing, invalid, or expired access token (`401`)
531
+ - forbidden (`403`)
532
+ - missing resource (`404`)
533
+ - unsupported PCI (`422`)
534
+ - a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
535
+
536
+ ## Interaction payloads
537
+
538
+ ```ts
539
+ type PciSubmission<Pcis extends PciId = PciId> = {
540
+ [K in Pcis]: {
541
+ type: "portable-custom"
542
+ pciId: K
543
+ value: PciValue<K>
544
+ }
545
+ }[Pcis]
546
+
547
+ type RendererSubmission<Pcis extends PciId = PciId> =
548
+ | { type: "choice"; selectedKeys: string[] }
549
+ | { type: "text-entry"; value: string }
550
+ | { type: "extended-text"; values: string[] }
551
+ | { type: "order"; orderedKeys: string[] }
552
+ | { type: "match"; pairs: Array<{ source: string; target: string }> }
553
+ | PciSubmission<Pcis>
554
+ ```
555
+
556
+ Notes:
238
557
 
239
- The SDK validates submissions before sending them to the server:
558
+ - single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
559
+ - match submissions are directional `{ source, target }` pairs
560
+ - portable custom submissions carry both `pciId` and a typed PCI `value`
240
561
 
241
- **Choice:** rejects fewer than `minChoices`, more than `maxChoices`, duplicates, unknown option identifiers.
562
+ ## Client-side validation
242
563
 
243
- **Extended text (multiple):** rejects fewer than `minStrings`, more than `maxStrings`.
564
+ The SDK performs **selective** client-side validation before sending some submissions.
244
565
 
245
- Failed validation returns `errored` (not `fatal`) with `ErrInvalidSubmission`. The original interaction state is still usable — call the submit method again with corrected input. The `retry()` on the errored state replays the same invalid submission and is not useful for validation errors.
566
+ | Interaction kind | Validation performed |
567
+ |---|---|
568
+ | `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
569
+ | `text-entry` | none |
570
+ | `extended-text` + `single` | none |
571
+ | `extended-text` + `multiple` | `minStrings`, `maxStrings` |
572
+ | `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
573
+ | `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
574
+ | `portable-custom` | no outbound value validation |
575
+
576
+ If validation fails:
577
+
578
+ - the submit call resolves to an `errored` state
579
+ - `state.error` will match `ErrInvalidSubmission`
580
+ - `state.retriable` will be `false`
581
+ - the original interaction object is still usable for a corrected resubmission
582
+ - `retry()` returns the same errored state instead of replaying the invalid payload
246
583
 
247
584
  ## Error sentinels
248
585
 
249
586
  ```ts
250
587
  import * as errors from "@superbuilders/errors"
251
- import { ErrNetwork, ErrRateLimited } from "@superbuilders/primer-tives"
252
-
253
- if (errors.is(state.error, ErrNetwork)) { /* handle */ }
588
+ import {
589
+ ErrInvalidSubmission,
590
+ ErrNetwork,
591
+ ErrRateLimited,
592
+ ErrUnsupportedPci,
593
+ } from "@superbuilders/primer-tives"
594
+
595
+ if (errors.is(state.error, ErrNetwork)) {
596
+ // handle offline / DNS / CORS / fetch failure
597
+ }
254
598
  ```
255
599
 
256
- **Retryable** surface as `errored`, consumer can call `retry()`:
600
+ ### Surfaced as `errored`
257
601
 
258
- | Sentinel | Cause |
259
- |---|---|
260
- | `ErrNetwork` | Fetch failed (offline, DNS, CORS) |
261
- | `ErrTimeout` | Request aborted or timed out |
262
- | `ErrRateLimited` | 429 |
263
- | `ErrServerError` | 500 or unrecognized 5xx |
264
- | `ErrServiceUnavailable` | 502/503/504 |
265
- | `ErrJsonParse` | Response not valid JSON |
266
- | `ErrConflict` | 409 server already processed this advance |
267
- | `ErrInvalidSubmission` | Client-side validation failed |
268
-
269
- **Permanent** surface as `fatal`, session is dead:
270
-
271
- | Sentinel | Cause |
602
+ | Sentinel | Meaning | `state.retriable` |
603
+ |---|---|---|
604
+ | `ErrNetwork` | fetch failed before a response was received | `true` |
605
+ | `ErrTimeout` | request was aborted or timed out | `true` |
606
+ | `ErrRateLimited` | `429` | `true` |
607
+ | `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
608
+ | `ErrServerError` | any other unhandled HTTP error status | `true` |
609
+ | `ErrJsonParse` | response body was not valid JSON | `true` |
610
+ | `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
611
+ | `ErrInvalidSubmission` | client-side validation failed | `false` |
612
+
613
+ ### Permanent: surfaced as `fatal`
614
+
615
+ | Sentinel | Meaning |
272
616
  |---|---|
273
- | `ErrBadRequest` | 400 — malformed request |
274
- | `ErrInvalidPublishableKey` | 401 wrong publishable key |
275
- | `ErrForbidden` | 403 origin not allowed |
276
- | `ErrNotFound` | 404 — resource not found |
277
- | `ErrUnsupportedPci` | 422 — server sent a PCI the client can't handle |
617
+ | `ErrBadRequest` | `400` |
618
+ | `ErrInvalidAccessToken` | `401` with an invalid signature / unknown key / malformed claims |
619
+ | `ErrTokenExpired` | `401` with an `exp` claim in the past |
620
+ | `ErrForbidden` | `403` |
621
+ | `ErrNotFound` | `404` |
622
+ | `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
278
623
 
279
- **Thrown directly** (not wrapped in errored/fatal):
624
+ ### Thrown directly
280
625
 
281
- | Sentinel | Cause |
626
+ | Sentinel | Meaning |
282
627
  |---|---|
283
- | `ErrMalformedPublishableKey` | Key doesn't start with `pk_` thrown by `create()` |
284
- | `ErrNotSerializable` | Attempted to JSON.stringify PrimerState thrown by `toJSON()` |
285
-
286
- ## Submission payloads
287
-
288
- ```ts
289
- type RendererSubmission =
290
- | { type: "choice"; selectedKeys: string[] }
291
- | { type: "text-entry"; value: string }
292
- | { type: "extended-text"; values: string[] }
293
- | { type: "portable-custom"; pciId: PciId; value: PciValue<PciId> }
294
- ```
628
+ | `ErrMalformedAccessToken` | `create()` received a token whose shape is not a JWS (must start with `eyJ` and contain two dots) |
629
+ | `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
295
630
 
296
631
  ## Content format
297
632
 
@@ -307,13 +642,44 @@ type ContentInline =
307
642
  type ContentBlock = { type: "paragraph"; children: ContentInline[] }
308
643
  ```
309
644
 
310
- All three inline variants share the uniform `{ type, value: string }` shape. `ContentSpan` covers HTML-ish rich text formatting. `latex` is for inline math expressions rendered via Temml.
645
+ ### Helpers
646
+
647
+ ```ts
648
+ inlinesToPlainText(nodes: ContentInline[]): string
649
+ blocksToPlainText(blocks: ContentBlock[]): string
650
+ ```
651
+
652
+ - `inlinesToPlainText()` strips formatting and concatenates inline values
653
+ - `blocksToPlainText()` flattens block content with newlines between paragraphs
654
+
655
+ Typical use cases:
656
+
657
+ - accessibility labels
658
+ - plain-text fallbacks
659
+ - analytics or logging output that should ignore inline formatting
660
+
661
+ ## Stimulus model
662
+
663
+ ```ts
664
+ interface BodyStimulus {
665
+ type: "body"
666
+ body: ContentBlock[]
667
+ }
668
+
669
+ interface ImageStimulus {
670
+ type: "image"
671
+ description: ContentInline[]
672
+ src: string
673
+ }
674
+
675
+ type RendererStimulus = BodyStimulus | ImageStimulus
676
+ ```
311
677
 
312
- `inlinesToPlainText(nodes)` strips inline formatting for accessibility labels. `blocksToPlainText(blocks)` flattens blocks to plain text.
678
+ A frame can also have `stimulus: null`.
313
679
 
314
680
  ## PCI system
315
681
 
316
- Portable Custom Interactions handle domain-specific input types with full type safety.
682
+ Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
317
683
 
318
684
  ### Registry
319
685
 
@@ -336,31 +702,117 @@ interface PciRegistry {
336
702
  ### Type helpers
337
703
 
338
704
  ```ts
705
+ type PciUrn = `urn:primer:pci:${string}`
339
706
  type PciId = keyof PciRegistry & string
340
- type PciProps<K extends PciId> // server-provided props
341
- type PciValue<K extends PciId> // submission value
707
+ type PciProps<K extends PciId> = PciRegistry[K]["props"]
708
+ type PciValue<K extends PciId> = PciRegistry[K]["value"]
709
+ ```
710
+
711
+ ### PCI renderer props
712
+
713
+ ```ts
714
+ type PciRenderProps<K extends PciId> =
715
+ | {
716
+ mode: "pending"
717
+ properties: PciProps<K>
718
+ onValueChange: (value: PciValue<K> | null) => void
719
+ }
720
+ | {
721
+ mode: "submitted"
722
+ properties: PciProps<K>
723
+ submission: PciValue<K>
724
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
725
+ }
342
726
  ```
343
727
 
344
728
  ### Adding a PCI
345
729
 
346
- 1. Add entry to `PciRegistry` in `src/pci.ts`
347
- 2. URN format: `urn:primer:pci:<name>`
348
- 3. Include in `supportedPcis` when calling `create()`
349
- 4. Build renderer with `PciRenderProps<"urn:primer:pci:your-pci">`
730
+ 1. Add a new entry to `PciRegistry` in `src/pci.ts`
731
+ 2. Use the URN format `urn:primer:pci:<name>`
732
+ 3. Add that PCI URN to `supportedPcis` when creating the client
733
+ 4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
350
734
 
351
- ## Import
735
+ ## Import guide
352
736
 
353
737
  Everything is exported from the package root:
354
738
 
355
739
  ```ts
356
- import { create, ADVANCE_PATH } from "@superbuilders/primer-tives"
357
- import { ErrNetwork, ErrTimeout, ErrUnsupportedPci } from "@superbuilders/primer-tives"
358
- import { inlinesToPlainText, blocksToPlainText } from "@superbuilders/primer-tives"
359
- import type { PrimerState, Config, Client, PrimerLogger } from "@superbuilders/primer-tives"
360
- import type { ContentBlock, ContentInline } from "@superbuilders/primer-tives"
361
- import type { PciId, PciProps, PciValue, PciRenderProps } from "@superbuilders/primer-tives"
740
+ import {
741
+ ADVANCE_PATH,
742
+ ErrConflict,
743
+ ErrInvalidAccessToken,
744
+ ErrInvalidSubmission,
745
+ ErrMalformedAccessToken,
746
+ ErrNetwork,
747
+ ErrTimeout,
748
+ ErrTokenExpired,
749
+ ErrUnsupportedPci,
750
+ SUBJECTS,
751
+ blocksToPlainText,
752
+ create,
753
+ inlinesToPlainText,
754
+ } from "@superbuilders/primer-tives"
755
+
756
+ import type {
757
+ ChoiceState,
758
+ Client,
759
+ Config,
760
+ ContentBlock,
761
+ ContentInline,
762
+ FeedbackState,
763
+ MatchState,
764
+ ObservationState,
765
+ OrderState,
766
+ PciId,
767
+ PciProps,
768
+ PciRenderProps,
769
+ PciValue,
770
+ PrimerLogger,
771
+ PrimerState,
772
+ RendererInteraction,
773
+ RendererStimulus,
774
+ RendererSubmission,
775
+ Subject,
776
+ SubjectScope,
777
+ } from "@superbuilders/primer-tives"
362
778
  ```
363
779
 
364
- ## Non-serializable state
780
+ ## Behavioral notes
781
+
782
+ ### States are not serializable
783
+
784
+ `PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
785
+
786
+ Do **not**:
365
787
 
366
- `PrimerState` contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`. Do not persist, stringify, or transfer `PrimerState` — it is ephemeral in-memory state.
788
+ - `JSON.stringify(state)`
789
+ - persist a state object to storage
790
+ - send a state object over the network
791
+ - treat a state object as a durable snapshot
792
+
793
+ Instead, keep it in memory and render it immediately.
794
+
795
+ ### Repeated actions are deduplicated narrowly
796
+
797
+ State objects memoize only true re-entry of the same in-flight action.
798
+
799
+ - calling `advance()` twice on the same observation state returns the same promise
800
+ - calling the same submit method twice with the same payload returns the same promise
801
+ - calling `retry()` twice on the same errored state returns the same promise
802
+ - submit and timeout do **not** alias to each other anymore
803
+ - conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
804
+
805
+ ### `start()` is memoized per client instance
806
+
807
+ A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
808
+
809
+ ## Build and publish
810
+
811
+ The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
812
+
813
+ Useful package scripts:
814
+
815
+ ```sh
816
+ bun run build
817
+ bun run typecheck
818
+ ```