@superbuilders/primer-tives 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +393 -411
  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 +49 -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/grade-level.js +7 -0
  43. package/dist/grade-level.js.map +10 -0
  44. package/dist/logger.js +2 -0
  45. package/dist/logger.js.map +9 -0
  46. package/dist/server/index.d.ts +0 -4
  47. package/dist/server/index.d.ts.map +1 -1
  48. package/dist/server/index.js +4 -19
  49. package/dist/server/index.js.map +4 -4
  50. package/dist/subject.js +7 -0
  51. package/dist/subject.js.map +10 -0
  52. package/package.json +13 -6
  53. package/dist/client/content.d.ts.map +0 -1
  54. package/dist/client/pci.d.ts.map +0 -1
  55. package/dist/index.d.ts +0 -3
  56. package/dist/index.d.ts.map +0 -1
  57. package/dist/index.js +0 -344
  58. package/dist/index.js.map +0 -12
  59. /package/dist/{client → contracts}/content.d.ts +0 -0
  60. /package/dist/{client → contracts}/pci.d.ts +0 -0
package/README.md CHANGED
@@ -6,107 +6,93 @@ 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
+ The package has **no root export**. Every symbol lives at exactly one subpath. Pick 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.
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…`) — only home for them |
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 |
46
24
 
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.
25
+ There is **no convenience re-export**. Importing `ErrInvalidSecretKey` from `/server` or `/client` will not work it lives at `/errors` only. Same for `PrimerLogger` (`/logger`), grade level constants (`/grade-level`), subjects (`/subject`), and every wire-shape type (`/contracts`).
48
26
 
49
27
  ## End-to-end examples
50
28
 
51
- ### Native/manual flow
29
+ ### Native/manual student flow
52
30
 
53
31
  ```ts
54
- // ── your backend ─────────────────────────────────────────────────────
55
32
  import * as errors from "@superbuilders/errors"
33
+ import * as logger from "@superbuilders/slog"
34
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
56
35
  import {
57
- createPrimerServer,
58
36
  ErrBadRequest,
59
37
  ErrConflict,
60
38
  ErrInvalidSecretKey,
61
39
  ErrStudentNotFound
62
- } from "@superbuilders/primer-tives/server"
40
+ } from "@superbuilders/primer-tives/errors"
63
41
 
64
42
  const primer = createPrimerServer({
65
43
  origin: "https://sb-primer.vercel.app",
66
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
67
- logger: console
44
+ secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
45
+ logger
68
46
  })
69
47
 
70
48
  // One time per user.
71
49
  const createResult = await errors.try(primer.createStudent())
72
50
  if (createResult.error) {
73
51
  if (errors.is(createResult.error, ErrInvalidSecretKey)) {
74
- throw new Error("Primer secret key is invalid")
52
+ logger.error("primer secret key invalid", { error: createResult.error })
53
+ throw errors.wrap(createResult.error, "primer client init")
75
54
  }
76
55
  if (errors.is(createResult.error, ErrConflict)) {
77
- throw new Error("This Primer frontend is not provisioned with routable content")
56
+ logger.error("primer frontend not provisioned", { error: createResult.error })
57
+ throw errors.wrap(createResult.error, "primer frontend provisioning")
78
58
  }
79
- throw createResult.error
59
+ logger.error("primer create student failed", { error: createResult.error })
60
+ throw errors.wrap(createResult.error, "primer create student")
80
61
  }
81
62
  const studentId = createResult.data
82
- // persist `studentId` alongside your own user record
63
+ // persist `studentId` alongside your own user record.
83
64
 
84
65
  // Required before the first session for native students.
85
- const hintsResult = await errors.try(
86
- primer.setStudentHints(studentId, { gradeLevel: "3" })
87
- )
66
+ const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
88
67
  if (hintsResult.error) {
89
68
  if (errors.is(hintsResult.error, ErrStudentNotFound)) {
90
- throw new Error("Stored Primer student id no longer exists on this frontend")
69
+ logger.error("primer student id not found on this frontend", {
70
+ studentId,
71
+ error: hintsResult.error
72
+ })
73
+ throw errors.wrap(hintsResult.error, "primer set hints")
91
74
  }
92
75
  if (errors.is(hintsResult.error, ErrBadRequest)) {
93
- throw new Error("Invalid hint payload (e.g. unsupported gradeLevel)")
76
+ logger.error("primer hint payload rejected", { error: hintsResult.error })
77
+ throw errors.wrap(hintsResult.error, "primer set hints")
94
78
  }
95
- throw hintsResult.error
79
+ logger.error("primer set hints failed", { error: hintsResult.error })
80
+ throw errors.wrap(hintsResult.error, "primer set hints")
96
81
  }
97
82
 
98
83
  // Every session start.
99
- const tokenResult = await errors.try(
100
- primer.exchangeStudentForAccessToken(studentId)
101
- )
84
+ const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
102
85
  if (tokenResult.error) {
103
86
  if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
104
- throw new Error("Primer secret key is invalid")
87
+ logger.error("primer secret key invalid", { error: tokenResult.error })
88
+ throw errors.wrap(tokenResult.error, "primer token exchange")
105
89
  }
106
90
  if (errors.is(tokenResult.error, ErrStudentNotFound)) {
107
- throw new Error("Stored Primer student id no longer exists on this frontend")
91
+ logger.error("primer student id stale", { studentId, error: tokenResult.error })
92
+ throw errors.wrap(tokenResult.error, "primer token exchange")
108
93
  }
109
- throw tokenResult.error
94
+ logger.error("primer token exchange failed", { error: tokenResult.error })
95
+ throw errors.wrap(tokenResult.error, "primer token exchange")
110
96
  }
111
97
 
112
98
  const { accessToken, expiresInSeconds } = tokenResult.data
@@ -115,58 +101,62 @@ const { accessToken, expiresInSeconds } = tokenResult.data
115
101
  ### Live-authoritative Timeback flow
116
102
 
117
103
  ```ts
118
- // ── your backend ─────────────────────────────────────────────────────
119
104
  import * as errors from "@superbuilders/errors"
105
+ import * as logger from "@superbuilders/slog"
106
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
120
107
  import {
121
- createPrimerServer,
122
108
  ErrConflict,
123
109
  ErrInvalidSecretKey,
124
110
  ErrStudentNotFound,
125
111
  ErrTimebackUnavailable,
126
112
  ErrUnsupportedGrade
127
- } from "@superbuilders/primer-tives/server"
113
+ } from "@superbuilders/primer-tives/errors"
128
114
 
129
115
  const primer = createPrimerServer({
130
116
  origin: "https://sb-primer.vercel.app",
131
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY!,
132
- logger: console
117
+ secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
118
+ logger
133
119
  })
134
120
 
135
121
  const sessionResult = await errors.try(
136
- primer.exchangeTimebackStudentForAccessToken("student-123")
122
+ primer.exchangeTimebackStudentForAccessToken(sourcedId)
137
123
  )
138
124
  if (sessionResult.error) {
139
125
  if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
140
- throw new Error("Primer secret key is invalid")
126
+ logger.error("primer secret key invalid", { error: sessionResult.error })
127
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
141
128
  }
142
129
  if (errors.is(sessionResult.error, ErrStudentNotFound)) {
143
- throw new Error("Timeback sourcedId was not found upstream")
130
+ logger.error("timeback sourcedId not found upstream", {
131
+ sourcedId,
132
+ error: sessionResult.error
133
+ })
134
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
144
135
  }
145
136
  if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
146
- throw new Error("Timeback returned a grade Primer does not support")
137
+ logger.error("timeback grade outside supported range", { error: sessionResult.error })
138
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
147
139
  }
148
140
  if (errors.is(sessionResult.error, ErrConflict)) {
149
- throw new Error("This Primer frontend is not provisioned with routable content")
141
+ logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
142
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
150
143
  }
151
144
  if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
152
- throw new Error("Timeback is temporarily unavailable")
145
+ logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
146
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
153
147
  }
154
- throw sessionResult.error
148
+ logger.error("primer timeback exchange failed", { error: sessionResult.error })
149
+ throw errors.wrap(sessionResult.error, "primer timeback exchange")
155
150
  }
156
151
 
157
152
  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
153
  ```
161
154
 
162
155
  ### Browser flow
163
156
 
164
157
  ```ts
165
- // ── your frontend ────────────────────────────────────────────────────
166
- import {
167
- create,
168
- type PrimerState
169
- } from "@superbuilders/primer-tives/client"
158
+ import { create } from "@superbuilders/primer-tives/client"
159
+ import type { PrimerState } from "@superbuilders/primer-tives/client"
170
160
 
171
161
  const client = create({
172
162
  accessToken,
@@ -196,10 +186,9 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
196
186
  case "errored":
197
187
  if (state.retriable) {
198
188
  state = await state.retry()
199
- } else {
200
- throw state.error
189
+ break
201
190
  }
202
- break
191
+ throw state.error
203
192
  }
204
193
  }
205
194
  ```
@@ -209,29 +198,17 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
209
198
  # `/server`
210
199
 
211
200
  ```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
201
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
202
+ import type {
203
+ PrimerServer,
204
+ PrimerServerConfig,
205
+ SessionToken,
206
+ TimebackSession,
207
+ PlacementHints,
208
+ PlacementHintsResult
234
209
  } from "@superbuilders/primer-tives/server"
210
+ import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
211
+ import { GRADE_LEVELS, type GradeLevel } from "@superbuilders/primer-tives/grade-level"
235
212
  ```
236
213
 
237
214
  ## `createPrimerServer(config)`
@@ -242,13 +219,13 @@ interface PrimerServerConfig {
242
219
  readonly secretKey: string // your sk_… key
243
220
  readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
244
221
  readonly abort?: AbortController // wired into every request signal
245
- readonly logger: PrimerLogger // required debug/info/warn/error logger (console works)
222
+ readonly logger: PrimerLogger // required debug/info/warn/error logger
246
223
  }
247
224
 
248
225
  function createPrimerServer(config: PrimerServerConfig): PrimerServer
249
226
  ```
250
227
 
251
- Returns a `PrimerServer` with exactly **four** methods:
228
+ Returns a `PrimerServer` with exactly four methods:
252
229
 
253
230
  ```ts
254
231
  interface PrimerServer {
@@ -261,213 +238,113 @@ interface PrimerServer {
261
238
 
262
239
  ## Method reference
263
240
 
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.
241
+ ### `createStudent()`
269
242
 
270
- ```ts
271
- const studentId = await primer.createStudent()
272
- ```
243
+ Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
273
244
 
274
- Notes:
245
+ Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
275
246
 
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.
281
-
282
- ### `setStudentHints(studentId, hints): Promise<PlacementHintsResult>`
247
+ ### `setStudentHints(studentId, hints)`
283
248
 
284
249
  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
250
 
286
251
  ```ts
287
- const { gradeLevel } = await primer.setStudentHints(studentId, {
288
- gradeLevel: "3"
289
- })
252
+ const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
290
253
  ```
291
254
 
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.
255
+ - Required for native/manual students before their first session the first `/advance` call fails server-side without a `gradeLevel` on record.
256
+ - Safe to call repeatedly. Each call is a partial upsert.
295
257
  - 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`).
258
+ - Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
297
259
 
298
- ### `exchangeStudentForAccessToken(studentId): Promise<SessionToken>`
260
+ ### `exchangeStudentForAccessToken(studentId)`
299
261
 
300
- Mint a short-lived access token for an existing **native/manual** Primer student.
262
+ Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
301
263
 
302
- ```ts
303
- const { accessToken, expiresInSeconds } =
304
- await primer.exchangeStudentForAccessToken(studentId)
305
- ```
306
-
307
- Call this at **every session start**.
308
-
309
- Important:
310
-
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
+ If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
314
265
 
315
- ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<TimebackSession>`
266
+ ### `exchangeTimebackStudentForAccessToken(sourcedId)`
316
267
 
317
- Perform a **live-authoritative Timeback session start**.
268
+ 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`.
318
269
 
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.
270
+ Use at every Timeback session start. Persist `studentId` if you need a stable Primer foreign key.
337
271
 
338
272
  ## Return types
339
273
 
340
- ### `SessionToken`
341
-
342
274
  ```ts
343
275
  interface SessionToken {
344
276
  readonly accessToken: string
345
277
  readonly expiresInSeconds: number
346
278
  }
347
- ```
348
-
349
- ### `TimebackSession`
350
279
 
351
- ```ts
352
280
  interface TimebackSession {
353
281
  readonly studentId: string
354
282
  readonly accessToken: string
355
283
  readonly expiresInSeconds: number
356
284
  }
357
- ```
358
285
 
359
- ### `PlacementHints`
360
-
361
- ```ts
362
286
  interface PlacementHints {
363
287
  readonly gradeLevel?: GradeLevel
364
288
  }
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
-
369
- ### `PlacementHintsResult`
370
289
 
371
- ```ts
372
290
  interface PlacementHintsResult {
373
291
  readonly studentId: string
374
292
  readonly gradeLevel: GradeLevel | null
375
293
  }
376
294
  ```
377
295
 
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
- ```
296
+ `gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
433
297
 
434
298
  ## Rules of the road
435
299
 
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.
300
+ - Native/manual flow: `createStudent()` once, then `exchangeStudentForAccessToken(studentId)` every session.
301
+ - Timeback flow: `exchangeTimebackStudentForAccessToken(sourcedId)` every session.
302
+ - Student ids are stable identifiers, not browser credentials. Always hand the browser a fresh `accessToken` from your backend.
303
+ - Server-only secrets: keep `secretKey` on your backend; never ship it to the browser.
441
304
 
442
305
  ---
443
306
 
444
307
  # `/client`
445
308
 
446
309
  ```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
310
+ import { create } from "@superbuilders/primer-tives/client"
311
+ import type {
312
+ Client,
313
+ Config,
314
+ PrimerState,
315
+ ObservationState,
316
+ InteractionState,
317
+ ChoiceState,
318
+ TextEntryState,
319
+ ExtendedTextState,
320
+ ExtendedTextSingleState,
321
+ ExtendedTextMultipleState,
322
+ OrderState,
323
+ MatchState,
324
+ PciInteractionState,
325
+ FeedbackState,
326
+ CompletedState,
327
+ ErroredState,
328
+ FatalState,
329
+ NonSerializable,
330
+ PciPendingRenderProps,
331
+ PciSubmittedRenderProps,
332
+ PciRenderProps
458
333
  } from "@superbuilders/primer-tives/client"
459
334
  ```
460
335
 
336
+ The `/client` subpath owns the **runtime state machine** only. Wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types) come from `/contracts`.
337
+
461
338
  ## `create(config)`
462
339
 
463
340
  ```ts
464
341
  function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
465
342
 
466
343
  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"
344
+ readonly accessToken: string
345
+ readonly supportedPcis: readonly Pcis[]
346
+ readonly origin: string
347
+ readonly subject: SubjectScope
471
348
  readonly fetch?: typeof globalThis.fetch
472
349
  readonly abort?: AbortController
473
350
  readonly logger?: PrimerLogger
@@ -478,46 +355,23 @@ interface Client<Pcis extends PciId = PciId> {
478
355
  }
479
356
  ```
480
357
 
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`.
358
+ `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
359
 
492
360
  ## `PrimerState` — the state machine
493
361
 
494
- `start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
495
-
496
362
  ```ts
497
363
  type PrimerState =
498
- | ObservationState // advance()
499
- | InteractionState // submit…() / timeout()
500
- | FeedbackState // advance()
501
- | CompletedState // terminal
502
- | ErroredState // retry() — retriable:boolean
503
- | FatalState // terminal
364
+ | ObservationState // .advance()
365
+ | InteractionState // .submit…() / .timeout()
366
+ | FeedbackState // .advance()
367
+ | CompletedState // terminal
368
+ | ErroredState // .retry() — .retriable: boolean
369
+ | FatalState // terminal
504
370
  ```
505
371
 
506
- ### `observation`
372
+ ### Interaction action methods
507
373
 
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`:
374
+ `InteractionState` is a discriminated union over `kind`:
521
375
 
522
376
  | `kind` | method |
523
377
  |---|---|
@@ -529,31 +383,31 @@ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
529
383
  | `match` | `submitMatch(pairs: MatchPair[])` |
530
384
  | `portable-custom` | `submit(value: PciValue<K>)` |
531
385
 
532
- All six shapes also expose `timeout(): Promise<PrimerState>`.
386
+ Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
533
387
 
534
- ### `feedback`
388
+ `MatchPair`, `PciValue<K>` come from `/contracts`.
535
389
 
536
- Server has graded the submission and returned feedback content.
390
+ ### `FeedbackState`
537
391
 
538
392
  ```ts
539
- interface FeedbackState {
393
+ interface FeedbackState extends NonSerializable {
540
394
  readonly phase: "feedback"
541
395
  readonly stimulus: RendererStimulus | null
542
396
  readonly interaction: RendererInteraction
543
397
  readonly submission: RendererSubmission
544
398
  readonly isCorrect: boolean
545
- readonly feedbackContent: ContentInline[] // server-provided, localized feedback
546
- readonly review: InteractionReview | null // correct answers etc., if available
399
+ readonly feedbackContent: ContentInline[]
400
+ readonly review: InteractionReview | null
547
401
  advance(): Promise<PrimerState>
548
402
  }
549
403
  ```
550
404
 
551
- ### `errored`
405
+ `RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentInline`, `InteractionReview` all come from `/contracts`.
552
406
 
553
- Transport or validation failure that might be recoverable.
407
+ ### `ErroredState`
554
408
 
555
409
  ```ts
556
- interface ErroredState {
410
+ interface ErroredState extends NonSerializable {
557
411
  readonly phase: "errored"
558
412
  readonly error: Error // sentinel-wrapped
559
413
  readonly retriable: boolean
@@ -561,109 +415,237 @@ interface ErroredState {
561
415
  }
562
416
  ```
563
417
 
564
- ### `fatal`
418
+ `errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
565
419
 
566
- Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
420
+ ### `FatalState`
421
+
422
+ Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
567
423
 
568
424
  ```ts
569
- interface FatalState {
425
+ interface FatalState extends NonSerializable {
570
426
  readonly phase: "fatal"
571
427
  readonly error: Error
572
428
  readonly retriable: false
573
429
  }
574
430
  ```
575
431
 
576
- ### `completed`
432
+ ## PCI render props
577
433
 
578
- Terminal. Session is done.
434
+ 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
435
 
580
436
  ```ts
581
- interface CompletedState {
582
- readonly phase: "completed"
437
+ type PciPendingRenderProps<K extends PciId> = {
438
+ mode: "pending"
439
+ properties: PciProps<K>
440
+ onValueChange: (value: PciValue<K> | null) => void
583
441
  }
442
+
443
+ type PciSubmittedRenderProps<K extends PciId> = {
444
+ mode: "submitted"
445
+ properties: PciProps<K>
446
+ submission: PciValue<K>
447
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
448
+ }
449
+
450
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
584
451
  ```
585
452
 
586
- ## Content format
453
+ ## Behavioral notes
454
+
455
+ - `start()` is idempotent — calling it twice returns the same promise.
456
+ - A returning student resumes wherever the server last placed them. No client-side cursor management.
457
+ - `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
458
+ - `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
459
+
460
+ ---
461
+
462
+ # `/contracts`
463
+
464
+ 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
465
 
588
466
  ```ts
589
- type ContentSpan =
590
- | { type: "text"; value: string }
591
- | { type: "italic"; value: string }
467
+ import {
468
+ ChoiceSubmissionSchema,
469
+ TextEntrySubmissionSchema,
470
+ ExtendedTextSubmissionSchema,
471
+ OrderSubmissionSchema,
472
+ MatchSubmissionSchema,
473
+ MatchPairSchema,
474
+ DivisionRemainderPciSubmissionSchema,
475
+ FractionAdditionPciSubmissionSchema,
476
+ RendererSubmissionSchema,
477
+ validateSubmissionForInteraction,
478
+ submissionValidationMessage,
479
+ blocksToPlainText,
480
+ inlinesToPlainText
481
+ } from "@superbuilders/primer-tives/contracts"
592
482
 
593
- type ContentInline =
594
- | ContentSpan
595
- | { type: "latex"; value: string }
483
+ import type {
484
+ // interaction & submission shapes
485
+ RendererInteraction,
486
+ RendererSubmission,
487
+ StandardRendererInteraction,
488
+ PciInteraction,
489
+ PciSubmission,
490
+ // stimulus shapes
491
+ RendererStimulus,
492
+ BodyStimulus,
493
+ ImageStimulus,
494
+ // choice shapes
495
+ RendererChoice,
496
+ RendererMatchChoice,
497
+ MatchPair,
498
+ // content
499
+ ContentBlock,
500
+ ContentInline,
501
+ ContentSpan,
502
+ // PCI base
503
+ PciId,
504
+ PciUrn,
505
+ PciRegistry,
506
+ PciProps,
507
+ PciValue,
508
+ DivisionRemainderProps,
509
+ DivisionRemainderSubmission,
510
+ FractionAdditionProps,
511
+ FractionAdditionSubmission,
512
+ // review (server emits these, client consumes)
513
+ InteractionReview,
514
+ ChoiceReview,
515
+ TextEntryReview,
516
+ ExtendedTextReview,
517
+ OrderReview,
518
+ MatchReview,
519
+ PciReview,
520
+ ReviewRecordField,
521
+ ReviewRecordFieldBaseType,
522
+ ReviewScalarValue,
523
+ // validation result
524
+ SubmissionValidationResult,
525
+ SubmissionValidationSuccess,
526
+ SubmissionValidationFailure
527
+ } from "@superbuilders/primer-tives/contracts"
528
+ ```
529
+
530
+ ## Schemas
531
+
532
+ Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
533
+
534
+ ```ts
535
+ import * as errors from "@superbuilders/errors"
536
+ import * as logger from "@superbuilders/slog"
537
+ import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
596
538
 
597
- type ContentBlock = { type: "paragraph"; children: ContentInline[] }
539
+ const result = RendererSubmissionSchema.safeParse(unknownPayload)
540
+ if (!result.success) {
541
+ logger.error("submission payload failed schema parse", { issues: result.error.issues })
542
+ throw errors.wrap(result.error, "renderer submission parse")
543
+ }
544
+ const submission = result.data
598
545
  ```
599
546
 
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).
547
+ ## `validateSubmissionForInteraction(interaction, submission)`
601
548
 
602
- Helpers:
549
+ Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
603
550
 
604
551
  ```ts
605
- inlinesToPlainText(nodes: ContentInline[]): string
606
- blocksToPlainText(blocks: ContentBlock[]): string
552
+ function validateSubmissionForInteraction(
553
+ interaction: RendererInteraction,
554
+ submission: RendererSubmission
555
+ ): SubmissionValidationResult
556
+
557
+ type SubmissionValidationResult =
558
+ | { ok: true; value: RendererSubmission }
559
+ | { ok: false; issues: readonly string[] }
607
560
  ```
608
561
 
609
- ## PCI system (Portable Custom Interactions)
562
+ `submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
610
563
 
611
564
  ```ts
612
- type PciUrn = "urn:primer:pci:division-remainder" | "urn:primer:pci:fraction-addition"
565
+ import * as errors from "@superbuilders/errors"
566
+ import * as logger from "@superbuilders/slog"
567
+ import {
568
+ submissionValidationMessage,
569
+ validateSubmissionForInteraction
570
+ } from "@superbuilders/primer-tives/contracts"
571
+ import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
613
572
 
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
- }
573
+ const validation = validateSubmissionForInteraction(interaction, submission)
574
+ if (!validation.ok) {
575
+ logger.warn("submission rejected by contract validation", { issues: validation.issues })
576
+ throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
623
577
  }
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
578
  ```
629
579
 
630
- Declare the PCI URNs your renderer can handle via `Config.supportedPcis`. Frames requiring an unsupported PCI resolve to `fatal` with `ErrUnsupportedPci`.
580
+ 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
581
 
632
- ### Renderer props
582
+ ---
633
583
 
634
- ```ts
635
- type PciPendingRenderProps<K extends PciId> = {
636
- mode: "pending"
637
- properties: PciProps<K>
638
- onValueChange: (value: PciValue<K> | null) => void
639
- }
584
+ # `/errors`
640
585
 
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
- }
586
+ 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
587
 
648
- type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
588
+ ```ts
589
+ import {
590
+ // shared (server + client transport)
591
+ ErrBadRequest,
592
+ ErrConflict,
593
+ ErrJsonParse,
594
+ ErrNetwork,
595
+ ErrServerError,
596
+ ErrTimeout,
597
+ // /server-thrown
598
+ ErrInvalidSecretKey,
599
+ ErrStudentNotFound,
600
+ ErrUnsupportedGrade,
601
+ ErrTimebackUnavailable,
602
+ ErrExternalAuthorityRequired,
603
+ // /client surfaced as `errored`
604
+ ErrServiceUnavailable,
605
+ ErrRateLimited,
606
+ ErrInvalidSubmission,
607
+ // /client surfaced as `fatal`
608
+ ErrInvalidAccessToken,
609
+ ErrTokenExpired,
610
+ ErrForbidden,
611
+ ErrNotFound,
612
+ ErrUnsupportedPci,
613
+ // /client thrown directly by `create()`
614
+ ErrMalformedAccessToken,
615
+ ErrNotSerializable
616
+ } from "@superbuilders/primer-tives/errors"
649
617
  ```
650
618
 
651
- ## Error sentinels (`/client`)
619
+ ## Server-side method failures
620
+
621
+ | Sentinel | Raised when |
622
+ |---|---|
623
+ | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
624
+ | `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
625
+ | `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
626
+ | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
627
+ | `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
628
+ | `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
629
+ | `ErrBadRequest` | HTTP 400 — validation failure |
630
+ | `ErrServerError` | HTTP 5xx |
631
+ | `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
632
+ | `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
633
+ | `ErrTimeout` | `fetch()` aborted |
652
634
 
653
- ### Surfaced as `errored`
635
+ ## Client `errored.error`
654
636
 
655
637
  | Sentinel | Raised when |
656
638
  |---|---|
657
- | `ErrNetwork` | fetch() rejected |
658
- | `ErrTimeout` | fetch() aborted |
639
+ | `ErrNetwork` | `fetch()` rejected |
640
+ | `ErrTimeout` | `fetch()` aborted |
659
641
  | `ErrServerError` | HTTP 5xx |
660
642
  | `ErrServiceUnavailable` | HTTP 502/503/504 |
661
643
  | `ErrRateLimited` | HTTP 429 |
662
644
  | `ErrConflict` | HTTP 409 |
663
- | `ErrJsonParse` | Success response body wasn't valid JSON |
645
+ | `ErrJsonParse` | success body wasn't valid JSON |
664
646
  | `ErrInvalidSubmission` | client-side validation rejected the submission |
665
647
 
666
- ### Surfaced as `fatal`
648
+ ## Client `fatal.error`
667
649
 
668
650
  | Sentinel | Raised when |
669
651
  |---|---|
@@ -674,16 +656,56 @@ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRe
674
656
  | `ErrNotFound` | HTTP 404 |
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`.