@uniweb/api 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=20.19"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "^0.27.0"
37
+ "@uniweb/core": "^0.29.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/mock/index.js CHANGED
@@ -42,7 +42,12 @@ import { DEFAULT_SEED } from './seed.js'
42
42
  * demo whose subject is the signed-in view. Development-only by construction, since
43
43
  * this whole module is; throws if the username is not seeded.
44
44
  * @param {string} [options.prefix] - the path the API is mounted under (default `/api`)
45
- * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore }}
45
+ * Supply `seed.schemas[model].sections` (the framework's LOWERED sections map) and
46
+ * item writes are shape-checked: a `many:` section is ENFORCED, everything else is
47
+ * recorded on `.diagnostics` and allowed — see `./schema-shape.js` for why that
48
+ * asymmetry is deliberate rather than lenient.
49
+ *
50
+ * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[] }}
46
51
  */
47
52
  export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signedInAs = null } = {}) {
48
53
  const store = new MockStore(seed, { signedInAs })
@@ -181,6 +186,16 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
181
186
 
182
187
  return {
183
188
  store,
189
+ /**
190
+ * Writes the mock shape-checked but did not refuse.
191
+ *
192
+ * ⛔ Each entry is a write whose storage mapping is an OPEN QUESTION
193
+ * (`./schema-shape.js`), not one that broke a rule and was let through. Empty
194
+ * is the good state; a long list is a map of what we are still guessing about.
195
+ */
196
+ get diagnostics() {
197
+ return store.diagnostics
198
+ },
184
199
  async fetch(request) {
185
200
  try {
186
201
  return await route(request)
@@ -195,3 +210,4 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
195
210
 
196
211
  export { MockStore } from './store.js'
197
212
  export { DEFAULT_SEED } from './seed.js'
213
+ export { STORAGE, UNRESOLVED_REASON, ENFORCEMENT, OUTCOME } from './schema-shape.js'
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Field-shape checking for writes, and an HONEST NAME for what we cannot check yet.
3
+ *
4
+ * ⭐ **Why this exists.** Until now the mock enforced exactly two things —
5
+ * `creatable_by` and `append_only` — and performed no field-shape validation at
6
+ * all. A demo could therefore write `{ minutes: "twelve" }` into a field the
7
+ * schema calls an int and find out at integration. This module closes that for
8
+ * the one storage shape we have evidence about, and refuses to guess about the
9
+ * rest.
10
+ *
11
+ * ## The evidence boundary — this is the whole design
12
+ *
13
+ * | shape | what we know | what we do |
14
+ * |---|---|---|
15
+ * | a `many:` (multi) section | **MEASURED** — stored as entity items | **ENFORCE** field shapes |
16
+ * | a single/`brief` section | ⛔ **OPEN** — see below | **DIAGNOSE**, never refuse |
17
+ * | a section the schema does not declare | ⛔ **OPEN** — same question | **DIAGNOSE**, never refuse |
18
+ * | the create-time entity `data` payload | ⛔ **OPEN** — same question | **DIAGNOSE**, never refuse |
19
+ *
20
+ * ⛔ **The open question (one question, three faces).** A schema written with flat
21
+ * `fields:` is lowered to exactly one `kind: 'single'`, `brief: true` section named
22
+ * after the model — MEASURED in `@uniweb/build@0.44.4`
23
+ * `src/uwx/data-schema.js::lowerFieldsForm`, which also notes "the registry's root
24
+ * is always `sections:`". Separately, this API's entity routes are
25
+ * list / read / readBatch / create / items / remove / removeBatch: entity `data` is
26
+ * supplied at creation and there is no route to update it, while items are mutable.
27
+ *
28
+ * So: **what is the intended backend representation for the fields of a
29
+ * single/brief section once the entity exists** — mutable entity data, an item, or
30
+ * another update mechanism? Nobody has answered that yet. It is why a real
31
+ * consumer (the Courses solution) keeps editable lesson fields in an item and
32
+ * overlays course metadata with a mutable item over create-time data.
33
+ *
34
+ * ⛔ **Do NOT read "diagnose" as a permanent mock semantic.** It is not a policy
35
+ * that writes of unknown shape are allowed; it is the absence of an answer, named.
36
+ * That is why the state is called `UNRESOLVED_STORAGE_MAPPING` rather than
37
+ * "warn" or "allow": when the question is answered, the fix is to change this
38
+ * model's entry in `ENFORCEMENT` (and, if the answer says so, teach
39
+ * `classifySection` the new mapping) — not to redesign the validator or hunt for
40
+ * the places that quietly let writes through.
41
+ */
42
+
43
+ /** Where a written section lives, and therefore what we are entitled to do about it. */
44
+ export const STORAGE = {
45
+ /**
46
+ * A section the schema declares — `single` or `multi` alike.
47
+ *
48
+ * ⭐ **RULED** *(Diego, 2026-09-11)*: sections ARE the storage model and hold
49
+ * items ("section => abstract, item => concrete"); a `single` section simply holds
50
+ * one. `brief: true` is an optional designation about what a card needs, **not** a
51
+ * storage class. So a declared section's records are checkable whatever its kind —
52
+ * which is why `single` no longer lives in the unresolved branch.
53
+ */
54
+ ITEMS: 'items',
55
+ /**
56
+ * ⛔ **Known divergence, on an explicit list — never a catch-all.**
57
+ *
58
+ * A `(model, section)` pair the site has declared as `migration_debt`: storage we
59
+ * know does not match its declaration, kept working while the thing that forced it
60
+ * is unwound. Two shapes qualify, and both are OUR debt, not the framework's:
61
+ * • a section the schema does not declare at all (`@/course` `meta`), and
62
+ * • a declared section whose records diverge (`@/quiz-key` `answers` holds one
63
+ * record carrying a map, where the schema declares many).
64
+ *
65
+ * Both exist because `@uniweb/api` had no route to update entity data — an
66
+ * omission, per Diego, not a backend rule. They retire with that route.
67
+ */
68
+ MIGRATION_DEBT: 'migration-debt',
69
+ /**
70
+ * A section that is neither declared nor listed as debt. **A violation** — a typo
71
+ * or an accidental section, which must not be able to hide in the debt bucket.
72
+ */
73
+ UNDECLARED: 'undeclared-section',
74
+ /**
75
+ * ⛔ STILL OPEN. The create-time `data` payload: the one shape whose wire contract
76
+ * is genuinely unknown, pending the entity-update route.
77
+ */
78
+ UNRESOLVED: 'unresolved-storage-mapping',
79
+ }
80
+
81
+ /** Why a write was tolerated rather than judged. Reported; never changes what happens. */
82
+ export const UNRESOLVED_REASON = {
83
+ /** On the site's `migration_debt` list: the schema declares no such section. */
84
+ UNDECLARED_SECTION: 'undeclared-section',
85
+ /** On the list: declared, but the records stored there do not match the declaration. */
86
+ DIVERGENT_SECTION: 'divergent-section',
87
+ /** The `data` supplied at creation. Awaiting the entity-update wire contract. */
88
+ ENTITY_DATA_PAYLOAD: 'entity-data-payload',
89
+ }
90
+
91
+ /**
92
+ * What to DO about each storage classification.
93
+ *
94
+ * ⭐ **This table is the resolution point.** Answering the open question means
95
+ * changing `UNRESOLVED` here (to `'enforce'`, or to whatever the answer implies),
96
+ * and nothing else in this file's callers moves.
97
+ */
98
+ export const ENFORCEMENT = {
99
+ [STORAGE.ITEMS]: 'enforce',
100
+ [STORAGE.UNDECLARED]: 'enforce',
101
+ [STORAGE.MIGRATION_DEBT]: 'diagnose',
102
+ [STORAGE.UNRESOLVED]: 'diagnose',
103
+ }
104
+
105
+ /** Outcomes a check can produce. `VIOLATES` is the only one a caller may refuse on. */
106
+ export const OUTCOME = {
107
+ CONFORMS: 'conforms',
108
+ VIOLATES: 'violates',
109
+ /** Shape problems found, but under a classification we do not enforce. */
110
+ DIAGNOSED: 'diagnosed',
111
+ /** No declaration to check against — the model is not described to the mock. */
112
+ UNCHECKED: 'unchecked',
113
+ }
114
+
115
+ /**
116
+ * ⚠️ **TWO LOWERED SPELLINGS, and this module accepts both.**
117
+ *
118
+ * The framework lowers a schema twice, and the two forms disagree on how they say
119
+ * "many" — MEASURED 2026-09-11 against `@uniweb/schemas@0.2.13` and
120
+ * `@uniweb/build@0.44.4`:
121
+ *
122
+ * | | a repeating section | a list field |
123
+ * |---|---|---|
124
+ * | `validateAndNormalizeSchema` | `kind: 'multi'` | `{ type: 'array', items: {…} }` |
125
+ * | `toDataSchemaDeclaration` (the **registry** form, what a backend is actually sent) | `multiple: true` | `{ type: 'string', multiple: true }` |
126
+ *
127
+ * ⛔ Reading only the first spelling is not a cosmetic bug: every multi section in a
128
+ * REGISTRY declaration would fall through to "single", so the mock would classify
129
+ * all of them as unresolved and enforce **nothing**, silently. Caught by feeding it
130
+ * real declarations rather than only hand-written fixtures.
131
+ */
132
+ function isMultiSection(section) {
133
+ return section?.multiple === true || section?.kind === 'multi'
134
+ }
135
+
136
+ /** The element shape of a list field, in either spelling, or null if it is not a list. */
137
+ function listElement(field) {
138
+ if (field?.multiple === true) return { type: field.type, fields: field.fields }
139
+ if (field?.type === 'array') return { type: field.items?.type, fields: field.items?.fields }
140
+ return null
141
+ }
142
+
143
+ /**
144
+ * Is this section a multi section of the model's declaration?
145
+ *
146
+ * `decl.sections` is the lowered sections map (`{ [name]: { kind, brief, fields } }`)
147
+ * as produced by the framework's own normalizer — the mock never parses schema
148
+ * source, so there is no second copy of the authoring format here to drift.
149
+ */
150
+ export function classifySection(decl, section) {
151
+ const sections = decl?.sections
152
+ if (!sections || typeof sections !== 'object') return null
153
+ const found = sections[section]
154
+
155
+ // ⭐ The debt list is consulted FIRST and is an exact `(model, section)` match, so
156
+ // it can excuse a declared section whose records diverge as well as an undeclared
157
+ // one — but only ever the pairs the site named. Anything else falls through.
158
+ const isDebt = (decl.migration_debt || []).includes(section)
159
+ if (isDebt) {
160
+ return {
161
+ storage: STORAGE.MIGRATION_DEBT,
162
+ reason: found ? UNRESOLVED_REASON.DIVERGENT_SECTION : UNRESOLVED_REASON.UNDECLARED_SECTION,
163
+ section: found || null,
164
+ }
165
+ }
166
+
167
+ // ⛔ Not declared and not owned as debt: a typo or an accidental section. Refused,
168
+ // so it cannot disappear into the debt bucket — the reason the list is explicit.
169
+ if (!found) return { storage: STORAGE.UNDECLARED, section: null }
170
+
171
+ // Declared — single or multi alike. Sections hold items; `single` holds one.
172
+ return { storage: STORAGE.ITEMS, section: found }
173
+ }
174
+
175
+ // ── Field shapes ──────────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Does a value satisfy a lowered type?
179
+ *
180
+ * Returns `null` for a type this does not know. ⭐ Unknown is TOLERATED, not
181
+ * failed: the type vocabulary belongs to `@uniweb/schemas` and grows there, and a
182
+ * mock that rejected a type it had not heard of would fail writes the real store
183
+ * accepts — the expensive direction of a fidelity error.
184
+ */
185
+ function satisfiesType(type, value) {
186
+ switch (type) {
187
+ case 'string':
188
+ case 'text':
189
+ case 'html':
190
+ case 'markdown':
191
+ case 'email':
192
+ case 'url':
193
+ case 'image':
194
+ case 'file':
195
+ case 'ref':
196
+ return typeof value === 'string'
197
+ case 'int':
198
+ case 'integer':
199
+ return typeof value === 'number' && Number.isInteger(value)
200
+ case 'number':
201
+ case 'decimal':
202
+ return typeof value === 'number' && Number.isFinite(value)
203
+ case 'bool':
204
+ case 'boolean':
205
+ return typeof value === 'boolean'
206
+ case 'date':
207
+ case 'datetime':
208
+ return typeof value === 'string' || value instanceof Date
209
+ case 'array':
210
+ return Array.isArray(value)
211
+ case 'object':
212
+ case 'json':
213
+ case 'group':
214
+ return typeof value === 'object' && value !== null
215
+ default:
216
+ return null
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Check a record against a lowered `fields` map. Returns a list of violations.
222
+ *
223
+ * ⛔ **A key the schema does not declare is NOT a violation.** We have no evidence
224
+ * that the real store rejects extras, and inventing that rule here would refuse
225
+ * writes production accepts. Unknown keys are reported by the caller as
226
+ * diagnostics instead.
227
+ */
228
+ export function checkFields(fields, data, path = '') {
229
+ const problems = []
230
+ if (!fields || typeof fields !== 'object') return problems
231
+ const record = data && typeof data === 'object' ? data : {}
232
+
233
+ for (const [name, field] of Object.entries(fields)) {
234
+ const at = path ? `${path}.${name}` : name
235
+ const value = record[name]
236
+ const missing = value === undefined || value === null
237
+
238
+ if (field.required && missing) {
239
+ problems.push({ field: at, rule: 'required', detail: `'${at}' is required` })
240
+ continue
241
+ }
242
+ if (missing) continue
243
+
244
+ // A list, in either spelling. The VALUE must be an array; each entry is then
245
+ // checked against the element shape, indexed the way `uniweb validate` names it
246
+ // (`outcomes[1]`), so one vocabulary covers both lanes.
247
+ const element = listElement(field)
248
+ if (element) {
249
+ if (!Array.isArray(value)) {
250
+ problems.push({ field: at, rule: 'type', detail: `'${at}' expects a list of ${element.type}` })
251
+ continue
252
+ }
253
+ value.forEach((entry, i) => {
254
+ const entryAt = `${at}[${i}]`
255
+ if (satisfiesType(element.type, entry) === false) {
256
+ problems.push({
257
+ field: entryAt,
258
+ rule: 'type',
259
+ detail: `'${entryAt}' expects ${element.type}, got ${Array.isArray(entry) ? 'array' : typeof entry}`,
260
+ })
261
+ return
262
+ }
263
+ if (element.fields) problems.push(...checkFields(element.fields, entry, entryAt))
264
+ })
265
+ continue
266
+ }
267
+
268
+ if (satisfiesType(field.type, value) === false) {
269
+ problems.push({
270
+ field: at,
271
+ rule: 'type',
272
+ detail: `'${at}' expects ${field.type}, got ${Array.isArray(value) ? 'array' : typeof value}`,
273
+ })
274
+ continue
275
+ }
276
+
277
+ if (Array.isArray(field.enum) && field.enum.length && !field.enum.includes(value)) {
278
+ problems.push({ field: at, rule: 'enum', detail: `'${at}' must be one of ${field.enum.join(', ')}` })
279
+ continue
280
+ }
281
+
282
+ if (field.fields) problems.push(...checkFields(field.fields, value, at))
283
+ }
284
+ return problems
285
+ }
286
+
287
+ /** Keys present in the write that the section does not declare. Diagnostic only — see checkFields. */
288
+ function undeclaredKeys(fields, data) {
289
+ if (!fields || !data || typeof data !== 'object') return []
290
+ return Object.keys(data).filter((k) => !(k in fields))
291
+ }
292
+
293
+ // ── The two entry points the store calls ──────────────────────────────────────
294
+
295
+ /**
296
+ * Check one item write (create or update) against the model's declaration.
297
+ *
298
+ * @returns {{outcome: string, storage?: string, reason?: string, problems: object[], undeclared: string[]}}
299
+ * `outcome: 'violates'` is the ONLY result a caller may refuse on.
300
+ */
301
+ export function checkItemWrite({ decl, section, data }) {
302
+ if (!decl?.sections) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
303
+
304
+ const where = classifySection(decl, section)
305
+ if (!where) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
306
+
307
+ // An undeclared section has no field set to conform to, so the violation is the
308
+ // section itself — stated here rather than falling out of an empty field check.
309
+ if (where.storage === STORAGE.UNDECLARED) {
310
+ return {
311
+ outcome: OUTCOME.VIOLATES,
312
+ storage: where.storage,
313
+ problems: [
314
+ {
315
+ field: section,
316
+ rule: 'undeclared-section',
317
+ detail: `'${section}' is not a section of this model`,
318
+ },
319
+ ],
320
+ undeclared: [],
321
+ }
322
+ }
323
+
324
+ const fields = where.section?.fields
325
+ const problems = fields ? checkFields(fields, data) : []
326
+ const undeclared = fields ? undeclaredKeys(fields, data) : []
327
+ const enforcement = ENFORCEMENT[where.storage]
328
+
329
+ if (enforcement === 'enforce') {
330
+ return {
331
+ outcome: problems.length ? OUTCOME.VIOLATES : OUTCOME.CONFORMS,
332
+ storage: where.storage,
333
+ problems,
334
+ undeclared,
335
+ }
336
+ }
337
+ // ⭐ ALWAYS diagnosed, even when the data looks fine and even when there were no
338
+ // fields to check it against. The finding is not "these values are wrong" — it is
339
+ // "this write's storage mapping is unanswered". A clean-looking write under an
340
+ // unresolved classification is still riding on the open question, and the whole
341
+ // value of the list is that it maps exactly which writes those are.
342
+ return {
343
+ outcome: OUTCOME.DIAGNOSED,
344
+ storage: where.storage,
345
+ reason: where.reason,
346
+ problems,
347
+ undeclared,
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Check the `data` supplied at entity creation.
353
+ *
354
+ * ⛔ **Always the unresolved branch, by construction.** Whether this payload is the
355
+ * brief section's fields (and therefore checkable against them) is precisely the
356
+ * open question. We report what we see and refuse nothing.
357
+ */
358
+ export function checkEntityDataWrite({ decl, data }) {
359
+ if (!decl?.sections) return { outcome: OUTCOME.UNCHECKED, problems: [], undeclared: [] }
360
+
361
+ const briefName = Object.keys(decl.sections).find((n) => decl.sections[n]?.brief)
362
+ const brief = briefName ? decl.sections[briefName] : null
363
+ const problems = brief?.fields ? checkFields(brief.fields, data) : []
364
+ const undeclared = brief?.fields ? undeclaredKeys(brief.fields, data) : []
365
+
366
+ // Always diagnosed, for the same reason as `checkItemWrite` — see there.
367
+ return {
368
+ outcome: OUTCOME.DIAGNOSED,
369
+ storage: STORAGE.UNRESOLVED,
370
+ reason: UNRESOLVED_REASON.ENTITY_DATA_PAYLOAD,
371
+ problems,
372
+ undeclared,
373
+ }
374
+ }
package/src/mock/store.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { FIELD, OP } from '../wire.js'
2
+ import { checkItemWrite, checkEntityDataWrite, OUTCOME, STORAGE } from './schema-shape.js'
2
3
 
3
4
  /**
4
5
  * The mock's state — accounts, one session, entities and their items.
@@ -31,7 +32,14 @@ export class MockStore {
31
32
  /**
32
33
  * @param {object} seed
33
34
  * @param {object[]} [seed.accounts] - `{ username, password, handle, roles?, units? }`
34
- * @param {object} [seed.schemas] - `{ '@/session': { creatable_by?, append_only? } }`
35
+ * @param {object} [seed.schemas] - `{ '@/session': { creatable_by?, append_only?, sections?, migration_debt? } }`.
36
+ * `migration_debt` is a list of section names this site KNOWS diverge from the
37
+ * declaration and is unwinding — tolerated and recorded, never silently passed.
38
+ * Anything undeclared and NOT on that list is refused, so a typo cannot hide in it.
39
+ * `sections` is the LOWERED sections map (`{ [name]: { kind, brief, fields } }`) as the
40
+ * framework's own normalizer produces it — supply it and writes are shape-checked
41
+ * (see `./schema-shape.js`). The mock never parses schema source itself, so there is
42
+ * no second copy of the authoring format here to drift from the real one.
35
43
  * @param {object[]} [seed.entities] - `{ uuid?, model, data?, items? }`
36
44
  * @param {object} [options]
37
45
  * @param {string} [options.signedInAs] - start with this account already signed in.
@@ -46,9 +54,18 @@ export class MockStore {
46
54
  units: a.units || [],
47
55
  ...a,
48
56
  }))
49
- // What the mock knows about a Model: only the two things it must ENFORCE.
50
- // Everything else about a schema is the site's business, not the server's.
57
+ // What the mock knows about a Model: the two permission rules it must ENFORCE,
58
+ // and — when the seed supplies `sections` — the field shapes to check writes
59
+ // against. Everything else about a schema is the site's business.
51
60
  this.schemas = seed.schemas || {}
61
+ /**
62
+ * Shape findings the mock saw but did not refuse.
63
+ *
64
+ * ⛔ Every entry here is a write under `STORAGE.UNRESOLVED` — the open storage
65
+ * question in `./schema-shape.js`, not a lenient policy. Read it to see exactly
66
+ * which writes are riding on an unanswered question; `mock.diagnostics` exposes it.
67
+ */
68
+ this.diagnostics = []
52
69
  this.entities = new Map()
53
70
  for (const e of seed.entities || []) this.seedEntity(e)
54
71
  /** The one session. A mock serves one developer, so one is the honest number. */
@@ -168,6 +185,42 @@ export class MockStore {
168
185
  return false
169
186
  }
170
187
 
188
+ /**
189
+ * Record a shape finding the mock chose not to refuse.
190
+ *
191
+ * ⛔ Called ONLY for `STORAGE.UNRESOLVED` writes. A finding here is "we could not
192
+ * judge this because the storage mapping is an open question", never "this was
193
+ * wrong but allowed" — see `./schema-shape.js` for the question and for the one
194
+ * table that resolves it.
195
+ */
196
+ diagnose(model, where, result) {
197
+ // ⭐ Deduped by (model, op, section, reason) with a count, so the list stays a
198
+ // MAP of what is unresolved rather than a log of every keystroke. An editor
199
+ // saving a lesson every few seconds would otherwise bury the distinct findings
200
+ // under thousands of identical rows, and the distinct set is the whole point.
201
+ const key = `${model}|${where.op}|${where.section ?? ''}|${result.reason}`
202
+ // `storage` travels with the finding: migration debt and a still-open mapping
203
+ // read the same in a list otherwise, and they have different futures.
204
+ const seen = this.diagnostics.find((d) => d.key === key)
205
+ if (seen) {
206
+ seen.count += 1
207
+ seen.lastAt = now()
208
+ return
209
+ }
210
+ this.diagnostics.push({
211
+ key,
212
+ model,
213
+ ...where,
214
+ storage: result.storage,
215
+ reason: result.reason,
216
+ problems: result.problems,
217
+ undeclared: result.undeclared,
218
+ count: 1,
219
+ firstAt: now(),
220
+ lastAt: now(),
221
+ })
222
+ }
223
+
171
224
  /** Is this section insert-only? Existing items may not be edited or removed. */
172
225
  isAppendOnly(model, section) {
173
226
  const decl = this.schemas[model]?.append_only
@@ -207,6 +260,10 @@ export class MockStore {
207
260
  // ── Writes ──────────────────────────────────────────────────────────────────
208
261
 
209
262
  create(model, data) {
263
+ // ⛔ Never refused. Whether this payload is the brief section's fields — and so
264
+ // whether it is checkable against them at all — IS the open question.
265
+ const check = checkEntityDataWrite({ decl: this.schemas[model], data })
266
+ if (check.outcome === OUTCOME.DIAGNOSED) this.diagnose(model, { op: 'create-entity' }, check)
210
267
  const entity = this.seedEntity({ model, data, owner: this.account?.uuid ?? null })
211
268
  return this.hydrate(entity)
212
269
  }
@@ -215,6 +272,41 @@ export class MockStore {
215
272
  return this.entities.delete(uuid)
216
273
  }
217
274
 
275
+ /**
276
+ * Shape-check an item write. Returns a refusal to hand straight back, or `null`.
277
+ *
278
+ * ⭐ **Refuses a declared section whose data does not fit, and an undeclared
279
+ * section that nobody owns.** It tolerates exactly two things: a `(model, section)`
280
+ * the site listed as `migration_debt`, and the create-time `data` payload — the
281
+ * one shape whose wire contract is still unsettled. See `./schema-shape.js`.
282
+ *
283
+ * The debt list is an explicit allowlist rather than a mode precisely so a typo
284
+ * cannot hide in it: `moduels` is a 422, `modules` is recorded and allowed.
285
+ */
286
+ shapeGuard(model, section, data, itemId) {
287
+ const decl = this.schemas[model]
288
+ if (!decl?.sections) return null
289
+ const check = checkItemWrite({ decl, section, data })
290
+
291
+ if (check.outcome === OUTCOME.VIOLATES) {
292
+ const first = check.problems[0]
293
+ return {
294
+ ok: false,
295
+ problem: {
296
+ status: 422,
297
+ title: 'SchemaViolation',
298
+ detail: `section '${section}' of ${model}: ${first.detail}`,
299
+ violations: check.problems,
300
+ ...(itemId != null ? { [FIELD.item]: itemId } : {}),
301
+ },
302
+ }
303
+ }
304
+ if (check.outcome === OUTCOME.DIAGNOSED) {
305
+ this.diagnose(model, { op: itemId == null ? 'create-item' : 'update-item', section }, check)
306
+ }
307
+ return null
308
+ }
309
+
218
310
  /**
219
311
  * Apply one op. Returns `{ ok, result }` or `{ ok: false, problem }` — the caller
220
312
  * turns a problem into the response, so a batch can stop at the first one and
@@ -252,11 +344,15 @@ export class MockStore {
252
344
  if (!op[FIELD.section]) {
253
345
  return { ok: false, problem: { status: 400, title: 'Validation', detail: 'create needs a section' } }
254
346
  }
347
+ const refusal = this.shapeGuard(entity.model, op[FIELD.section], op.data, null)
348
+ if (refusal) return refusal
255
349
  const made = this.makeItem({ section: op[FIELD.section], data: op.data, parent: op[FIELD.parent] ?? null })
256
350
  this.place(entity, made, op.position)
257
351
  return { ok: true, result: { [FIELD.item]: made[FIELD.item], [FIELD.token]: made[FIELD.token] } }
258
352
  }
259
353
  if (kind === OP.update) {
354
+ const refusal = this.shapeGuard(entity.model, item.section, op.data, itemId)
355
+ if (refusal) return refusal
260
356
  // Whole-data replace, like the real write: round-trip what you do not edit.
261
357
  item.data = op.data ?? {}
262
358
  item[FIELD.token] = stamp()