@superbuilders/primer-tives 2.0.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +742 -540
  2. package/dist/client/create.d.ts +22 -6
  3. package/dist/client/create.d.ts.map +1 -1
  4. package/dist/client/index.js +116 -54
  5. package/dist/client/index.js.map +14 -11
  6. package/dist/client/session.d.ts +1 -1
  7. package/dist/client/session.d.ts.map +1 -1
  8. package/dist/client/transport.d.ts +4 -5
  9. package/dist/client/transport.d.ts.map +1 -1
  10. package/dist/contracts/index.d.ts +3 -2
  11. package/dist/contracts/index.d.ts.map +1 -1
  12. package/dist/contracts/index.js +42 -36
  13. package/dist/contracts/index.js.map +6 -5
  14. package/dist/contracts/pci-schemas.d.ts +24 -20
  15. package/dist/contracts/pci-schemas.d.ts.map +1 -1
  16. package/dist/contracts/pci.d.ts +26 -27
  17. package/dist/contracts/pci.d.ts.map +1 -1
  18. package/dist/contracts/validation.d.ts +34 -23
  19. package/dist/contracts/validation.d.ts.map +1 -1
  20. package/dist/errors.d.ts +2 -6
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +3 -11
  23. package/dist/errors.js.map +3 -3
  24. package/dist/server/create-server.d.ts +4 -33
  25. package/dist/server/create-server.d.ts.map +1 -1
  26. package/dist/server/exchange.d.ts +8 -14
  27. package/dist/server/exchange.d.ts.map +1 -1
  28. package/dist/server/index.d.ts +1 -3
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +70 -415
  31. package/dist/server/index.js.map +6 -9
  32. package/dist/subject-pcis.d.ts +13 -0
  33. package/dist/subject-pcis.d.ts.map +1 -0
  34. package/dist/version.d.ts +1 -1
  35. package/package.json +5 -1
  36. package/dist/server/hints.d.ts +0 -25
  37. package/dist/server/hints.d.ts.map +0 -1
  38. package/dist/server/students.d.ts +0 -12
  39. package/dist/server/students.d.ts.map +0 -1
package/README.md CHANGED
@@ -1,714 +1,882 @@
1
1
  # @superbuilders/primer-tives
2
2
 
3
- TypeScript SDK for the Primer adaptive learning engine.
3
+ TypeScript SDK primitives for the Primer adaptive learning runtime.
4
+
5
+ The package gives you two runtime surfaces:
6
+
7
+ - A **server SDK** for trusted backend code. It exchanges an already-verified learner email for a short-lived Primer browser token.
8
+ - A **browser SDK** for interactive UI code. It drives Primer's `/advance` state machine and returns typed states for observations, interactions, feedback, completion, retriable errors, and fatal errors.
9
+
10
+ Primer intentionally does not expose a public student-management lifecycle through this SDK. Your app proves identity; Primer resolves the internal student and routing state behind the token.
4
11
 
5
12
  ```sh
6
13
  bun add @superbuilders/primer-tives
7
14
  ```
8
15
 
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.
16
+ The SDK uses sentinel errors from `@superbuilders/errors`. Use `errors.try()` to capture failures and `errors.is()` to classify them.
10
17
 
11
- ## Subpaths
18
+ ## Package Version
12
19
 
13
- Every symbol is exported from exactly one subpath. Import from the subpath that owns the thing you need.
20
+ The current SDK version is `2.2.1`.
14
21
 
15
- | Subpath | What it owns |
16
- |---|---|
17
- | `@superbuilders/primer-tives/server` | `createPrimerServer` and the four `PrimerServer` methods, the config and return types |
18
- | `@superbuilders/primer-tives/client` | `create` (browser SDK), the `PrimerState` discriminated union, every `*State` interface, the PCI render props |
19
- | `@superbuilders/primer-tives/contracts` | wire-shape types (`RendererInteraction`, `RendererSubmission`, `ContentInline`, PCI base types, review types), Zod schemas, the optional submission validator |
20
- | `@superbuilders/primer-tives/errors` | every error sentinel (`Err…`) |
21
- | `@superbuilders/primer-tives/logger` | the `PrimerLogger` interface, accepted by both server and client config |
22
- | `@superbuilders/primer-tives/grade-level` | `GradeLevel` type and the `GRADE_LEVELS` constant |
23
- | `@superbuilders/primer-tives/subject` | `Subject`, `SubjectScope`, the `SUBJECTS` constant |
22
+ The browser SDK sends `SDK_VERSION` as `X-Primer-SDK-Version` on `/api/v0/advance`. If the server requires a newer SDK, the browser state becomes `fatal` with `ErrSdkUpgradeRequired`.
24
23
 
25
- ## End-to-end examples
24
+ ## Entrypoints
26
25
 
27
- ### Native/manual student flow
26
+ There is no package-root export. Pick the subpath for the runtime boundary you are on.
28
27
 
29
- ```ts
30
- import * as errors from "@superbuilders/errors"
31
- import * as logger from "@superbuilders/slog"
32
- import { createPrimerServer } from "@superbuilders/primer-tives/server"
33
- import {
34
- ErrBadRequest,
35
- ErrConflict,
36
- ErrInvalidSecretKey,
37
- ErrStudentNotFound
38
- } from "@superbuilders/primer-tives/errors"
28
+ | Subpath | Runtime | Owns |
29
+ | --- | --- | --- |
30
+ | `@superbuilders/primer-tives/server` | Backend only | `createPrimerServer`, `PrimerServer`, `PrimerServerConfig`, `GetTokenInput` |
31
+ | `@superbuilders/primer-tives/client` | Browser | `create`, `Client`, `Config`, `PrimerState`, every state interface, PCI render props |
32
+ | `@superbuilders/primer-tives/contracts` | Shared | Content, stimulus, interaction, submission, review, PCI types, Zod schemas, validation helpers |
33
+ | `@superbuilders/primer-tives/errors` | Shared | Every SDK error sentinel |
34
+ | `@superbuilders/primer-tives/logger` | Shared | `PrimerLogger` interface |
35
+ | `@superbuilders/primer-tives/subject` | Shared | `Subject`, `SubjectScope`, `SUBJECTS` |
36
+ | `@superbuilders/primer-tives/subject-pcis` | Shared | Required PCI helpers for subject scopes |
37
+ | `@superbuilders/primer-tives/grade-level` | Shared | `GradeLevel`, `GRADE_LEVELS` |
39
38
 
40
- const primer = createPrimerServer({
41
- origin: "https://sb-primer.vercel.app",
42
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
43
- logger
44
- })
39
+ ## Architecture
45
40
 
46
- // One time per user.
47
- const createResult = await errors.try(primer.createStudent())
48
- if (createResult.error) {
49
- if (errors.is(createResult.error, ErrInvalidSecretKey)) {
50
- logger.error("primer secret key invalid", { error: createResult.error })
51
- throw errors.wrap(createResult.error, "primer client init")
52
- }
53
- if (errors.is(createResult.error, ErrConflict)) {
54
- logger.error("primer frontend not provisioned", { error: createResult.error })
55
- throw errors.wrap(createResult.error, "primer frontend provisioning")
56
- }
57
- logger.error("primer create student failed", { error: createResult.error })
58
- throw errors.wrap(createResult.error, "primer create student")
59
- }
60
- const studentId = createResult.data
61
- // persist `studentId` alongside your own user record.
62
-
63
- // Required before the first session for native students.
64
- const hintsResult = await errors.try(primer.setStudentHints(studentId, { gradeLevel: "3" }))
65
- if (hintsResult.error) {
66
- if (errors.is(hintsResult.error, ErrStudentNotFound)) {
67
- logger.error("primer student id not found on this frontend", {
68
- studentId,
69
- error: hintsResult.error
70
- })
71
- throw errors.wrap(hintsResult.error, "primer set hints")
72
- }
73
- if (errors.is(hintsResult.error, ErrBadRequest)) {
74
- logger.error("primer hint payload rejected", { error: hintsResult.error })
75
- throw errors.wrap(hintsResult.error, "primer set hints")
76
- }
77
- logger.error("primer set hints failed", { error: hintsResult.error })
78
- throw errors.wrap(hintsResult.error, "primer set hints")
79
- }
41
+ Primer's runtime has three identities in play:
80
42
 
81
- // Every session start.
82
- const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
83
- if (tokenResult.error) {
84
- if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
85
- logger.error("primer secret key invalid", { error: tokenResult.error })
86
- throw errors.wrap(tokenResult.error, "primer token exchange")
87
- }
88
- if (errors.is(tokenResult.error, ErrStudentNotFound)) {
89
- logger.error("primer student id stale", { studentId, error: tokenResult.error })
90
- throw errors.wrap(tokenResult.error, "primer token exchange")
91
- }
92
- logger.error("primer token exchange failed", { error: tokenResult.error })
93
- throw errors.wrap(tokenResult.error, "primer token exchange")
94
- }
43
+ | Identity | Owner | Where it lives |
44
+ | --- | --- | --- |
45
+ | Host user | Your app | Your auth/user tables |
46
+ | Verified email | Your backend proves it; Primer hashes it | Sent only from trusted backend to Primer |
47
+ | Primer student | Primer | Internal DB, scoped to a Primer frontend |
95
48
 
96
- const { accessToken, expiresInSeconds } = tokenResult.data
97
- ```
49
+ The SDK only asks your backend for `verifiedEmail`. That name is deliberate. It means your backend has already verified email ownership through your own auth system. Primer does not send a verification email in this flow.
50
+
51
+ The backend token flow is:
52
+
53
+ 1. Your app authenticates a user.
54
+ 2. Your app verifies the user's email.
55
+ 3. Your backend calls `primer.getToken({ verifiedEmail: user.email })` with a Primer `sk_...` secret key.
56
+ 4. Primer normalizes and hashes the email server-side.
57
+ 5. Primer resolves or creates the internal frontend-scoped student.
58
+ 6. Primer returns a short-lived browser access token.
59
+ 7. Your browser UI passes that token to `create({ accessToken, ... })`.
60
+
61
+ Primer stores a secret-keyed SHA-512/256 digest of normalized email, not raw email. The browser never sees your Primer secret key and never sends the email to `/advance`.
98
62
 
99
- ### Live-authoritative Timeback flow
63
+ ## End-To-End Backend Example
100
64
 
101
65
  ```ts
102
66
  import * as errors from "@superbuilders/errors"
103
67
  import * as logger from "@superbuilders/slog"
104
68
  import { createPrimerServer } from "@superbuilders/primer-tives/server"
105
- import {
106
- ErrConflict,
107
- ErrInvalidSecretKey,
108
- ErrStudentNotFound,
109
- ErrTimebackUnavailable,
110
- ErrUnsupportedGrade
111
- } from "@superbuilders/primer-tives/errors"
69
+ import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
112
70
 
113
71
  const primer = createPrimerServer({
114
- origin: "https://sb-primer.vercel.app",
115
- secretKey: process.env.PRIMER_DEMO_SECRET_KEY ?? "",
116
- logger
72
+ origin: "https://primerlearn.dev",
73
+ secretKey: process.env.PRIMER_SECRET_KEY,
74
+ logger
117
75
  })
118
76
 
119
- const sessionResult = await errors.try(
120
- primer.exchangeTimebackStudentForAccessToken(sourcedId)
121
- )
122
- if (sessionResult.error) {
123
- if (errors.is(sessionResult.error, ErrInvalidSecretKey)) {
124
- logger.error("primer secret key invalid", { error: sessionResult.error })
125
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
126
- }
127
- if (errors.is(sessionResult.error, ErrStudentNotFound)) {
128
- logger.error("timeback sourcedId not found upstream", {
129
- sourcedId,
130
- error: sessionResult.error
131
- })
132
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
133
- }
134
- if (errors.is(sessionResult.error, ErrUnsupportedGrade)) {
135
- logger.error("timeback grade outside supported range", { error: sessionResult.error })
136
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
137
- }
138
- if (errors.is(sessionResult.error, ErrConflict)) {
139
- logger.error("primer frontend not provisioned for routing", { error: sessionResult.error })
140
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
141
- }
142
- if (errors.is(sessionResult.error, ErrTimebackUnavailable)) {
143
- logger.error("timeback authority temporarily unavailable", { error: sessionResult.error })
144
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
145
- }
146
- logger.error("primer timeback exchange failed", { error: sessionResult.error })
147
- throw errors.wrap(sessionResult.error, "primer timeback exchange")
77
+ async function getPrimerAccessToken(user: { email: string; emailVerified: boolean }): Promise<string> {
78
+ if (!user.emailVerified) {
79
+ logger.error("user email not verified")
80
+ throw errors.new("email not verified")
81
+ }
82
+
83
+ const result = await errors.try(primer.getToken({ verifiedEmail: user.email }))
84
+ if (result.error) {
85
+ if (errors.is(result.error, ErrInvalidSecretKey)) {
86
+ logger.error("primer secret key invalid", { error: result.error })
87
+ throw errors.wrap(result.error, "primer token")
88
+ }
89
+ if (errors.is(result.error, ErrBadRequest)) {
90
+ logger.error("primer token request rejected", { error: result.error })
91
+ throw errors.wrap(result.error, "primer token")
92
+ }
93
+ if (errors.is(result.error, ErrNetwork) || errors.is(result.error, ErrTimeout)) {
94
+ logger.error("primer token transport failed", { error: result.error })
95
+ throw errors.wrap(result.error, "primer token")
96
+ }
97
+ logger.error("primer token failed", { error: result.error })
98
+ throw errors.wrap(result.error, "primer token")
99
+ }
100
+
101
+ return result.data
148
102
  }
149
-
150
- const { studentId, accessToken, expiresInSeconds } = sessionResult.data
151
103
  ```
152
104
 
153
- ### Browser flow
105
+ ## End-To-End Browser Example
154
106
 
155
107
  ```ts
156
- import { create } from "@superbuilders/primer-tives/client"
157
- import type { PrimerState } from "@superbuilders/primer-tives/client"
158
108
  import * as logger from "@superbuilders/slog"
109
+ import { create, type PrimerState } from "@superbuilders/primer-tives/client"
159
110
 
160
111
  const client = create({
161
- accessToken,
162
- origin: "https://sb-primer.vercel.app",
163
- subject: "math",
164
- supportedPcis: [
165
- "urn:primer:pci:division-remainder",
166
- "urn:primer:pci:fraction-addition"
167
- ],
168
- logger
112
+ origin: "https://primerlearn.dev",
113
+ accessToken,
114
+ subject: "math",
115
+ supportedPcis: ["urn:primer:pci:fraction-input"],
116
+ logger
169
117
  })
170
118
 
171
119
  let state: PrimerState = await client.start()
172
120
 
173
121
  while (state.phase !== "completed" && state.phase !== "fatal") {
174
- switch (state.phase) {
175
- case "observation":
176
- renderFrame(state.body, state.stimulus)
177
- state = await state.advance()
178
- break
179
- case "interaction":
180
- state = await runInteraction(state)
181
- break
182
- case "feedback":
183
- renderFeedback(state.feedbackContent, state.isCorrect)
184
- state = await state.advance()
185
- break
186
- case "errored":
187
- if (state.retriable) {
188
- state = await state.retry()
189
- break
190
- }
191
- throw state.error
192
- }
122
+ switch (state.phase) {
123
+ case "observation": {
124
+ renderFrame(state.body, state.stimulus)
125
+ state = await state.advance()
126
+ break
127
+ }
128
+ case "interaction": {
129
+ state = await renderAndSubmitInteraction(state)
130
+ break
131
+ }
132
+ case "feedback": {
133
+ renderFeedback(state.feedbackContent, state.isCorrect, state.review)
134
+ state = await state.advance()
135
+ break
136
+ }
137
+ case "errored": {
138
+ if (state.retriable) {
139
+ state = await state.retry()
140
+ break
141
+ }
142
+ throw state.error
143
+ }
144
+ }
193
145
  }
194
- ```
195
146
 
196
- ---
147
+ if (state.phase === "fatal") {
148
+ throw state.error
149
+ }
150
+ ```
197
151
 
198
152
  # `/server`
199
153
 
154
+ The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
155
+
200
156
  ```ts
201
157
  import { createPrimerServer } from "@superbuilders/primer-tives/server"
202
- import type {
203
- PrimerServer,
204
- PrimerServerConfig,
205
- SessionToken,
206
- TimebackSession,
207
- PlacementHints,
208
- PlacementHintsResult
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"
158
+ import type { GetTokenInput, PrimerServer, PrimerServerConfig } from "@superbuilders/primer-tives/server"
212
159
  ```
213
160
 
214
161
  ## `createPrimerServer(config)`
215
162
 
216
163
  ```ts
217
164
  interface PrimerServerConfig {
218
- readonly origin: string // e.g. https://sb-primer.vercel.app (no trailing slash)
219
- readonly secretKey: string // your sk_… key
220
- readonly fetch?: typeof globalThis.fetch // override (tests, proxies, instrumentation)
221
- readonly abort?: AbortController // wired into every request signal
222
- readonly logger: PrimerLogger // required debug/info/warn/error logger
165
+ readonly origin: string
166
+ readonly secretKey: string
167
+ readonly fetch?: typeof globalThis.fetch
168
+ readonly abort?: AbortController
169
+ readonly logger: PrimerLogger
223
170
  }
224
171
 
225
172
  function createPrimerServer(config: PrimerServerConfig): PrimerServer
226
173
  ```
227
174
 
228
- Returns a `PrimerServer` with exactly four methods:
175
+ Config fields:
176
+
177
+ | Field | Required | Description |
178
+ | --- | --- | --- |
179
+ | `origin` | Yes | Primer deployment origin, for example `https://primerlearn.dev`. |
180
+ | `secretKey` | Yes | Primer `sk_...` frontend secret. Keep it on the backend. |
181
+ | `fetch` | No | Custom fetch for tests, proxies, tracing, or platform adapters. Defaults to `globalThis.fetch`. |
182
+ | `abort` | No | `AbortController`; its signal is passed to every server SDK request. |
183
+ | `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, `error`. |
184
+
185
+ ## `PrimerServer`
229
186
 
230
187
  ```ts
231
188
  interface PrimerServer {
232
- createStudent(): Promise<string>
233
- setStudentHints(studentId: string, hints: PlacementHints): Promise<PlacementHintsResult>
234
- exchangeStudentForAccessToken(studentId: string): Promise<SessionToken>
235
- exchangeTimebackStudentForAccessToken(sourcedId: string): Promise<TimebackSession>
189
+ getToken(input: GetTokenInput): Promise<string>
190
+ }
191
+
192
+ type GetTokenInput = {
193
+ readonly verifiedEmail: string
236
194
  }
237
195
  ```
238
196
 
239
- ## Method reference
197
+ `getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
240
198
 
241
- ### `createStudent()`
199
+ The SDK sends this wire body:
242
200
 
243
- Provision a new frontend-owned Primer student and return its stable `studentId`. Use only for the native/manual flow.
201
+ ```json
202
+ {
203
+ "verified_email": "student@example.com"
204
+ }
205
+ ```
244
206
 
245
- Persist the returned `studentId` in your own database. Before the student's first session, call `setStudentHints(studentId, { gradeLevel })`.
207
+ The raw API response includes token metadata and internal student identity, but the SDK deliberately returns only `access_token`. That keeps the public SDK focused on the one value the app has to pass to the browser runtime.
246
208
 
247
- ### `setStudentHints(studentId, hints)`
209
+ ## `verifiedEmail`
248
210
 
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.
211
+ `verifiedEmail` must be server-trusted. The SDK does not validate that the user owns this address. It only validates that Primer accepts the request shape.
212
+
213
+ Correct usage:
250
214
 
251
215
  ```ts
252
- const { gradeLevel } = await primer.setStudentHints(studentId, { gradeLevel: "3" })
216
+ const accessToken = await primer.getToken({ verifiedEmail: authenticatedUser.email })
253
217
  ```
254
218
 
255
- - Required for native/manual students before their first session. If the browser calls `/advance` without a `gradeLevel` on record, the server returns HTTP 412 `needs_hints` and the SDK surfaces it as `ErrNeedsHints` on a `fatal` state.
256
- - Safe to call repeatedly. Each call is a partial upsert.
257
- - Not needed for Timeback-linked students: `exchangeTimebackStudentForAccessToken` syncs the grade level from the authority on every call.
258
- - Throws `ErrStudentNotFound` if `studentId` is unknown; `ErrBadRequest` if the payload fails validation (e.g. unsupported `gradeLevel`).
219
+ Only do this after your own auth system has verified `authenticatedUser.email`.
220
+
221
+ Incorrect usage:
222
+
223
+ ```ts
224
+ const accessToken = await primer.getToken({ verifiedEmail: request.body.email })
225
+ ```
259
226
 
260
- ### `exchangeStudentForAccessToken(studentId)`
227
+ That treats an untrusted browser payload as identity proof and defeats the security model.
261
228
 
262
- Mint a short-lived access token for an existing native/manual Primer student. Call at every session start.
229
+ ## Server Error Sentinels
263
230
 
264
- If `studentId` belongs to a Timeback-linked student, throws `ErrExternalAuthorityRequired`. Tokens are short-lived (typically 15 minutes).
231
+ Import sentinels from `/errors`, not `/server`.
265
232
 
266
- ### `exchangeTimebackStudentForAccessToken(sourcedId)`
233
+ ```ts
234
+ import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
235
+ ```
267
236
 
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`.
237
+ `getToken` can surface:
269
238
 
270
- Use at every Timeback session start. Persist `studentId` if you need a stable Primer foreign key.
239
+ | Sentinel | Raised when |
240
+ | --- | --- |
241
+ | `ErrInvalidSecretKey` | Primer returned HTTP 401. The `sk_...` key is missing, malformed, inactive, or unknown. |
242
+ | `ErrBadRequest` | Primer returned HTTP 400. The request body was invalid, usually a malformed email. |
243
+ | `ErrServerError` | Primer returned another unexpected non-2xx status. |
244
+ | `ErrJsonParse` | Primer returned 2xx but the response body was not valid JSON or lacked `access_token`. |
245
+ | `ErrNetwork` | `fetch` rejected before a response arrived. |
246
+ | `ErrTimeout` | Fetch was aborted. |
271
247
 
272
- ## Return types
248
+ Recommended pattern:
273
249
 
274
250
  ```ts
275
- interface SessionToken {
276
- readonly accessToken: string
277
- readonly expiresInSeconds: number
251
+ import * as errors from "@superbuilders/errors"
252
+ import * as logger from "@superbuilders/slog"
253
+ import { ErrBadRequest, ErrInvalidSecretKey } from "@superbuilders/primer-tives/errors"
254
+
255
+ const result = await errors.try(primer.getToken({ verifiedEmail }))
256
+ if (result.error) {
257
+ if (errors.is(result.error, ErrInvalidSecretKey)) {
258
+ logger.error("primer secret key invalid", { error: result.error })
259
+ throw errors.wrap(result.error, "primer token")
260
+ }
261
+ if (errors.is(result.error, ErrBadRequest)) {
262
+ logger.error("primer token request rejected", { error: result.error })
263
+ throw errors.wrap(result.error, "primer token")
264
+ }
265
+ logger.error("primer token failed", { error: result.error })
266
+ throw errors.wrap(result.error, "primer token")
278
267
  }
279
268
 
280
- interface TimebackSession {
281
- readonly studentId: string
282
- readonly accessToken: string
283
- readonly expiresInSeconds: number
284
- }
269
+ const accessToken = result.data
270
+ ```
285
271
 
286
- interface PlacementHints {
287
- readonly gradeLevel?: GradeLevel
288
- }
272
+ # `/client`
273
+
274
+ The client subpath owns the browser runtime state machine.
275
+
276
+ ```ts
277
+ import { create } from "@superbuilders/primer-tives/client"
278
+ import type { Client, Config, PrimerState } from "@superbuilders/primer-tives/client"
279
+ ```
289
280
 
290
- interface PlacementHintsResult {
291
- readonly studentId: string
292
- readonly gradeLevel: GradeLevel | null
281
+ ## `create(config)`
282
+
283
+ ```ts
284
+ function create<const S extends SubjectScope, const Pcis extends PciId = never>(
285
+ config: Config<S, Pcis>
286
+ ): Client<PciId>
287
+ ```
288
+
289
+ Config shape:
290
+
291
+ ```ts
292
+ type Config<S extends SubjectScope, Pcis extends PciId = never> = {
293
+ readonly accessToken: string
294
+ readonly subject: S
295
+ readonly origin: string
296
+ readonly fetch?: typeof globalThis.fetch
297
+ readonly abort?: AbortController
298
+ readonly logger: PrimerLogger
299
+ } & PciConfigForSubject<S, Pcis>
300
+ ```
301
+
302
+ Fields:
303
+
304
+ | Field | Required | Description |
305
+ | --- | --- | --- |
306
+ | `accessToken` | Yes | Token string returned by `primer.getToken`. |
307
+ | `subject` | Yes | `"math"`, `"vocabulary"`, `"science"`, or `"all"`. |
308
+ | `origin` | Yes | Primer deployment origin. |
309
+ | `supportedPcis` | Conditionally | Literal array of PCI URNs your renderer supports. Required when the chosen subject can emit required PCIs. |
310
+ | `fetch` | No | Custom fetch implementation. |
311
+ | `abort` | No | `AbortController`; signal passed to `/advance`. |
312
+ | `logger` | Yes | Structured logger. |
313
+
314
+ `create` performs local checks before any network request:
315
+
316
+ - `accessToken` must look like a JWS: starts with `eyJ` and has two dots. Otherwise it throws `ErrMalformedAccessToken`.
317
+ - `supportedPcis` must include all PCIs required by the selected subject. Missing support throws `ErrMissingRequiredPci` immediately and logs `renderer missing required pcis`.
318
+
319
+ ## `Client`
320
+
321
+ ```ts
322
+ interface Client<Pcis extends PciId = PciId> {
323
+ start(): Promise<PrimerState<Pcis>>
293
324
  }
294
325
  ```
295
326
 
296
- `gradeLevel` in `PlacementHintsResult` is `null` only on native students who have never had one set.
327
+ `start()` sends the first observation request. It is idempotent: multiple calls return the same pending promise.
297
328
 
298
- ## Rules of the road
329
+ ## Subject Scopes
299
330
 
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.
331
+ ```ts
332
+ import { SUBJECTS } from "@superbuilders/primer-tives/subject"
333
+ import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
304
334
 
305
- ---
335
+ const SUBJECTS = ["math", "vocabulary", "science"] as const
336
+ type Subject = (typeof SUBJECTS)[number]
337
+ type SubjectScope = Subject | "all"
338
+ ```
306
339
 
307
- # `/client`
340
+ | Scope | Behavior |
341
+ | --- | --- |
342
+ | `"math"` | Runtime is scoped to math content and math-required PCIs. |
343
+ | `"vocabulary"` | Runtime is scoped to vocabulary content. |
344
+ | `"science"` | Runtime is scoped to science content. |
345
+ | `"all"` | Runtime can draw from all subjects available to the Primer frontend. |
346
+
347
+ To change subjects, create a new client. Placement state is server-owned and subject-scoped; the browser does not manage cursors.
348
+
349
+ ## Required PCIs
350
+
351
+ Math currently requires the fraction-input PCI. Vocabulary and science currently require none.
308
352
 
309
353
  ```ts
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
333
- } from "@superbuilders/primer-tives/client"
334
- ```
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`.
354
+ import { missingPcisForSubject, requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
337
355
 
338
- ## `create(config)`
356
+ requiredPcisForSubject("math")
357
+ // ["urn:primer:pci:fraction-input"]
358
+ ```
359
+
360
+ For math or `all`, pass a `supportedPcis` literal array that includes `"urn:primer:pci:fraction-input"`.
339
361
 
340
362
  ```ts
341
- function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
363
+ const client = create({
364
+ origin,
365
+ accessToken,
366
+ subject: "math",
367
+ supportedPcis: ["urn:primer:pci:fraction-input"],
368
+ logger
369
+ })
370
+ ```
342
371
 
343
- interface Config<Pcis extends PciId = PciId> {
344
- readonly accessToken: string
345
- readonly supportedPcis: readonly Pcis[]
346
- readonly origin: string
347
- readonly subject: SubjectScope
348
- readonly fetch?: typeof globalThis.fetch
349
- readonly abort?: AbortController
350
- readonly logger: PrimerLogger
351
- }
372
+ The array values are not sent to the server. They exist so TypeScript and client-side runtime preflight can prove that the renderer has declared support for required PCI URNs before `/advance` is called.
352
373
 
353
- interface Client<Pcis extends PciId = PciId> {
354
- start(): Promise<PrimerState<Pcis>> // idempotent
374
+ # `PrimerState`
375
+
376
+ `PrimerState` is a discriminated union over `phase`.
377
+
378
+ ```ts
379
+ type PrimerState<Pcis extends PciId = PciId> =
380
+ | ObservationState<Pcis>
381
+ | InteractionState<Pcis>
382
+ | FeedbackState<Pcis>
383
+ | CompletedState
384
+ | ErroredState<Pcis>
385
+ | FatalState
386
+ ```
387
+
388
+ Every state is intentionally non-serializable. It contains closures for advancing, submitting, retrying, and deduplicating in-flight calls. Do not store it in localStorage, persist it through JSON, or treat it as data. Keep it in memory and call `client.start()` again after reload.
389
+
390
+ ## `ObservationState`
391
+
392
+ ```ts
393
+ interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
394
+ readonly phase: "observation"
395
+ readonly body: ContentBlock[]
396
+ readonly stimulus: RendererStimulus | null
397
+ advance(): Promise<PrimerState<Pcis>>
355
398
  }
356
399
  ```
357
400
 
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.
401
+ Render `body` and `stimulus`, then call `advance()` when the learner is ready to continue. Observation frames have no interaction to submit.
402
+
403
+ ## `InteractionState`
359
404
 
360
- ## `PrimerState` the state machine
405
+ `InteractionState` is a union over `kind`.
406
+
407
+ | `kind` | State type | Submit method |
408
+ | --- | --- | --- |
409
+ | `choice` | `ChoiceState` | `submitChoice(selectedKeys: string[])` |
410
+ | `text-entry` | `TextEntryState` | `submitText(value: string)` |
411
+ | `extended-text` single | `ExtendedTextSingleState` | `submitText(value: string)` |
412
+ | `extended-text` multiple | `ExtendedTextMultipleState` | `submitTexts(values: string[])` |
413
+ | `order` | `OrderState` | `submitOrder(orderedKeys: string[])` |
414
+ | `match` | `MatchState` | `submitMatch(pairs: MatchPair[])` |
415
+ | `portable-custom` | `PciInteractionState` | `submit(value: PciValue<K>)` |
416
+
417
+ Every interaction state includes:
418
+
419
+ - `phase: "interaction"`
420
+ - `body`
421
+ - `stimulus`
422
+ - `interaction`
423
+ - a typed submit method
424
+ - `timeout(): Promise<PrimerState<Pcis>>`
425
+
426
+ Interaction submissions are validated client-side before transport. Invalid payloads return an `errored` state with `ErrInvalidSubmission`; they are not sent to the server.
427
+
428
+ ### Choice
361
429
 
362
430
  ```ts
363
- type PrimerState =
364
- | ObservationState // .advance()
365
- | InteractionState // .submit…() / .timeout()
366
- | FeedbackState // .advance()
367
- | CompletedState // terminal
368
- | ErroredState // .retry() .retriable: boolean
369
- | FatalState // terminal
431
+ interface ChoiceState<Pcis extends PciId = PciId> extends NonSerializable {
432
+ readonly phase: "interaction"
433
+ readonly kind: "choice"
434
+ readonly body: ContentBlock[]
435
+ readonly stimulus: RendererStimulus | null
436
+ readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
437
+ readonly options: RendererChoice[]
438
+ readonly maxChoices: number
439
+ readonly minChoices: number
440
+ submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
441
+ timeout(): Promise<PrimerState<Pcis>>
442
+ }
370
443
  ```
371
444
 
372
- ### Interaction action methods
445
+ Use `minChoices` and `maxChoices` to decide whether to auto-submit on click or require a Submit button. Primer's grade onboarding prompt is currently delivered as a normal single-choice interaction.
373
446
 
374
- `InteractionState` is a discriminated union over `kind`:
447
+ ### Text Entry
375
448
 
376
- | `kind` | method |
377
- |---|---|
378
- | `choice` | `submitChoice(selectedKeys: string[])` |
379
- | `text-entry` | `submitText(value: string)` |
380
- | `extended-text` (single) | `submitText(value: string)` |
381
- | `extended-text` (multiple) | `submitTexts(values: string[])` |
382
- | `order` | `submitOrder(orderedKeys: string[])` |
383
- | `match` | `submitMatch(pairs: MatchPair[])` |
384
- | `portable-custom` | `submit(value: PciValue<K>)` |
449
+ ```ts
450
+ interface TextEntryState<Pcis extends PciId = PciId> extends NonSerializable {
451
+ readonly phase: "interaction"
452
+ readonly kind: "text-entry"
453
+ readonly body: ContentBlock[]
454
+ readonly stimulus: RendererStimulus | null
455
+ readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
456
+ submitText(value: string): Promise<PrimerState<Pcis>>
457
+ timeout(): Promise<PrimerState<Pcis>>
458
+ }
459
+ ```
385
460
 
386
- Every `InteractionState` shape also exposes `timeout(): Promise<PrimerState>`.
461
+ `interaction` may include `expectedLength`, `patternMask`, and `placeholderText` for UI hints.
387
462
 
388
- `MatchPair`, `PciValue<K>` come from `/contracts`.
463
+ ### Extended Text
389
464
 
390
- ### `FeedbackState`
465
+ Extended text has two cardinalities.
391
466
 
392
467
  ```ts
393
- interface FeedbackState extends NonSerializable {
394
- readonly phase: "feedback"
395
- readonly body: ContentBlock[]
396
- readonly stimulus: RendererStimulus | null
397
- readonly interaction: RendererInteraction
398
- readonly submission: RendererSubmission
399
- readonly isCorrect: boolean
400
- readonly feedbackContent: ContentInline[]
401
- readonly review: InteractionReview | null
402
- advance(): Promise<PrimerState>
468
+ interface ExtendedTextSingleState<Pcis extends PciId = PciId> extends NonSerializable {
469
+ readonly phase: "interaction"
470
+ readonly kind: "extended-text"
471
+ readonly cardinality: "single"
472
+ submitText(value: string): Promise<PrimerState<Pcis>>
473
+ timeout(): Promise<PrimerState<Pcis>>
474
+ }
475
+
476
+ interface ExtendedTextMultipleState<Pcis extends PciId = PciId> extends NonSerializable {
477
+ readonly phase: "interaction"
478
+ readonly kind: "extended-text"
479
+ readonly cardinality: "multiple"
480
+ readonly minStrings: number
481
+ readonly maxStrings: number
482
+ submitTexts(values: string[]): Promise<PrimerState<Pcis>>
483
+ timeout(): Promise<PrimerState<Pcis>>
403
484
  }
404
485
  ```
405
486
 
406
- Every state that carries frame content (`ObservationState`, every `InteractionState` variant, `FeedbackState`) exposes the frame as three independent fields:
487
+ Both include `body`, `stimulus`, and `interaction` fields like every other interaction state.
407
488
 
408
- - `body: ContentBlock[]` — the frame's prose, possibly empty.
409
- - `stimulus: RendererStimulus | null` — a discriminated union over media kinds (currently `{ kind: "image", alt, src }`).
410
- - `interaction: RendererInteraction | null` — the question, when present.
489
+ ### Order
411
490
 
412
- `RendererStimulus`, `RendererInteraction`, `RendererSubmission`, `ContentBlock`, `ContentInline`, `InteractionReview` all come from `/contracts`.
491
+ ```ts
492
+ interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
493
+ readonly phase: "interaction"
494
+ readonly kind: "order"
495
+ readonly choices: RendererChoice[]
496
+ readonly minChoices: number
497
+ readonly maxChoices: number
498
+ submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
499
+ timeout(): Promise<PrimerState<Pcis>>
500
+ }
501
+ ```
502
+
503
+ Submit identifiers in learner-selected order.
413
504
 
414
- ### `ErroredState`
505
+ ### Match
415
506
 
416
507
  ```ts
417
- interface ErroredState extends NonSerializable {
418
- readonly phase: "errored"
419
- readonly error: Error // sentinel-wrapped
420
- readonly retriable: boolean
421
- retry(): Promise<PrimerState> // no-op on non-retriable
508
+ interface MatchPair {
509
+ source: string
510
+ target: string
511
+ }
512
+
513
+ interface MatchState<Pcis extends PciId = PciId> extends NonSerializable {
514
+ readonly phase: "interaction"
515
+ readonly kind: "match"
516
+ readonly sourceChoices: RendererMatchChoice[]
517
+ readonly targetChoices: RendererMatchChoice[]
518
+ readonly minAssociations: number
519
+ readonly maxAssociations: number
520
+ submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
521
+ timeout(): Promise<PrimerState<Pcis>>
422
522
  }
423
523
  ```
424
524
 
425
- `errored.retriable === false` for client-validation errors (e.g. wrapped `ErrInvalidSubmission`) and for non-recoverable transports. Those must be fixed, not retried.
525
+ Each `RendererMatchChoice` has `matchMin` and `matchMax`. A `matchMax` of `0` means unbounded.
526
+
527
+ ### Portable Custom Interaction
426
528
 
427
- ### `FatalState`
529
+ ```ts
530
+ type PciInteractionState<Pcis extends PciId = PciId> = {
531
+ [K in Pcis]: NonSerializable & {
532
+ readonly phase: "interaction"
533
+ readonly kind: "portable-custom"
534
+ readonly body: ContentBlock[]
535
+ readonly stimulus: RendererStimulus | null
536
+ readonly interaction: PciInteraction<K>
537
+ readonly pciId: K
538
+ readonly properties: PciProps<K>
539
+ submit(value: PciValue<K>): Promise<PrimerState<Pcis>>
540
+ timeout(): Promise<PrimerState<Pcis>>
541
+ }
542
+ }[Pcis]
543
+ ```
428
544
 
429
- Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden):
545
+ For `urn:primer:pci:fraction-input`, `properties` has:
430
546
 
431
547
  ```ts
432
- interface FatalState extends NonSerializable {
433
- readonly phase: "fatal"
434
- readonly error: Error
435
- readonly retriable: false
548
+ interface FractionInputProps {
549
+ form: "whole" | "proper" | "improper" | "mixed"
550
+ requireSimplified: boolean
436
551
  }
437
552
  ```
438
553
 
439
- ## PCI render props
554
+ The submitted value is one of:
440
555
 
441
- 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`.
556
+ ```ts
557
+ type FractionInputSubmission =
558
+ | { form: "whole"; whole: string }
559
+ | { form: "proper"; numerator: string; denominator: string }
560
+ | { form: "improper"; numerator: string; denominator: string }
561
+ | { form: "mixed"; whole: string; numerator: string; denominator: string }
562
+ ```
563
+
564
+ ## `FeedbackState`
442
565
 
443
566
  ```ts
444
- type PciPendingRenderProps<K extends PciId> = {
445
- mode: "pending"
446
- properties: PciProps<K>
447
- onValueChange: (value: PciValue<K> | null) => void
567
+ interface FeedbackState<Pcis extends PciId = PciId> extends NonSerializable {
568
+ readonly phase: "feedback"
569
+ readonly body: ContentBlock[]
570
+ readonly stimulus: RendererStimulus | null
571
+ readonly interaction: RendererInteraction<Pcis>
572
+ readonly submission: RendererSubmission<Pcis>
573
+ readonly isCorrect: boolean
574
+ readonly feedbackContent: ContentInline[]
575
+ readonly review: InteractionReview<Pcis> | null
576
+ advance(): Promise<PrimerState<Pcis>>
448
577
  }
578
+ ```
579
+
580
+ Feedback state is returned after a successful interaction submission. Render the submitted frame, the submitted value, correctness, server feedback, and optional review data. Call `advance()` when the learner is ready to continue.
449
581
 
450
- type PciSubmittedRenderProps<K extends PciId> = {
451
- mode: "submitted"
452
- properties: PciProps<K>
453
- submission: PciValue<K>
454
- review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
582
+ ## `CompletedState`
583
+
584
+ ```ts
585
+ interface CompletedState extends NonSerializable {
586
+ readonly phase: "completed"
455
587
  }
588
+ ```
456
589
 
457
- type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
590
+ Terminal state. There is no action method.
591
+
592
+ ## `ErroredState`
593
+
594
+ ```ts
595
+ interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
596
+ readonly phase: "errored"
597
+ readonly error: Error
598
+ readonly retriable: boolean
599
+ retry(): Promise<PrimerState<Pcis>>
600
+ }
458
601
  ```
459
602
 
460
- ## Behavioral notes
603
+ `retry()` re-runs the exact same intent that failed. If `retriable` is false, `retry()` resolves to the same errored state.
461
604
 
462
- - `start()` is idempotent — calling it twice returns the same promise.
463
- - A returning student resumes wherever the server last placed them. No client-side cursor management.
464
- - `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
465
- - `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.
605
+ ## `FatalState`
466
606
 
467
- ---
607
+ ```ts
608
+ interface FatalState extends NonSerializable {
609
+ readonly phase: "fatal"
610
+ readonly error: Error
611
+ readonly retriable: false
612
+ }
613
+ ```
614
+
615
+ Fatal means the browser cannot recover by retrying the current intent. Examples include invalid token, expired token, forbidden request, unsupported PCI, or SDK upgrade required.
468
616
 
469
617
  # `/contracts`
470
618
 
471
- 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.
619
+ The contracts subpath contains renderer wire shapes and helpers shared between custom renderers and server-adjacent tooling.
472
620
 
473
621
  ```ts
474
622
  import {
475
- ChoiceSubmissionSchema,
476
- TextEntrySubmissionSchema,
477
- ExtendedTextSubmissionSchema,
478
- OrderSubmissionSchema,
479
- MatchSubmissionSchema,
480
- MatchPairSchema,
481
- DivisionRemainderPciSubmissionSchema,
482
- FractionAdditionPciSubmissionSchema,
483
- RendererSubmissionSchema,
484
- validateSubmissionForInteraction,
485
- submissionValidationMessage,
486
- blocksToPlainText,
487
- inlinesToPlainText
623
+ RendererSubmissionSchema,
624
+ blocksToPlainText,
625
+ inlinesToPlainText,
626
+ submissionValidationMessage,
627
+ validateSubmissionForInteraction
488
628
  } from "@superbuilders/primer-tives/contracts"
489
-
490
629
  import type {
491
- // interaction & submission shapes
492
- RendererInteraction,
493
- RendererSubmission,
494
- StandardRendererInteraction,
495
- PciInteraction,
496
- PciSubmission,
497
- // stimulus shapes
498
- RendererStimulus,
499
- ImageStimulus,
500
- // choice shapes
501
- RendererChoice,
502
- RendererMatchChoice,
503
- MatchPair,
504
- // content
505
- ContentBlock,
506
- ContentInline,
507
- ContentSpan,
508
- // PCI base
509
- PciId,
510
- PciUrn,
511
- PciRegistry,
512
- PciProps,
513
- PciValue,
514
- DivisionRemainderProps,
515
- DivisionRemainderSubmission,
516
- FractionAdditionProps,
517
- FractionAdditionSubmission,
518
- // review (server emits these, client consumes)
519
- InteractionReview,
520
- ChoiceReview,
521
- TextEntryReview,
522
- ExtendedTextReview,
523
- OrderReview,
524
- MatchReview,
525
- PciReview,
526
- ReviewRecordField,
527
- ReviewRecordFieldBaseType,
528
- ReviewScalarValue,
529
- // validation result
530
- SubmissionValidationResult,
531
- SubmissionValidationSuccess,
532
- SubmissionValidationFailure
630
+ ContentBlock,
631
+ ContentInline,
632
+ ContentSpan,
633
+ ImageStimulus,
634
+ InteractionReview,
635
+ MatchPair,
636
+ PciId,
637
+ PciInteraction,
638
+ PciProps,
639
+ PciSubmission,
640
+ PciValue,
641
+ RendererChoice,
642
+ RendererInteraction,
643
+ RendererMatchChoice,
644
+ RendererStimulus,
645
+ RendererSubmission,
646
+ StandardRendererInteraction
533
647
  } from "@superbuilders/primer-tives/contracts"
534
648
  ```
535
649
 
536
- ## Schemas
650
+ ## Content
537
651
 
538
- Every `RendererSubmission` variant has a Zod schema; `RendererSubmissionSchema` is the discriminated union over all of them and is the canonical `safeParse`-able wire shape.
652
+ ```ts
653
+ type ContentSpan = { type: "text"; value: string } | { type: "italic"; value: string }
654
+ type ContentInline = ContentSpan | { type: "latex"; value: string }
655
+ type ContentBlock = { type: "paragraph"; children: ContentInline[] }
656
+ ```
657
+
658
+ Helpers:
539
659
 
540
660
  ```ts
541
- import * as errors from "@superbuilders/errors"
542
- import * as logger from "@superbuilders/slog"
543
- import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
661
+ inlinesToPlainText(nodes: ContentInline[]): string
662
+ blocksToPlainText(blocks: ContentBlock[]): string
663
+ ```
544
664
 
545
- const result = RendererSubmissionSchema.safeParse(unknownPayload)
546
- if (!result.success) {
547
- logger.error("submission payload failed schema parse", { issues: result.error.issues })
548
- throw errors.wrap(result.error, "renderer submission parse")
665
+ Use these helpers for accessibility labels, alt fallbacks, logging summaries, and non-rich previews.
666
+
667
+ ## Stimulus
668
+
669
+ ```ts
670
+ interface ImageStimulus {
671
+ kind: "image"
672
+ alt: ContentInline[]
673
+ src: string
549
674
  }
550
- const submission = result.data
675
+
676
+ type RendererStimulus = ImageStimulus
551
677
  ```
552
678
 
553
- ## `validateSubmissionForInteraction(interaction, submission)`
679
+ `RendererStimulus` is currently image-only, but it is still a discriminated union so renderers stay future-safe.
554
680
 
555
- Optional semantic validator. Checks a submission against the interaction it claims to answer (cardinality bounds, identifier membership, duplicate rules, PCI payload shape).
681
+ ## Interactions
556
682
 
557
683
  ```ts
558
- function validateSubmissionForInteraction(
559
- interaction: RendererInteraction,
560
- submission: RendererSubmission
561
- ): SubmissionValidationResult
684
+ type RendererInteraction<Pcis extends PciId = PciId> =
685
+ | StandardRendererInteraction
686
+ | PciInteraction<Pcis>
687
+ ```
688
+
689
+ Standard interactions:
690
+
691
+ | Type | Key fields |
692
+ | --- | --- |
693
+ | `choice` | `prompt`, `options`, `shuffle`, `minChoices`, `maxChoices` |
694
+ | `text-entry` | `prompt`, `base`, `expectedLength`, `patternMask`, `placeholderText` |
695
+ | `extended-text` single | `prompt`, `format`, `expectedLines`, `expectedLength`, `patternMask`, `placeholderText` |
696
+ | `extended-text` multiple | single fields plus `minStrings`, `maxStrings` |
697
+ | `order` | `prompt`, `choices`, `shuffle`, `minChoices`, `maxChoices` |
698
+ | `match` | `prompt`, `sourceChoices`, `targetChoices`, `shuffle`, `minAssociations`, `maxAssociations` |
699
+ | `portable-custom` | `prompt`, `pciId`, `properties` |
562
700
 
563
- type SubmissionValidationResult =
564
- | { ok: true; value: RendererSubmission }
565
- | { ok: false; issues: readonly string[] }
701
+ Choice objects:
702
+
703
+ ```ts
704
+ interface RendererChoice {
705
+ identifier: string
706
+ content: ContentInline[]
707
+ }
566
708
  ```
567
709
 
568
- `submissionValidationMessage(failure)` joins `failure.issues` with `"; "` for a single string.
710
+ Match choice objects:
569
711
 
570
712
  ```ts
571
- import * as errors from "@superbuilders/errors"
572
- import * as logger from "@superbuilders/slog"
573
- import {
574
- submissionValidationMessage,
575
- validateSubmissionForInteraction
576
- } from "@superbuilders/primer-tives/contracts"
577
- import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
713
+ interface RendererMatchChoice {
714
+ identifier: string
715
+ content: ContentInline[]
716
+ matchMax: number
717
+ matchMin: number
718
+ }
719
+ ```
720
+
721
+ ## Submissions
722
+
723
+ ```ts
724
+ type RendererSubmission<Pcis extends PciId = PciId> =
725
+ | { type: "choice"; selectedKeys: string[] }
726
+ | { type: "text-entry"; value: string }
727
+ | { type: "extended-text"; values: string[] }
728
+ | { type: "order"; orderedKeys: string[] }
729
+ | { type: "match"; pairs: MatchPair[] }
730
+ | PciSubmission<Pcis>
731
+ ```
732
+
733
+ Schemas:
734
+
735
+ ```ts
736
+ ChoiceSubmissionSchema
737
+ TextEntrySubmissionSchema
738
+ ExtendedTextSubmissionSchema
739
+ OrderSubmissionSchema
740
+ MatchSubmissionSchema
741
+ FractionInputPciSubmissionSchema
742
+ RendererSubmissionSchema
743
+ ```
744
+
745
+ Always use `safeParse` when parsing arbitrary input:
746
+
747
+ ```ts
748
+ const parsed = RendererSubmissionSchema.safeParse(payload)
749
+ if (!parsed.success) {
750
+ throw parsed.error
751
+ }
752
+ ```
753
+
754
+ ## Semantic Validation
578
755
 
756
+ `RendererSubmissionSchema` validates shape. `validateSubmissionForInteraction` validates a submission against a specific interaction.
757
+
758
+ ```ts
579
759
  const validation = validateSubmissionForInteraction(interaction, submission)
580
760
  if (!validation.ok) {
581
- logger.warn("submission rejected by contract validation", { issues: validation.issues })
582
- throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
761
+ throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
583
762
  }
584
763
  ```
585
764
 
586
- 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.
765
+ Checks include:
587
766
 
588
- ---
767
+ - submission type matches interaction type
768
+ - choice identifiers exist
769
+ - choice cardinality is within `minChoices` and `maxChoices`
770
+ - duplicate choice/order identifiers are rejected
771
+ - extended-text value count matches cardinality bounds
772
+ - match source/target identifiers exist
773
+ - match min/max usage bounds hold
774
+ - portable-custom `pciId` matches and payload parses against the PCI schema
589
775
 
590
- # `/errors`
776
+ The built-in browser state objects call this before submitting. Custom renderers that bypass state methods should call it themselves.
591
777
 
592
- Every sentinel `Err…` value used by the SDK lives here and only here. Use `errors.is()` from `@superbuilders/errors` to type-check against them.
778
+ ## Review Types
779
+
780
+ `FeedbackState.review` is `InteractionReview | null`.
593
781
 
594
782
  ```ts
595
- import {
596
- // shared (server + client transport)
597
- ErrBadRequest,
598
- ErrConflict,
599
- ErrJsonParse,
600
- ErrNetwork,
601
- ErrServerError,
602
- ErrTimeout,
603
- // /server-thrown
604
- ErrInvalidSecretKey,
605
- ErrStudentNotFound,
606
- ErrUnsupportedGrade,
607
- ErrTimebackUnavailable,
608
- ErrExternalAuthorityRequired,
609
- // /client surfaced as `errored`
610
- ErrServiceUnavailable,
611
- ErrRateLimited,
612
- ErrInvalidSubmission,
613
- // /client surfaced as `fatal`
614
- ErrInvalidAccessToken,
615
- ErrTokenExpired,
616
- ErrForbidden,
617
- ErrNotFound,
618
- ErrNeedsHints,
619
- ErrUnsupportedPci,
620
- // /client thrown directly by `create()`
621
- ErrMalformedAccessToken,
622
- ErrNotSerializable
623
- } from "@superbuilders/primer-tives/errors"
783
+ type InteractionReview<Pcis extends PciId = PciId> =
784
+ | ChoiceReview
785
+ | TextEntryReview
786
+ | ExtendedTextReview
787
+ | OrderReview
788
+ | MatchReview
789
+ | PciReview<Pcis>
624
790
  ```
625
791
 
626
- ## Server-side method failures
627
-
628
- | Sentinel | Raised when |
629
- |---|---|
630
- | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
631
- | `ErrStudentNotFound` | HTTP 404 — native `studentId` unknown on this frontend, or Timeback `sourcedId` unknown upstream |
632
- | `ErrUnsupportedGrade` | HTTP 400 — Timeback returned a grade outside Primer's supported range |
633
- | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed during live exchange |
634
- | `ErrExternalAuthorityRequired` | HTTP 409 — attempted native/manual exchange for a Timeback-linked student |
635
- | `ErrConflict` | HTTP 409 — frontend not provisioned for routing/content |
636
- | `ErrBadRequest` | HTTP 400 — validation failure |
637
- | `ErrServerError` | HTTP 5xx |
638
- | `ErrJsonParse` | response body was not valid JSON or had the wrong shape |
639
- | `ErrNetwork` | `fetch()` rejected (DNS, connection, TLS, etc.) |
640
- | `ErrTimeout` | `fetch()` aborted |
641
-
642
- ## Client `errored.error`
643
-
644
- | Sentinel | Raised when |
645
- |---|---|
646
- | `ErrNetwork` | `fetch()` rejected |
647
- | `ErrTimeout` | `fetch()` aborted |
648
- | `ErrServerError` | HTTP 5xx |
649
- | `ErrServiceUnavailable` | HTTP 502/503/504 |
650
- | `ErrRateLimited` | HTTP 429 |
651
- | `ErrConflict` | HTTP 409 |
652
- | `ErrJsonParse` | success body wasn't valid JSON |
653
- | `ErrInvalidSubmission` | client-side validation rejected the submission |
654
-
655
- ## Client `fatal.error`
792
+ Review variants:
656
793
 
657
- | Sentinel | Raised when |
658
- |---|---|
659
- | `ErrBadRequest` | HTTP 400 |
660
- | `ErrInvalidAccessToken` | HTTP 401 |
661
- | `ErrTokenExpired` | HTTP 401 with token-expired detail |
662
- | `ErrForbidden` | HTTP 403 |
663
- | `ErrNotFound` | HTTP 404 |
664
- | `ErrNeedsHints` | HTTP 412 — the student has no `gradeLevel` on record; backend must call `setStudentHints` and mint a fresh access token |
665
- | `ErrSdkUpgradeRequired` | the SDK's version is older than the server's minimum-supported version; bump `@superbuilders/primer-tives` to the latest |
666
- | `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
667
-
668
- ## Thrown directly by `create()`
794
+ | Type | Data |
795
+ | --- | --- |
796
+ | `choice` | `correctKeys: string[]` |
797
+ | `text-entry` | `correctValue: ReviewScalarValue | null` |
798
+ | `extended-text` | `correctValues: ReviewScalarValue[]` |
799
+ | `order` | `correctOrder: string[]` |
800
+ | `match` | `correctPairs: MatchPair[]` |
801
+ | `portable-custom` | `pciId`, `fields: ReviewRecordField[]` |
669
802
 
670
- | Sentinel | Raised when |
671
- |---|---|
672
- | `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
673
- | `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
803
+ `review` is for display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
674
804
 
675
- ## Recommended consumer pattern
805
+ # `/errors`
676
806
 
677
- Per `@superbuilders/errors`: log every error before throwing, wrap external errors with terse Go-style context, never bare `throw new Error(...)`.
807
+ All sentinels are exported from `/errors`.
678
808
 
679
809
  ```ts
680
- import * as errors from "@superbuilders/errors"
681
- import * as logger from "@superbuilders/slog"
682
810
  import {
683
- ErrExternalAuthorityRequired,
684
- ErrInvalidSecretKey,
685
- ErrStudentNotFound
811
+ ErrBadRequest,
812
+ ErrConflict,
813
+ ErrForbidden,
814
+ ErrInvalidAccessToken,
815
+ ErrInvalidSecretKey,
816
+ ErrInvalidSubmission,
817
+ ErrJsonParse,
818
+ ErrMalformedAccessToken,
819
+ ErrMissingRequiredPci,
820
+ ErrNetwork,
821
+ ErrNotFound,
822
+ ErrNotSerializable,
823
+ ErrRateLimited,
824
+ ErrSdkUpgradeRequired,
825
+ ErrServerError,
826
+ ErrServiceUnavailable,
827
+ ErrTimeout,
828
+ ErrTokenExpired,
829
+ ErrUnsupportedPci
686
830
  } from "@superbuilders/primer-tives/errors"
687
-
688
- const tokenResult = await errors.try(primer.exchangeStudentForAccessToken(studentId))
689
- if (tokenResult.error) {
690
- if (errors.is(tokenResult.error, ErrInvalidSecretKey)) {
691
- logger.error("primer secret key invalid", { error: tokenResult.error })
692
- throw errors.wrap(tokenResult.error, "primer token exchange")
693
- }
694
- if (errors.is(tokenResult.error, ErrStudentNotFound)) {
695
- logger.error("primer student id stale", { studentId, error: tokenResult.error })
696
- throw errors.wrap(tokenResult.error, "primer token exchange")
697
- }
698
- if (errors.is(tokenResult.error, ErrExternalAuthorityRequired)) {
699
- logger.error("primer student requires timeback exchange", {
700
- studentId,
701
- error: tokenResult.error
702
- })
703
- throw errors.wrap(tokenResult.error, "primer token exchange")
704
- }
705
- logger.error("primer token exchange failed", { error: tokenResult.error })
706
- throw errors.wrap(tokenResult.error, "primer token exchange")
707
- }
708
- const { accessToken } = tokenResult.data
709
831
  ```
710
832
 
711
- ---
833
+ ## Server SDK Errors
834
+
835
+ | Sentinel | Meaning |
836
+ | --- | --- |
837
+ | `ErrInvalidSecretKey` | Secret key rejected by Primer. |
838
+ | `ErrBadRequest` | Token request was malformed. |
839
+ | `ErrServerError` | Primer returned an unexpected non-2xx status. |
840
+ | `ErrJsonParse` | Successful response did not parse or lacked token shape. |
841
+ | `ErrNetwork` | Fetch rejected before response. |
842
+ | `ErrTimeout` | Fetch aborted. |
843
+
844
+ ## Browser Transport Errors
845
+
846
+ These become `ErroredState` unless classified as fatal:
847
+
848
+ | Sentinel | Meaning |
849
+ | --- | --- |
850
+ | `ErrNetwork` | Fetch rejected. |
851
+ | `ErrTimeout` | Fetch aborted. |
852
+ | `ErrServerError` | HTTP 5xx not otherwise mapped. |
853
+ | `ErrServiceUnavailable` | HTTP 502, 503, or 504. |
854
+ | `ErrRateLimited` | HTTP 429. |
855
+ | `ErrConflict` | HTTP 409. |
856
+ | `ErrJsonParse` | Successful response body failed to parse. |
857
+ | `ErrInvalidSubmission` | Local semantic validation rejected the submission. |
858
+
859
+ ## Browser Fatal Errors
860
+
861
+ These become `FatalState`:
862
+
863
+ | Sentinel | Meaning |
864
+ | --- | --- |
865
+ | `ErrBadRequest` | HTTP 400. |
866
+ | `ErrInvalidAccessToken` | HTTP 401. |
867
+ | `ErrTokenExpired` | HTTP 401 with token-expired detail. |
868
+ | `ErrForbidden` | HTTP 403. |
869
+ | `ErrNotFound` | HTTP 404. |
870
+ | `ErrSdkUpgradeRequired` | Server requires a newer SDK. |
871
+ | `ErrUnsupportedPci` | Server returned a portable-custom interaction whose `pciId` is not declared in `supportedPcis`. |
872
+
873
+ ## Direct Client Construction Errors
874
+
875
+ | Sentinel | Meaning |
876
+ | --- | --- |
877
+ | `ErrMalformedAccessToken` | Token string does not look like a JWS. |
878
+ | `ErrMissingRequiredPci` | `create` was called for a subject whose required PCI URNs are not present in `supportedPcis`. |
879
+ | `ErrNotSerializable` | `JSON.stringify` was called on live `PrimerState`. |
712
880
 
713
881
  # `/logger`
714
882
 
@@ -716,16 +884,21 @@ const { accessToken } = tokenResult.data
716
884
  import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
717
885
 
718
886
  interface PrimerLogger {
719
- debug(message: string, attributes?: Record<string, unknown>): void
720
- info(message: string, attributes?: Record<string, unknown>): void
721
- warn(message: string, attributes?: Record<string, unknown>): void
722
- error(message: string, attributes?: Record<string, unknown>): void
887
+ debug(message: string, attributes?: Record<string, unknown>): void
888
+ info(message: string, attributes?: Record<string, unknown>): void
889
+ warn(message: string, attributes?: Record<string, unknown>): void
890
+ error(message: string, attributes?: Record<string, unknown>): void
723
891
  }
724
892
  ```
725
893
 
726
- Required by both `PrimerServerConfig` and `Config` (the client config). The SDK never silently swallows operational events every fatal, parse failure, transport error, and submission rejection goes through this logger. `@superbuilders/slog` matches this shape directly — `import * as logger from "@superbuilders/slog"` and pass `logger`.
894
+ The same logger shape is used by server and browser SDKs. `@superbuilders/slog` matches it directly:
895
+
896
+ ```ts
897
+ import * as logger from "@superbuilders/slog"
727
898
 
728
- ---
899
+ const primer = createPrimerServer({ origin, secretKey, logger })
900
+ const client = create({ origin, accessToken, subject: "vocabulary", logger })
901
+ ```
729
902
 
730
903
  # `/grade-level`
731
904
 
@@ -737,26 +910,55 @@ const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "1
737
910
  type GradeLevel = (typeof GRADE_LEVELS)[number]
738
911
  ```
739
912
 
740
- Used as the `gradeLevel` field on `PlacementHints` / `PlacementHintsResult`.
913
+ The server SDK no longer accepts a grade argument. Primer asks the learner for selected grade inside the browser runtime if needed.
741
914
 
742
- ---
915
+ # Onboarding Grade Selection
743
916
 
744
- # `/subject`
917
+ Grade selection is not a separate SDK API.
745
918
 
746
- ```ts
747
- import { SUBJECTS } from "@superbuilders/primer-tives/subject"
748
- import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
919
+ If the learner's email profile has no selected grade yet, the first `/advance` response is a normal `choice` interaction:
749
920
 
750
- const SUBJECTS = ["math", "vocabulary", "science"] as const
751
- type Subject = (typeof SUBJECTS)[number]
752
- type SubjectScope = Subject | "all"
753
- ```
921
+ - `phase: "interaction"`
922
+ - `kind: "choice"`
923
+ - `minChoices: 1`
924
+ - `maxChoices: 1`
925
+ - options with identifiers from `GRADE_LEVELS`
926
+
927
+ Your renderer should treat it like any other single-choice interaction. Submit the selected option through `submitChoice([grade])`. The server captures that answer, stores the selected grade on the hashed email profile, bootstraps placement, and returns real content.
928
+
929
+ This design keeps the SDK small:
930
+
931
+ - no grade argument to `getToken`
932
+ - no server-side student creation method
933
+ - no separate onboarding endpoint
934
+ - no browser special case beyond rendering a normal choice interaction
935
+
936
+ # Integration Checklist
937
+
938
+ Use this as the minimum correct integration:
939
+
940
+ 1. Backend imports `createPrimerServer` from `/server`.
941
+ 2. Backend keeps `secretKey` server-only.
942
+ 3. Backend calls `getToken` only for an authenticated user with a verified email.
943
+ 4. Backend returns the token string to the browser.
944
+ 5. Browser imports `create` from `/client`.
945
+ 6. Browser passes `accessToken`, `origin`, `subject`, `logger`, and required `supportedPcis`.
946
+ 7. Browser calls `start()` once per runtime mount.
947
+ 8. Browser renders by switching on `state.phase`.
948
+ 9. Browser never serializes `PrimerState`.
949
+ 10. Browser recreates the client after reload or subject switch.
950
+
951
+ # What This SDK Does Not Do
754
952
 
755
- Used as the `subject` field on the client `Config`.
953
+ The current SDK does not expose:
756
954
 
757
- | Value | Behavior |
758
- |---|---|
759
- | `"math"` / `"vocabulary"` / `"science"` | restrict drill selection to courses of that subject |
760
- | `"all"` | no filter; drills from any subject the frontend is bound to |
955
+ - raw student creation
956
+ - student ids as public integration inputs
957
+ - SIS-specific identity-provider flows
958
+ - host-app email verification
959
+ - Primer-sent email verification links
960
+ - a grade-level argument on token creation
961
+ - persistent browser sessions
962
+ - root package exports
761
963
 
762
- 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`.
964
+ Those omissions are intentional. The public integration point is one backend method, `getToken`, and one browser state machine, `create(...).start()`.