@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
@@ -38,7 +38,7 @@ Values come from the verified Better Auth user record. Declare stored fields wit
38
38
 
39
39
  SQL cannot reference caller values: `auth.user()` fails with `USER_REF_SCOPE_ONLY`, and `auth.hasRole()` / `auth.role()` with `AUTH_ROLE_REMOVED`. Names are literals; Better Auth core fields and names beginning `mantle` are reserved.
40
40
 
41
- Values come from the original frozen Caller, which holds identity in a private WeakMap. Anonymous and copied/unbranded callers have no roles or user fields. Values are validated and frozen: finite numbers; strings of 1–256 UTF-8 bytes without NUL or lone surrogates; arrays of at most 64 items with duplicates removed, only for multi-valued fields. `{ all: true }` is the typed "every value" state of one field; storing it requires a Better Auth additional field of type `json`. Unknown user fields are discarded. A missing or empty value is refused before SQL runs (`UNAUTHENTICATED` for anonymous callers, otherwise `AUTH_DENIED`); it never matches every row. Never mint verified users from request data. Resolve credentials before constructing an identity, and pass the original Caller to generated operations.
41
+ Values come from the original frozen Caller, which holds identity in a private WeakMap. Anonymous and copied/unbranded callers have no roles or user fields. Values are validated and frozen: finite numbers, integers when the field scopes an `integer` column; strings of 1–256 UTF-8 bytes without NUL or lone surrogates; arrays of at most 64 items with duplicates removed, only for multi-valued fields. `{ all: true }` is the typed "every value" state of one field; storing it requires a Better Auth additional field of type `json`. Unknown user fields are discarded. A missing or empty value is refused before SQL runs (`UNAUTHENTICATED` for anonymous callers, otherwise `AUTH_DENIED`); it never matches every row. Never mint verified users from request data. Resolve credentials before constructing an identity, and pass the original Caller to generated operations.
42
42
 
43
43
  ## Gateway mode
44
44
 
@@ -52,6 +52,8 @@ Snapshots may read only: SQL writes, Store/Admin mutations, publishing transitio
52
52
 
53
53
  Pins use [describe](describe.md)'s `_meta.id`: `rest:<METHOD> <path>`, `admin:<METHOD> <path template>`, or the View/Procedure target for MCP and web. The route checks `target` and the generated target hash before execution: mismatches return 412 `TARGET_MISMATCH` or `TARGET_HASH_MISMATCH`. The generated `access-targets.ts` map loads only for gateway requests. Manually composed boundaries can supply an `accessTargets` loader returning the same metadata.
54
54
 
55
+ A gateway computes the pin from the request with [`matchTarget(describe, request)`](describe.md#matching-a-request-to-its-target), which applies the routers' own path normalisation, `HEAD` handling, Admin mount and precedence to the saved `mantle describe` JSON. It returns the target's `_meta`, including `targetHash` and the `readOnly` flag that the freshness rule reads, or `null` when the application serves no such route or tool.
56
+
55
57
  MCP host callers may use `initialize` and `tools/call` of their pinned tool; batches stay refused and `tools/list` returns 403 because the host provides its catalogue.
56
58
 
57
59
  `dryRun: true` runs target/hash checks, input validation, generated caller checks, `authorize` and freshness, without SQL or side effects. It returns 200 `{ dryRun: true, event }`; MCP carries this result in the SDK tool response. Ref handlers, custom guards, uploads and opaque work return 412 `DRY_RUN_UNSUPPORTED`. Dry runs validate authority and input; they do not verify database state, row existence or OCC conflicts.
@@ -46,6 +46,48 @@ Each target’s `_meta` contains:
46
46
  Descriptions contain no SQL or rows. They describe declarations; application
47
47
  router additions and optional module mounting remain application-owned.
48
48
 
49
+ ## Matching a request to its target
50
+
51
+ `matchTarget(describe, request)` from `@aotter/mantle/describe` returns the
52
+ `_meta` of the target that the generated routers select for a request, or
53
+ `null` when they serve none. It reads only the JSON this command prints: it
54
+ imports no application, opens no database and makes no network call, so a
55
+ gateway can compute `props.mantle.target` and `targetHash` before forwarding.
56
+
57
+ ```ts
58
+ import { matchTarget } from "@aotter/mantle/describe";
59
+ const url = new URL(request.url);
60
+ matchTarget(describe, { surface: "rest", method: request.method, path: url.pathname }); // rest:GET /api/views/orders
61
+ matchTarget(describe, { surface: "admin", method: request.method, path: url.pathname }); // admin:GET /entries/:id
62
+ matchTarget(describe, { surface: "mcp", tool: params.name, path: url.pathname }); // view:orders, admin:tool:<name>
63
+ ```
64
+
65
+ The matcher compiles describe ids with the routers' own pattern grammar and
66
+ precedence, and a conformance test checks it against the generated REST, MCP
67
+ and Admin routers for every route of the example apps:
68
+
69
+ - Paths are the URL pathname. Each segment is percent-decoded and re-encoded,
70
+ so `/api/views/%6Frders` matches `/api/views/orders`; a malformed escape
71
+ matches nothing. One trailing slash is optional. Repeated slashes and a
72
+ different letter case do not match.
73
+ - Methods are compared as `Request.method` reports them. `HEAD` selects a
74
+ public View's `GET` route; Admin and Procedure routes do not serve `HEAD`.
75
+ - REST precedence is the generated router's order (see
76
+ [http Triggers](trigger.md#route-precedence)). Admin precedence is Admin's
77
+ declared route order, so `/entries/export` wins over `/entries/:id`.
78
+ - Admin paths start with `/admin/api`, where Admin mounts its API. Admin ids
79
+ are relative to it.
80
+ - MCP matches the `tools/call` name. Pass the `/mcp` or `/mcp/admin` path to
81
+ limit the match to that endpoint's tools. Without a path, a name that the two
82
+ endpoints map to different targets matches nothing.
83
+
84
+ Media tools (`admin:tool:<name>`) and the optional Admin developer and
85
+ statistics routes match even when the application does not mount them; the
86
+ application then answers `NOT_FOUND`. Routes and tools the application adds
87
+ itself, including Admin `routes` it passes to `admin()`, are not in describe and
88
+ return `null`. Web and site pages (pinned by `view:<name>`) and auth routes are
89
+ outside `matchTarget`.
90
+
49
91
  Diff records contain `id`, `field`, `direction` and optional `before`/`after`.
50
92
  Removing a scope or restrict term widens access; adding one narrows it.
51
93
  Reading another Schema, dropping requirements, adding surfaces or Triggers,
@@ -8,14 +8,28 @@ Bare queries preserve native errors except required conflict/uncertain-write cla
8
8
  | --- | --- | --- |
9
9
  | INPUT_VALIDATION_FAILED | 400 | Invalid external input, paging or Store operation |
10
10
  | UNAUTHENTICATED | 401 | Caller lacks required authenticated authority |
11
- | AUTH_DENIED | 403 | Predicate, role or session-origin refusal |
11
+ | AUTH_DENIED | 403 | Predicate, role or session-origin refusal; a generic Admin write to a read-only Schema |
12
12
  | NOT_FOUND | 404 | Missing or excluded resource |
13
13
  | METHOD_NOT_ALLOWED | 405 | Path exists for another method |
14
- | CONFLICT | 409 | Unique clash, stale lock or failed row/state guard |
14
+ | CONFLICT | 409 | Unique clash, stale lock, failed row/state guard or a PostgreSQL transaction race |
15
15
  | OUTCOME_UNKNOWN | 503 | Write may have committed; reconcile without automatic retry |
16
16
 
17
+ A `CONFLICT` carries `kind`: `"row"` when a row statement, or a Procedure step marked `onNoRows: conflict`, changed no row (missing or not visible, a stale version, or a
18
+ state its WHERE no longer allows; one message covers all three, so it never reveals whether the row exists), `"unique"`
19
+ for a unique-index clash, `"serialization"` for a definitive PostgreSQL serialization failure (`40001`) or deadlock (`40P01`), `"check"` for a Schema check (with `schema` and `check`) and `"lock"` when a [Schema lock](schema.md#locks)
20
+ holds the row (with `schema`, `verb`, `lock` and `message`, the lock's static text; the row is visible to the caller, so
21
+ naming the lock reveals nothing new). Match `kind`, not the message. A `"lock"` refusal never succeeds on retry: change the
22
+ state that locks the row first (for a live revision, switch the post to another revision).
23
+ To recover from `"row"`, read the record again and decide from its current state: retry with its current version only when the change still applies, and never retry blindly. A Procedure can name a precise reason itself (a ref handler that reads the row first, or a RETURNING the caller checks).
24
+
25
+ A `"serialization"` conflict means PostgreSQL refused and rolled back the write, whether the refusal happened during a statement or COMMIT. The native error remains attached as the in-process `cause`, never exposed on the wire. Re-read the relevant state before deciding whether to retry; Mantle never retries automatically. A lost COMMIT response or missing server evidence stays `OUTCOME_UNKNOWN`, not `CONFLICT`.
26
+
17
27
  Unknown errors map to redacted `INTERNAL_ERROR` (500). A custom `ContractError` outside the core set must carry an explicit status. Media uses its selected `mediaError` contract. Never expose SQL errors, tokens or credentials.
18
28
 
19
29
  The runtime runs no DDL or schema checks. Schema installation and failures belong to [the native migration tool](../concepts/runtime-and-adapters.md#schema-installation-and-migrations), not a boot diagnostic.
20
30
 
31
+ `Schema.spec.publishing` that is malformed or on a Schema that is not `lifecycle: publishing` is `SCHEMA_PUBLISHING_INVALID`. A Store update that sets a field `publish` requires to null on a published row is `INPUT_VALIDATION_FAILED`, naming the field, for every caller. A malformed `Schema.spec.lock` entry is `SCHEMA_LOCK_INVALID`; a lock `when` outside the [lock subset](schema.md#locks) is `SQL_SHAPE` (or the SQL compiler's own code), and an `EXISTS` no declared index serves is `LOCK_INDEX_MISSING`, whose message names the index to declare.
32
+
33
+ A Procedure `handler` with other than exactly one of `sql`, `steps` and `ref`, an empty `steps` list, a step key other than `sql` and `onNoRows`, an empty step `sql` or an `onNoRows` other than `conflict` is `INVALID_MANIFEST_ENVELOPE` at that pointer. A step that holds several statements or none, and `onNoRows` on a row-classified statement, are `SQL_SHAPE` at `/spec/handler/steps/<i>/sql` and `/spec/handler/steps/<i>/onNoRows`; any other SQL refusal in a step points at that step, its line counted from the step's first line.
34
+
21
35
  User-field generation diagnostics are `AUTH_ROLE_REMOVED` (`auth.role` / `auth.hasRole`), `USER_REF_SCOPE_ONLY` (`auth.user` outside `scope` / `restrict`), `USER_FIELD_INVALID` (literal name/value or template grammar), `USER_FIELD_POSITION` (SET of a partition) and `USER_FIELD_CONFLICT` (column type/enum disagreement, or a reference to a field-scoped Schema without its scope terms). Warnings are `USER_FIELD_VALUES_UNENUMERATED` (no enum list), `USER_SCOPE_PUBLIC_ANON_EMPTY` (anonymous public reads are refused), `USER_SCOPE_ADMIN_VALUES` (an admin target reads field-scoped rows) and `USER_SCOPE_JOIN_DROPS` (a join of Schemas scoped by different fields). A generated app supplies the literal USER_REFS_V1 bind capability version; a mismatch throws `USER_REFS_VERSION` before native SQL.
@@ -1,14 +1,14 @@
1
1
  # Procedure
2
2
 
3
- A Procedure requires `input`, `output` JSON Schemas and a `handler` containing SQL or a ref name. Optional fields include title, description, requires, target, MCP annotations and Admin uiSchema. See [manifest envelope](manifest.md).
3
+ A Procedure requires `input`, `output` JSON Schemas and a `handler` with exactly one of `sql` (the program), `steps` (the same program, one statement per step: `[{ sql, onNoRows? }]`) or `ref`. Optional fields include title, description, requires, target, MCP annotations and Admin uiSchema. See [manifest envelope](manifest.md).
4
4
 
5
- SQL uses declared Schemas, `input.<name>`, `auth.uid()` and native expressions accepted by the selected compiler dialect. Compiler policy supplies scope and system fields. Do not assign scope, version, status, published_at, timestamps or author columns yourself. Publishing writes use generated transitions. `restrict` narrows the rows the statements read and write, as on a [View](view.md#restrict); it is refused on ref handlers.
5
+ SQL uses declared Schemas, `input.<name>`, `auth.uid()` and the expressions [the SQL reference](sql.md) lists for the selected dialect; [SQL patterns](../guides/sql-patterns.md) shows conditional status, approval, partial updates, upserts, guards and creating a row only when its parent exists. Compiler policy supplies scope and system fields. Do not assign scope, version, status, published_at, timestamps or author columns yourself. A SQL Procedure cannot write a `publishing` Schema: create and edit its rows from a ref handler through the Store, and change status with the generated publish/unpublish/archive transitions. `restrict` narrows the rows the statements read and write, as on a [View](view.md#restrict); it is refused on ref handlers.
6
6
 
7
- A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error: check the returned rows when zero means failure. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409); statements run atomically. A check failure keeps that code and its existing `A check of the Schema failed` message, and sets `ContractError.fields` to `{ kind: "check", schema, check }`; `check` is the expression on SQLite/D1 and the generated constraint name on PostgreSQL. Results are raw `{ results }`, not runtime-validated authored outputs. RETURNING uses SQL output names; quote an explicit alias to preserve declared case (`RETURNING reqStatus AS "reqStatus"`). Generation warns about unaliased direct fields whose declared name differs from storage.
7
+ A row-classified INSERT/UPDATE/DELETE (one that targets a single row, e.g. `WHERE id = input.id`) must affect one row. Use `WHERE id = input.id AND version = input.expectedVersion` for explicit OCC. Set operations, including UPDATE with a subquery and `INSERT ... SELECT`, may affect zero rows without an error. When zero means failure, write the handler as `steps` and give that statement `onNoRows: conflict` (`steps: [{ sql: "INSERT … SELECT …", onNoRows: conflict }]`): it must write at least one row, or the call is `CONFLICT` kind `"row"` and the whole program rolls back ([create only if the parent exists](../guides/sql-patterns.md#create-only-if-the-parent-exists-onnorows)). `steps` compile exactly as the same statements in `sql`, in order and in one batch; each step must be exactly one statement. Generation refuses a step with several statements or none, `onNoRows` on a row-classified statement (it already must change exactly one row) and any `onNoRows` value but `conflict`; a ref handler has no steps. A unique clash, a failed Schema `checks` constraint or a failed row assertion is `CONFLICT` (409) with `kind` `"unique"`, `"check"` or `"row"` ([diagnostics](diagnostics.md)); statements run atomically. A check failure keeps that code, and its message is `A check of the Schema failed: <schema>: <check>` on every target, with the driver's own error only in `cause`; `ContractError.fields` is `{ kind: "check", schema, check }`. `check` is the expression on SQLite/D1 and the generated constraint name on PostgreSQL. Results are raw `{ results }`: one array of RETURNING rows per statement, in order (`results[0][0].id` is the first statement's first row), not runtime-validated authored outputs, with driver-native values: a SQLite/D1 boolean is `1`/`0` here, while a View returns a declared boolean column as `true`/`false`; read a row back through a View when a client needs the typed form. Likewise `RETURNING version` is a number on SQLite/D1 and int8 text (`"2"`) on PostgreSQL; the Store converts `version` to a number, a Procedure result does not, so use `Number(version)` or read the row back through the Store. An array input is a row source: `json_each(input.items)` on SQLite/D1 and `jsonb_array_elements(input.items) AS j(value)` on PostgreSQL write any number of rows in the same atomic program. Callers pass the array itself (from a handler, `ctx.store.procedures["place-order"]({ orderRef, items })`); the generated input type is the authored one and the generated query serializes a JSON input for the driver, so no `JSON.stringify` is needed (a string is bound as it is). See [several rows that commit together](../guides/typed-queries.md#several-rows-that-commit-together). RETURNING uses SQL output names; quote an explicit alias to preserve declared case (`RETURNING reqStatus AS "reqStatus"`). Generation warns about unaliased direct fields whose declared name differs from storage.
8
8
 
9
9
  A row-classified UPDATE/DELETE on an excluded or missing id affects zero rows and returns HTTP 409 (`CONFLICT`), just like a stale version; reads return not found. Native Admin's visible-row pre-read returns 404 for excluded/missing ids.
10
10
 
11
- A ref handler is application code called as `(ctx, input)`. Generation prints required `Handlers` keys; typecheck and implement them. Wire them at `store(db, caller, { handlers })` and selected surfaces. The context has the Caller, the native db and a Store for that caller. A handler is not one transaction by itself: when it runs inside a caller's `store.transaction`, its db and Store are that transaction's; otherwise each write commits on its own. On PostgreSQL and bun:sqlite, `ctx.transaction(async ({ store, db }) => …)` makes writes that must commit together atomic; it joins the transaction the handler already runs in. A joined transaction has no savepoint, so let an error inside `ctx.transaction` propagate: a handler that catches it and carries on keeps the writes made before the error. Keep external calls (payments, supplier APIs) outside it, so a callback never waits for an uncommitted row. D1 has no interactive transactions, so its `HandlerContext` has no `transaction`: put writes that must commit together in one SQL Procedure, whose statements commit together, and call it through `ctx.store.procedures`. It is not a sandbox; the app owns external side effects and input/output handling. The context has no `env`: on Workers read bindings and secrets with `import { env } from "cloudflare:workers"`. The Caller carries no name or email; `userOf(caller)` from `@aotter/mantle/auth` returns the Better Auth user record it was resolved from, for session, OAuth and service credentials alike (undefined for system callers).
11
+ A ref handler is application code called as `(ctx, input)`. Generation lists the `Handlers` keys the app must provide (every run, whether or not they exist yet); `tsc --noEmit` checks the implementation against the generated `Handlers` type. Wire them at `store(db, caller, { handlers })` and selected surfaces. The context has the Caller, the native db and a Store for that caller. A handler is not one transaction by itself: when it runs inside a caller's `store.transaction`, its db and Store are that transaction's; otherwise each awaited write commits on its own. For known multi-writes, `ctx.store.batch([...])` is available on every target, including D1: it commits atomically standalone and joins the same outer transaction when one exists. A batch step cannot consume a previous step's result. A rejected batch marks that outer transaction rollback-only, even if the handler catches the error: the transaction rejects with the first batch failure and rolls back all writes. No nested BEGIN or savepoint is added. On PostgreSQL and bun:sqlite, `ctx.transaction(async ({ store, db }) => …)` supports dependent writes and joins the transaction the handler already runs in. A joined transaction has no savepoint; application errors outside a batch must propagate to roll back its writes. Keep external calls (payments, supplier APIs) outside it, so a callback never waits for an uncommitted row. D1 has no interactive transactions, so its `HandlerContext` has no `transaction`; use `ctx.store.batch` or a multi-statement SQL Procedure through `ctx.store.procedures` for atomic writes. It is not a sandbox; the app owns external side effects and input/output handling. The context has no `env`: on Workers read bindings and secrets with `import { env } from "cloudflare:workers"`. The Caller carries no name or email; `userOf(caller)` from `@aotter/mantle/auth` returns the Better Auth user record it was resolved from, for session, OAuth and service credentials alike (undefined for system callers).
12
12
 
13
13
  `target: { schema, id, version? }` binds an Admin operation to a row. SQL targets can be inferred for a single locked row operation; declare a target for a ref operation when needed. Input fields must have the required shapes.
14
14
 
@@ -18,6 +18,6 @@ Every generated AI-facing tool (MCP, WebMCP, publishing and media tools) carries
18
18
 
19
19
  Generated requires checks gate calls. Boundary input validators are generated separately; direct queries accept driver-ready, app-validated input. Dynamic guards and lifecycle hooks are rejected. See [native execution](../guides/native-execution.md) and [surface contracts](surface.md).
20
20
 
21
- SQL INSERT/upsert into a user-field scoped Schema is system-only. For a verified user, insert through `ctx.store` so membership and single-partition autofill run before the native write. `ctx.store` speaks storage names, not the manifest's: the handler's `input` uses authored names (`input.parentId`), while Store writes, filters and rows use the folded column (`insert({ parentid: input.parentId })`, `where: { parentid }`, `row.parentid`); generated Store types show the exact names ([typed queries](../guides/typed-queries.md)). SET cannot change a field-scope column, including ON CONFLICT DO UPDATE. Scoped unique conflict targets gain the owner/field prefix automatically.
21
+ SQL `INSERT … VALUES`/upsert into a user-field scoped Schema follows the Store's insert rule ([field scope](schema.md#field-scope)); `INSERT … SELECT` into it is system-only, so a ref handler inserts those rows through `ctx.store`. `ctx.store` speaks storage names, not the manifest's: the handler's `input` uses authored names (`input.parentId`), while Store writes, filters and rows use the folded column (`insert({ parentid: input.parentId })`, `where: { parentid }`, `row.parentid`); generated Store types show the exact names ([typed queries](../guides/typed-queries.md)). SET cannot change a field-scope column, including ON CONFLICT DO UPDATE. Scoped unique conflict targets gain the owner/field prefix automatically.
22
22
 
23
- Triggers are the only way request data reaches a Procedure: REST and MCP validate the input against the Procedure's schema, then call it. App code may also call a generated Procedure directly (`import run from "../generated/procedures/<name>.js"`, `await run(db, caller, input)`); `requires`, row policy and checks still apply, but input validation does not, so pass only data the app produced or has verified (a cron job, a callback after its signature check). A page that takes user input submits it to the Procedure's REST Trigger; see `node_modules/@aotter/mantle/docs/examples/purchasing/src/requests-page.ts`.
23
+ Triggers are the only way request data reaches a Procedure: REST and MCP validate the input against the Procedure's schema, then call it. App code may also call a generated Procedure directly (`import run from "../generated/procedures/<name>.js"`, `await run(db, caller, input)`) or through the Store (`ctx.store.procedures[name](input)` in a handler); `requires`, row policy and checks still apply, but input validation does not, so pass only data the app produced or has verified (a cron job, a callback after its signature check). A page that takes user input submits it to the Procedure's REST Trigger; see `node_modules/@aotter/mantle/docs/examples/purchasing/src/requests-page.ts`.
@@ -1,6 +1,6 @@
1
1
  # Schema
2
2
 
3
- A Schema declares one table and its typed Store collection. Required keys are title and an object JSON Schema. Optional keys include lifecycle, scope, TTL, indexes, uniqueIndexes, checks, searchableFields, localized, translates and uiSchema. Unknown keys fail validation.
3
+ A Schema declares one table and its typed Store collection. Required keys are title and an object JSON Schema. Optional keys include lifecycle, scope, lock, TTL, indexes, uniqueIndexes, checks, searchableFields, localized, translates and uiSchema. Unknown keys fail validation.
4
4
 
5
5
  ```yaml
6
6
  apiVersion: cms.mantle.aotter.net/v2
@@ -38,7 +38,7 @@ A string `pattern` needs a `maxLength`: generation bounds each regex's backtrack
38
38
  | object/array | JSON text | jsonb |
39
39
  | geo | lat/lng columns, R*Tree | lat/lng columns |
40
40
 
41
- System columns include id, version, created_at, updated_at and author_id; publishing adds status/published_at. Do not declare reserved fields. Manifest native column names in indexes use camelCase (`createdAt`); generated Store/SQL use storage names (`created_at`). Scope is omitted from returned rows.
41
+ System columns include id, version, created_at, updated_at and author_id; publishing adds status/published_at. Initial DDL declares a field NOT NULL when an `operational` Schema lists it in `required` and it does not allow null; `default` never does, and no column gets a DDL `DEFAULT` ([server-maintained fields](#admin-uischema)). Do not declare reserved fields. Manifest native column names in indexes use camelCase (`createdAt`); generated Store/SQL use storage names (`created_at`). Scope is omitted from returned rows.
42
42
 
43
43
  Declared date/date-time outputs are SQL-formatted strings (`YYYY-MM-DD`, UTC ISO with six fractional digits). Computed timestamps remain native. Inputs and other output scalars follow [the native contract](../guides/native-execution.md). Date conversion through JS Date loses microseconds.
44
44
 
@@ -46,10 +46,57 @@ Generated types constrain Store fields and writes; Store does not interpret a JS
46
46
 
47
47
  ## Row rules
48
48
 
49
- Schemas default to operational. Publishing opts into draft/published/archived and generated admin/system transitions. Store cannot assign system columns or scope; generated policy fills them. See [lifecycle and locales](../concepts/lifecycle-and-locales.md).
49
+ Schemas default to operational. Publishing opts into draft/published/archived and generated admin/system transitions. Store cannot assign system columns or scope; generated policy fills them. `publishing: { liveEdits: true }` (only with `lifecycle: publishing`) lets Admin's generic patch edit a published entry in place; without it, generic patches edit drafts only, an implicit lock named `publishing` that never binds the transitions. No update clears a field `publish` requires from a published row (`INPUT_VALIDATION_FAILED`): a Store update that sets one to null is refused, while Admin's live edit validates the whole merged entry as `publish` does. See [lifecycle and locales](../concepts/lifecycle-and-locales.md).
50
50
 
51
51
  Owner and field columns prefix every unique index in that order. User reads/writes see their own rows; anonymous reads see none. System callers bypass scope. TTL uses `{ field, expireAfterSeconds }` over a date-time field; expired rows become invisible but remain stored until a system sweep. TTL is not allowed on translations.
52
52
 
53
+ ### Locks
54
+
55
+ `lock` freezes a row while a condition on it holds. It is a data-state invariant, not an authorization rule: it binds every caller, admins included.
56
+
57
+ ```yaml
58
+ apiVersion: cms.mantle.aotter.net/v2
59
+ kind: Schema
60
+ metadata: { name: posts }
61
+ spec:
62
+ title: Posts
63
+ lifecycle: publishing
64
+ indexes: [[current_revision]]
65
+ schema:
66
+ type: object
67
+ properties:
68
+ slug: { type: string }
69
+ current_revision: { type: string, x-mantle-ref: post_revisions }
70
+ ---
71
+ apiVersion: cms.mantle.aotter.net/v2
72
+ kind: Schema
73
+ metadata: { name: post_revisions }
74
+ spec:
75
+ title: Post revisions
76
+ lifecycle: operational
77
+ lock:
78
+ - name: live_revision
79
+ on: [update, delete]
80
+ when: EXISTS (SELECT 1 FROM posts p WHERE p.current_revision = post_revisions.id)
81
+ message: "This revision is live. Create a new revision and switch to it."
82
+ schema:
83
+ type: object
84
+ required: [post, body]
85
+ properties:
86
+ post: { type: string, x-mantle-ref: posts }
87
+ body: { type: string }
88
+ ```
89
+
90
+ - `when` true means locked. It reads the existing row only (like RLS `USING`); new rows stay with `checks`. `on` lists `update` and/or `delete`; names are unique per Schema.
91
+ - A caller's update or delete of the Schema gets `AND NOT (<when>)` in its WHERE, beside scope and the version check, so the write stays one atomic statement. Several locks are ANDed. A NULL `when` refuses the write: a lock fails closed. So `when: body = 'keep'` also locks a row whose `body` is NULL; write `body IS NOT NULL AND body = 'keep'` to lock only the rows that hold the value. An `ON CONFLICT DO UPDATE` carries the `update` locks too, as a set op: an upsert whose conflicting row is locked leaves it unchanged and returns no row for it, like `DO NOTHING`; use a row-classified `UPDATE` to get `CONFLICT` `kind: "lock"`.
92
+ - It applies to every caller-facing write: Admin's generic patch and delete (which the Admin MCP App and WebMCP reach through the same routes), caller-mode SQL Procedures' `UPDATE`/`DELETE` of the locked Schema (over REST, MCP or a direct call), the generated publishing transitions (`<schema>.publish` / `.unpublish` / `.archive`, which carry the `update` locks) and Stores bound to a caller (`store(db, caller)`, a ref handler's `ctx.store`). Only a trusted write (the system caller, a system Store, the TTL sweep) bypasses it: the maintenance escape hatch, visible in code review.
93
+ - A refused write is `CONFLICT` with `kind: "lock"` and `{ schema, verb, lock, message }` ([diagnostics](diagnostics.md)). When the write changed no row, one read of the row as the caller sees it classifies the failure: a missing or invisible row stays `kind: "row"`.
94
+ - `when` allows the Schema's own columns, constants, comparisons (`=`, `<>`, `<`, `>`, `<=`, `>=`, `IN (…)`), `AND`/`OR`/`NOT`, `IS [NOT] NULL` and `[NOT] EXISTS (SELECT … FROM <declared Schema> [alias] WHERE …)`. It calls no function (`now()`, `auth.*`, aggregates, windows) and reads no `input.*`, so it holds the same for every caller and request. Inside a subquery every column is qualified: `p.current_revision` for the subquery's Schema, `post_revisions.id` for the locked row (an unqualified `id` would silently read `posts.id`). A subquery reads one Schema, without join, `WITH`, `GROUP BY`, `ORDER BY`, `LIMIT` or `DISTINCT`, and its alias may not hide the locked Schema's name. `when` is one expression: a trailing `ORDER BY`, `GROUP BY` or `LIMIT` is refused. The compiler gives each subquery relation a reserved `_mantle_lock_<n>` alias, so the alias of a write the lock guards can never capture its references (relation aliases starting with `_mantle` are refused in authored SQL).
95
+ - Subqueries read the stored rows, without the reader's scope, publishing filter or TTL: a referencing row the caller cannot see still locks.
96
+ - **Concurrency.** The lock is a predicate on the write's database snapshot, not a lock on every row its subquery reads. On PostgreSQL READ COMMITTED, a concurrent change in another Schema can make a cross-row `EXISTS` condition true after an UPDATE has already passed that check. For a publication guarantee across editors, the [revisions recipe](../guides/revisions.md) first CAS-seals the revision's own row and locks on that stored seal; its parent pointer changes only after sealing succeeds. A transaction around the pointer change alone does not serialize unrelated autocommit edits.
97
+ - Admin knows before the edit: its editor and list reads evaluate each lock per row, and each entry carries `locks: { update, delete }` (per verb, the holding lock's `{ lock, message }` or `false`), so Save, Delete and transitions are disabled with the message ([native Admin](../guides/native-admin.md)). On a publishing Schema the draft-only rule shows there too, as the implicit lock `publishing`, after any declared lock on the same verb. The write's WHERE remains the authority.
98
+ - **Index rule.** Locks are index-proven reads, like [paged Views](#index-prefixes-for-paged-views): every guarded write runs them. Each `EXISTS` needs an equality (`=` to the locked row's column, an outer subquery's column or a literal, or `IN (<literals>)`) among its top-level `AND` terms on the first column of a declared index, unique index or `id` of its Schema. The example needs `indexes: [[current_revision]]` on `posts`; otherwise generation fails with `LOCK_INDEX_MISSING`, naming the index to declare. A condition on the locked row's own columns needs no index. An `OR` of correlations is not index-proven: split it into separate locks.
99
+
53
100
  `localized` and `translates: { parent, on }` declare a translation child. Parent visibility and publication are retained, with matching scope where needed. Locale values stay plain strings.
54
101
 
55
102
  ## Indexes, checks and search
@@ -70,15 +117,17 @@ Declaring both indexes does not make one View bounded for every caller. System (
70
117
 
71
118
  Publishing fields may be NULL in drafts even when required, so they cannot be View paging keys. Store accepts `published_at` ordering, but View paging rejects it. Use `ORDER BY id`, or keep the business date in an operational Schema as an indexed, required non-null field. See [View paging](view.md#results-and-paging).
72
119
 
73
- `checks` are restricted SQL expressions over the table's columns, emitted in initial DDL. Native databases enforce them; bare database errors are not automatically mapped to input-validation failures. Searchable fields must be plain string fields. SQLite/D1 emit FTS5 trigram tables and maintenance triggers; PostgreSQL uses ILIKE. Short SQLite terms use LIKE. Store search additionally matches exact id and combines with row policy.
120
+ `checks` are restricted SQL expressions over the table's columns, emitted in initial DDL. Native databases enforce them; bare database errors are not automatically mapped to input-validation failures. `searchableFields` defaults to none: Store and Admin list search then match the exact id only, and Admin's search box says so. Name the fields people search by, such as the list `primaryField` or a `title`; with Admin selected, `mantle generate` warns when a Schema has no `searchableFields` but has such a field that could be one (a plain string, not a date, date-time or reference). Searchable fields must be plain string fields. SQLite/D1 emit FTS5 trigram tables and maintenance triggers; PostgreSQL uses ILIKE. Short SQLite terms use LIKE. Store search additionally matches exact id and combines with row policy.
74
121
 
75
122
  The runtime never creates, alters or checks tables. Initial DDL is for an empty database. Changed fields, checks, indexes and search DDL require [agent-owned native migrations](../concepts/runtime-and-adapters.md#schema-installation-and-migrations), including explicit data migration for destructive/type changes. Mantle drops nothing.
76
123
 
77
124
  ## Admin uiSchema
78
125
 
79
- A property marked `readOnly: true` in the JSON Schema is server-maintained (a counter, a total): Admin's generic create stores its `default` whatever the input says, Admin edits ignore it, and only Procedures (or server code through the Store) change it afterwards. JSON Schema defaults are applied by Admin and the Store, not by the database: a SQL Procedure that inserts the row sets the field itself. A root `readOnly: true` makes the whole Schema read-only in Admin.
126
+ A property marked `readOnly: true` in the JSON Schema is server-maintained (a counter, a total): Admin's generic create stores its `default` whatever the input says, Admin edits ignore it, and only Procedures (or server code through the Store) change it afterwards. JSON Schema defaults are applied by Admin's generic create only, not by the Store or the database: initial DDL emits no `DEFAULT` for a field, so server code through the Store and a SQL Procedure that inserts the row set the field themselves. A root `readOnly: true` makes the whole Schema read-only in Admin: a generic Admin write is refused with 403 `AUTH_DENIED` (not a 409 version conflict).
127
+
128
+ `default` does not make a column NOT NULL; `required` does (in an `operational` Schema). For a server-maintained value that is never NULL, list it in both, `required: [name, total]` with `total: {type: integer, readOnly: true, default: 0}`: initial DDL declares `total` NOT NULL, Store and View row types read it as `number` rather than `number | null`, Admin's create still fills the default when the client omits it, and the Store's insert type requires server code to pass it. Without `required` the column stays nullable, and an INSERT that omits it stores NULL. Adding `required` to an existing table is a [migration](../concepts/runtime-and-adapters.md#schema-installation-and-migrations): backfill NULLs, then add NOT NULL.
80
129
 
81
- `fields.<name>.widget` selects a built-in control (`textarea`); `list.primaryField`, `list.columns` and enum `filterField` select list presentation; `nav.parentField` chooses the parent a child folds under (required when two or more required `id` references exist) and `nav.standalone` lists the child in the sidebar; `nav.group/order/hidden` place it in the sidebar ([sidebar](../guides/native-admin.md#sidebar)). `list.columns` must not repeat `list.primaryField` or itself (`SCHEMA_UI_INVALID`). Generation validates these against declared fields/indexes. Components that render a field, cell or record aside are not manifest references: they live in `defineAdmin` ([customising Admin](../guides/native-admin.md#customising-admin)), and a manifest that still names one (`widget: <extension>/<contribution>`, `widget` `options`, `list.cells`, `panels`) is refused. JSON Schema root `schema.readOnly: true` disables generic Admin writes, not declared Procedures; `uiSchema.readOnly` is rejected with `SCHEMA_UI_INVALID`. `x-mantle-ref` relates a field to another Schema id or single-field unique field.
130
+ `fields.<name>.widget` selects a built-in control (`textarea`); `list.primaryField`, `list.columns` and enum `filterField` select list presentation (operational Schemas only; a publishing Schema's list shows its title field and status); `nav.parentField` chooses the parent a child folds under (required when two or more required `id` references exist) and `nav.standalone` lists the child in the sidebar; `nav.group/order/hidden` place it in the sidebar ([sidebar](../guides/native-admin.md#sidebar)). `list.columns` must not repeat `list.primaryField` or itself (`SCHEMA_UI_INVALID`). Generation validates these against declared fields/indexes. Components that render a field, cell or record aside are not manifest references: they live in `defineAdmin` ([customising Admin](../guides/native-admin.md#customising-admin)), and a manifest that still names one (`widget: <extension>/<contribution>`, `widget` `options`, `list.cells`, `panels`) is refused. JSON Schema root `schema.readOnly: true` disables generic Admin writes, not declared Procedures; `uiSchema.readOnly` is rejected with `SCHEMA_UI_INVALID`. `x-mantle-ref` relates a field to another Schema id or single-field unique field. It is not a foreign key: the database does not check that the referenced row exists, and deleting that row leaves the reference dangling. Check existence in the write (a JOIN or `EXISTS` in the Procedure, with `onNoRows: conflict` on its step when a missing parent must be a 409) and guard deletes with `NOT EXISTS` ([SQL patterns](../guides/sql-patterns.md)).
82
131
 
83
132
  See [Admin](../guides/admin-ui.md) and [diagnostics](diagnostics.md).
84
133
 
@@ -88,7 +137,11 @@ See [Admin](../guides/admin-ui.md) and [diagnostics](diagnostics.md).
88
137
 
89
138
  A caller whose value for a field is `{ all: true }` drops that field's term only; the other terms still apply. Generated SQL holds one statement per target and selects the form for each caller at run time; values are always bound parameters. Neither roles nor the admin layer widen a field scope or a translation parent's visibility. A Schema whose `x-mantle-ref` points to a field-scoped Schema must carry the same scope terms.
90
139
 
91
- For inserts, one allowed partition autofills a missing value; multiple values require an explicit allowed partition, and missing/empty values fail with `AUTH_DENIED`. Callers holding `{ all: true }` and system callers always provide an explicit partition. Procedure INSERT/upsert is trusted-only; use the scoped Store for user writes. The partition is never writable through SET, including upsert and native Admin patch. An excluded id behaves like a missing row: reads return not found; Store update/delete and row-classified Procedure UPDATE/DELETE affecting zero rows return HTTP 409 (`CONFLICT`). Native Admin's visible-row pre-read returns 404 for either id. SQLite binds a JSON array; PostgreSQL binds a quoted, escaped array. Public Views over field scope warn that anonymous callers are refused.
140
+ For inserts, one allowed partition autofills a missing value; multiple values require an explicit allowed partition, and missing/empty values fail with `AUTH_DENIED`. Callers holding `{ all: true }` and system callers always provide an explicit partition. A Procedure `INSERT … VALUES` follows the same rule for user callers, checked before any SQL runs: leave the partition column out to autofill a caller's single value, or take it from an input declared with the column's own type (`VALUES (input.region, …)`) or a matching literal, which must be inside the caller's set; any other expression there is refused. A Procedure that callers holding several values or `{ all: true }` use, such as an Admin create action, takes the partition as an optional input. `INSERT … SELECT` into a field-scoped Schema, and an insert into a Schema the target `restrict`s, run only for system callers, which this check does not cover: they name the partition themselves; use the scoped Store for user writes there. The partition is never writable through SET, including upsert and native Admin patch. An excluded id behaves like a missing row: reads return not found; Store update/delete and row-classified Procedure UPDATE/DELETE affecting zero rows return HTTP 409 (`CONFLICT`). Native Admin's visible-row pre-read returns 404 for either id. SQLite binds a JSON array; PostgreSQL binds a quoted, escaped array. Public Views over field scope warn that anonymous callers are refused.
141
+
142
+ #### References to a scoped Schema
143
+
144
+ A non-null `x-mantle-ref` value that points to a scoped Schema (field or owner scope) must name a row of that Schema the caller can read, matched on `id` or on the object form's `field`, and that row must have the same value as the child for every scope field the two share (`ADR-0034`: a reference between scoped Schemas stays inside one owner or partition). A Store insert or update, a Procedure `INSERT … VALUES`, `UPDATE … SET` or `DO UPDATE SET`, and native Admin's generic create and patch check it in the same statement; a missing, unreadable or other-partition parent behaves like a missing row: a single-row statement returns HTTP 409 (`CONFLICT`), and an `ON CONFLICT` insert or a set update writes nothing for that row. A caller holding several values or `{ all: true }` can still not point a child in one partition at a parent in another. `INSERT … SELECT` that writes such a reference runs only for system callers. A caller's `UPDATE … SET` or `DO UPDATE SET` of such a reference takes no subquery, which could read the parent's columns as its own. Trusted writes (`store(db, caller)` or a ref handler's `ctx.store` with a system caller, `{ kind: "system", reason }`) are not checked, like the other row rules. NULL is always allowed; deleting a parent does not touch its children.
92
145
 
93
146
  ## Reserved entry columns
94
147
 
@@ -100,5 +153,22 @@ the publishing lifecycle; `version` backs `expectedVersion`; `author_id` is the
100
153
  `restrict: { <schema>: "author_id = auth.uid()" }`.
101
154
 
102
155
  A seed or other SQL that inserts rows outside Mantle (`wrangler d1 execute --file seed.sql`) sets the native columns
103
- itself: `id` (text, unique) and `created_at` (integer microseconds since the Unix epoch, e.g. `1767225600000000`);
104
- `updated_at` and `version` have defaults. Procedures and the Store fill them for you.
156
+ itself. Procedures and the Store fill every one of them for you; prefer them for fixtures. The Store accepts no
157
+ value for these columns or the scope column, for any caller: it sets them itself, so SQL is also the only way to keep
158
+ their existing values.
159
+
160
+ | Column | SQLite and D1 | PostgreSQL | If the SQL omits it |
161
+ |---|---|---|---|
162
+ | `id` | text, unique | text, unique | the insert fails |
163
+ | `version` | integer, `1` for a new row | `int8` | `1` |
164
+ | `created_at` | integer microseconds since the Unix epoch (`1767225600000000` is 2026-01-01T00:00:00Z) | `timestamptz` | the insert fails |
165
+ | `updated_at` | integer microseconds, like `created_at` | `timestamptz` | SQLite and D1 store `0`, which Admin shows as 1970-01-01; PostgreSQL stores `now()` |
166
+ | `author_id` | the creator's Better Auth user id, or `NULL` | the same | `NULL`: `restrict: { <schema>: "author_id = auth.uid()" }` shows the row to no member |
167
+ | `status` (publishing) | `'draft'`, `'published'` or `'archived'` | the same | `'draft'` |
168
+ | `published_at` (publishing) | integer microseconds when `status` is `'published'`, otherwise `NULL` | `timestamptz` or `NULL` | `NULL` |
169
+ | scope column (owner or field scope) | the field the `scope` names (`owner` in `owner = auth.uid()`), text, not null | the same | the insert fails |
170
+
171
+ Units differ by table: entry timestamps (`created_at`, `updated_at`, `published_at` and declared `date-time` fields)
172
+ are microseconds on SQLite and D1, while the optional media library's `media_assets.created_at` is epoch
173
+ **milliseconds** on every dialect ([media](../cloudflare/media-r2.md)). A declared `date` field is integer days since
174
+ 1970-01-01 on SQLite and D1. [Seed data](../guides/local-testing.md#seed-data) shows a complete seed for each dialect.
@@ -0,0 +1,65 @@
1
+ ---
2
+ description: The SQL that Views and Procedures accept on SQLite/D1 and on PostgreSQL, and what each refuses with which diagnostic.
3
+ ---
4
+ # SQL accepted by each dialect
5
+
6
+ Views and Procedures share one allowlist with two profiles. SQLite and D1 run the base profile; PostgreSQL runs the
7
+ base profile plus the additions below. Anything not listed is refused at generation with a code (`SQL_UNSUPPORTED`,
8
+ `SQL_FUNCTION`, `SQL_SHAPE`, `SQL_TYPE`) and a source position, so generation is the authority for each target. For
9
+ worked examples see [SQL patterns](../guides/sql-patterns.md).
10
+
11
+ ## Both dialects
12
+
13
+ | Area | Accepted |
14
+ | --- | --- |
15
+ | Statements | `SELECT`, `INSERT … VALUES` / `INSERT … SELECT`, `UPDATE … SET … WHERE`, `DELETE … WHERE`, each with `RETURNING`; `WITH` (non-recursive) on a `SELECT` |
16
+ | Upsert | `ON CONFLICT (<unique index columns>) DO NOTHING` / `DO UPDATE SET …`, with `excluded.<column>` |
17
+ | Joins | `INNER JOIN`, `LEFT JOIN`; a comma join only for a row source (`json_each`, or PostgreSQL's below) |
18
+ | Clauses | `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT` |
19
+ | Predicates | `=`, `<>`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IS [NOT] NULL`, `IS [NOT] DISTINCT FROM`, `[NOT] BETWEEN`, `IN (list)`, `IN (subquery)`, `LIKE`, `[NOT] EXISTS (subquery)` |
20
+ | Expressions | `+ - * / %`, `\|\|`, `->>` (JSON field), `CASE`, `COALESCE`, `NULLIF`, scalar subqueries, `CAST(… AS text \| int4 \| int8 \| float8 \| bool \| timestamptz \| date \| numeric \| interval)` |
21
+ | Aggregates | `count`, `sum`, `min`, `max`, `avg` |
22
+ | Windows | `row_number()`, `rank()`, `sum()`, `count()` with `OVER (PARTITION BY … ORDER BY …)` and the default frame |
23
+ | Scalar functions | `lower`, `upper`, `length`, `abs`, `round`, `substr`, `replace`, `trim`, `ltrim`, `rtrim`, `instr` |
24
+ | Time | `now()`, `date_trunc('hour' \| 'day' \| 'week' \| 'month' \| 'year', …)`, `extract(year \| month \| day \| dow \| hour FROM …)` |
25
+ | Mantle | `auth.uid()`, `input.<name>`, `mantle.search`, `mantle.search_rank`, `mantle.near`, `mantle.distance` ([features](features.md)) |
26
+
27
+ ## SQLite and D1 only
28
+
29
+ | Accepted | PostgreSQL instead |
30
+ | --- | --- |
31
+ | `json_each(<array>)` as a row source (`FROM json_each(input.items) j`, `j.value ->> 'field'`) | `jsonb_array_elements(…) AS j(value)` |
32
+ | `json_group_array`, `json_group_object` | `jsonb_agg`, `jsonb_object_agg` |
33
+ | `json_extract`, `json_set`, `json_insert`, `json_remove`, `json_array_length`; build an object with `json_set('{}', '$.k', v, …)` (`json_object` is refused) | `jsonb_build_object`, `jsonb_build_array` and the jsonb operators |
34
+ | `typeof`, `hex` | refused (`typeof` is SQLite's; PostgreSQL has `pg_typeof`) |
35
+
36
+ An integer `CAST` of anything but a literal is refused (`SQL_TYPE`): SQLite truncates where PostgreSQL rounds. Write
37
+ `round(x)`; `->>` already returns a JSON number as a number.
38
+
39
+ ## PostgreSQL additions
40
+
41
+ | Area | Accepted |
42
+ | --- | --- |
43
+ | Set operations | `UNION [ALL]`, `INTERSECT`, `EXCEPT`, inside a `WITH` or a subquery (the View's own SELECT is the one that pages) |
44
+ | Relations | `LATERAL` subqueries, `WITH RECURSIVE`, `MATERIALIZED` / `NOT MATERIALIZED` CTEs |
45
+ | Row sources | `jsonb_array_elements`, `jsonb_array_elements_text`, `jsonb_each`, `jsonb_each_text`, with `WITH ORDINALITY` |
46
+ | Operators | `ILIKE`, `~`, `~*`, `!~`, `!~*`, jsonb `->`, `#>`, `#>>`, `@>`, `<@`, `?`, `?\|`, `?&` |
47
+ | Aggregates | `string_agg`, `jsonb_agg`, `jsonb_object_agg`, with `FILTER (WHERE …)` and `ORDER BY` inside the call |
48
+ | Windows | also `avg`, `min`, `max`, `lag`, `lead`, `first_value`, `last_value`, `dense_rank`; frames except `GROUPS` and `EXCLUDE` |
49
+ | Functions | `GREATEST`, `LEAST`, `floor`, `ceil`, `sqrt`, `power`, `split_part`, `timezone`, `jsonb_build_object`, `jsonb_build_array`, `jsonb_strip_nulls`, `to_jsonb`, `jsonb_typeof`, `CAST(… AS jsonb)` |
50
+ | Time | `date_trunc` also `'minute'`, `'quarter'`; `extract` also `minute`, `quarter`, `week`, `isoyear`, `isodow`, `doy`, `epoch` |
51
+
52
+ `jsonb_agg` of no rows is `NULL`: write `COALESCE(jsonb_agg(…), CAST('[]' AS jsonb))` for an empty array.
53
+
54
+ ## Refused on every target
55
+
56
+ - `OFFSET`: page with the generated cursor (`limit`, `cursor`) instead.
57
+ - `RIGHT` and `FULL` joins, `UPDATE … FROM`, `DELETE … USING`, DDL and any function not listed.
58
+ - `DISTINCT` with `ORDER BY`: the paging key Mantle appends would change what is distinct; use `GROUP BY`.
59
+ - A program of more than 2000 syntax nodes.
60
+
61
+ Two write rules come from Mantle, not the dialect ([Procedure](procedure.md)). A row-classified `UPDATE`/`DELETE`
62
+ (`WHERE id = input.id …`) must change exactly one row, otherwise `CONFLICT`. A set form (`WHERE id IN (SELECT …)`,
63
+ `INSERT … SELECT`) may change none, so write a conditional write that may do nothing in that form; a Procedure step with
64
+ `onNoRows: conflict` turns "none" into `CONFLICT`. Scope, version,
65
+ timestamps and author columns are assigned by Mantle, never by your SQL.
@@ -233,7 +233,7 @@ surface its generated `publish_<schema>`, `unpublish_<schema>` and
233
233
  `archive_<schema>` tools use the same snake-case naming rule, are admin-only
234
234
  and take `{ id, expectedVersion }` through the shared generated validation.
235
235
 
236
- `/mcp/admin` admits only admins: `isAdmin(caller)` must hold. Better Auth’s admin plugin `adminRoles` defines this layer (default `["admin"]`); the application supplies its verified Caller. MCP bodies are limited
236
+ `/mcp/admin` admits only admins: `isAdmin(caller)` must hold. It lists only admin-surface tools; public Views and Procedures stay on `/mcp`, and generation never copies them across. An admin's AI that changes records also needs admin Views to find them and read back the committed row and its version (search, detail), declared by the app; the [acceptance design audit](../guides/agent-workflow-acceptance.md#audit-mcp-tool-design-before-implementation) checks this. Better Auth’s admin plugin `adminRoles` defines this layer (default `["admin"]`); the application supplies its verified Caller. MCP bodies are limited
237
237
  to 1 MiB (1,048,576 bytes). Resource metadata challenges use the origin of
238
238
  `request.url`; behind a proxy, the app must pass a Request with its public
239
239
  origin so the audience and metadata URL point to the client-visible service.
@@ -352,13 +352,17 @@ REST and MCP View pages expose declared JSON columns as JSON values and declared
352
352
  boolean columns as booleans, including explicitly authored SQL aliases. SQLite
353
353
  and D1 conversions are compiled into only the selected boundary pages;
354
354
  PostgreSQL already-native JSON/boolean values pass through. SQL NULL stays null and absent
355
- fields stay absent. Expressions without declared column lineage stay native.
355
+ fields stay absent. A computed output that is a JSON document by construction
356
+ (`json_group_array`, `json_set`, `jsonb_agg`, `jsonb_build_object` and the like, a scalar
357
+ subquery of one, or a `COALESCE` of those) is converted like a declared JSON column, so it
358
+ is a JSON value on every target. Other expressions without declared column lineage stay native.
356
359
  Malformed persisted JSON becomes a redacted internal failure. The conversion
357
360
  adds no database operations and does not mutate native query rows.
358
361
 
359
362
  Bare Views, Store rows, native SQL Procedure `{ results }` and application-owned
360
- ref outputs retain their native contracts. Use the generated `rest()` / `mcp()`
361
- module for this client wire contract; do not duplicate JSON/boolean decoding in
363
+ ref outputs retain their native contracts, except that a Store row's `version` is a
364
+ number on every target, so a ref handler returning it outputs the same type
365
+ everywhere. Use the generated `rest()` / `mcp()` module for this client wire contract; do not duplicate JSON/boolean decoding in
362
366
  application route handlers or decode PostgreSQL JSON strings a second time.
363
367
 
364
368
  Declared integer columns (including a selected system `version`) on every target
@@ -35,6 +35,18 @@ spec:
35
35
 
36
36
  The target names a declared Procedure. `http` sources use POST/PUT/PATCH/DELETE and an `/api/` path. Path parameters bind to corresponding declared input fields and override JSON-body values. The generated REST module retains the Procedure's authorization and validates boundary input.
37
37
 
38
+ A path parameter is a whole `{name}` segment. Other segments are literal and may not start with `:` or contain braces, since [describe](describe.md) writes `{name}` as `:name` (`TRIGGER_PATH_INVALID`). Two Triggers with the same method and the same path apart from parameter names are refused (`TRIGGER_PATH_COLLISION`).
39
+
40
+ ### Route precedence
41
+
42
+ When several http Triggers with the same method match a request, the generated router picks the most specific one, independent of manifest order:
43
+
44
+ 1. fewer `{name}` parameters first;
45
+ 2. then, comparing segments from the left, a literal segment before a parameter;
46
+ 3. then the path text.
47
+
48
+ So `POST /api/items/export` wins over `POST /api/items/{id}`, and for `/api/items/latest` the route `POST /api/items/{id}` wins over `POST /api/{kind}/latest`. A public View's `GET /api/views/<name>` route comes before every Trigger. Gateways resolve the same order from [`matchTarget`](describe.md#matching-a-request-to-its-target).
49
+
38
50
  An MCP source is `{ kind: mcp, surface: public }` or admin. The selected MCP module exposes the Procedure as a snake-case tool with its description, [tool hints](procedure.md#mcp-tool-hints) and requires checks.
39
51
 
40
52
  By default, pair every HTTP Trigger with an MCP Trigger so the same operation is reachable by the user's AI. `mantle generate` warns when a Procedure has HTTP exposure but no MCP exposure, suggesting `surface: admin` for an admin-only Procedure and `public` otherwise. Triggers still decide every Procedure surface: intentional asymmetric exposure is allowed and generation succeeds.
@@ -1,12 +1,14 @@
1
1
  # View
2
2
 
3
- A View requires `surface: public | admin | internal` and one `sql` SELECT. Optional keys include title, description, input, requires, restrict, uiSchema (admin only), and cache. The app imports its generated typed function and supplies a native handle, verified Caller and driver-ready input.
3
+ A View requires `surface: public | admin | internal` and one `sql` SELECT. Optional keys include title, description, input, requires, restrict, uiSchema (admin only), and cache. The app imports its generated typed function and supplies a native handle, verified Caller and driver-ready input; a JSON input (an array read by `json_each(input.ids)`) takes its authored array or object, which the generated query serializes.
4
4
 
5
5
  ## SQL
6
6
 
7
7
  PostgreSQL syntax is parsed and checked at generation. Reads may reference declared Schemas, CTEs and reusable internal Views; no auth/system tables or arbitrary external relations. The compiler adds scope, TTL, publication and translation-parent visibility at every relation. SQL policy is not injected at request time.
8
8
 
9
- SQLite supports the compiler's subset: scalar expressions, comparisons, AND/OR/NOT, CASE, COALESCE, supported casts, INNER/LEFT JOIN, subqueries, grouping/aggregates, supported windows, ordered LIMIT, date operations and declared Mantle search/geo functions. PostgreSQL additionally supports recursive CTEs, lateral relations, more window/aggregate forms, ILIKE and jsonb operators where the allowlist permits them. Unsupported SQL fails with a source position; generation is the authority for each target.
9
+ The SQL each dialect accepts, for Views and Procedures alike, is listed in [the SQL reference](sql.md); [SQL patterns](../guides/sql-patterns.md) shows the common ones. Unsupported SQL fails with a source position; generation is the authority for each target.
10
+
11
+ `*` and `t.*` expand to the Schema's declared fields only. Select `id`, `version`, `created_at` and `updated_at` by name when a client needs them (a write's `expectedVersion`, a detail link, keyset paging).
10
12
 
11
13
  OFFSET, arbitrary functions/relations, DDL and transaction control are not a read escape hatch. Use keyset paging. Select latitude/longitude explicitly instead of native geo projections.
12
14
 
@@ -16,6 +18,12 @@ OFFSET, arbitrary functions/relations, DDL and transaction control are not a rea
16
18
 
17
19
  ## Search and totals
18
20
 
21
+ `mantle.search(<alias>, <text>)` matches the Schema's declared `searchableFields`, and `mantle.search_rank(<alias>)`
22
+ orders by relevance; qualify both with `mantle.`. An optional search reads
23
+ `WHERE p.active AND (input.q IS NULL OR mantle.search(p, input.q))`, and a ranked one
24
+ `WHERE mantle.search(p, input.q) ORDER BY mantle.search_rank(p), p.id` (see `examples/reference-service` `catalog.yaml`
25
+ for cursor paging over the rank).
26
+
19
27
  Views using `mantle.search`, Store search and Admin list search share the dialect search: SQLite/D1 use FTS for queries of 3 or more characters (shorter queries use LIKE); PostgreSQL uses ILIKE over the declared fields and scans by default. A View does not make PostgreSQL search faster. For large PostgreSQL tables, the application's agent adds trigram indexes in a migration, which ILIKE then uses with no code change:
20
28
 
21
29
  ```sql
@@ -27,7 +35,7 @@ Store keeps `find`/`first` search and `count`. `count` scans every matching row;
27
35
 
28
36
  ## Results and paging
29
37
 
30
- Each SELECT output needs a unique static name. Unquoted SQL identifiers fold to lower case; use an explicit quoted alias to preserve case (`r.reqStatus AS "reqStatus"`). Generation warns when an unaliased direct declared field would use its lower-case storage name. Declared-column lineage supplies generated types; computed outputs remain native/unknown. SQL formats declared date-time/date outputs, while computed timestamps retain driver values. See [native execution](../guides/native-execution.md).
38
+ Each SELECT output needs a unique static name. Unquoted SQL identifiers fold to lower case; use an explicit quoted alias to preserve case (`r.reqStatus AS "reqStatus"`). Generation warns when an unaliased direct declared field would use its lower-case storage name. Declared-column lineage supplies generated types; computed outputs remain native/unknown, except a top-level output that is a JSON document by construction (a JSON aggregate or builder, a scalar subquery of one, or a `COALESCE` of those; see [SQL patterns](../guides/sql-patterns.md#a-row-with-its-children-in-one-read)), which is typed JSON. An output is non-null (`string`, not `string | null`) only when it reads unchanged a column its table declares NOT NULL (`id`, `version`, `created_at`, `updated_at`, a publishing `status`, or a [required](schema.md#fields-and-storage) field of an operational Schema) directly or through FROM subqueries; the right side of a LEFT JOIN, an aggregate, a column beside an aggregate without GROUP BY, a CTE and an expression stay nullable. SQL formats declared date-time/date outputs, while computed timestamps retain driver values. See [native execution](../guides/native-execution.md).
31
39
 
32
40
  Bare `views/<encoded-name>.ts` returns row arrays. Proved pageable exposed/internal Views also receive native `paged/views/*`, returning `{ rows, nextCursor? }`. Selected REST/MCP boundaries use separate `paged/wire/views/*` SQL when declared integers require exact transport projections; bare modules also export `WireRow`. Indexed ordering after fixed equality prefixes ([index prefixes](schema.md#index-prefixes-for-paged-views)), non-null typed keys, complete groups and singleton joins/lookups are checked at generation. Unsupported internal paging retains its bare View; exposure requires a supported page contract. Aggregates still scan/group their sources: inspect EXPLAIN.
33
41
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Every Mantle release line — what 0.2.0 changes, and what each stable 0.1.x release contained.
2
+ description: Every Mantle release line — what 0.2.x changes, and what each stable 0.1.x release contained.
3
3
  ---
4
4
  # Releases
5
5
 
@@ -15,15 +15,79 @@ From 0.2.0-alpha.7, Mantle publishes three packages together at one version:
15
15
  and `@aotter/mantle-admin` (optional Admin server, SPA and MCP App).
16
16
  Pin every package you use to the same exact version and upgrade them together.
17
17
 
18
- ## 0.2.0 — in preparation
19
-
20
- ### Upgrading to 0.2.0-alpha.9
18
+ ## 0.2.1 — in preparation
19
+
20
+ 0.2.1 adds Schema locks: a manifest rule that refuses a row's update or delete
21
+ for every caller path (REST, MCP, Admin and Procedures), with Admin showing the
22
+ lock before an edit. `spec.publishing.liveEdits` lets an application edit a
23
+ published row in place, and the [revisions guide](../guides/revisions.md) now
24
+ freezes the live revision with a lock and switches it atomically. It also brings
25
+ Admin form and refresh fixes, the media picker for custom inputs and pages
26
+ (`@aotter/mantle-admin/media`), `matchTarget` in `@aotter/mantle/describe`, and
27
+ dependency updates for current advisories.
28
+
29
+ Procedures gain `handler.steps`: a program written one statement per step, where
30
+ `onNoRows: conflict` makes a set-op write that writes nothing a `CONFLICT` and
31
+ rolls the whole program back ("create only if the parent exists"). Ref handlers
32
+ and interactive transactions can use `store.batch` for atomic multi-writes, and
33
+ `mantle bundle` under `wrangler dev` reports stale generated output in one line
34
+ instead of a failed build.
35
+
36
+ ### Upgrading to 0.2.1-alpha.1
37
+
38
+ Pin `@aotter/mantle`, and any selected `@aotter/mantle-ui` and
39
+ `@aotter/mantle-admin`, to `0.2.1-alpha.1`, refresh the lockfile, regenerate and
40
+ review owned output. No DDL changes are required. Breaking changes:
41
+
42
+ - **PostgreSQL `version`:** Store results return `version` as a number, as on
43
+ SQLite and D1. Procedure results stay driver-native.
44
+ - **Admin record form:** clearing an optional field sends `null`; generated
45
+ form types for optional fields are `T | null`.
46
+ - **REST route precedence** is fixed and most-specific-first. Only overlapping
47
+ same-method http Triggers with the same parameter count can change winner.
48
+ `TRIGGER_PATH_INVALID` and `TRIGGER_PATH_COLLISION` refuse ambiguous paths
49
+ at generation; see [route precedence](../reference/trigger.md#route-precedence).
50
+ - **Tooling:** the templates move to Wrangler 4.153.0 and
51
+ `@cloudflare/workers-types` 5.20261027.1.
52
+ - **PostgreSQL serialization failures:** SQLSTATE `40001` and `40P01` from a
53
+ definitive server refusal are now `CONFLICT` with `kind: "serialization"`
54
+ (409), instead of an unclassified driver error. Nothing is retried
55
+ automatically; read again before deciding to retry.
56
+ - **Revisions recipe:** an application that copied the guide's
57
+ `switch-revision` handler should adopt the new version. It requires
58
+ `expectedRevisionVersion` from the preview and seals and switches in one
59
+ `ctx.store.batch`.
60
+
61
+ ## 0.2.0 (2026-10-19)
62
+
63
+ The first stable native 0.2 release compiles closed v2 manifests to SQL and
64
+ TypeScript for Bun SQLite/PostgreSQL and Cloudflare D1/PostgreSQL. It ships
65
+ three matching packages: Core, the pure UI kit and the optional application-built
66
+ Admin. Applications own routing, verified identity, database handles, migrations
67
+ and deployment; selected REST, MCP, public pages, publishing, translations and
68
+ media compose around the generated operations.
69
+
70
+ This is a breaking upgrade from 0.1.x: follow the [0.1-to-0.2 migration
71
+ guide](../../upgrade-0.1-to-0.2.md), use a new 0.2 database and explicitly move
72
+ data. From alpha.11, pin the three selected packages to 0.2.0, regenerate and
73
+ review owned output; the stable version commit changes release metadata and
74
+ documentation rather than adding another runtime API.
75
+
76
+ [GitHub release](https://github.com/aotter/mantle/releases/tag/v0.2.0) is the
77
+ complete change history. Known follow-ups remain tracked: [Admin/authoring
78
+ refinements](https://github.com/aotter/mantle/issues/1752), [production social
79
+ and magic-link recipe verification](https://github.com/aotter/mantle/issues/1273)
80
+ and [real Sites guide verification](https://github.com/aotter/mantle/issues/1272).
81
+ Those provider-specific paths are not claimed as verified by local OTP or
82
+ native-host tests.
83
+
84
+ ### Upgrading to 0.2.0
21
85
 
22
86
  Pin every selected `@aotter/mantle`, `@aotter/mantle-ui` and
23
- `@aotter/mantle-admin` dependency to `0.2.0-alpha.9`, refresh the lockfile,
87
+ `@aotter/mantle-admin` dependency to `0.2.0`, refresh the lockfile,
24
88
  then regenerate and check the application's owned artifacts. Keep existing
25
89
  production migrations application-owned; generated initial DDL is for an empty
26
- database. This alpha includes breaking Admin authoring and paging changes.
90
+ database. This release includes breaking Admin authoring and paging changes.
27
91
 
28
92
  1. **Build output and host configuration.** `mantle bundle` writes server
29
93
  modules to `dist/worker` and static files to `dist/client`, including
@@ -112,7 +176,7 @@ Existing alpha.6 applications require a source migration:
112
176
  application code. Review [authorization and Caller wiring](../concepts/runtime-and-adapters.md)
113
177
  and regenerate all owned artifacts before running `generate --check`.
114
178
 
115
- The [reference Worker](https://github.com/aotter/mantle/tree/v0.2.0-alpha.9/docs/examples/reference-service) and
179
+ The [reference Worker](https://github.com/aotter/mantle/tree/v0.2.0/docs/examples/reference-service) and
116
180
  [app shell](../../examples/app-shell/) demonstrate the current composition.
117
181
  The following earlier-alpha notes are historical; the current handbook and
118
182
  the native migration above supersede their runtime and CLI instructions.