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