@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.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 (180) hide show
  1. package/README.md +15 -5
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +6 -6896
  14. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  15. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  16. package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
  17. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  18. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  19. package/dist/bun/assets.js +0 -2
  20. package/dist/domain/adminOrder.js +0 -2
  21. package/dist/domain/columns.js +7 -20
  22. package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
  23. package/dist/domain/describe/index.d.ts +1 -0
  24. package/dist/domain/describe/index.js +1 -115
  25. package/dist/domain/describe/match.d.ts +19 -0
  26. package/dist/domain/describe/target-hash.js +1 -12
  27. package/dist/domain/entry.js +0 -14
  28. package/dist/domain/generated-tools.js +0 -1
  29. package/dist/domain/input.js +0 -2
  30. package/dist/domain/paging.js +0 -17
  31. package/dist/domain/recipes.js +10 -7
  32. package/dist/domain/userReferences.js +0 -1
  33. package/dist/domain/wireScalars.js +0 -6
  34. package/dist/infrastructure/adminEntries.js +27 -8
  35. package/dist/infrastructure/adminPaging.js +55 -57
  36. package/dist/infrastructure/artifacts.js +0 -4
  37. package/dist/infrastructure/authSchema.js +0 -9
  38. package/dist/infrastructure/bundle.js +45 -38
  39. package/dist/infrastructure/cli.js +26 -12
  40. package/dist/infrastructure/generate.js +26 -19
  41. package/dist/infrastructure/generateAuth.js +1 -9
  42. package/dist/infrastructure/help.js +55 -0
  43. package/dist/infrastructure/init.js +50 -0
  44. package/dist/infrastructure/inputs.js +0 -9
  45. package/dist/infrastructure/mounts.js +22 -0
  46. package/dist/infrastructure/wrangler.js +0 -6
  47. package/dist/kernel/authorization.js +0 -1
  48. package/dist/kernel/binds.d.ts +34 -7
  49. package/dist/kernel/binds.js +5 -11
  50. package/dist/kernel/caller-identity.d.ts +0 -3
  51. package/dist/kernel/caller-identity.js +0 -15
  52. package/dist/kernel/caller-mode.d.ts +4 -0
  53. package/dist/kernel/caller-mode.js +14 -0
  54. package/dist/kernel/contracts.d.ts +13 -10
  55. package/dist/kernel/contracts.js +14 -41
  56. package/dist/kernel/cron.js +0 -4
  57. package/dist/kernel/dialect/common.js +0 -3
  58. package/dist/kernel/dialect/postgres.js +0 -2
  59. package/dist/kernel/dialect/sqlite.js +1 -6
  60. package/dist/kernel/drain.js +2 -8
  61. package/dist/kernel/driver/bun-sqlite.js +3 -7
  62. package/dist/kernel/driver/d1.d.ts +1 -1
  63. package/dist/kernel/driver/d1.js +6 -4
  64. package/dist/kernel/driver/postgres.d.ts +2 -1
  65. package/dist/kernel/driver/postgres.js +12 -22
  66. package/dist/kernel/driver/transaction-context.js +0 -1
  67. package/dist/kernel/email.js +0 -4
  68. package/dist/kernel/host-access.js +2 -3
  69. package/dist/kernel/host-caller.d.ts +1 -1
  70. package/dist/kernel/host-caller.js +3 -6
  71. package/dist/kernel/json-body.js +0 -2
  72. package/dist/kernel/mcp-apps.js +0 -5
  73. package/dist/kernel/mcp-call.js +0 -1
  74. package/dist/kernel/mcp-handler.js +4 -27
  75. package/dist/kernel/mcp.js +0 -5
  76. package/dist/kernel/media.js +0 -18
  77. package/dist/kernel/paging.js +1 -9
  78. package/dist/kernel/publishing.js +3 -4
  79. package/dist/kernel/r2-media.js +0 -7
  80. package/dist/kernel/rest-dispatch.js +0 -1
  81. package/dist/kernel/rest-router.js +5 -8
  82. package/dist/kernel/rest.d.ts +2 -1
  83. package/dist/kernel/rest.js +1 -3
  84. package/dist/kernel/route-pattern.d.ts +26 -0
  85. package/dist/kernel/route-pattern.js +34 -0
  86. package/dist/kernel/scalars.js +1 -9
  87. package/dist/kernel/search.js +0 -1
  88. package/dist/kernel/site.js +0 -2
  89. package/dist/kernel/store.d.ts +26 -2
  90. package/dist/kernel/store.js +69 -49
  91. package/dist/kernel/surface-failure.d.ts +10 -0
  92. package/dist/kernel/surface-failure.js +29 -0
  93. package/dist/kernel/surface.js +0 -3
  94. package/dist/kernel/sweep.js +0 -1
  95. package/dist/kernel/translations.js +0 -1
  96. package/dist/kernel/user-caller.js +2 -8
  97. package/dist/kernel/user-fields.d.ts +3 -2
  98. package/dist/kernel/user-fields.js +9 -9
  99. package/dist/kernel/validation.js +0 -8
  100. package/dist/kernel/web-gateway.js +0 -2
  101. package/dist/kernel/web-head.js +0 -1
  102. package/dist/kernel/web-index.js +0 -1
  103. package/dist/kernel/web-response.js +0 -1
  104. package/dist/kernel/web-serve.js +97 -0
  105. package/dist/kernel/web.d.ts +1 -1
  106. package/dist/kernel/web.js +10 -91
  107. package/dist/kernel/where.js +0 -6
  108. package/dist/usecase/adminBindings.js +32 -23
  109. package/dist/usecase/adminClient.js +4 -22
  110. package/dist/usecase/adminDescriptors.js +2 -9
  111. package/dist/usecase/generate.js +14 -17
  112. package/dist/usecase/handlers.js +1 -4
  113. package/dist/usecase/mcp-media.js +0 -1
  114. package/dist/usecase/mcp.js +0 -4
  115. package/dist/usecase/paging.js +1 -3
  116. package/dist/usecase/publishing.js +20 -11
  117. package/dist/usecase/rest.js +5 -6
  118. package/dist/usecase/site.js +0 -1
  119. package/dist/usecase/stores.js +12 -17
  120. package/dist/usecase/tool-version.js +0 -3
  121. package/dist/usecase/translations.js +0 -2
  122. package/dist/usecase/validation.js +0 -20
  123. package/docs/examples/README.md +13 -5
  124. package/docs/examples/app-shell/README.md +91 -12
  125. package/docs/examples/app-shell/accept.mjs +91 -0
  126. package/docs/examples/app-shell/gitignore +7 -0
  127. package/docs/examples/app-shell/package.json +10 -9
  128. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  129. package/docs/examples/app-shell/src/routes.ts +6 -3
  130. package/docs/examples/app-shell/src/service.ts +2 -1
  131. package/docs/examples/club-events/README.md +7 -2
  132. package/docs/examples/club-events/manifests/club-events.yaml +17 -0
  133. package/docs/examples/club-events/smoke.mjs +7 -1
  134. package/docs/examples/generated-web/seed.sql +5 -4
  135. package/docs/examples/purchasing/README.md +12 -3
  136. package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
  137. package/docs/examples/purchasing/smoke.mjs +14 -0
  138. package/docs/examples/recipes/README.md +5 -3
  139. package/docs/handbook/cloudflare/media-r2.md +6 -3
  140. package/docs/handbook/cloudflare/service-entry.md +5 -0
  141. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  142. package/docs/handbook/examples/hub.md +1 -1
  143. package/docs/handbook/guides/admin-ui.md +1 -1
  144. package/docs/handbook/guides/agent-setup.md +20 -10
  145. package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
  146. package/docs/handbook/guides/local-testing.md +85 -0
  147. package/docs/handbook/guides/native-admin.md +242 -16
  148. package/docs/handbook/guides/native-execution.md +10 -8
  149. package/docs/handbook/guides/public-pages.md +44 -4
  150. package/docs/handbook/guides/revisions.md +584 -0
  151. package/docs/handbook/guides/size-and-startup.md +140 -0
  152. package/docs/handbook/guides/sql-patterns.md +312 -0
  153. package/docs/handbook/guides/typed-queries.md +146 -3
  154. package/docs/handbook/navigation.json +20 -0
  155. package/docs/handbook/reference/admin-descriptors.md +4 -1
  156. package/docs/handbook/reference/authorization.md +3 -1
  157. package/docs/handbook/reference/describe.md +42 -0
  158. package/docs/handbook/reference/diagnostics.md +16 -2
  159. package/docs/handbook/reference/procedure.md +6 -6
  160. package/docs/handbook/reference/schema.md +79 -9
  161. package/docs/handbook/reference/sql.md +65 -0
  162. package/docs/handbook/reference/surface.md +8 -4
  163. package/docs/handbook/reference/trigger.md +12 -0
  164. package/docs/handbook/reference/view.md +11 -3
  165. package/docs/handbook/releases/index.md +71 -7
  166. package/docs/handbook/start/agents.md +49 -0
  167. package/docs/handbook/start/project-and-cli.md +10 -0
  168. package/docs/handbook/start/quickstart-worker.md +1 -1
  169. package/docs/skills/develop/SKILL.md +19 -6
  170. package/docs/skills/integrate/SKILL.md +3 -1
  171. package/docs/skills/performance/SKILL.md +169 -0
  172. package/docs/skills/update/SKILL.md +10 -4
  173. package/docs/upgrade-0.1-to-0.2.md +151 -39
  174. package/package.json +31 -11
  175. package/skills/README.md +6 -2
  176. package/skills/mantle/SKILL.md +53 -120
  177. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  178. package/skills/mantle/references/local-testing.md +0 -20
  179. package/skills/mantle/references/web.md +0 -41
  180. package/skills/mantle/references/website.md +0 -51
@@ -85,21 +85,37 @@ All paths below start with `/admin/api` and require an admin session; mutations
85
85
  also require the shared same-origin check. Mount `entries(...)` for collection
86
86
  routes. Send JSON with `Content-Type: application/json`.
87
87
 
88
+ These routes serve Admin's console with its session cookie; they are not a client API. A REST client reads back what it
89
+ wrote through the app's own Views with HTTP Triggers (`mantle help trigger`).
90
+
88
91
  | Method and path | Request / response |
89
92
  | --- | --- |
90
93
  | `POST /entries` | Body `{ collection, data }`; creates an entry. |
91
94
  | `GET /entries/{id}?collection=<schema>` | Reads an entry and its current `entry.version`. |
92
- | `PATCH /entries/{id}?collection=<schema>` | Body `{ data, expectedVersion }`; sends the observed version, not the next one. Publishing entries must be drafts. |
95
+ | `PATCH /entries/{id}?collection=<schema>` | Body `{ data, expectedVersion }`; sends the observed version, not the next one. A key left out of `data` keeps its value and `null` clears it ([leave out, replace or clear](#leave-out-replace-or-clear)). Publishing entries must be drafts unless the Schema sets `publishing: { liveEdits: true }`; a [lock](../reference/schema.md#locks) that holds refuses it with 409 `kind: "lock"`. |
93
96
  | `POST /entries/{id}/publish?collection=<schema>` | Body `{ expectedVersion }`; validates required data before publishing. |
94
97
  | `POST /entries/{id}/unpublish?collection=<schema>` | Body `{ expectedVersion }`; returns the entry to draft. |
95
- | `POST /operations/{name}` | Body is the Procedure's input; runs an Admin operation (a Procedure with `requires: ctx.admin` or a `target`). |
98
+ | `POST /operations/{name}` | Body is the Procedure's input; runs an Admin operation (a Procedure with `requires: ctx.admin` or a `target`). Returns `{ ok: true, output }`, where `output` is what REST and MCP return (`{ results }` for a SQL Procedure). |
96
99
  | `GET /views/{name}` | Generated admin View query parameters plus `limit`/`cursor`; returns `{ rows, nextCursor? }`. |
97
100
  | `GET /view-batch?names=a,b` | One to 20 comma-separated admin View names, deduplicated; reads the first row of each as the caller, with the single route's checks (`requires`, scope, TTL, `authorize`), and returns `{ views: { <name>: { rows } \| { unavailable: true } \| { error: true } } }`. A refused View and a missing one are the same `{ unavailable: true }`; a View that cannot answer with no input is `{ error: true }`. Neither fails the batch. Empty or more than 20 names is a 400, before the database is acquired. |
98
101
  | `GET /me` | Returns `{ userId, role, admin, login, image }` for the signed-in caller. |
102
+ | `GET /admin` | Administrators and pending invitations, plus `adminRoles` (mounted with `identity`). |
103
+ | `GET /members?search=&limit=&cursor=&direction=` | Pages members (1–100), searching id, name and email. |
104
+ | `PATCH /admin/{id}/role` | Body `{ role }`: an admin role name grants Admin, `null` removes it. You cannot change your own role. |
105
+ | `POST /admin/invitations` | Body `{ email, role }`; invites an administrator by email. Returns `{ ok, userId, emailSent }`. |
106
+ | `DELETE /admin/invitations/{id}` | Revokes an invitation nobody has signed in to yet (otherwise 409). |
99
107
 
100
108
  Entry create/read/patch/transitions return
101
109
  `{ collection, entry, parentEntryId, parentEntryTitle, related }`; the entry has
102
- `{ id, collection, locale, status, version, data, created_at, updated_at }`.
110
+ `{ id, collection, locale, status, version, data, created_at, updated_at }`. On a Schema with
111
+ [locks](../reference/schema.md#locks), each entry of the editor payload and of `GET /entries` also has
112
+ `locks: { update, delete }`: per verb, `{ lock, message }` of the first lock that holds for your generic write of that row,
113
+ or `false`. Admin disables Save, rename, Delete and transitions on such a row and shows the
114
+ message; the operational "Unlock editing" toggle does not override it. The write itself stays the authority: a refusal is
115
+ 409 `CONFLICT` with `kind: "lock"`. Every publishing entry has `locks` too: the draft-only rule (without `liveEdits`) and
116
+ "unpublish before deleting" are the implicit lock `lock: "publishing"`, named only when no declared lock holds for that verb.
117
+ It binds data writes only, so Publish, Unpublish and Archive stay available; with `liveEdits` a published entry's form is
118
+ enabled with a "changes go live on save" notice, and Save waits until its required fields are filled.
103
119
  Transitions accept `{}` to use the current version; tests should send the
104
120
  observed `expectedVersion` to catch stale writes. Generic writes reject native
105
121
  columns inside `data`. See [publishing](../concepts/lifecycle-and-locales.md).
@@ -137,7 +153,7 @@ refused View or collection from a missing one. Showing or hiding a component gra
137
153
  | Hook | Route | Surface |
138
154
  | --- | --- | --- |
139
155
  | `useView(name, input, { limit? })` -> `{ rows, status, hasMore, loadMore, loadingMore }` | `GET /views/{name}` | browser and MCP App |
140
- | `useOperation(name)` -> `{ run, pending }`; success refreshes Views, entries, entry editors and totals | `POST /operations/{name}` | browser and MCP App |
156
+ | `useOperation(name, { refresh? })` -> `{ run, pending }`; success refreshes every mounted View (each input, each loaded page), entries, entry editors, totals and badges once, `refresh` narrowing the Views ([below](#scoped-refresh)) | `POST /operations/{name}` | browser and MCP App |
141
157
  | `useEntries(collection, { filter?, search?, limit? })` -> `{ items, status, hasMore, loadMore, loadingMore }` | `GET /entries` (one `filter_field`/`filter_value`) | browser only |
142
158
  | `useEntry(collection, id)` | `GET /entries/{id}` | browser only |
143
159
  | `useCollectionTotal(collection)` | `GET /collections/{name}/statistics`, its `total` | browser only |
@@ -150,6 +166,18 @@ options) starts again from the first page. A failed `loadMore()` leaves `status`
150
166
  calling `loadMore()` again retries. Admin's own lists work the same way, under a "Load more" button, and so does the MCP App, which
151
167
  shows the button only when the host has `serverTools` (it calls the View's tool again with the same input and the `cursor`).
152
168
 
169
+ <a id="scoped-refresh"></a>By default an operation's success reads every shown View again, since Admin cannot know which a
170
+ Procedure changed: every mounted `useView`, for each input it is mounted with, and for each page it has loaded, one request
171
+ per page (a View with five loaded pages makes five requests). To keep that cheap, unmount what is collapsed (a closed
172
+ branch of a tree reads nothing, and reads again only when it is next shown), keep `limit` small so that fewer pages
173
+ accumulate, and prefer one wider View over many narrow ones. A page with many Views (say one per expanded branch of a
174
+ tree) can also name the ones to read again:
175
+ `useOperation("move", { refresh: ["children", { view: "children", input: { parent: id } }] })`. A name covers every input of
176
+ that View; `{ view, input }` covers the one `useView(view, input)` (any `limit`); `refresh: []` reads no View again. The names and inputs are typed like `useView`'s.
177
+ Views not named are marked stale rather than read again, so each reads again when it is next shown. Entries, open entries,
178
+ totals and badges still refresh: the Procedure may write any collection, and skipping those would show stale records with no
179
+ way to name them. Leave `refresh` out when unsure; a View you forget to name keeps showing its old rows until it is shown again.
180
+
153
181
  In an MCP host, `useView` and `useOperation` call the View's or operation's admin MCP tool (its name lowercased, kebab to snake) through the host (`tools/call`),
154
182
  under the caller's MCP authorization; the browser-only hooks read `unavailable` there, so declare the data an App needs as an
155
183
  admin View. Without the host's `serverTools`, `run` is `undefined`. `useHost()` is `null` in the browser and the host's API
@@ -205,14 +233,164 @@ collection's name, `<field>` one of its fields and `<column>` a column of an adm
205
233
  | `list.actions:<schema>` | browser | `collection`, `selection` (empty in the toolbar, the selected ids in the bulk bar) |
206
234
  | `cell:<schema>.<field>` | browser | `value`, `row` (the list's columns only, `null` when empty), `property`, `surface` |
207
235
  | `cell:view:<view>.<column>` | both | `value`, `row`, `property`, `surface` |
208
- | `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface` |
236
+ | `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface`, optional `onStatusChange`, `error`, `errorId` |
209
237
 
210
238
  `record.aside:` and `record.actions:` render beside Admin's own side cards and actions, which they do not replace. A
211
- record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A read-only field
212
- (a bound or `readOnly` property) has no `input:` component, and `readOnly` tells yours that the caller cannot edit the
213
- record. In the browser Admin, a View column that reads a Schema field also goes through that field's `cell:` component, with the column's own
239
+ record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A field the Schema
240
+ does not require is `T | null` in the generated types, and so is every field of a publishing collection, whose draft may lack a
241
+ required one: it reads `null` when unset, and `onChange(null)` clears it (Save sends `null`, which the entry PATCH stores as
242
+ NULL; a field left out keeps its value). The built-in controls clear the same way, and an emptied required field is `null` too:
243
+ an operational record refuses it with a validation error, and a draft keeps it empty until Publish, which refuses it. A read-only field
244
+ (a bound or `readOnly` property) never reaches the slot: it has no `input:` component and shows its value read-only.
245
+
246
+ `readOnly` is one flag for the whole form, not for the field: it is `true` whenever this caller cannot edit this record now,
247
+ which is when any of these holds:
248
+
249
+ - the caller is not an admin, which includes the moment while `/me` is still loading, so expect it to start `true`;
250
+ - the collection is read-only (`readOnly: true` at the root of its Schema), or the entry's version is outside the safe integer range;
251
+ - an operational record has not been unlocked with "Unlock editing";
252
+ - a publishing entry is not a draft: published without `publishing: { liveEdits: true }`, or archived, which stays read-only
253
+ even with `liveEdits`. With `liveEdits` a published entry's form is editable and `readOnly` is `false`;
254
+ - a declared [Schema lock](../reference/schema.md#locks) holds the row for `update` (`entry.locks.update`). No toggle overrides
255
+ it. The form shows the lock's message, unless the collection is read-only, whose read-only hint shows instead.
256
+
257
+ The built-in form wraps the fields in a `<fieldset disabled>` that also blocks the pointer. That disables native form controls
258
+ inside it, but not a custom element that is focusable on its own (a `contenteditable`, a `tabIndex` element, a third-party
259
+ widget's buttons): it can still take keyboard focus and call `onChange`. Honour `readOnly` in your component. The Save button
260
+ stays disabled either way, so such a change is never sent.
261
+
262
+ A custom input can report validation and uncommitted local text with the optional
263
+ `onStatusChange({ error, dirty })` callback. Each call replaces that mounted root field's report:
264
+ `error` is a message (or `null`), and `dirty` says the control holds unsaved text outside `onChange`.
265
+ An error disables Save even if another field changes; a dirty local draft uses the record's existing
266
+ navigation and `beforeunload` guard. These are independent: a valid but uncommitted draft can be dirty,
267
+ and a validity error need not claim a change. Pass `{}` or call with no argument to clear both.
268
+
269
+ Admin renders the error as an alert beside the input and passes back `error` and `errorId`.
270
+ Connect your control with `aria-invalid` and `aria-describedby`, and respect `readOnly`:
271
+
272
+ ```tsx
273
+ import * as React from "react";
274
+ import type { InputProps } from "@aotter/mantle-admin/app";
275
+
276
+ function JsonInput({ value, onChange, onStatusChange, error, errorId, readOnly }: InputProps<unknown>) {
277
+ const [text, setText] = React.useState(() => JSON.stringify(value));
278
+ return <textarea value={text} readOnly={readOnly} aria-invalid={Boolean(error)} aria-describedby={errorId}
279
+ onChange={event => {
280
+ const next = event.target.value;
281
+ setText(next);
282
+ try {
283
+ const parsed = JSON.parse(next);
284
+ onChange(parsed);
285
+ onStatusChange?.({}); // the form now owns the valid draft and protects it until Save
286
+ } catch {
287
+ onStatusChange?.({ error: "Enter valid JSON", dirty: true });
288
+ }
289
+ }} />;
290
+ }
291
+ ```
292
+
293
+ Do not emit invalid text through `onChange` merely to mark the form dirty. If valid text stays local
294
+ until an explicit Apply action, keep reporting `{ dirty: true }` until Apply calls `onChange`.
295
+ The server's generated checks remain authoritative: a parsed value the component does not validate
296
+ can still be refused on Save, with the error above the form.
297
+
298
+ Reports belong to mounted **root** `input:<schema>.<field>` slots, including an object or array as
299
+ one structured value; there are no per-array-index input slots. A report clears when that field
300
+ unmounts. Another record, a clean newer server version, an explicit **Load latest version**,
301
+ or a completed Save resets the inputs and their local state. A row becoming locked/read-only
302
+ preserves its local draft and dirty navigation protection; the reported error is hidden until it
303
+ becomes editable again. Reports never override `readOnly` or enable Save on a locked row. A related
304
+ re-read preserves a dirty local draft. A draft typed while Save is in flight is kept. A custom
305
+ component that conditionally hides its own editor while its field stays mounted should clear its
306
+ report when discarding that editor's draft. Procedure input forms do not use these record slots.
307
+
308
+ At runtime the component also receives `field` and `build`, which the built-in control uses to render itself. They are internal,
309
+ not part of `InputProps`, and may change in any release: do not read them. Pass the props you were given to `renderDefault`, as
310
+ with every slot.
311
+
312
+ In the browser Admin, a View column that reads a Schema field also goes through that field's `cell:` component, with the column's own
214
313
  `cell:view:` component outside it; the MCP App renders only `cell:view:`. The developer workspace (`/admin/dev`) is Admin's own tool and does not go through any slot. A record with folded child collections opens on a workbench at `/admin/c/<schema>/<id>`, which is not `RecordView`; the record form (and so `RecordView`, `record.aside:`, `record.actions:` and `input:`) is its `/edit` page. A `Shell` that does not call `renderDefault` has no status bar: the record form's Save and Publish bar then stays in the page.
215
314
 
315
+ Creating a parent opens its field editor first. Save the parent fields, then use the named workspace link or the child-collection links above the form to continue in its workbench. Missing publish requirements name their fields; a read-only requirement must be set by an available operation or related record rather than typed into the form. Workspace links use the same unsaved-changes guard as other Admin navigation.
316
+
317
+ While the record form has unsaved changes, leaving it asks first: Admin's own links, its in-app navigation and Back/Forward
318
+ show a confirm dialog, and a reload, closing the tab or another site show the browser's own prompt (`beforeunload`). Save,
319
+ "Load latest version" or confirming the leave clears it, and a `#hash` change on the same page never asks; a cancelled Back or
320
+ Forward leaves the browser history as it was; there is nothing to configure or opt out of. Custom pages and
321
+ operation forms have no such guard. An `input:` component's own state counts when it calls `onChange`
322
+ or reports `onStatusChange({ dirty: true })`; unreported local state is outside the form's guard.
323
+
324
+ ### Media fields
325
+
326
+ A string field with `x-mantle-ref: media_assets` holds the id of an asset in the media library (`media_assets`), never a
327
+ URL. The record form renders a built-in control for it: a thumbnail of the asset, the id in a text box, **Pick**, which opens
328
+ the library in a dialog, and **Upload**, which uploads an image and fills the id. It works at the top level and in the
329
+ properties of nested objects, including objects inside an array:
330
+
331
+ ```yaml
332
+ properties:
333
+ cover: { type: string, x-mantle-ref: media_assets }
334
+ gallery:
335
+ type: array
336
+ items:
337
+ type: object
338
+ properties:
339
+ image: { type: string, x-mantle-ref: media_assets }
340
+ caption: { type: string }
341
+ ```
342
+
343
+ An array of bare ids (`items: { type: string, x-mantle-ref: media_assets }`) gets a plain text box per item; wrap the id
344
+ in an object, as above, to get the picker. The control needs the media library mounted (`site` and `media` in `admin()`,
345
+ see [media on R2](../cloudflare/media-r2.md)). Upload chooses a media purpose from the site's purposes by the collection
346
+ and field names, and is disabled when the site declares none. Procedure and View input forms render the same control with
347
+ Upload disabled, so an operation's media input is picked from the library.
348
+
349
+ Only a top-level field goes through `input:`. To add to the built-in control, wrap it with `renderDefault`:
350
+
351
+ ```tsx
352
+ "input:products.cover": (props) => (
353
+ <div>
354
+ {props.renderDefault(props)}
355
+ <p className="text-xs text-muted-foreground">Use a 1200 x 630 image; it is also the social card.</p>
356
+ </div>
357
+ ),
358
+ ```
359
+
360
+ For a custom structured input or custom page, import the browser-only picker from the optional
361
+ `@aotter/mantle-admin/media` entry. It runs inside `<MantleAdmin>` and uses the same caller-authorized media library and
362
+ upload routes. Keep the picker import in the browser entry; when also building an MCP App, use a sibling
363
+ `admin.mcp.tsx` without it ([separate entries](mcp-apps.md#customising-admin-for-mcp-hosts)). The host build rejects browser
364
+ media routes. There is no public raw media HTTP client or upload function.
365
+
366
+ ```tsx
367
+ import * as React from "react";
368
+ import { MediaPicker } from "@aotter/mantle-admin/media";
369
+
370
+ type Card = { image?: string; caption?: string };
371
+
372
+ function CardImage({ value, onChange, readOnly }: { value: Card; onChange: (value: Card) => void; readOnly: boolean }) {
373
+ const [open, setOpen] = React.useState(false);
374
+ return <>
375
+ <button type="button" disabled={readOnly} onClick={() => setOpen(true)}>Choose image</button>
376
+ <MediaPicker open={open} onOpenChange={setOpen} readOnly={readOnly} uploadPurpose="post-cover"
377
+ onSelect={asset => onChange({ ...value, image: asset.id })} />
378
+ </>;
379
+ }
380
+ ```
381
+
382
+ `onSelect` receives only `{ id, primaryUrl: string | null }` after an explicit selection and closes the dialog via
383
+ `onOpenChange(false)`. Cancel never calls `onSelect` or clears a value. The application owns the structured value and
384
+ Save; selection only updates its local draft. Pass the input's `readOnly`, and key a reused custom input by its record
385
+ identity so an open picker does not follow navigation to another record.
386
+
387
+ `uploadPurpose` is an exact site media purpose, not an asset filter: existing assets remain selectable across purposes.
388
+ Omit it for selection only; an unknown purpose disables upload, and the upload helper refuses unknown explicit purposes
389
+ rather than choosing another policy. Upload uses the existing raster image variants and server permission checks.
390
+ Uploads commit to the library independently of selection: closing, cancelling, becoming read-only or unmounting never
391
+ selects a late upload and never deletes an already committed asset. Search stays in the dialog and preserves the page's
392
+ draft. Applications that do not import `/media` do not load this optional component.
393
+
216
394
  `plugins` are fragments of `components`, applied in order: each one's `renderDefault` is the plugin before it, the first
217
395
  plugin's is the built-in component, and the application's own `components` are outermost. A plugin is
218
396
  `{ components: { ... } }`, so one can be written once and shared.
@@ -295,14 +473,18 @@ returns its one row on one page and has no cursor.
295
473
  Generated full/partial input checks validate creates, edits, operations and
296
474
  filters. Plain titled string `oneOf` choices use the same enum checks; general
297
475
  or constrained alternatives remain refused. Draft creates can be incomplete;
298
- publish checks required data. Publishing edits apply only to drafts. Read-only
299
- collections refuse generic edits while their declared Procedures can still run.
476
+ publish checks required data. Publishing edits apply only to drafts unless the Schema sets
477
+ `publishing: { liveEdits: true }`, which opens published (never archived) entries; a live edit of a published entry must still pass those publish checks
478
+ (`INPUT_VALIDATION_FAILED`), keeps `published_at` and goes live on save. Read-only
479
+ collections refuse generic creates, edits, deletes and transitions with 403 `AUTH_DENIED`
480
+ ("This record is read-only; use its operations.") before any read or write, while
481
+ their declared Procedures can still run.
300
482
 
301
483
  Writes use compiler-printed native Procedure frames, bind recipes and only the
302
484
  supplied field fragments. They do not update omitted columns or run a schema
303
485
  interpreter. PATCH retains its visible-row read and exact OCC check; the mutation
304
486
  returns its row. Missing/excluded rows are 404; stale locks or failed state guards
305
- are 409. Safe versions are numbers; exact native int64 strings can be bound
487
+ are 409 (a [lock](../reference/schema.md#locks) that holds is `kind: "lock"`). Safe versions are numbers; exact native int64 strings can be bound
306
488
  without rounding, while unsafe numbers are rejected. The console keeps versions
307
489
  outside its supported safe range read-only rather than casting them to numbers.
308
490
  Full cross-target int64 output parity remains incomplete; bare driver values
@@ -321,6 +503,24 @@ from `@aotter/mantle/media`; other custom ContractErrors must carry an explicit
321
503
  status, otherwise the boundary returns a redacted 500. Do not leak native
322
504
  error messages, tokens or credentials to clients.
323
505
 
506
+ ### Leave out, replace or clear
507
+
508
+ Each write path reads a field it is given differently. Pick the one whose rule fits the edit:
509
+
510
+ | Write path | Field left out | A value | `null` |
511
+ | --- | --- | --- | --- |
512
+ | Admin create (`POST /entries`) | unset, or the Schema's default | sets it | dropped, the same as leaving it out, so the default applies |
513
+ | Admin patch (`PATCH /entries/{id}`, the record form's Save) | keeps the stored value | replaces it | clears it to NULL; a required operational field refuses it, and so does a live edit (`liveEdits`) of a field `publish` requires (`INPUT_VALIDATION_FAILED`) |
514
+ | Store `update` (a ref handler's `ctx.store`) | keeps the stored value | replaces it | clears it to NULL; on a published row a field `publish` requires is refused (`INPUT_VALIDATION_FAILED`) |
515
+ | SQL Procedure (`input.x`) | binds NULL, so `SET note = input.note` clears it | replaces it | binds NULL too, where the input's schema accepts `null` |
516
+ | `ref` handler input | the key is absent | present | present, as `null`, where the input's schema accepts it |
517
+
518
+ A SQL Procedure cannot tell a field left out from one cleared, since both bind NULL. For a partial update, keep the stored
519
+ value with `COALESCE(input.note, note)`, and give clearing its own input, such as `clearNote: true` with
520
+ `CASE WHEN input.clearNote THEN NULL ELSE COALESCE(input.note, note) END`
521
+ ([SQL patterns](sql-patterns.md#change-only-the-fields-given-coalesce)). A ref handler sees the difference: test with
522
+ `Object.hasOwn(input, "note")`, and pass only the keys present to `store.collection(...).update`.
523
+
324
524
  ## Statistics
325
525
 
326
526
  `GET /admin/api/collections/{name}/statistics?range=7d` supports primary or
@@ -371,7 +571,17 @@ field and stored value. The server resolves them for the caller with one page qu
371
571
  target. A missing, deleted or unreadable target is absent, so the three look the same: Admin shows the exact ID with copy.
372
572
  A resolved title links to the related record. Admin Views and the MCP App have no such step, since their rows come from the
373
573
  View itself; a View that wants a title selects it (join the referenced table, within its own `requires`). `/bootstrap?collection=...` can include
374
- the first requested list page. CSV exports use `/entries/export` and
574
+ the first requested list page.
575
+ In the entry editor and every operation form (including a `collectionAction` create), a string field with
576
+ `x-mantle-ref` to another Admin collection is a searchable picker: it lists the target through the same `/entries` route
577
+ (20 records per search, read as the caller), shows each record's title and stores the referenced value (`id`, or the
578
+ ref's unique field). A target the caller cannot list (403/404) or that is not an Admin collection keeps the plain text
579
+ input. In an operation bound to a row (`target`), a picker whose Schema folds under that row's collection
580
+ (`nav.parentField`) lists only that row's children, filtered by `<parentField> = <the bound row's value>` as the folded
581
+ child list is: `switch-revision`'s `revision` on a post lists that post's revisions. There is no way to pick another
582
+ parent's child there: an operation that needs one binds to that other row instead, or is not bound (`collectionAction`
583
+ or the Operations page), where the picker lists the whole target. The picker is a convenience; the
584
+ server still validates the value. CSV exports use `/entries/export` and
375
585
  `/views/{name}/export`; native browser downloads stream paged server output.
376
586
  A failure after headers aborts the stream, so a download can fail after partial
377
587
  bytes have arrived. Existing databases need an app-owned migration for the
@@ -418,7 +628,10 @@ tabs. The parent is the reference named by `Schema.spec.uiSchema.nav.parentField
418
628
  the first required unique-field reference. A Schema with two or more required `id` references to
419
629
  existing Schemas (translations excluded) and no `parentField`, with or without `standalone`, is refused by `mantle generate` with `SCHEMA_UI_INVALID`; the diagnostic lists the
420
630
  candidates and the `nav: { parentField: <field> }` line that keeps the first one that is not a parent cycle. This applies to every build, with or without Admin, so a join table with two required `id` references needs it too. `parentField` is
421
- refused on a translation child and when its parent chain leads back to the Schema. Choosing the
631
+ refused on a translation child and when its parent chain leads back to the Schema. An explicit
632
+ `parentField` decides the fold direction: a required reference from that parent back to the child
633
+ (a post's required `current_revision`) is not an inferred fold and does not count as a cycle,
634
+ while two `parentField`s pointing at each other are still refused. Choosing the
422
635
  parent changes Admin navigation only: storage and authorization are unchanged.
423
636
 
424
637
  Set `nav.standalone: true` to put a child with a required `id` reference at the top level instead;
@@ -516,7 +729,7 @@ placement on a Schema without a sidebar entry of its own: a translation, or a fo
516
729
  [`standalone`](#collection-navigation-and-operation-placement).
517
730
  The purchasing example (`node_modules/@aotter/mantle/docs/examples/purchasing/`) puts its approval queue and requests in an Approvals group.
518
731
 
519
- Operational collections have no generic create or draft: officers add records through a Procedure placed with `collectionAction` (the list header shows its button and input form), so every write is a declared, validated program. For a user-field scoped Schema, SQL INSERT is system-only, so that Procedure is a ref handler that inserts through `ctx.store` ([procedure.md](../reference/procedure.md)).
732
+ Operational collections have no generic create or draft: officers add records through a Procedure placed with `collectionAction` (the list header shows its button and input form), so every write is a declared, validated program. For a user-field scoped Schema the Procedure can stay SQL; see [field scope](../reference/schema.md#field-scope) for the partition input.
520
733
 
521
734
  Admin operations are Procedures exposed by an admin MCP Trigger. Declare
522
735
  `Procedure.spec.uiSchema.collectionAction: <schema-name>` to show the operation
@@ -527,9 +740,22 @@ Names such as `approve` never determine placement. An operation without a row
527
740
  target or collection action appears on the Operations page, whose sidebar link
528
741
  appears whenever such operations exist.
529
742
 
743
+ A collection action of a folded child (one with a parent; see above) also shows in that child's panel of the parent's
744
+ workbench. Run from there, the input named exactly like the child's `nav.parentField` (the child's field holding the
745
+ parent's value) is filled with this parent's value and locked: the form hides it and the dialog lists it under the
746
+ bound values, like a row operation's `id`. The name is the rule because it is unambiguous (one input at most, the same
747
+ name as the column the action writes). The value takes the input's type (a numeric parent key binds a number); an
748
+ input with that name whose `x-mantle-ref` names another Schema or key, or whose type cannot hold the parent's value, is
749
+ left to the person, and so is every input outside a workbench. With `nav.parentField: post`, `new-revision { post }` placed with
750
+ `collectionAction: post_revisions` runs on the open post.
751
+
752
+ A record's page shows its row operations as labeled buttons in the header (more than three: two buttons and a
753
+ "More actions" menu); list rows keep them in an "Actions for …" menu.
530
754
  Generated row metadata includes simple AND-ed equality/IN literal conditions
531
- from the target-row UPDATE/DELETE WHERE. The menu hides known mismatches; missing
532
- preview values and unsupported predicates leave actions available. These are
755
+ from the target-row UPDATE/DELETE WHERE, and an operation whose conditions the
756
+ row does not meet is hidden: with `WHERE … AND orderStatus = 'approved'`, "Mark
757
+ fulfilled" appears only once the order is approved. Missing preview values and
758
+ unsupported predicates leave actions available. These are
533
759
  presentation hints: the Procedure still enforces its WHERE and authorization.
534
760
  Conflicts show a localized refresh message with the technical diagnostic in
535
761
  Details. Successful actions show a short summary and changes, with raw output
@@ -25,8 +25,8 @@ query. Authored output names should be emitted into SQL where valid, avoiding
25
25
  an extra per-row projection merely to repair names already known at generation.
26
26
 
27
27
  Selected REST/MCP/Admin boundaries use static generated column lineage. Declared
28
- integers (including aliased system versions) are `number | string | null` on every
29
- target: safe integers become numbers; unsafe integers remain exact decimal text.
28
+ integers (including aliased system versions) are `number | string` on every
29
+ target, `| null` unless the View proves the column NOT NULL ([nullability](../reference/view.md#results-and-paging)): safe integers become numbers; unsafe integers remain exact decimal text.
30
30
  Selected SQL projections cast integers and hidden integer cursor keys to text
31
31
  before the driver reads them, preserving stored int64 values on SQLite/D1 and
32
32
  PostgreSQL. Values already rounded when written
@@ -34,7 +34,8 @@ PostgreSQL. Values already rounded when written
34
34
  `views/<name>.ts` exports both driver-native `Row` and `WireRow` (declared wire fields agree across targets);
35
35
  `paged/views/<name>.ts` keeps native `Row` rows; REST/MCP use separate
36
36
  `paged/wire/views/<name>.ts` SQL and convert at their boundary. SQLite/D1 declared JSON
37
- and boolean columns become JSON values and booleans, preserving aliases and nulls;
37
+ and boolean columns, and computed outputs that are JSON documents by construction
38
+ (JSON aggregates and builders), become JSON values and booleans, preserving aliases and nulls;
38
39
  PostgreSQL already returns those native values.
39
40
  Declared real/float outputs remain numbers. Computed outputs without proved lineage,
40
41
  SQL Procedures and app-owned ref results retain native values and types.
@@ -45,7 +46,7 @@ transport conversion or runtime schema interpreter.
45
46
  Declared authorization, ownership/TTL SQL policy, explicit OCC and atomic writes
46
47
  remain required. Generate them as the native application would implement them.
47
48
  Transaction rollback and uncertain COMMIT handling must still preserve data-loss
48
- protection and client disposal responsibility; native errors are rethrown (one exception, below), and
49
+ protection and client disposal responsibility; native errors are rethrown (with the contract exceptions below), and
49
50
  redaction before they reach HTTP users belongs to the application boundary through `wire` / `surfaceFailure`. Removing generic error
50
51
  normalization does not authorize dropping transaction outcome evidence.
51
52
 
@@ -56,12 +57,12 @@ unique `id =`), so a failed count check after autocommit has changed nothing.
56
57
  On PG it runs at the server's default isolation, not SERIALIZABLE: a concurrent
57
58
  OCC loser re-evaluates its `version =` predicate, matches zero rows and returns
58
59
  `CONFLICT` instead of SQLSTATE 40001. A server ErrorResponse (SQLSTATE and
59
- severity) is a known rollback and is rethrown natively, except a unique violation (23505, SQLite's `UNIQUE constraint failed`), which, in a generated Procedure or a Store write, is `ContractError("CONFLICT")` with the native error as `cause` on every target, and except connection,
60
+ severity) is a known rollback and is rethrown natively, except a unique violation (23505, SQLite's `UNIQUE constraint failed`), which, in a generated Procedure or a Store write, is `ContractError("CONFLICT")` with the native error as `cause` on every target, and serialization/deadlock refusals (`40001` / `40P01`) with a definitive server response, which are `CONFLICT` with `kind: "serialization"` and the native `cause`, and except connection,
60
61
  resource and shutdown classes (08, 28, 53, 57, 58, XX), which, like any failure
61
62
  without a server response (lost socket), are `OUTCOME_UNKNOWN`. SQLSTATE is
62
63
  `errno` on Bun.SQL and `code` on postgres.js. Multi-statement Procedures run in
63
64
  `begin("isolation level serializable", ...)`: a failure before the callback
64
- returns was rolled back by `begin`; only a failed COMMIT is classified as above.
65
+ returns was rolled back by `begin`; only a failed COMMIT is classified as above. The same serialization/deadlock mapping applies to Store batches and interactive transactions, including a definitive refusal at COMMIT. No write is retried automatically; re-read before deciding whether it still applies.
65
66
 
66
67
  **Date-time wire form:** a View output that is a declared `date-time` column
67
68
  (including `created_at`/`updated_at`) returns the handbook's wire form, an ISO
@@ -77,14 +78,15 @@ microseconds and days while PostgreSQL receives the canonical text. Computed val
77
78
  PostgreSQL through Bun.SQL that is a `Date`, which drops microseconds. Other
78
79
  declared scalars are driver-native.
79
80
 
80
- Driver-ready JSON parameters are text on every target (a JavaScript array would otherwise be bound as a PostgreSQL array), so the application serializes them. Bare PG SQL declares each JSON parameter as text
81
+ JSON parameters are text on every target (a JavaScript array would otherwise be bound as a PostgreSQL array): an input field read as JSON takes its authored array or object, which the generated query serializes, and a string is bound as it is. Bare PG SQL declares each JSON parameter as text
81
82
  (`CAST(CAST($n AS text) AS jsonb)`), so Bun.SQL sends the text verbatim instead of
82
83
  JSON-encoding it again; generation refuses a JSON parameter used any other way. SQLite bool/date/timestamp/numeric parameters use their declared
83
84
  storage representations. Bound optional fields become explicit required values,
84
85
  with null representing absence; D1 cannot bind undefined. Native output keys are
85
86
  SQL names rather than a post-query camel-case projection. PG uses the driver's
86
87
  default type parsers (including Date and int8 strings); custom application parser
87
- configuration changes application-owned behavior. Procedures expose raw
88
+ configuration changes application-owned behavior. The Store alone converts a row's
89
+ `version` to a number ([typed queries](typed-queries.md)). Procedures expose raw
88
90
  `{ results }`, not a claim of validated authored output.
89
91
 
90
92
  The application resolves a verified Caller once at its entry, using its selected
@@ -11,6 +11,12 @@ here instead of repeating it.
11
11
  - **App-owned pages** in `src/routes.ts`, when the page needs its own layout, JSON-LD or languages. The reference
12
12
  implementation is `node_modules/@aotter/mantle/docs/examples/recipes` (`src/pages.ts`, `src/routes.ts`; `smoke.mjs` checks every item below).
13
13
 
14
+ ## URLs
15
+
16
+ Mantle owns `/admin`, `/mcp`, `/api/auth/`, `/oauth/consent` and `/.well-known/oauth-*`, and the app's HTTP Triggers own
17
+ their declared paths. Give visitor pages their own prefix (`/{locale}/…`, `/teas/{slug}`), or make a root page (`/{slug}`)
18
+ return nothing for those paths; the app shell already keeps `/admin` and `/mcp` away from `src/routes.ts`.
19
+
14
20
  ## Every public page
15
21
 
16
22
  - Server-rendered HTML per URL with the content in the body. A client-only SPA shell or metadata set by script is not
@@ -26,7 +32,9 @@ here instead of repeating it.
26
32
 
27
33
  - JSON-LD of the matching schema.org type (`Recipe`, `Article`, `Event`, `Product`, ...), built from the same row as
28
34
  the visible content: `name`, `description`, `url`, `inLanguage`, `datePublished`, `dateModified`, and `image` when
29
- there is one (some rich results require it). Escape `<` in the serialized JSON so stored text cannot close the
35
+ there is one (some rich results require it). A type that has no date or language properties (`Product`) gets them on
36
+ a page node instead: an `@graph` with an `ItemPage` (`inLanguage`, dates, `mainEntity`) pointing to the `Product`
37
+ (`offers` with `price` and `priceCurrency`). Escape `<` in the serialized JSON so stored text cannot close the
30
38
  script element. Do not add facts the page does not show.
31
39
  - A short answer block for answer engines: one question heading and a one-sentence answer from the row's data.
32
40
  - A Markdown alternate at `<url>.md`, linked with `<link rel="alternate" type="text/markdown">`.
@@ -46,8 +54,40 @@ here instead of repeating it.
46
54
  - `/llms.txt` ([llmstxt.org](https://llmstxt.org)): a title, a one-line summary and a link to each item's Markdown
47
55
  alternate.
48
56
 
57
+ ## A complete site
58
+
59
+ When the brief names a site, shop, catalogue or blog for visitors, these are part of the work without the user
60
+ listing them:
61
+
62
+ - Every item on this page, for every public page type the site has.
63
+ - Accessible, responsive pages with the app's own styling; the examples are unstyled on purpose.
64
+ - Content managed in Admin (draft, publish, edit, unpublish) shows on the site without a rebuild. Images go through
65
+ Admin's media library (`mantle help media-r2`); store committed asset IDs and never use an ID as an image URL.
66
+ - Members' pages, if any, send anonymous visitors to `/sign-in` (`signInRedirect` in the app shell's `src/sign-in.ts`).
67
+ - Who runs the site: an About/contact page and a footer with the business's name, location and contact, and on the
68
+ home page `Organization` (or `LocalBusiness`) JSON-LD with the same facts and the logo. Answer engines use these to
69
+ say whether the site is legitimate. Ask for the facts the brief lacks; never invent an address.
70
+ - A favicon, and `og:image` on the home and list pages too.
71
+ - A detail page leads to the action the brief sells (order, register, book), saying when it needs an account.
72
+ - A cache policy: `Last-Modified` (from the row's `updated_at`) or an `ETag` on pages, `.md`, sitemap and `llms.txt`,
73
+ so crawlers can revalidate; `no-store` everywhere is safe but makes every crawl a full fetch.
74
+ - Structured data and answer blocks state only what the page shows. Never promise ranking or AI citation.
75
+
49
76
  ## Check before you report done
50
77
 
51
- Request the HTML without running JavaScript and check the items above; publish, edit and unpublish an item and check
52
- that its page, Markdown, sitemap and `llms.txt` follow. The `mantle` skill's
53
- `references/local-testing.md` covers running this against `wrangler dev`.
78
+ Request each page without running JavaScript (plain `fetch` in `smoke.mjs`; `mantle help local-testing`) and assert:
79
+
80
+ 1. HTML content type and the visible content; `<title>`, description, canonical on `PUBLIC_ORIGIN`, Open Graph; on
81
+ detail pages JSON-LD that parses with the visible name, and the answer block.
82
+ 2. Each `.md` alternate returns `text/markdown` with the current text.
83
+ 3. Walk the list View's complete cursor chain: its rows equal the `llms.txt` entries and the sitemap's detail URLs
84
+ (below the generated `web()` caps: 5,000 rows for `llms.txt`, 50,000 for the sitemap). `/robots.txt` names the
85
+ sitemap.
86
+ 4. An unknown URL, a malformed path, a draft and a row the visitor may not read are 404, also with an admin session.
87
+ 5. Publish, edit and unpublish an item through Admin's API (or the Procedure Admin uses); after each, its page,
88
+ `.md`, sitemap and `llms.txt` follow.
89
+ 6. Stored text with `<`, `&` and `</script>` renders escaped in HTML and inside JSON-LD.
90
+
91
+ Report a check you could not run (for example a production origin you do not have) as UNVERIFIED with the reason.
92
+ Then run [independent acceptance](agent-workflow-acceptance.md) with a visitor evaluator in a browser on the
93
+ `pnpm accept` URL.