@x12i/static-memorix-explorer-api 1.0.0 → 1.1.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 (49) hide show
  1. package/README.md +150 -16
  2. package/dist/config.js +1 -1
  3. package/dist/engine/identity.js +11 -9
  4. package/dist/engine/write.js +48 -4
  5. package/dist/routes/index.js +57 -7
  6. package/dist/server.js +42 -20
  7. package/guides/demo-app.md +524 -0
  8. package/guides/managing-json-files.md +310 -0
  9. package/guides/running-the-service.md +171 -0
  10. package/guides/using-the-api.md +265 -0
  11. package/mocks/data/admin/snapshots.json +52 -0
  12. package/mocks/data/assets/analysis.json +14 -0
  13. package/mocks/data/assets/decisions.json +14 -0
  14. package/mocks/data/assets/events.json +4 -0
  15. package/mocks/data/assets/snapshots.json +49 -0
  16. package/mocks/data/findings/analysis.json +4 -0
  17. package/mocks/data/findings/decisions.json +4 -0
  18. package/mocks/data/findings/events.json +3 -0
  19. package/mocks/data/findings/snapshots.json +21 -0
  20. package/mocks/data/marketing/snapshots.json +75 -0
  21. package/mocks/data/memory/memory.json +31 -0
  22. package/mocks/data/product/snapshots.json +84 -0
  23. package/mocks/data/system/inventory.json +8 -0
  24. package/mocks/data/users/snapshots.json +5 -0
  25. package/mocks/demo.html +828 -0
  26. package/mocks/metadata/agents.json +7 -0
  27. package/mocks/metadata/lists/assets-default.json +22 -0
  28. package/mocks/metadata/lists/backlog.json +10 -0
  29. package/mocks/metadata/lists/findings-default.json +9 -0
  30. package/mocks/metadata/lists/my-plate.json +10 -0
  31. package/mocks/metadata/lists/this-week.json +10 -0
  32. package/mocks/metadata/narratives/admin.json +7 -0
  33. package/mocks/metadata/narratives/assets.json +17 -0
  34. package/mocks/metadata/narratives/findings.json +7 -0
  35. package/mocks/metadata/narratives/marketing.json +17 -0
  36. package/mocks/metadata/narratives/product.json +12 -0
  37. package/mocks/metadata/object-types/admin.json +6 -0
  38. package/mocks/metadata/object-types/assets.json +9 -0
  39. package/mocks/metadata/object-types/findings.json +8 -0
  40. package/mocks/metadata/object-types/marketing.json +6 -0
  41. package/mocks/metadata/object-types/memory.json +6 -0
  42. package/mocks/metadata/object-types/product.json +6 -0
  43. package/mocks/metadata/object-types/users.json +13 -0
  44. package/mocks/metadata/write-descriptors/admin-task-write.json +20 -0
  45. package/mocks/metadata/write-descriptors/assets-analysis-write.json +14 -0
  46. package/mocks/metadata/write-descriptors/marketing-task-write.json +20 -0
  47. package/mocks/metadata/write-descriptors/memory-write.json +14 -0
  48. package/mocks/metadata/write-descriptors/product-task-write.json +20 -0
  49. package/package.json +5 -2
@@ -0,0 +1,524 @@
1
+ # Demo App — FlowState
2
+
3
+ This repository ships with a complete reference demo application — **FlowState**,
4
+ a small task manager — that exercises the Memorix Explorer API end-to-end. The
5
+ demo is served at `GET /demo` and runs entirely in the browser with no build
6
+ step.
7
+
8
+ This document explains how every piece of the demo is implemented: the
9
+ metadata that defines the schema, the seed data the demo reads, and the way the
10
+ UI maps each user action (create / edit / delete / comment) onto Explorer API
11
+ calls.
12
+
13
+ ---
14
+
15
+ ## TL;DR
16
+
17
+ ```bash
18
+ npm run build
19
+ npm start
20
+ # open http://localhost:5030/demo
21
+ ```
22
+
23
+ Every mutation you make in the UI is persisted back to `mocks/data/**/*.json`
24
+ through the Explorer API. Restart the server and your edits are still there.
25
+
26
+ ---
27
+
28
+ ## What the demo proves
29
+
30
+ The demo is intentionally small, but it touches every part of the Explorer API
31
+ that matters for a real frontend:
32
+
33
+ | Capability | Endpoint used |
34
+ | ----------------------- | -------------------------------------------------------- |
35
+ | List users | `GET /api/explorer/records/collection?entityName=users` |
36
+ | List tasks per area | `GET /api/explorer/records/collection?entityName=<area>`|
37
+ | Full-text search | `?searchText=` / `?q=` |
38
+ | Equality filters | `?filter=status:eq:Doing&filter=priority:eq:Urgent` |
39
+ | Create a task | `POST /api/explorer/records/write` (`operation=add`) |
40
+ | Update a task | `POST /api/explorer/records/write` (`operation=upsert`) |
41
+ | Delete a task | `DELETE /api/explorer/records/item` |
42
+ | Move a task between areas | `DELETE` on old collection + `POST …/write op=add` on new |
43
+ | Object-type discovery | `GET /api/explorer/object-types` (used for inventory) |
44
+
45
+ No private endpoints, no shortcuts: the demo is a normal API client.
46
+
47
+ ---
48
+
49
+ ## Architecture overview
50
+
51
+ ```
52
+ ┌─────────────────────────── Browser (/demo) ────────────────────────────┐
53
+ │ │
54
+ │ FlowState UI ──► fetch() ──► /api/explorer/* (Explorer API) │
55
+ │ │
56
+ └────────────────────────────────────────────────────────────────────────┘
57
+
58
+
59
+ ┌──────────────────────── Fastify mock server ───────────────────────────┐
60
+ │ │
61
+ │ routes/index.ts │
62
+ │ ├── /demo serves mocks/demo.html │
63
+ │ ├── /records/collection reads ← store.getCollection │
64
+ │ ├── /records/write writes → store.setCollection │
65
+ │ └── /records/item (DELETE) writes → store.setCollection │
66
+ │ │
67
+ │ engine/write.ts validates + executes add/upsert/patch/delete │
68
+ │ engine/records.ts query pipeline (filter / sort / search / page) │
69
+ │ │
70
+ │ storage/InMemoryStore.ts in-memory + debounced disk flush │
71
+ │ │
72
+ └────────────────────────────────────────────────────────────────────────┘
73
+
74
+
75
+ mocks/metadata/**/*.json
76
+ mocks/data/**/*.json
77
+ ```
78
+
79
+ The UI never talks to the filesystem directly. Every mutation goes through
80
+ `POST /api/explorer/records/write` (or `DELETE /api/explorer/records/item`),
81
+ which mutates the in-memory store, which debounces a flush back to the JSON
82
+ file on disk.
83
+
84
+ ---
85
+
86
+ ## Metadata model
87
+
88
+ Metadata files (under `mocks/metadata/`) describe the **shape** of the system.
89
+ The demo contributes the following metadata.
90
+
91
+ ### Object types — `mocks/metadata/object-types/*.json`
92
+
93
+ Each area in the demo is its own object type:
94
+
95
+ ```json
96
+ // mocks/metadata/object-types/product.json
97
+ {
98
+ "name": "product",
99
+ "summary": "Building the thing.",
100
+ "catalogRelations": [],
101
+ "rootPropertyCatalog": []
102
+ }
103
+ ```
104
+
105
+ The same shape is repeated for `marketing.json`, `admin.json`. Plus a `users`
106
+ object type so people are first-class queryable records:
107
+
108
+ ```json
109
+ // mocks/metadata/object-types/users.json
110
+ {
111
+ "name": "users",
112
+ "summary": "People who can be assigned to tasks.",
113
+ "catalogRelations": [
114
+ {
115
+ "sourceProperty": "assigneeId",
116
+ "targetObject": "product",
117
+ "targetProperty": "recordId",
118
+ "relationType": "assigned-to"
119
+ }
120
+ ],
121
+ "rootPropertyCatalog": []
122
+ }
123
+ ```
124
+
125
+ `catalogRelations` is what powers `GET /api/explorer/inventory/graph`.
126
+
127
+ ### Narratives — `mocks/metadata/narratives/*.json`
128
+
129
+ Narratives are authored tags that get merged into the snapshot view. The demo
130
+ ships with a few per area:
131
+
132
+ ```json
133
+ // mocks/metadata/narratives/product.json
134
+ {
135
+ "website-refresh": {
136
+ "key": "website-refresh",
137
+ "label": "Website Refresh",
138
+ "description": "Redesigning the public-facing site."
139
+ },
140
+ "launch-prep": {
141
+ "key": "launch-prep",
142
+ "label": "Launch Prep",
143
+ "description": "Getting ready for public launch."
144
+ }
145
+ }
146
+ ```
147
+
148
+ Records reference narratives via `doc.narratives["website-refresh"] = true`.
149
+ You can filter on them with the virtual filter syntax:
150
+ `?filter=narrativeId:eq:website-refresh`.
151
+
152
+ ### Lists — `mocks/metadata/lists/*.json`
153
+
154
+ Saved queries. The demo ships with three:
155
+
156
+ - `my-plate.json` — sort by priority desc
157
+ - `this-week.json` — sort by dueDate asc
158
+ - `backlog.json` — default ordering
159
+
160
+ ```json
161
+ // mocks/metadata/lists/this-week.json
162
+ {
163
+ "id": "this-week",
164
+ "entity": "product",
165
+ "entityName": "product",
166
+ "baseContentType": "snapshots",
167
+ "fields": ["recordId", "title", "status", "priority", "owner", "dueDate"],
168
+ "sort": { "dueDate": 1 },
169
+ "query": {},
170
+ "listId": "this-week"
171
+ }
172
+ ```
173
+
174
+ ### Write descriptors — `mocks/metadata/write-descriptors/*.json`
175
+
176
+ This is the contract between the UI's mutation layer and the server's write
177
+ engine. The demo ships with one descriptor per area:
178
+
179
+ ```json
180
+ // mocks/metadata/write-descriptors/product-task-write.json
181
+ {
182
+ "writeDescriptorId": "product-task-write",
183
+ "targetCollection": "product/snapshots",
184
+ "schema": {
185
+ "type": "object",
186
+ "required": ["recordId", "title"],
187
+ "properties": {
188
+ "recordId": { "type": "string" },
189
+ "title": { "type": "string" },
190
+ "status": { "type": "string" },
191
+ "priority": { "type": "string" },
192
+ "owner": { "type": "string" },
193
+ "area": { "type": "string" },
194
+ "assigneeId": { "type": "string" },
195
+ "dueDate": { "type": "string" },
196
+ "notes": { "type": "string" },
197
+ "chat": { "type": "array" }
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ The same shape is mirrored for `marketing-task-write.json` and
204
+ `admin-task-write.json`. The `targetCollection` field tells the write engine
205
+ which `objectType/contentType` pair to mutate.
206
+
207
+ **Required-field semantics:** for `operation: "add"` every field in `required`
208
+ must be present. For `upsert`, `patch`, and `delete`, only the identity field
209
+ (`recordId` for tasks, `memoryId` for the memory tier) is mandatory — the rest
210
+ of the payload is treated as a partial update.
211
+
212
+ ---
213
+
214
+ ## Data model
215
+
216
+ Data files (under `mocks/data/`) hold the actual records.
217
+
218
+ ### Users — `mocks/data/users/snapshots.json`
219
+
220
+ ```json
221
+ [
222
+ { "recordId": "usr-1", "name": "Ami", "role": "owner" },
223
+ { "recordId": "usr-2", "name": "Devran", "role": "engineer" },
224
+ { "recordId": "usr-3", "name": "Ilan", "role": "growth" }
225
+ ]
226
+ ```
227
+
228
+ The demo loads these with:
229
+
230
+ ```
231
+ GET /api/explorer/records/collection?entityName=users&limit=500
232
+ ```
233
+
234
+ …and uses `recordId` as the foreign key on each task's `assigneeId`.
235
+
236
+ ### Tasks — `mocks/data/{product,marketing,admin}/snapshots.json`
237
+
238
+ Each area is its own collection. A typical task:
239
+
240
+ ```json
241
+ {
242
+ "recordId": "t1",
243
+ "title": "Review new frontend styling for the landing page",
244
+ "status": "doing",
245
+ "priority": "urgent",
246
+ "owner": "Sam",
247
+ "area": "product",
248
+ "assigneeId": "usr-2",
249
+ "dueDate": "2026-07-21",
250
+ "notes": "Ensure the dark-mode contrast ratios meet the updated accessibility guidelines.",
251
+ "chat": [
252
+ {
253
+ "id": "cmt-1",
254
+ "authorId": "usr-1",
255
+ "timestamp": "2026-07-19T14:22:00Z",
256
+ "text": "Can you add tests for the refresh token flow as well?"
257
+ }
258
+ ],
259
+ "narratives": { "website-refresh": true }
260
+ }
261
+ ```
262
+
263
+ Notes and chat live **on the snapshot itself**. This keeps the data model
264
+ simple for the demo. In a richer deployment you could split chat into the
265
+ `events` content type and join via the list `extensions` mechanism.
266
+
267
+ ---
268
+
269
+ ## UI → API call map
270
+
271
+ Every interactive element in `mocks/demo.html` is wired to a specific API
272
+ call. The table below is the source of truth.
273
+
274
+ ### Reads
275
+
276
+ | UI element | API call |
277
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- |
278
+ | Initial load | `GET /records/collection?entityName=users&limit=500` + `GET /records/collection?entityName=<area>&limit=500` (×3 areas, in parallel) |
279
+ | Search box (`/` to focus) | `GET /records/collection?entityName=<area>&searchText=<q>` |
280
+ | "To Do" / "Doing" / "Done" filter pills | `GET /records/collection?entityName=<area>&filter=status:eq:<Value>` |
281
+ | "Urgent" filter pill | `GET /records/collection?entityName=<area>&filter=priority:eq:Urgent` |
282
+ | Sidebar area / quick view selections | client-side filter on the already-loaded rows (no extra round trip) |
283
+
284
+ Because tasks span 3 collections, the demo loads all of them up front and
285
+ filters client-side. For larger datasets you would issue one `records/collection`
286
+ per navigation and rely on `?filter=` and `?limit=` server-side.
287
+
288
+ ### Mutations
289
+
290
+ Every mutation is a single API call. The UI uses optimistic updates with
291
+ rollback on failure (the previous state is restored and a toast is shown).
292
+
293
+ | UI action | API call |
294
+ | ------------------------------- | ------------------------------------------------------------------------------------------------- |
295
+ | **+ New Task** (modal) | `POST /records/write` body: `{writeDescriptorId:"<area>-task-write", operation:"add", input:{…}}` |
296
+ | **Status pill** (Todo/Doing/Done/Someday) | `POST /records/write` body: `{operation:"upsert", input:{recordId, status}}` |
297
+ | **Priority pill** (Urgent/Normal/Low) | `POST /records/write` body: `{operation:"upsert", input:{recordId, priority}}` |
298
+ | **Area dropdown** (move task) | Descriptor-verified `DELETE /records/item` then `POST /records/write operation:add` with the new area |
299
+ | **Assignee dropdown** | `POST /records/write` body: `{operation:"upsert", input:{recordId, assigneeId}}` |
300
+ | **Due date input** | `POST /records/write` body: `{operation:"upsert", input:{recordId, dueDate}}` |
301
+ | **Notes textarea** (debounced) | `POST /records/write` body: `{operation:"upsert", input:{recordId, notes}}` |
302
+ | **Send comment** | `POST /records/write` body: `{operation:"upsert", input:{recordId, chat:[…]}}` (full chat array) |
303
+ | **Delete** button | `DELETE /records/item?entityName=<area>&recordId=<id>&writeDescriptorId=<area>-task-write` |
304
+
305
+ Two equivalent ways to delete exist:
306
+
307
+ 1. **REST-style:** `DELETE /api/explorer/records/item?entityName=…&recordId=…&writeDescriptorId=…`
308
+ 2. **Operation-style:** `POST /api/explorer/records/write` body:
309
+ `{writeDescriptorId:"…", operation:"delete", input:{recordId:"…"}}`
310
+
311
+ The demo uses #1 (the REST form) because it doesn't require knowing the
312
+ writeDescriptorId for the source collection.
313
+
314
+ ---
315
+
316
+ ## Create flow (modal)
317
+
318
+ When the user clicks **+ New Task** (`Cmd/Ctrl-N` also works), a modal opens
319
+ with fields for title, area, status, priority, and assignee. On submit:
320
+
321
+ 1. The UI generates a `recordId` of the form `t-<Date.now()>` to avoid
322
+ collisions with the seed data (`t1`…`t12`).
323
+ 2. A `POST /api/explorer/records/write` is sent with `operation:"add"` and the
324
+ descriptor for the chosen area (`product-task-write`, etc.).
325
+ 3. The server validates the payload against the descriptor's JSON schema.
326
+ 4. On success, the record is appended to the in-memory collection and the
327
+ debounced flush writes it to `mocks/data/<area>/snapshots.json` within 300 ms.
328
+ 5. The UI unshifts the returned record into `state.tasks`, selects it, and
329
+ re-renders.
330
+
331
+ On validation failure, the server returns
332
+ `{ok:false, validated:false, errors:[…]}` and the UI shows a toast.
333
+
334
+ ---
335
+
336
+ ## Edit flow
337
+
338
+ Edit affordances are inline in the **Overview** tab of the detail panel:
339
+
340
+ - **Status** — four pill buttons (Todo / Doing / Done / Someday). Clicking one
341
+ immediately fires an `upsert` with `{recordId, status}`.
342
+ - **Priority** — three pill buttons (Urgent / Normal / Low). Same pattern.
343
+ - **Area** — dropdown. **Moving areas is a delete + add** (because each area
344
+ is a separate collection on disk). The UI sequences:
345
+ 1. `DELETE /records/item?entityName=<old>&recordId=<id>&writeDescriptorId=<old>-task-write`
346
+ 2. `POST /records/write` with `operation:"add"` on the new area's descriptor.
347
+ - **Assignee** — dropdown. `upsert` with `{recordId, assigneeId}`.
348
+ - **Due date** — date input. `upsert` with `{recordId, dueDate}`.
349
+ - **Notes** — textarea. Debounced 400 ms before sending `upsert`.
350
+
351
+ All edits are **optimistic**: the local state is updated immediately, the UI
352
+ re-renders, and the network call fires in the background. If the call fails,
353
+ the previous state is restored and a red toast is shown.
354
+
355
+ ---
356
+
357
+ ## Delete flow
358
+
359
+ The detail panel header has a **Delete** button. Clicking it shows a
360
+ confirmation dialog explaining which endpoint will be called. On confirm:
361
+
362
+ 1. `DELETE /api/explorer/records/item?entityName=<area>&recordId=<id>&writeDescriptorId=<area>-task-write`
363
+ 2. The server removes the record from the in-memory collection, returns the
364
+ removed document, and schedules a disk flush.
365
+ 3. The UI removes the record from `state.tasks`, clears the selection, and
366
+ re-renders.
367
+
368
+ ---
369
+
370
+ ## Comment flow
371
+
372
+ Comments live on the task's `chat` array. Adding a comment:
373
+
374
+ 1. The UI appends `{id:"cmt-<ts>", authorId, timestamp, text}` to a **copy** of
375
+ the task's existing `chat` array.
376
+ 2. Sends `POST /records/write` with `operation:"upsert"` and
377
+ `input:{recordId, chat:[…full array…]}`.
378
+
379
+ This rewrites the whole `chat` array per comment, which is fine for the demo's
380
+ scale. For higher volume you'd model comments as `events` records keyed by
381
+ `eventId` and join them in via a list descriptor's `extensions` array.
382
+
383
+ ---
384
+
385
+ ## Why each area is a separate collection
386
+
387
+ We could have put all tasks in one `tasks` collection with an `area` field.
388
+ The demo deliberately splits them across `product/snapshots.json`,
389
+ `marketing/snapshots.json`, and `admin/snapshots.json` to demonstrate:
390
+
391
+ 1. **Multi-entity applications** — typical real apps have more than one object
392
+ type. Showing 3 object types is more representative than 1.
393
+ 2. **Cross-collection navigation** — the demo's "All Tasks" view merges 3
394
+ parallel `records/collection` calls, the same pattern a real dashboard uses.
395
+ 3. **Cross-collection moves** — moving a task between areas exercises
396
+ `DELETE` + `add`, which is what you'd do when an entity changes type.
397
+ 4. **Per-object-type metadata** — each area can carry its own narratives,
398
+ catalog relations, and write descriptors. The demo ships per-area narratives
399
+ (`product.json`, `marketing.json`, `admin.json`) to show this.
400
+
401
+ If you want a single-collection layout, just put all tasks in one
402
+ `tasks/snapshots.json`, drop the area field, and update the demo's
403
+ `loadAllTasks()` to fetch from one entity.
404
+
405
+ ---
406
+
407
+ ## Object identity
408
+
409
+ Every record carries exactly one identity key. For the demo, that's always
410
+ `recordId`. The mock server's `MemorixRecordArraySchema` validates this at
411
+ startup — see `engine/identity.ts`. If a record is missing its identity key,
412
+ the server logs a warning at boot but continues running.
413
+
414
+ The other supported identity keys are `entityId`, `eventId`, `knowledgeId`,
415
+ and `memoryId`. The memory tier (`mocks/data/memory/memory.json`) uses
416
+ `memoryId` and is the only place in the demo where `recordId` is not the key.
417
+
418
+ ---
419
+
420
+ ## Persistence guarantees
421
+
422
+ - **In-memory state** is updated synchronously on every write.
423
+ - **Disk flush** is debounced by `DISK_FLUSH_DEBOUNCE_MS` (default 300 ms).
424
+ - **On `SIGINT` / `SIGTERM`**, the server flushes everything before exiting —
425
+ see `server.ts`.
426
+ - **Crash** (SIGKILL, panic) loses any writes that hadn't been flushed yet
427
+ (at most 300 ms of writes).
428
+
429
+ This matches the production semantics of a write-behind cache. For tests that
430
+ need to assert against on-disk JSON, wait > `DISK_FLUSH_DEBOUNCE_MS` after the
431
+ write call.
432
+
433
+ ---
434
+
435
+ ## Feature flags
436
+
437
+ `POST /records/write` and `DELETE /records/item` are **not** gated behind a
438
+ feature flag — they're always on. This matches the production behavior where
439
+ record writes are gated by the write-descriptor contract, not by an
440
+ environment toggle.
441
+
442
+ Mutations on **lists** and **narratives** (`POST/PATCH/PUT/DELETE` under
443
+ `/lists` and `/narratives`) are gated behind `MEMORIX_EXPLORER_ENABLE_METADATA_WRITES=true`.
444
+ The demo does not exercise those endpoints.
445
+
446
+ ---
447
+
448
+ ## Verifying it works
449
+
450
+ A 30-second smoke test, runnable from the repo root:
451
+
452
+ ```bash
453
+ # 1. Start the server
454
+ npm run build && npm start &
455
+
456
+ # 2. Read
457
+ curl -s "localhost:5030/api/explorer/records/collection?entityName=users&limit=10"
458
+
459
+ # 3. Create
460
+ curl -s -X POST localhost:5030/api/explorer/records/write \
461
+ -H "Content-Type: application/json" \
462
+ -d '{"writeDescriptorId":"product-task-write","operation":"add","input":{"recordId":"t-smoke-1","title":"Smoke","area":"product","status":"todo","priority":"normal"}}'
463
+
464
+ # 4. Update (partial)
465
+ curl -s -X POST localhost:5030/api/explorer/records/write \
466
+ -H "Content-Type: application/json" \
467
+ -d '{"writeDescriptorId":"product-task-write","operation":"upsert","input":{"recordId":"t-smoke-1","status":"doing"}}'
468
+
469
+ # 5. Delete
470
+ curl -s -X DELETE "localhost:5030/api/explorer/records/item?entityName=product&recordId=t-smoke-1&writeDescriptorId=product-task-write"
471
+
472
+ # 6. Open the UI
473
+ open http://localhost:5030/demo
474
+ ```
475
+
476
+ ---
477
+
478
+ ## Extending the demo
479
+
480
+ Common modifications:
481
+
482
+ - **Add a new area** — create `mocks/metadata/object-types/<area>.json`,
483
+ `mocks/metadata/write-descriptors/<area>-task-write.json`,
484
+ `mocks/data/<area>/snapshots.json`, then add the area key to the `AREAS` map
485
+ at the top of `mocks/demo.html`.
486
+ - **Add a new task field** — extend the write descriptor's `schema.properties`,
487
+ then render a new input in `renderDetail()` and send it via `updateTask()`.
488
+ - **Switch to per-view server-side pagination** — replace the demo's
489
+ `loadAllTasks()` (3 parallel fetches) with a single fetch per navigation that
490
+ uses `?filter=`, `?limit=`, and `?offset=`.
491
+ - **Add a list descriptor** — create a JSON file under
492
+ `mocks/metadata/lists/`, then call
493
+ `GET /api/explorer/lists/<listId>/records` from the UI.
494
+
495
+ ---
496
+
497
+ ## File map
498
+
499
+ ```
500
+ mocks/
501
+ ├── demo.html ← the UI
502
+ ├── metadata/
503
+ │ ├── object-types/
504
+ │ │ ├── product.json marketing.json admin.json
505
+ │ │ └── users.json ← demo users (assignable)
506
+ │ ├── narratives/
507
+ │ │ ├── product.json marketing.json admin.json
508
+ │ ├── lists/
509
+ │ │ ├── my-plate.json this-week.json backlog.json
510
+ │ ├── write-descriptors/
511
+ │ │ ├── product-task-write.json
512
+ │ │ ├── marketing-task-write.json
513
+ │ │ └── admin-task-write.json
514
+ │ └── agents.json ← still here for the assets demo
515
+ └── data/
516
+ ├── users/snapshots.json ← 3 seed users
517
+ ├── product/snapshots.json ← 5 seed tasks
518
+ ├── marketing/snapshots.json ← 4 seed tasks
519
+ └── admin/snapshots.json ← 3 seed tasks
520
+ ```
521
+
522
+ The `assets/`, `findings/`, and `memory/` collections are not used by the
523
+ demo UI; they exist to keep the Explorer API parity complete and to support
524
+ the smoke tests in the guides.