@superbuilders/primer-tives 1.1.3 → 1.2.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.
Files changed (66) hide show
  1. package/README.md +395 -413
  2. package/dist/client/choice-state.d.ts +3 -2
  3. package/dist/client/choice-state.d.ts.map +1 -1
  4. package/dist/client/create.d.ts +1 -1
  5. package/dist/client/create.d.ts.map +1 -1
  6. package/dist/client/extended-text-state.d.ts +3 -2
  7. package/dist/client/extended-text-state.d.ts.map +1 -1
  8. package/dist/client/feedback-state.d.ts +5 -3
  9. package/dist/client/feedback-state.d.ts.map +1 -1
  10. package/dist/client/index.d.ts +1 -8
  11. package/dist/client/index.d.ts.map +1 -1
  12. package/dist/client/index.js +54 -70
  13. package/dist/client/index.js.map +17 -18
  14. package/dist/client/match-state.d.ts +3 -2
  15. package/dist/client/match-state.d.ts.map +1 -1
  16. package/dist/client/observation-state.d.ts +3 -2
  17. package/dist/client/observation-state.d.ts.map +1 -1
  18. package/dist/client/order-state.d.ts +3 -2
  19. package/dist/client/order-state.d.ts.map +1 -1
  20. package/dist/client/pci-state.d.ts +3 -2
  21. package/dist/client/pci-state.d.ts.map +1 -1
  22. package/dist/client/session-context.d.ts +3 -2
  23. package/dist/client/session-context.d.ts.map +1 -1
  24. package/dist/client/session.d.ts +1 -1
  25. package/dist/client/session.d.ts.map +1 -1
  26. package/dist/client/text-entry-state.d.ts +3 -2
  27. package/dist/client/text-entry-state.d.ts.map +1 -1
  28. package/dist/client/transport.d.ts +4 -3
  29. package/dist/client/transport.d.ts.map +1 -1
  30. package/dist/client/types.d.ts +5 -55
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/contracts/content.d.ts.map +1 -0
  33. package/dist/contracts/index.d.ts +4 -0
  34. package/dist/contracts/index.d.ts.map +1 -1
  35. package/dist/contracts/index.js +27 -1
  36. package/dist/contracts/index.js.map +5 -4
  37. package/dist/contracts/pci.d.ts.map +1 -0
  38. package/dist/contracts/review.d.ts +55 -0
  39. package/dist/contracts/review.d.ts.map +1 -0
  40. package/dist/contracts/types.d.ts +2 -2
  41. package/dist/contracts/types.d.ts.map +1 -1
  42. package/dist/errors.d.ts +2 -1
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/errors.js +3 -1
  45. package/dist/errors.js.map +3 -3
  46. package/dist/grade-level.js +7 -0
  47. package/dist/grade-level.js.map +10 -0
  48. package/dist/logger.js +2 -0
  49. package/dist/logger.js.map +9 -0
  50. package/dist/server/create-server.d.ts +6 -4
  51. package/dist/server/create-server.d.ts.map +1 -1
  52. package/dist/server/index.d.ts +0 -4
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/index.js +5 -19
  55. package/dist/server/index.js.map +6 -6
  56. package/dist/subject.js +7 -0
  57. package/dist/subject.js.map +10 -0
  58. package/package.json +13 -6
  59. package/dist/client/content.d.ts.map +0 -1
  60. package/dist/client/pci.d.ts.map +0 -1
  61. package/dist/index.d.ts +0 -3
  62. package/dist/index.d.ts.map +0 -1
  63. package/dist/index.js +0 -344
  64. package/dist/index.js.map +0 -12
  65. /package/dist/{client → contracts}/content.d.ts +0 -0
  66. /package/dist/{client → contracts}/pci.d.ts +0 -0
package/README.md CHANGED
@@ -6,107 +6,91 @@ TypeScript SDK for the Primer adaptive learning engine.
6
6
  bun add @superbuilders/primer-tives
7
7
  ```
8
8
 
9
- Dependency note: `@superbuilders/errors` is installed automatically and is used for `errors.try()` / `errors.is()`.
9
+ `@superbuilders/errors` is installed automatically. The SDK throws sentinel-wrapped errors and the recommended consumer pattern uses `errors.try` / `errors.is` / `errors.wrap` from that library.
10
10
 
11
- ## Entrypoints
11
+ ## Subpaths
12
12
 
13
- | Import | Runs on | Use for |
14
- |---|---|---|
15
- | `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/students`, `PATCH /api/v0/students/:id/hints`, `POST /api/v0/auth/exchange`, `POST /api/v0/auth/exchange/timeback` |
16
- | `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` |
17
- | `@superbuilders/primer-tives/contracts` | either side | shared interaction/submission types, Zod schemas, optional submission validator |
18
- | `@superbuilders/primer-tives/errors` | either side | every error sentinel |
19
- | `@superbuilders/primer-tives` (root) | either side | convenience re-export of `/contracts` + `/errors` |
13
+ Every symbol is exported from exactly one subpath. Import from the subpath that owns the thing you need.
20
14
 
21
- `/server` and `/client` are not on the root — pick the side of the wire you are on and import from its subpath. `/contracts` and `/errors` are wire-neutral and safe on either side.
22
-
23
- ## Supported student flows
24
-
25
- Primer intentionally supports **two** backend-authenticated student flows.
26
-
27
- ### 1. Primer-native / manual students
28
-
29
- Use this when **your system** owns the learner identity.
30
-
31
- 1. Call `createStudent()` once to mint a stable Primer `studentId`.
32
- 2. Persist that `studentId` in your own database alongside your user record.
33
- 3. **Before the student's first session**, call `setStudentHints(studentId, { gradeLevel })` at least once. Native students are created hint-less, and the first `/advance` call fails without a `gradeLevel` on record.
34
- 4. At each session start, call `exchangeStudentForAccessToken(studentId)`.
35
- 5. Hand the returned `accessToken` to the browser SDK.
36
-
37
- ### 2. Live-authoritative Timeback students
38
-
39
- Use this when **Timeback / OneRoster** remains the live authority for each login.
40
-
41
- 1. At each session start, call `exchangeTimebackStudentForAccessToken(sourcedId)`.
42
- 2. Primer verifies the learner against Timeback on **every call**.
43
- 3. Primer resolves or provisions the frontend-owned Primer student row behind the scenes.
44
- 4. The call returns both the stable Primer `studentId` and a short-lived `accessToken`.
45
- 5. Hand the returned `accessToken` to the browser SDK.
46
-
47
- Persisting the returned `studentId` is fine if you want a stable local foreign key. Session startup for this flow still begins from `sourcedId`, and the returned `accessToken` is the session credential you pass to the browser SDK.
15
+ | Subpath | What it owns |
16
+ |---|---|
17
+ | `@superbuilders/primer-tives/server` | `createPrimerServer` and the four `PrimerServer` methods, the config and return types |
18
+ | `@superbuilders/primer-tives/client` | `create` (browser SDK), the `PrimerState` discriminated union, every `*State` interface, the PCI render props |
19
+ | `@superbuilders/primer-tives/contracts` | wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types), Zod schemas, the optional submission validator |
20
+ | `@superbuilders/primer-tives/errors` | every error sentinel (`Err…`) |
21
+ | `@superbuilders/primer-tives/logger` | the `PrimerLogger` interface, accepted by both server and client config |
22
+ | `@superbuilders/primer-tives/grade-level` | `GradeLevel` type and the `GRADE_LEVELS` constant |
23
+ | `@superbuilders/primer-tives/subject` | `Subject`, `SubjectScope`, the `SUBJECTS` constant |
48
24
 
49
25
  ## End-to-end examples
50
26
 
51
- ### Native/manual flow
27
+ ### Native/manual student flow
52
28
 
53
29
  ```ts
54
- // ── your backend ─────────────────────────────────────────────────────
55
30
  import * as errors from "@superbuilders/errors"
31
+ import * as logger from "@superbuilders/slog"
32
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
56
33
  import {
57
- createPrimerServer,
58
34
  ErrBadRequest,
59
35
  ErrConflict,
60
36
  ErrInvalidSecretKey,
61
37
  ErrStudentNotFound
62
- } from "@superbuilders/primer-tives/server"
38
+ } from "@superbuilders/primer-tives/errors"
63
39
 
64
40
  const primer = createPrimerServer({
65
41
  origin: "https://sb-primer.vercel.app",
66
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
67
- logger: console
42
+ secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
43
+ logger
68
44
  })
69
45
 
70
46
  // One time per user.
71
47
  const createResult = await errors.try(primer.createStudent())
72
48
  if (createResult.error) {
73
49
  if (errors.is(createResult.error, ErrInvalidSecretKey)) {
74
- throw new Error("Primer secret key is invalid")
50
+ logger.error("primer secret key invalid", { error: createResult.error })
51
+ throw errors.wrap(createResult.error, "primer client init")
75
52
  }
76
53
  if (errors.is(createResult.error, ErrConflict)) {
77
- throw new Error("This Primer frontend is not provisioned with routable content")
54
+ logger.error("primer frontend not provisioned", { error: createResult.error })
55
+ throw errors.wrap(createResult.error, "primer frontend provisioning")
78
56
  }
79
- throw createResult.error
57
+ logger.error("primer create student failed", { error: createResult.error })
58
+ throw errors.wrap(createResult.error, "primer create student")
80
59
  }
81
60
  const studentId = createResult.data
82
- // persist `studentId` alongside your own user record
61
+ // persist `studentId` alongside your own user record.
83
62
 
84
63
  // Required before the first session for native students.
85
- const hintsResult = await errors.try(
86
- primer.setStudentHints(studentId, { gradeLevel: "3" })
87
- )
64
+ const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
88
65
  if (hintsResult.error) {
89
66
  if (errors.is(hintsResult.error, ErrStudentNotFound)) {
90
- throw new Error("Stored Primer student id no longer exists on this frontend")
67
+ logger.error("primer student id not found on this frontend", {
68
+ studentId,
69
+ error: hintsResult.error
70
+ })
71
+ throw errors.wrap(hintsResult.error, "primer set hints")
91
72
  }
92
73
  if (errors.is(hintsResult.error, ErrBadRequest)) {
93
- throw new Error("Invalid hint payload (e.g. unsupported gradeLevel)")
74
+ logger.error("primer hint payload rejected", { error: hintsResult.error })
75
+ throw errors.wrap(hintsResult.error, "primer set hints")
94
76
  }
95
- throw hintsResult.error
77
+ logger.error("primer set hints failed", { error: hintsResult.error })
78
+ throw errors.wrap(hintsResult.error, "primer set hints")
96
79
  }
97
80
 
98
81
  // Every session start.
99
- const tokenResult = await errors.try(
100
- primer.exchangeStudentForAccessToken(studentId)
101
- )
82
+ const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
102
83
  if (tokenResult.error) {
103
84
  if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
104
- throw new Error("Primer secret key is invalid")
85
+ logger.error("primer secret key invalid", { error: tokenResult.error })
86
+ throw errors.wrap(tokenResult.error, "primer token exchange")
105
87
  }
106
88
  if (errors.is(tokenResult.error, ErrStudentNotFound)) {
107
- throw new Error("Stored Primer student id no longer exists on this frontend")
89
+ logger.error("primer student id stale", { studentId, error: tokenResult.error })
90
+ throw errors.wrap(tokenResult.error, "primer token exchange")
108
91
  }
109
- throw tokenResult.error
92
+ logger.error("primer token exchange failed", { error: tokenResult.error })
93
+ throw errors.wrap(tokenResult.error, "primer token exchange")
110
94
  }
111
95
 
112
96
  const { accessToken, expiresInSeconds } = tokenResult.data
@@ -115,58 +99,62 @@ const { accessToken, expiresInSeconds } = tokenResult.data
115
99
  ### Live-authoritative Timeback flow
116
100
 
117
101
  ```ts
118
- // ── your backend ─────────────────────────────────────────────────────
119
102
  import * as errors from "@superbuilders/errors"
103
+ import * as logger from "@superbuilders/slog"
104
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
120
105
  import {
121
- createPrimerServer,
122
106
  ErrConflict,
123
107
  ErrInvalidSecretKey,
124
108
  ErrStudentNotFound,
125
109
  ErrTimebackUnavailable,
126
110
  ErrUnsupportedGrade
127
- } from "@superbuilders/primer-tives/server"
111
+ } from "@superbuilders/primer-tives/errors"
128
112
 
129
113
  const primer = createPrimerServer({
130
114
  origin: "https://sb-primer.vercel.app",
131
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
132
- logger: console
115
+ secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
116
+ logger
133
117
  })
134
118
 
135
119
  const sessionResult = await errors.try(
136
- primer.exchangeTimebackStudentForAccessToken("student-123")
120
+ primer.exchangeTimebackStudentForAccessToken(sourcedId)
137
121
  )
138
122
  if (sessionResult.error) {
139
123
  if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
140
- throw new Error("Primer secret key is invalid")
124
+ logger.error("primer secret key invalid", { error: sessionResult.error })
125
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
141
126
  }
142
127
  if (errors.is(sessionResult.error, ErrStudentNotFound)) {
143
- throw new Error("Timeback sourcedId was not found upstream")
128
+ logger.error("timeback sourcedId not found upstream", {
129
+ sourcedId,
130
+ error: sessionResult.error
131
+ })
132
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
144
133
  }
145
134
  if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
146
- throw new Error("Timeback returned a grade Primer does not support")
135
+ logger.error("timeback grade outside supported range", { error: sessionResult.error })
136
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
147
137
  }
148
138
  if (errors.is(sessionResult.error, ErrConflict)) {
149
- throw new Error("This Primer frontend is not provisioned with routable content")
139
+ logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
140
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
150
141
  }
151
142
  if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
152
- throw new Error("Timeback is temporarily unavailable")
143
+ logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
144
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
153
145
  }
154
- throw sessionResult.error
146
+ logger.error("primer timeback exchange failed", { error: sessionResult.error })
147
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
155
148
  }
156
149
 
157
150
  const { studentId, accessToken, expiresInSeconds } = sessionResult.data
158
- // persist `studentId` if you want a stable Primer foreign key
159
- // use `accessToken` for this session only
160
151
  ```
161
152
 
162
153
  ### Browser flow
163
154
 
164
155
  ```ts
165
- // ── your frontend ────────────────────────────────────────────────────
166
- import {
167
- create,
168
- type PrimerState
169
- } from "@superbuilders/primer-tives/client"
156
+ import { create } from "@superbuilders/primer-tives/client"
157
+ import type { PrimerState } from "@superbuilders/primer-tives/client"
170
158
 
171
159
  const client = create({
172
160
  accessToken,
@@ -196,10 +184,9 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
196
184
  case "errored":
197
185
  if (state.retriable) {
198
186
  state = await state.retry()
199
- } else {
200
- throw state.error
187
+ break
201
188
  }
202
- break
189
+ throw state.error
203
190
  }
204
191
  }
205
192
  ```
@@ -209,29 +196,17 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
209
196
  # `/server`
210
197
 
211
198
  ```ts
212
- import {
213
- createPrimerServer,
214
- type PrimerServer,
215
- type PrimerServerConfig,
216
- type SessionToken,
217
- type TimebackSession,
218
- type PlacementHints,
219
- type PlacementHintsResult,
220
- type GradeLevel,
221
- GRADE_LEVELS,
222
- type PrimerLogger,
223
- ErrBadRequest,
224
- ErrConflict,
225
- ErrExternalAuthorityRequired,
226
- ErrInvalidSecretKey,
227
- ErrJsonParse,
228
- ErrNetwork,
229
- ErrServerError,
230
- ErrStudentNotFound,
231
- ErrTimebackUnavailable,
232
- ErrTimeout,
233
- ErrUnsupportedGrade
199
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
200
+ import type {
201
+ PrimerServer,
202
+ PrimerServerConfig,
203
+ SessionToken,
204
+ TimebackSession,
205
+ PlacementHints,
206
+ PlacementHintsResult
234
207
  } from "@superbuilders/primer-tives/server"
208
+ import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
209
+ import { GRADE_LEVELS, type GradeLevel } from "@superbuilders/primer-tives/grade-level"
235
210
  ```
236
211
 
237
212
  ## `createPrimerServer(config)`
@@ -242,13 +217,13 @@ interface PrimerServerConfig {
242
217
  readonly secretKey: string // your sk_… key
243
218
  readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
244
219
  readonly abort?: AbortController // wired into every request signal
245
- readonly logger: PrimerLogger // required debug/info/warn/error logger (console works)
220
+ readonly logger: PrimerLogger // required debug/info/warn/error logger
246
221
  }
247
222
 
248
223
  function createPrimerServer(config: PrimerServerConfig): PrimerServer
249
224
  ```
250
225
 
251
- Returns a `PrimerServer` with exactly **four** methods:
226
+ Returns a `PrimerServer` with exactly four methods:
252
227
 
253
228
  ```ts
254
229
  interface PrimerServer {
@@ -261,213 +236,113 @@ interface PrimerServer {
261
236
 
262
237
  ## Method reference
263
238
 
264
- ### `createStudent(): Promise<string>`
265
-
266
- Provision a new **frontend-owned Primer student** and return its stable `studentId`.
267
-
268
- Use this only for the native/manual flow.
269
-
270
- ```ts
271
- const studentId = await primer.createStudent()
272
- ```
239
+ ### `createStudent()`
273
240
 
274
- Notes:
241
+ Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
275
242
 
276
- - Call this when your system is the source of truth for learner identity.
277
- - Persist the returned `studentId` in your own database.
278
- - **Before the first session**, call `setStudentHints(studentId, { gradeLevel })`. Native students are created without a `gradeLevel`, and `/advance` requires one.
279
- - Use that stored `studentId` with `exchangeStudentForAccessToken(studentId)` at each session start.
280
- - `logger` is required. Pass any object implementing `debug/info/warn/error`; `console` is acceptable for basic integrations.
243
+ Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
281
244
 
282
- ### `setStudentHints(studentId, hints): Promise<PlacementHintsResult>`
245
+ ### `setStudentHints(studentId, hints)`
283
246
 
284
247
  Partial upsert of a native/manual student's placement-routing hints. Omitted fields are left untouched; the server returns the persisted state after upsert.
285
248
 
286
249
  ```ts
287
- const { gradeLevel } = await primer.setStudentHints(studentId, {
288
- gradeLevel: "3"
289
- })
250
+ const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
290
251
  ```
291
252
 
292
- - **Required** for native/manual students before their first session. Without a `gradeLevel` on record, the first `/advance` call fails server-side.
293
- - `gradeLevel` is the only hint today; future hint kinds (raw context, interests, etc.) will appear as additional optional fields on `PlacementHints`.
294
- - Safe to call repeatedly — each call is a partial upsert.
253
+ - Required for native/manual students before their first session. If the browser calls `/advance` without a `gradeLevel` on record, the server returns HTTP 412 `needs_hints` and the SDK surfaces it as `ErrNeedsHints` on a `fatal` state.
254
+ - Safe to call repeatedly. Each call is a partial upsert.
295
255
  - Not needed for Timeback-linked students: `exchangeTimebackStudentForAccessToken` syncs the grade level from the authority on every call.
296
- - Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g., unsupported `gradeLevel`).
297
-
298
- ### `exchangeStudentForAccessToken(studentId): Promise<SessionToken>`
299
-
300
- Mint a short-lived access token for an existing **native/manual** Primer student.
256
+ - Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
301
257
 
302
- ```ts
303
- const { accessToken, expiresInSeconds } =
304
- await primer.exchangeStudentForAccessToken(studentId)
305
- ```
258
+ ### `exchangeStudentForAccessToken(studentId)`
306
259
 
307
- Call this at **every session start**.
260
+ Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
308
261
 
309
- Important:
262
+ If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
310
263
 
311
- - This is for **native/manual** students only.
312
- - If `studentId` belongs to a Timeback-linked student, the method throws `ErrExternalAuthorityRequired`.
313
- - Tokens are short-lived (typically 15 minutes).
264
+ ### `exchangeTimebackStudentForAccessToken(sourcedId)`
314
265
 
315
- ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<TimebackSession>`
266
+ Live Timeback session start. Verifies the learner against Timeback on every call, resolves or provisions the corresponding Primer student row, and returns the stable `studentId`, the short-lived `accessToken`, and `expiresInSeconds`.
316
267
 
317
- Perform a **live-authoritative Timeback session start**.
318
-
319
- Primer verifies the learner against Timeback on every call, then resolves or provisions the corresponding frontend-owned Primer student and returns:
320
-
321
- - the stable Primer `studentId`
322
- - a short-lived `accessToken`
323
- - `expiresInSeconds`
324
-
325
- ```ts
326
- const { studentId, accessToken, expiresInSeconds } =
327
- await primer.exchangeTimebackStudentForAccessToken(sourcedId)
328
- ```
329
-
330
- Use this at **every Timeback session start**.
331
-
332
- Operational rule:
333
-
334
- - Call this at every Timeback session start.
335
- - Persist `studentId` if you need a stable Primer foreign key in your own system.
336
- - Use the returned `accessToken` for the active browser session.
268
+ Use at every Timeback session start. Persist `studentId` if you need a stable Primer foreign key.
337
269
 
338
270
  ## Return types
339
271
 
340
- ### `SessionToken`
341
-
342
272
  ```ts
343
273
  interface SessionToken {
344
274
  readonly accessToken: string
345
275
  readonly expiresInSeconds: number
346
276
  }
347
- ```
348
277
 
349
- ### `TimebackSession`
350
-
351
- ```ts
352
278
  interface TimebackSession {
353
279
  readonly studentId: string
354
280
  readonly accessToken: string
355
281
  readonly expiresInSeconds: number
356
282
  }
357
- ```
358
-
359
- ### `PlacementHints`
360
283
 
361
- ```ts
362
284
  interface PlacementHints {
363
285
  readonly gradeLevel?: GradeLevel
364
286
  }
365
- ```
366
-
367
- Partial shape: pass only the fields you want to set. Today the only hint is `gradeLevel` (the learner's current grade). Re-export `GRADE_LEVELS` enumerates the supported values.
368
287
 
369
- ### `PlacementHintsResult`
370
-
371
- ```ts
372
288
  interface PlacementHintsResult {
373
289
  readonly studentId: string
374
290
  readonly gradeLevel: GradeLevel | null
375
291
  }
376
292
  ```
377
293
 
378
- The persisted state after upsert. `gradeLevel` is `null` only on native students who have never had one set.
379
-
380
- ## Error sentinels (`/server`)
381
-
382
- All `/server` methods throw sentinel-wrapped `Error`s. Use `errors.try()` and `errors.is()` from `@superbuilders/errors`.
383
-
384
- | Sentinel | Raised when |
385
- |---|---|
386
- | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
387
- | `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
388
- | `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
389
- | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
390
- | `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
391
- | `ErrConflict` | HTTP 409 — frontend is not provisioned for routing/content |
392
- | `ErrBadRequest` | HTTP 400 — validation failure |
393
- | `ErrServerError` | HTTP 5xx |
394
- | `ErrJsonParse` | Success response body was not valid JSON or had the wrong shape |
395
- | `ErrNetwork` | fetch() rejected (DNS, connection, TLS, etc.) |
396
- | `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
397
-
398
- ### Recommended error-handling pattern
399
-
400
- ```ts
401
- import * as errors from "@superbuilders/errors"
402
- import {
403
- createPrimerServer,
404
- ErrExternalAuthorityRequired,
405
- ErrInvalidSecretKey,
406
- ErrStudentNotFound
407
- } from "@superbuilders/primer-tives/server"
408
-
409
- const primer = createPrimerServer({
410
- origin: "https://sb-primer.vercel.app",
411
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
412
- logger: console
413
- })
414
-
415
- const result = await errors.try(
416
- primer.exchangeStudentForAccessToken(studentId)
417
- )
418
- if (result.error) {
419
- if (errors.is(result.error, ErrInvalidSecretKey)) {
420
- // rotate or fix your sk_
421
- }
422
- if (errors.is(result.error, ErrStudentNotFound)) {
423
- // your stored studentId is stale or wrong for this frontend
424
- }
425
- if (errors.is(result.error, ErrExternalAuthorityRequired)) {
426
- // this student must authenticate through live Timeback exchange
427
- }
428
- throw result.error
429
- }
430
-
431
- const { accessToken } = result.data
432
- ```
294
+ `gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
433
295
 
434
296
  ## Rules of the road
435
297
 
436
- - **Native/manual flow:** `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
437
- - **Timeback flow:** `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
438
- - **Student ids are stable identifiers, not browser credentials.** Always hand the browser a fresh `accessToken` from your backend.
439
- - **Server-only secrets.** Keep `secretKey` on your backend; never ship it to the browser.
440
- - **Explicit subpaths only.** Import from `/server` or `/client`, never a package root.
298
+ - Native/manual flow: `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
299
+ - Timeback flow: `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
300
+ - Student ids are stable identifiers, not browser credentials. Always hand the browser a fresh `accessToken` from your backend.
301
+ - Server-only secrets: keep `secretKey` on your backend; never ship it to the browser.
441
302
 
442
303
  ---
443
304
 
444
305
  # `/client`
445
306
 
446
307
  ```ts
447
- import {
448
- create,
449
- type Client,
450
- type Config,
451
- type PrimerState,
452
- type PrimerLogger,
453
- type Subject,
454
- type SubjectScope,
455
- SUBJECTS,
456
- ErrRateLimited,
457
- // …sentinels + every state/interaction/content/PCI type
308
+ import { create } from "@superbuilders/primer-tives/client"
309
+ import type {
310
+ Client,
311
+ Config,
312
+ PrimerState,
313
+ ObservationState,
314
+ InteractionState,
315
+ ChoiceState,
316
+ TextEntryState,
317
+ ExtendedTextState,
318
+ ExtendedTextSingleState,
319
+ ExtendedTextMultipleState,
320
+ OrderState,
321
+ MatchState,
322
+ PciInteractionState,
323
+ FeedbackState,
324
+ CompletedState,
325
+ ErroredState,
326
+ FatalState,
327
+ NonSerializable,
328
+ PciPendingRenderProps,
329
+ PciSubmittedRenderProps,
330
+ PciRenderProps
458
331
  } from "@superbuilders/primer-tives/client"
459
332
  ```
460
333
 
334
+ The `/client` subpath owns the **runtime state machine** only. Wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types) come from `/contracts`.
335
+
461
336
  ## `create(config)`
462
337
 
463
338
  ```ts
464
339
  function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
465
340
 
466
341
  interface Config<Pcis extends PciId = PciId> {
467
- readonly accessToken: string // JWS from /server
468
- readonly supportedPcis: readonly Pcis[] // PCI URNs this renderer handles ([] if none)
469
- readonly origin: string // same origin you gave /server
470
- readonly subject: SubjectScope // "math" | "vocabulary" | "science" | "all"
342
+ readonly accessToken: string
343
+ readonly supportedPcis: readonly Pcis[]
344
+ readonly origin: string
345
+ readonly subject: SubjectScope
471
346
  readonly fetch?: typeof globalThis.fetch
472
347
  readonly abort?: AbortController
473
348
  readonly logger?: PrimerLogger
@@ -478,46 +353,23 @@ interface Client<Pcis extends PciId = PciId> {
478
353
  }
479
354
  ```
480
355
 
481
- `create()` does a cheap structural check on the token (must start with `eyJ` and contain two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
482
-
483
- ### `subject` scope
484
-
485
- | Value | Behavior |
486
- |---|---|
487
- | `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
488
- | `"all"` | No filter; drills from any subject the frontend is bound to |
489
-
490
- Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.
356
+ `create()` does a structural check on the token (must start with `eyJ` and contain two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
491
357
 
492
358
  ## `PrimerState` — the state machine
493
359
 
494
- `start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
495
-
496
360
  ```ts
497
361
  type PrimerState =
498
- | ObservationState // advance()
499
- | InteractionState // submit…() / timeout()
500
- | FeedbackState // advance()
501
- | CompletedState // terminal
502
- | ErroredState // retry() — retriable:boolean
503
- | FatalState // terminal
362
+ | ObservationState // .advance()
363
+ | InteractionState // .submit…() / .timeout()
364
+ | FeedbackState // .advance()
365
+ | CompletedState // terminal
366
+ | ErroredState // .retry() — .retriable: boolean
367
+ | FatalState // terminal
504
368
  ```
505
369
 
506
- ### `observation`
370
+ ### Interaction action methods
507
371
 
508
- Server wants you to show a stimulus and the student to indicate they're done reading.
509
-
510
- ```ts
511
- interface ObservationState {
512
- readonly phase: "observation"
513
- readonly stimulus: RendererStimulus | null
514
- advance(): Promise<PrimerState>
515
- }
516
- ```
517
-
518
- ### `interaction`
519
-
520
- Server wants an answer. `InteractionState` is a discriminated union over `kind`:
372
+ `InteractionState` is a discriminated union over `kind`:
521
373
 
522
374
  | `kind` | method |
523
375
  |---|---|
@@ -529,31 +381,31 @@ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
529
381
  | `match` | `submitMatch(pairs: MatchPair[])` |
530
382
  | `portable-custom` | `submit(value: PciValue<K>)` |
531
383
 
532
- All six shapes also expose `timeout(): Promise<PrimerState>`.
384
+ Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
533
385
 
534
- ### `feedback`
386
+ `MatchPair`, `PciValue<K>` come from `/contracts`.
535
387
 
536
- Server has graded the submission and returned feedback content.
388
+ ### `FeedbackState`
537
389
 
538
390
  ```ts
539
- interface FeedbackState {
391
+ interface FeedbackState extends NonSerializable {
540
392
  readonly phase: "feedback"
541
393
  readonly stimulus: RendererStimulus | null
542
394
  readonly interaction: RendererInteraction
543
395
  readonly submission: RendererSubmission
544
396
  readonly isCorrect: boolean
545
- readonly feedbackContent: ContentInline[] // server-provided, localized feedback
546
- readonly review: InteractionReview | null // correct answers etc., if available
397
+ readonly feedbackContent: ContentInline[]
398
+ readonly review: InteractionReview | null
547
399
  advance(): Promise<PrimerState>
548
400
  }
549
401
  ```
550
402
 
551
- ### `errored`
403
+ `RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentInline`, `InteractionReview` all come from `/contracts`.
552
404
 
553
- Transport or validation failure that might be recoverable.
405
+ ### `ErroredState`
554
406
 
555
407
  ```ts
556
- interface ErroredState {
408
+ interface ErroredState extends NonSerializable {
557
409
  readonly phase: "errored"
558
410
  readonly error: Error // sentinel-wrapped
559
411
  readonly retriable: boolean
@@ -561,109 +413,238 @@ interface ErroredState {
561
413
  }
562
414
  ```
563
415
 
564
- ### `fatal`
416
+ `errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
417
+
418
+ ### `FatalState`
565
419
 
566
- Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
420
+ Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
567
421
 
568
422
  ```ts
569
- interface FatalState {
423
+ interface FatalState extends NonSerializable {
570
424
  readonly phase: "fatal"
571
425
  readonly error: Error
572
426
  readonly retriable: false
573
427
  }
574
428
  ```
575
429
 
576
- ### `completed`
430
+ ## PCI render props
577
431
 
578
- Terminal. Session is done.
432
+ Only the renderer-side render props live here. PCI base types (`PciId`, `PciProps`, `PciValue`, `PciRegistry`, `PciUrn`, plus the per-PCI `*Props`/`*Submission` interfaces) come from `/contracts`.
579
433
 
580
434
  ```ts
581
- interface CompletedState {
582
- readonly phase: "completed"
435
+ type PciPendingRenderProps<K extends PciId> = {
436
+ mode: "pending"
437
+ properties: PciProps<K>
438
+ onValueChange: (value: PciValue<K> | null) => void
583
439
  }
440
+
441
+ type PciSubmittedRenderProps<K extends PciId> = {
442
+ mode: "submitted"
443
+ properties: PciProps<K>
444
+ submission: PciValue<K>
445
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
446
+ }
447
+
448
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
584
449
  ```
585
450
 
586
- ## Content format
451
+ ## Behavioral notes
452
+
453
+ - `start()` is idempotent — calling it twice returns the same promise.
454
+ - A returning student resumes wherever the server last placed them. No client-side cursor management.
455
+ - `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
456
+ - `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
457
+
458
+ ---
459
+
460
+ # `/contracts`
461
+
462
+ Wire-shape types, Zod schemas, review types, and an optional submission validator. Safe to import from either side of the wire — backend code lives here too.
587
463
 
588
464
  ```ts
589
- type ContentSpan =
590
- | { type: "text"; value: string }
591
- | { type: "italic"; value: string }
465
+ import {
466
+ ChoiceSubmissionSchema,
467
+ TextEntrySubmissionSchema,
468
+ ExtendedTextSubmissionSchema,
469
+ OrderSubmissionSchema,
470
+ MatchSubmissionSchema,
471
+ MatchPairSchema,
472
+ DivisionRemainderPciSubmissionSchema,
473
+ FractionAdditionPciSubmissionSchema,
474
+ RendererSubmissionSchema,
475
+ validateSubmissionForInteraction,
476
+ submissionValidationMessage,
477
+ blocksToPlainText,
478
+ inlinesToPlainText
479
+ } from "@superbuilders/primer-tives/contracts"
592
480
 
593
- type ContentInline =
594
- | ContentSpan
595
- | { type: "latex"; value: string }
481
+ import type {
482
+ // interaction & submission shapes
483
+ RendererInteraction,
484
+ RendererSubmission,
485
+ StandardRendererInteraction,
486
+ PciInteraction,
487
+ PciSubmission,
488
+ // stimulus shapes
489
+ RendererStimulus,
490
+ BodyStimulus,
491
+ ImageStimulus,
492
+ // choice shapes
493
+ RendererChoice,
494
+ RendererMatchChoice,
495
+ MatchPair,
496
+ // content
497
+ ContentBlock,
498
+ ContentInline,
499
+ ContentSpan,
500
+ // PCI base
501
+ PciId,
502
+ PciUrn,
503
+ PciRegistry,
504
+ PciProps,
505
+ PciValue,
506
+ DivisionRemainderProps,
507
+ DivisionRemainderSubmission,
508
+ FractionAdditionProps,
509
+ FractionAdditionSubmission,
510
+ // review (server emits these, client consumes)
511
+ InteractionReview,
512
+ ChoiceReview,
513
+ TextEntryReview,
514
+ ExtendedTextReview,
515
+ OrderReview,
516
+ MatchReview,
517
+ PciReview,
518
+ ReviewRecordField,
519
+ ReviewRecordFieldBaseType,
520
+ ReviewScalarValue,
521
+ // validation result
522
+ SubmissionValidationResult,
523
+ SubmissionValidationSuccess,
524
+ SubmissionValidationFailure
525
+ } from "@superbuilders/primer-tives/contracts"
526
+ ```
527
+
528
+ ## Schemas
529
+
530
+ Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
531
+
532
+ ```ts
533
+ import * as errors from "@superbuilders/errors"
534
+ import * as logger from "@superbuilders/slog"
535
+ import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
596
536
 
597
- type ContentBlock = { type: "paragraph"; children: ContentInline[] }
537
+ const result = RendererSubmissionSchema.safeParse(unknownPayload)
538
+ if (!result.success) {
539
+ logger.error("submission payload failed schema parse", { issues: result.error.issues })
540
+ throw errors.wrap(result.error, "renderer submission parse")
541
+ }
542
+ const submission = result.data
598
543
  ```
599
544
 
600
- All three inline variants share the uniform `{ type, value: string }` shape. `ContentSpan` covers HTML-ish rich-text formatting. `latex` is for inline math expressions (render via Temml).
545
+ ## `validateSubmissionForInteraction(interaction, submission)`
601
546
 
602
- Helpers:
547
+ Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
603
548
 
604
549
  ```ts
605
- inlinesToPlainText(nodes: ContentInline[]): string
606
- blocksToPlainText(blocks: ContentBlock[]): string
550
+ function validateSubmissionForInteraction(
551
+ interaction: RendererInteraction,
552
+ submission: RendererSubmission
553
+ ): SubmissionValidationResult
554
+
555
+ type SubmissionValidationResult =
556
+ | { ok: true; value: RendererSubmission }
557
+ | { ok: false; issues: readonly string[] }
607
558
  ```
608
559
 
609
- ## PCI system (Portable Custom Interactions)
560
+ `submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
610
561
 
611
562
  ```ts
612
- type PciUrn = "urn:primer:pci:division-remainder" | "urn:primer:pci:fraction-addition"
563
+ import * as errors from "@superbuilders/errors"
564
+ import * as logger from "@superbuilders/slog"
565
+ import {
566
+ submissionValidationMessage,
567
+ validateSubmissionForInteraction
568
+ } from "@superbuilders/primer-tives/contracts"
569
+ import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
613
570
 
614
- type PciRegistry = {
615
- "urn:primer:pci:division-remainder": {
616
- props: DivisionRemainderProps
617
- value: DivisionRemainderSubmission
618
- }
619
- "urn:primer:pci:fraction-addition": {
620
- props: FractionAdditionProps
621
- value: FractionAdditionSubmission
622
- }
571
+ const validation = validateSubmissionForInteraction(interaction, submission)
572
+ if (!validation.ok) {
573
+ logger.warn("submission rejected by contract validation", { issues: validation.issues })
574
+ throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
623
575
  }
624
-
625
- type PciId = keyof PciRegistry
626
- type PciProps<K extends PciId> = PciRegistry[K]["props"]
627
- type PciValue<K extends PciId> = PciRegistry[K]["value"]
628
576
  ```
629
577
 
630
- Declare the PCI URNs your renderer can handle via `Config.supportedPcis`. Frames requiring an unsupported PCI resolve to `fatal` with `ErrUnsupportedPci`.
578
+ Validation is **optional at every layer**. The Primer server grades submissions independently and is the only correctness boundary that matters. The browser SDK's built-in interaction state machines call this validator on your behalf before each submit; if you bypass those state machines and write to the wire yourself, you decide whether to validate.
631
579
 
632
- ### Renderer props
580
+ ---
633
581
 
634
- ```ts
635
- type PciPendingRenderProps<K extends PciId> = {
636
- mode: "pending"
637
- properties: PciProps<K>
638
- onValueChange: (value: PciValue<K> | null) => void
639
- }
582
+ # `/errors`
640
583
 
641
- type PciSubmittedRenderProps<K extends PciId> = {
642
- mode: "submitted"
643
- properties: PciProps<K>
644
- submission: PciValue<K>
645
- review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
646
- }
584
+ Every sentinel `Err…` value used by the SDK lives here and only here. Use `errors.is()` from `@superbuilders/errors` to type-check against them.
647
585
 
648
- type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
649
- ```
586
+ ```ts
587
+ import {
588
+ // shared (server + client transport)
589
+ ErrBadRequest,
590
+ ErrConflict,
591
+ ErrJsonParse,
592
+ ErrNetwork,
593
+ ErrServerError,
594
+ ErrTimeout,
595
+ // /server-thrown
596
+ ErrInvalidSecretKey,
597
+ ErrStudentNotFound,
598
+ ErrUnsupportedGrade,
599
+ ErrTimebackUnavailable,
600
+ ErrExternalAuthorityRequired,
601
+ // /client surfaced as `errored`
602
+ ErrServiceUnavailable,
603
+ ErrRateLimited,
604
+ ErrInvalidSubmission,
605
+ // /client surfaced as `fatal`
606
+ ErrInvalidAccessToken,
607
+ ErrTokenExpired,
608
+ ErrForbidden,
609
+ ErrNotFound,
610
+ ErrNeedsHints,
611
+ ErrUnsupportedPci,
612
+ // /client thrown directly by `create()`
613
+ ErrMalformedAccessToken,
614
+ ErrNotSerializable
615
+ } from "@superbuilders/primer-tives/errors"
616
+ ```
617
+
618
+ ## Server-side method failures
650
619
 
651
- ## Error sentinels (`/client`)
620
+ | Sentinel | Raised when |
621
+ |---|---|
622
+ | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
623
+ | `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
624
+ | `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
625
+ | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
626
+ | `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
627
+ | `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
628
+ | `ErrBadRequest` | HTTP 400 — validation failure |
629
+ | `ErrServerError` | HTTP 5xx |
630
+ | `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
631
+ | `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
632
+ | `ErrTimeout` | `fetch()` aborted |
652
633
 
653
- ### Surfaced as `errored`
634
+ ## Client `errored.error`
654
635
 
655
636
  | Sentinel | Raised when |
656
637
  |---|---|
657
- | `ErrNetwork` | fetch() rejected |
658
- | `ErrTimeout` | fetch() aborted |
638
+ | `ErrNetwork` | `fetch()` rejected |
639
+ | `ErrTimeout` | `fetch()` aborted |
659
640
  | `ErrServerError` | HTTP 5xx |
660
641
  | `ErrServiceUnavailable` | HTTP 502/503/504 |
661
642
  | `ErrRateLimited` | HTTP 429 |
662
643
  | `ErrConflict` | HTTP 409 |
663
- | `ErrJsonParse` | Success response body wasn't valid JSON |
644
+ | `ErrJsonParse` | success body wasn't valid JSON |
664
645
  | `ErrInvalidSubmission` | client-side validation rejected the submission |
665
646
 
666
- ### Surfaced as `fatal`
647
+ ## Client `fatal.error`
667
648
 
668
649
  | Sentinel | Raised when |
669
650
  |---|---|
@@ -672,18 +653,59 @@ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRe
672
653
  | `ErrTokenExpired` | HTTP 401 with token-expired detail |
673
654
  | `ErrForbidden` | HTTP 403 |
674
655
  | `ErrNotFound` | HTTP 404 |
656
+ | `ErrNeedsHints` | HTTP 412 — the student has no `gradeLevel` on record; backend must call `setStudentHints` and mint a fresh access token |
675
657
  | `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
676
658
 
677
- ### Thrown directly by `create()`
659
+ ## Thrown directly by `create()`
678
660
 
679
661
  | Sentinel | Raised when |
680
662
  |---|---|
681
663
  | `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
682
664
  | `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
683
665
 
684
- ## Logger interface
666
+ ## Recommended consumer pattern
667
+
668
+ Per `@superbuilders/errors`: log every error before throwing, wrap external errors with terse Go-style context, never bare `throw new Error(...)`.
669
+
670
+ ```ts
671
+ import * as errors from "@superbuilders/errors"
672
+ import * as logger from "@superbuilders/slog"
673
+ import {
674
+ ErrExternalAuthorityRequired,
675
+ ErrInvalidSecretKey,
676
+ ErrStudentNotFound
677
+ } from "@superbuilders/primer-tives/errors"
678
+
679
+ const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
680
+ if (tokenResult.error) {
681
+ if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
682
+ logger.error("primer secret key invalid", { error: tokenResult.error })
683
+ throw errors.wrap(tokenResult.error, "primer token exchange")
684
+ }
685
+ if (errors.is(tokenResult.error, ErrStudentNotFound)) {
686
+ logger.error("primer student id stale", { studentId, error: tokenResult.error })
687
+ throw errors.wrap(tokenResult.error, "primer token exchange")
688
+ }
689
+ if (errors.is(tokenResult.error, ErrExternalAuthorityRequired)) {
690
+ logger.error("primer student requires timeback exchange", {
691
+ studentId,
692
+ error: tokenResult.error
693
+ })
694
+ throw errors.wrap(tokenResult.error, "primer token exchange")
695
+ }
696
+ logger.error("primer token exchange failed", { error: tokenResult.error })
697
+ throw errors.wrap(tokenResult.error, "primer token exchange")
698
+ }
699
+ const { accessToken } = tokenResult.data
700
+ ```
701
+
702
+ ---
703
+
704
+ # `/logger`
685
705
 
686
706
  ```ts
707
+ import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
708
+
687
709
  interface PrimerLogger {
688
710
  debug(message: string, attributes?: Record<string, unknown>): void
689
711
  info(message: string, attributes?: Record<string, unknown>): void
@@ -692,80 +714,40 @@ interface PrimerLogger {
692
714
  }
693
715
  ```
694
716
 
695
- Same shape on both sides. Plug in your slog/pino/console adapter.
696
-
697
- ## Behavioral notes
698
-
699
- - **`start()` is idempotent.** Calling it twice returns the same promise.
700
- - **Retry semantics.** `errored.retry()` re-runs the same intent. `errored.retriable === false` for client-validation errors (e.g. `ErrInvalidSubmission`) — those must be fixed, not retried.
701
- - **Session resumption.** A returning student resumes wherever the server last placed them. No client-side cursor management.
702
- - **Live state.** `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
703
- - **PCI type safety.** `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState`/`PciSubmission` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
717
+ Required by `PrimerServerConfig`, optional on `Config` (the client config). `@superbuilders/slog` matches this shape directly — `import * as logger from "@superbuilders/slog"` and pass `logger`.
704
718
 
705
719
  ---
706
720
 
707
- # `/contracts`
708
-
709
- Wire-shape types, Zod schemas, and an optional submission validator. Safe to import from either side of the wire.
721
+ # `/grade-level`
710
722
 
711
723
  ```ts
712
- import {
713
- // schemas
714
- ChoiceSubmissionSchema,
715
- TextEntrySubmissionSchema,
716
- ExtendedTextSubmissionSchema,
717
- OrderSubmissionSchema,
718
- MatchSubmissionSchema,
719
- MatchPairSchema,
720
- DivisionRemainderPciSubmissionSchema,
721
- FractionAdditionPciSubmissionSchema,
722
- RendererSubmissionSchema,
723
- // validator
724
- validateSubmissionForInteraction,
725
- submissionValidationMessage,
726
- // types
727
- type RendererInteraction,
728
- type RendererSubmission,
729
- type StandardRendererInteraction,
730
- type PciInteraction,
731
- type PciSubmission,
732
- type BodyStimulus,
733
- type ImageStimulus,
734
- type MatchPair,
735
- type RendererChoice,
736
- type RendererMatchChoice,
737
- type RendererStimulus,
738
- type SubmissionValidationResult,
739
- type SubmissionValidationSuccess,
740
- type SubmissionValidationFailure
741
- } from "@superbuilders/primer-tives/contracts"
742
- ```
743
-
744
- ## Schemas
745
-
746
- Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
724
+ import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
725
+ import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
747
726
 
748
- ```ts
749
- const result = RendererSubmissionSchema.safeParse(unknownPayload)
750
- if (!result.success) { /* … */ }
751
- const submission = result.data // typed as RendererSubmission
727
+ const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
728
+ type GradeLevel = (typeof GRADE_LEVELS)[number]
752
729
  ```
753
730
 
754
- ## `validateSubmissionForInteraction(interaction, submission)`
731
+ Used as the `gradeLevel` field on `PlacementHints` / `PlacementHintsResult`.
755
732
 
756
- Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
733
+ ---
734
+
735
+ # `/subject`
757
736
 
758
737
  ```ts
759
- function validateSubmissionForInteraction(
760
- interaction: RendererInteraction,
761
- submission: RendererSubmission
762
- ): SubmissionValidationResult
738
+ import { SUBJECTS } from "@superbuilders/primer-tives/subject"
739
+ import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
763
740
 
764
- type SubmissionValidationResult =
765
- | { ok: true; value: RendererSubmission }
766
- | { ok: false; issues: readonly string[] }
741
+ const SUBJECTS = ["math", "vocabulary", "science"] as const
742
+ type Subject = (typeof SUBJECTS)[number]
743
+ type SubjectScope = Subject | "all"
767
744
  ```
768
745
 
769
- `submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for cases where you want a single string.
746
+ Used as the `subject` field on the client `Config`.
770
747
 
771
- Validation is **optional at every layer.** The Primer server grades submissions independently and is the only correctness boundary that matters. The browser SDK's built-in interaction state machines call this validator on your behalf before each submit; if you bypass those state machines and write to the wire yourself, you decide whether to validate.
748
+ | Value | Behavior |
749
+ |---|---|
750
+ | `"math"` / `"vocabulary"` / `"science"` | restrict drill selection to courses of that subject |
751
+ | `"all"` | no filter; drills from any subject the frontend is bound to |
752
+
753
+ Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.