@aotter/mantle 0.0.11-alpha.73 → 0.1.0-alpha.10

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 (111) hide show
  1. package/README.md +107 -28
  2. package/dist/admin.d.ts +2 -0
  3. package/dist/admin.d.ts.map +1 -0
  4. package/dist/admin.js +2 -0
  5. package/dist/admin.js.map +1 -0
  6. package/dist/bun.d.ts +2 -0
  7. package/dist/bun.d.ts.map +1 -0
  8. package/dist/bun.js +2 -0
  9. package/dist/bun.js.map +1 -0
  10. package/dist/cli/create.d.ts +2 -0
  11. package/dist/cli/create.d.ts.map +1 -0
  12. package/dist/cli/create.js +243 -0
  13. package/dist/cli/create.js.map +1 -0
  14. package/dist/cli/generate.d.ts.map +1 -0
  15. package/dist/cli/generate.js +101 -0
  16. package/dist/cli/generate.js.map +1 -0
  17. package/dist/cli/harness.d.ts +3 -0
  18. package/dist/cli/harness.d.ts.map +1 -0
  19. package/dist/{harness-cli.js → cli/harness.js} +13 -8
  20. package/dist/cli/harness.js.map +1 -0
  21. package/dist/cli/main.d.ts +3 -0
  22. package/dist/cli/main.d.ts.map +1 -0
  23. package/dist/{cli.js → cli/main.js} +6 -8
  24. package/dist/cli/main.js.map +1 -0
  25. package/dist/cli/skills.d.ts +3 -0
  26. package/dist/cli/skills.d.ts.map +1 -0
  27. package/dist/{skills.js → cli/skills.js} +51 -6
  28. package/dist/cli/skills.js.map +1 -0
  29. package/dist/cli/update.d.ts.map +1 -0
  30. package/dist/{update.js → cli/update.js} +72 -46
  31. package/dist/cli/update.js.map +1 -0
  32. package/dist/codegen/emitMantleModule.d.ts +16 -0
  33. package/dist/codegen/emitMantleModule.d.ts.map +1 -0
  34. package/dist/codegen/emitMantleModule.js +217 -0
  35. package/dist/codegen/emitMantleModule.js.map +1 -0
  36. package/dist/codegen.d.ts +2 -0
  37. package/dist/codegen.d.ts.map +1 -0
  38. package/dist/codegen.js +2 -0
  39. package/dist/codegen.js.map +1 -0
  40. package/dist/provision/renderProvisionBundle.d.ts +70 -0
  41. package/dist/provision/renderProvisionBundle.d.ts.map +1 -0
  42. package/dist/provision/renderProvisionBundle.js +367 -0
  43. package/dist/provision/renderProvisionBundle.js.map +1 -0
  44. package/dist/provision.d.ts +2 -0
  45. package/dist/provision.d.ts.map +1 -0
  46. package/dist/provision.js +2 -0
  47. package/dist/provision.js.map +1 -0
  48. package/dist/vercel-libsql.d.ts +2 -0
  49. package/dist/vercel-libsql.d.ts.map +1 -0
  50. package/dist/vercel-libsql.js +2 -0
  51. package/dist/vercel-libsql.js.map +1 -0
  52. package/dist/vercel.d.ts +2 -0
  53. package/dist/vercel.d.ts.map +1 -0
  54. package/dist/vercel.js +2 -0
  55. package/dist/vercel.js.map +1 -0
  56. package/dist/web.d.ts +2 -0
  57. package/dist/web.d.ts.map +1 -0
  58. package/dist/web.js +2 -0
  59. package/dist/web.js.map +1 -0
  60. package/docs/adapter-guide.md +95 -51
  61. package/docs/adr/0001-four-atom-manifest-model.md +48 -307
  62. package/docs/adr/0002-closed-enums-for-bindings.md +6 -15
  63. package/docs/adr/0007-ai-as-primary-author.md +6 -4
  64. package/docs/adr/0008-structured-diagnostic-shape.md +2 -2
  65. package/docs/adr/0009-consumer-supplied-manifests.md +15 -9
  66. package/docs/adr/0010-locale-and-translates.md +13 -8
  67. package/docs/adr/0011-adapter-port-spec.md +39 -43
  68. package/docs/adr/0012-views-as-public-rest.md +6 -10
  69. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +124 -43
  70. package/docs/adr/0018-core-starters-repository-boundary.md +22 -1
  71. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +204 -0
  72. package/docs/adr/README.md +16 -14
  73. package/docs/api-mcp-authorization.md +78 -44
  74. package/docs/assets/mantle-admin-operations.png +0 -0
  75. package/docs/assets/mantle-hero.jpg +0 -0
  76. package/docs/auth-hosting-model.md +15 -11
  77. package/docs/cloudflare-low-level-composition.md +30 -18
  78. package/docs/deferred-lifecycle-queues.md +20 -41
  79. package/docs/design-atoms.md +159 -400
  80. package/docs/labels.md +5 -3
  81. package/docs/media-uploads.md +9 -2
  82. package/docs/migration-0.1.2.md +45 -0
  83. package/docs/performance-harness.md +23 -23
  84. package/docs/release-process.md +91 -28
  85. package/docs/schema-indexes.md +1 -1
  86. package/docs/sealed-pipeline-ownership.md +100 -0
  87. package/package.json +85 -15
  88. package/skills/README.md +39 -7
  89. package/skills/develop/SKILL.md +33 -19
  90. package/skills/install/SKILL.md +26 -25
  91. package/skills/media-gc/SKILL.md +85 -0
  92. package/skills/plugin/SKILL.md +2 -1
  93. package/skills/provision/SKILL.md +24 -2
  94. package/skills/theme/SKILL.md +11 -1
  95. package/skills/update/SKILL.md +1 -0
  96. package/dist/cli.d.ts +0 -3
  97. package/dist/cli.d.ts.map +0 -1
  98. package/dist/cli.js.map +0 -1
  99. package/dist/generate.d.ts.map +0 -1
  100. package/dist/generate.js +0 -181
  101. package/dist/generate.js.map +0 -1
  102. package/dist/harness-cli.d.ts +0 -3
  103. package/dist/harness-cli.d.ts.map +0 -1
  104. package/dist/harness-cli.js.map +0 -1
  105. package/dist/skills.d.ts +0 -2
  106. package/dist/skills.d.ts.map +0 -1
  107. package/dist/skills.js.map +0 -1
  108. package/dist/update.d.ts.map +0 -1
  109. package/dist/update.js.map +0 -1
  110. /package/dist/{generate.d.ts → cli/generate.d.ts} +0 -0
  111. /package/dist/{update.d.ts → cli/update.d.ts} +0 -0
@@ -3,14 +3,7 @@
3
3
  > If you're an AI working in a project that consumes `@aotter/mantle-*`,
4
4
  > read this first. It explains the entire surface area in one page.
5
5
  >
6
- > **Status**: v0.1 grammar lock. Atoms are shipped; rich sub-spec
7
- > grammar (policies, recursive views, temporal predicates, quotas,
8
- > projection triggers, cron/queue sources, and extended lifecycle hooks) is
9
- > reserved as **DRAFT** — see "Future grammar" appendix. Editorial
10
- > lifecycle is the one shipped grammar key whose runtime is **deferred
11
- > to v0.1.x**: parser and boot accept the shape, while
12
- > `request_publish` rejects it with `LIFECYCLE_NOT_IN_V010` until the approval
13
- > queue lands. Do not use editorial for a v0.1 publishing workflow.
6
+ > **Status**: v0.1 shipped grammar. Unknown keys and enum values are rejected.
14
7
  >
15
8
  > **This is the reference manual** — what the system is. For *why* it
16
9
  > ended up this shape (alternatives considered, trade-offs accepted),
@@ -32,8 +25,8 @@ primitives Postgres has shipped for 30 years.
32
25
  |---|---|---|---|
33
26
  | **`Schema`** | `CREATE TABLE` | no (manipulated via View / Procedure) | no |
34
27
  | **`View`** | `CREATE VIEW` | **yes** (auto-mounted on its declared public/staff REST and MCP surface; see ADR-0012) | no |
35
- | **`Procedure`** | `CREATE FUNCTION ... LANGUAGE plpgsql` | **no** (transport-agnostic; needs a `Trigger` to bind it) | **yes — handler ref to consumer's TS file** |
36
- | **`Trigger`** | `CREATE TRIGGER` + `pg_cron` + PostgREST route + `LISTEN/NOTIFY` | yes (the binding atom — turns Procedures into HTTP endpoints, cron jobs, MCP tools, lifecycle hooks) | no |
28
+ | **`Procedure`** | `CREATE FUNCTION ... LANGUAGE plpgsql` | **no** (transport-agnostic; needs a `Trigger` to bind it) | **yes — handler ref to the consumer's registry** |
29
+ | **`Trigger`** | `CREATE TRIGGER` + route/tool binding | yes (the binding atom — turns Procedures into HTTP endpoints, MCP tools, and lifecycle hooks) | no |
37
30
 
38
31
  The **read/write asymmetry is by design** and matches HTTP safe-vs-unsafe
39
32
  semantics. Reads (`View`) are idempotent and cacheable; the SDK
@@ -52,16 +45,14 @@ these four plus your own TypeScript.
52
45
  apiVersion: cms.mantle.aotter.net/v1
53
46
  kind: Schema | View | Procedure | Trigger
54
47
  metadata:
55
- name: posts # required, kebab-case, unique within deployment
56
- labels: { ... } # optional, free-form
48
+ name: posts # required, non-empty, unique within its kind
57
49
  spec:
58
50
  ... # kind-specific
59
51
  ```
60
52
 
61
- There is **no `namespace` field**. Resource names are unique within the
62
- deployment. K8s-style namespaces are a "premature scale" abstraction for
63
- our scope; SaaS multi-tenancy belongs in the consumer's app layer with a
64
- `tenant_id` column gated by RBAC, not in the SDK metadata layer.
53
+ There is **no manifest `namespace` field**. Resource names are unique within
54
+ their kind, so a Schema and View may share a name. SaaS multi-tenancy belongs
55
+ in the consumer's app layer, not in manifest metadata.
65
56
 
66
57
  ### Multi-doc YAML — keeping file count down
67
58
 
@@ -73,7 +64,7 @@ A logical feature commonly bundles a Procedure + a Trigger (and often a
73
64
  Schema and a View). Put related atoms in one file separated by `---`:
74
65
 
75
66
  ```yaml
76
- # manifests/contact.yaml
67
+ # manifests/contact.yml
77
68
  apiVersion: cms.mantle.aotter.net/v1
78
69
  kind: Procedure
79
70
  metadata: { name: send-contact-message }
@@ -93,8 +84,9 @@ spec:
93
84
  target: { procedure: send-contact-message }
94
85
  ```
95
86
 
96
- One file, two atoms. File count stays low; conceptual atom separation
97
- stays honest.
87
+ Use one file or split features across files. Mantle reads every immediate
88
+ `.yaml` and `.yml` file in `./manifests` by default; `---` separates multiple
89
+ documents in one file.
98
90
 
99
91
  ## What each atom is for (v0.1 minimum essential grammar)
100
92
 
@@ -112,7 +104,7 @@ metadata: { name: posts }
112
104
  spec:
113
105
  title: Posts # required: human-readable label for the admin UI
114
106
  localized: true # opt-in: row carries data.locale (ADR-0010)
115
- lifecycle: simple # default; 'none' is operational, 'editorial' is reserved
107
+ lifecycle: publishing # default; use 'operational' for live records
116
108
  schema:
117
109
  $schema: https://json-schema.org/draft/2020-12/schema
118
110
  type: object
@@ -126,7 +118,8 @@ spec:
126
118
  # against localized Schemas.
127
119
  content: { type: string }
128
120
  authorId: { type: string, format: uuid, x-mantle-bind: ctx.user }
129
- createdAt: { type: string, format: date-time, x-mantle-bind: now }
121
+ createdAt: { type: integer, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
122
+ searchableFields: [title, slug] # id is always searched
130
123
  uniqueIndexes: [[slug, locale]]
131
124
  indexes: [[locale, title]] # ordered, non-unique hot path
132
125
  ```
@@ -135,6 +128,33 @@ spec:
135
128
  in the user's primary language (the install-time chosen locale); the
136
129
  SPA shows this everywhere instead of `metadata.name`.
137
130
 
131
+ Schema titles and optional descriptions accept either a string or a locale
132
+ map such as `{ en: Products, "zh-TW": 商品 }`. Procedure titles and
133
+ descriptions and View titles use the same shape; all are optional.
134
+
135
+ **`spec.searchableFields`** — optional allowlist of top-level string
136
+ properties used by Admin and Staff MCP substring search. Entry id is always
137
+ searchable. This is separate from `indexes`; ordinary B-tree indexes do not
138
+ accelerate leading-wildcard substring search.
139
+
140
+ **`spec.uiSchema.list.filterField`** — optional Admin-only primary filter for
141
+ an operational collection. The field must declare a non-empty string `enum`
142
+ and be the first field of an `indexes` or `uniqueIndexes` tuple. Admin reuses
143
+ the enum for sidebar links and list tabs; Staff MCP uses declared Views for
144
+ richer query capabilities instead of reading UI configuration.
145
+
146
+ Operational list data is also explicit: `uiSchema.list.primaryField` names the
147
+ linked leading value and `uiSchema.list.columns` names the remaining scalar
148
+ columns. Without them Admin shows only platform metadata. Form-only choices use
149
+ `uiSchema.fields.<field>.widget: textarea`; rich content remains declared with
150
+ `x-mcp-hint: markdown|html|richtext`. Neither setting changes runtime or MCP
151
+ input validation.
152
+
153
+ A staff-operable Procedure may declare `uiSchema.collectionAction: orders` to
154
+ appear as an action in that collection's Admin header. The target must be an
155
+ existing Schema. This binding is Admin-only; the Procedure input, authorization,
156
+ runtime handler, and MCP exposure remain unchanged.
157
+
138
158
  **`spec.localized: bool`** (default `false`, ADR-0010) — opt-in per
139
159
  Schema. Localized Schemas store locale in `data.locale`; non-localized
140
160
  Schemas reject `data.locale` writes. Site config must declare the set
@@ -144,25 +164,22 @@ validates manifest shape.
144
164
 
145
165
  **`spec.translates: { parent, on }`** (ADR-0010) — declares this
146
166
  Schema as the translation companion to a non-localized parent, joined
147
- on the named field (typically `slug`). Implies `localized: true`. The
167
+ on the named field (typically `slug`). Requires an explicit
168
+ `localized: true`. The
148
169
  admin UI surfaces the child only as locale tabs in the parent's editor.
149
170
 
171
+ Use a standalone localized Schema only when each locale row is an
172
+ independent record. When several locale rows are versions of one entity,
173
+ declare a non-localized parent plus a localized `translates` child; that
174
+ relationship is what powers translation grouping and completeness in Admin.
175
+
150
176
  #### Lifecycle
151
177
 
152
- **`spec.lifecycle: 'simple' | 'editorial' | 'none'`** — controls the
178
+ **`spec.lifecycle: 'publishing' | 'operational'`** — controls the
153
179
  entry's state machine.
154
180
 
155
- - `simple` (default) — `draft → published → archived`. No approval
156
- queue. **This is the only content-workflow mode whose runtime ships
157
- in v0.1.0.**
158
- - `editorial` — the six-state machine with an approval queue
159
- (`draft → review → approved → scheduled → published → archived`,
160
- with `published` returnable to `draft` for republish). The grammar and
161
- state-machine vocabulary are reserved for forward compatibility, but the
162
- approval/request-publish runtime is on the v0.1.x roadmap. In v0.1,
163
- `request_publish` rejects with `LIFECYCLE_NOT_IN_V010`; do not declare
164
- editorial for a current publishing workflow.
165
- - `none` — **operational records**, not authored content: orders,
181
+ - `publishing` (default) — `draft → published → archived`. No approval queue.
182
+ - `operational` — records that are not authored content: orders,
166
183
  inventory snapshots, grant/audit rows — anything written by
167
184
  Procedures as a side effect rather than drafted by a person. No
168
185
  content workflow applies: entries are live (`published`) the moment
@@ -175,8 +192,15 @@ entry's state machine.
175
192
  `update_record_<schema>` for these Schemas instead of draft tools.
176
193
 
177
194
  The modes are **per-Schema and mix freely** within a site. There is no
178
- site-wide lifecycle setting; one Schema can be `simple` while another
179
- is `editorial` and a third is `none`.
195
+ site-wide lifecycle setting; one Schema can be `publishing` while another
196
+ is `operational`.
197
+
198
+ **Root `schema.readOnly: true`** — standard JSON Schema annotation for a
199
+ Procedure-managed collection. Admin and Staff MCP keep list/detail access and
200
+ declared row Procedures, but suppress and reject generic create, update,
201
+ status-change, and delete operations. Trusted Procedure handlers may still use
202
+ the runtime write use cases to maintain the projection. Put this on operational
203
+ mirrors and audit rows whose authority lives outside generic authoring.
180
204
 
181
205
  **Property-level extensions** (JSON Schema vendor keywords, all optional):
182
206
 
@@ -188,9 +212,9 @@ extensions. Three are part of `cms.mantle.aotter.net/v1`:
188
212
  > See ADR-0002 for rationale — why this is a closed enum and not an
189
213
  > open expression language.
190
214
 
191
- Marks a property as **server-controlled**. The SDK fills the value at
192
- write time; the caller MUST NOT supply it (callers who try to set a
193
- bound field get `INPUT_VALIDATION_FAILED`). Eliminates the entire
215
+ Marks a property as **server-controlled**. On create, the SDK overwrites any
216
+ caller value with the bound value; updates preserve the existing stamp. This
217
+ eliminates the entire
194
218
  class of handler-side `userId: ctx.user.id` boilerplate, and gives
195
219
  the dispatcher an authoritative "who/when" tag without trusting the
196
220
  wire.
@@ -201,20 +225,12 @@ wire.
201
225
  |---|---|---|
202
226
  | `ctx.user` | UUID of the signed-in end-user (from session cookie); `null` if anonymous | row ownership: `authorId`, `submittedBy`, `creatorId` |
203
227
  | `ctx.staff` | UUID of the staff member acting (when a staff session is active); `null` for end-user-only paths | audit trail: `approvedBy`, `moderatedBy`, `grantedBy` |
204
- | `now` | Server timestamp at write (ISO-8601 string with timezone) | `createdAt`, `submittedAt`, `grantedAt` |
228
+ | `now` | Server timestamp at write (Unix epoch milliseconds) | `createdAt`, `submittedAt`, `grantedAt` |
205
229
 
206
230
  **v0.1 stamping behavior**:
207
- - Stamped on `INSERT` only. Update-stamping (e.g. `updatedAt: now` that
208
- re-stamps on every UPDATE) is DRAFT — it lands when first use case
209
- surfaces.
210
- - Caller-supplied value: rejected at input validation. The Procedure's
211
- effective input schema strips bound fields before checking caller
212
- input, so callers can't accidentally send them.
231
+ - Stamped on `INSERT` only.
232
+ - Caller-supplied create value: ignored and replaced by the server stamp.
213
233
  - Visible in View output: yes, as ordinary columns. No special masking.
214
- - Used by `requires.auth.owns:` predicate (DRAFT): the predicate
215
- compares a row's `x-mantle-bind: ctx.user` field to the current
216
- `:ctx.user` to verify ownership without configuring per-Schema
217
- owner-column names.
218
234
 
219
235
  **Why a closed enum** (highest-leverage discipline in the spec): bind
220
236
  values are server-controlled identity + time facts. If we accepted
@@ -227,9 +243,9 @@ named bounds the semantic surface forever.
227
243
  properties:
228
244
  title: { type: string, minLength: 1, maxLength: 200 }
229
245
  authorId: { type: string, format: uuid, x-mantle-bind: ctx.user }
230
- createdAt: { type: string, format: date-time, x-mantle-bind: now }
246
+ createdAt: { type: integer, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
231
247
  # caller sends only: { title }
232
- # SDK stamps: { title, authorId: <session UUID>, createdAt: <now ISO> }
248
+ # SDK stamps: { title, authorId: <session UUID>, createdAt: <epoch ms> }
233
249
  ```
234
250
 
235
251
  #### `x-mantle-ref: <other-schema-name>` — cross-Schema reference
@@ -237,7 +253,17 @@ properties:
237
253
  Informational FK marker on a string-typed field that holds an ID
238
254
  referencing rows in another Schema. Not enforced in v0.1 (the SDK
239
255
  passes it through; no foreign-key constraint, no cascade, no orphan
240
- detection). Future grammar may upgrade it to enforced.
256
+ detection). The admin uses declared refs to show related rows and to nest
257
+ required child collections; it does not infer relationships from field names.
258
+ Declare a single-field `indexes` entry for the ref field (for example,
259
+ `indexes: [[authorId]]`) when reverse lookups must stay bounded. Mantle adds
260
+ the native entry-order columns to that access path.
261
+
262
+ On a Procedure input property, the Admin also uses `x-mantle-ref` to expose
263
+ that Procedure in the referenced collection row's three-dot menu. It locks
264
+ the referenced value to the selected row and infers the remaining form from
265
+ the Procedure input schema. The value comes from a same-named Schema property,
266
+ then a lone single-field unique index, and finally the entry `id`.
241
267
 
242
268
  **Example**:
243
269
  ```yaml
@@ -258,7 +284,9 @@ Descriptive hint for AI agents and admin UI widgets. The string is
258
284
  accepted as free-form for forward compatibility, but conventional
259
285
  values (`markdown`, `richtext`, `code`, `media`, `media-image`,
260
286
  `media-video`, `media-file`) tell consumers how to render or generate
261
- the field's value.
287
+ the field's value. `idempotency-key` asks the Admin to generate and hide a
288
+ stable UUID for one form invocation; other callers must generate one and reuse
289
+ it when retrying the same operation.
262
290
 
263
291
  **Examples** (from the publication/blog starter manifests):
264
292
  ```yaml
@@ -281,21 +309,16 @@ MCP tool schemas preserve the hint for agents. Admin surfaces expose
281
309
  media-shaped fields but first-party upload hosting is optional and not
282
310
  part of first-run provisioning.
283
311
 
284
- ---
285
-
286
- Anything beyond these three (policies, computed columns, RBAC grants,
287
- owner declarations, `x-mantle-bind` enum extension) is **DRAFT** — see
288
- appendix.
289
-
290
312
  **Postgres analogue**: `CREATE TABLE posts (id UUID PRIMARY KEY, ...,
291
313
  UNIQUE (slug, locale));`
292
314
 
293
315
  ### 2. `View` — the read surface (auto-exposed by surface)
294
316
 
295
- A named, declarative read over Schemas. No Trigger is required. A View with
296
- no `spec.surface` (or `surface: public`) mounts at
297
- `GET /api/views/<name>` and becomes `query_view_<name>` on `/mcp`.
298
- `surface: staff` instead mounts at `GET /admin/api/views/<name>` behind the
317
+ A named, read-only SQL query over Schemas. Every Schema is available to SQL as
318
+ a logical table named after `Schema.metadata.name`; storage internals stay
319
+ hidden. No Trigger is required. `surface` is required: `surface: public` mounts
320
+ at `GET /api/views/<name>` and becomes `query_view_<name>` on `/mcp`, while
321
+ `surface: staff` mounts at `GET /admin/api/views/<name>` behind the
299
322
  staff gate and appears only on `/mcp/staff`. See ADR-0012 for the full design
300
323
  rationale.
301
324
 
@@ -304,34 +327,35 @@ apiVersion: cms.mantle.aotter.net/v1
304
327
  kind: View
305
328
  metadata: { name: recent-published }
306
329
  spec:
307
- from: posts
308
- fields: [id, title, slug, locale, publishedAt, updatedAt]
309
- filter:
310
- eq: { field: status, value: published }
311
- orderBy:
312
- - { field: updatedAt, direction: desc }
330
+ surface: public
331
+ sql: |
332
+ SELECT id, title, slug, locale, publishedAt, updatedAt
333
+ FROM posts
334
+ WHERE status = 'published'
335
+ ORDER BY updatedAt DESC
313
336
  limit: 20
314
337
  ```
315
338
 
316
339
  **Param-driven Views** declare `spec.params` (a JSON Schema with
317
- `type: object`); filter values reference them via the
318
- `{ $param: <name> }` sentinel:
340
+ `type: object`); SQL references required properties as named `:params`. The
341
+ runtime validates and binds them; it never interpolates caller values:
319
342
 
320
343
  ```yaml
321
344
  apiVersion: cms.mantle.aotter.net/v1
322
345
  kind: View
323
346
  metadata: { name: posts-by-locale }
324
347
  spec:
325
- from: post-translations
348
+ surface: public
349
+ sql: |
350
+ SELECT id, slug, locale, title, updatedAt
351
+ FROM post-translations
352
+ WHERE status = 'published' AND locale = :locale
353
+ ORDER BY slug ASC
326
354
  params:
327
355
  type: object
328
356
  properties:
329
357
  locale: { type: string }
330
358
  required: [locale]
331
- filter:
332
- and:
333
- - eq: { field: status, value: published }
334
- - eq: { field: locale, value: { $param: locale } }
335
359
  limit: 100
336
360
  ```
337
361
 
@@ -341,6 +365,22 @@ names — `page` / `show` / `cursor` — must NOT appear in
341
365
  `spec.params.properties` (the parser rejects with
342
366
  `VIEW_PARAMS_RESERVED_NAME`).
343
367
 
368
+ Staff report lists may opt into the Admin's standard columns, substring
369
+ search, and exact filters without changing the public REST/MCP contract:
370
+
371
+ ```yaml
372
+ uiSchema:
373
+ list:
374
+ columns: [orderNumber, customerName, orderStatus]
375
+ searchFields: [orderNumber, customerName, customerEmail]
376
+ filterFields: [orderStatus]
377
+ ```
378
+
379
+ These names are SQL output aliases. The Admin applies search and filters
380
+ before pagination and carries them into
381
+ `GET /admin/api/views/<name>/export`; the CSV contains all matching rows,
382
+ not only the visible page.
383
+
344
384
  Views may declare the same `requires.auth.all` predicates and optional
345
385
  `requires.guard.procedure` as Procedures. Static auth runs before parameter
346
386
  validation; the guard receives validated params and authorizes the whole
@@ -356,13 +396,10 @@ Response envelope:
356
396
  `hasMore` is the lazy form: `rows.length === show` ⇒ `true`. No COUNT
357
397
  query, no `LIMIT n+1` probe.
358
398
 
359
- **v0.1 filter AST**: comparison operators (`eq`, `gt`, `gte`, `lt`,
360
- `lte`) plus `and` / `or`. Comparison `value` may be a literal or a
361
- `{ $param: <name> }` sentinel. v0.1.0 enforces required-only param refs;
362
- optional-with-skip semantics are reserved for v0.1.x. Field-to-field
363
- comparisons are not supported yet; compare a field to a literal or
364
- param. Anything else (`contains`, `recursive`, `gatedBy`,
365
- `join.aggregate`, `policies.skip`) is DRAFT.
399
+ The previous `from` / `fields` / `filter` / `orderBy` declarative form remains
400
+ accepted for existing manifests. New Views should use one `SELECT`; writes,
401
+ multiple statements, semicolons, and combining SQL with the legacy clauses are
402
+ rejected.
366
403
 
367
404
  **Postgres analogue**: `CREATE VIEW recent_published AS SELECT ...
368
405
  FROM posts WHERE status = 'published' ORDER BY updated_at DESC LIMIT
@@ -378,8 +415,8 @@ provides the handler function with auto-generated TS types.
378
415
  `Procedure` is **transport-agnostic and not directly exposed**. It does
379
416
  not contain HTTP paths, methods, or MCP tool names. To call a Procedure
380
417
  from outside the SDK runtime, declare a `Trigger` whose `target` points
381
- at it. The same Procedure can be bound by multiple Triggers (HTTP +
382
- cron + MCP + lifecycle, all sharing one handler).
418
+ at it. The same Procedure can be bound by multiple Triggers (HTTP + MCP +
419
+ lifecycle, all sharing one handler).
383
420
 
384
421
  ```yaml
385
422
  apiVersion: cms.mantle.aotter.net/v1
@@ -421,9 +458,6 @@ export const handlers = {
421
458
  - `ctx.auth.scope: <scope>` — verified credential carries the exact opaque,
422
459
  consumer-owned scope; repeat to require multiple scopes
423
460
 
424
- Anything beyond this (`any:`, `owns:`, `withinMinutes:`, `contains:`,
425
- `requires.window`, `requires.quota`, `errors`, `retry`) is DRAFT.
426
-
427
461
  Both Procedures and Views may add one dynamic guard beside `auth`:
428
462
 
429
463
  ```yaml
@@ -480,7 +514,7 @@ spec:
480
514
  ```
481
515
 
482
516
  The same Procedure can have multiple Triggers — that's how it becomes
483
- "an HTTP endpoint AND a cron job AND an MCP tool" without duplicating
517
+ an HTTP endpoint and an MCP tool without duplicating
484
518
  handler logic. Each transport is one Trigger; the Procedure body is
485
519
  shared.
486
520
 
@@ -491,17 +525,14 @@ tool on `surface: public | staff`), or `lifecycle` (entry-writer hook). For `lif
491
525
  Lifecycle hooks are wired through `LifecycleHookingEntryRepository`, so
492
526
  MCP, admin, and builtin write paths share the same hook behavior.
493
527
 
494
- - `cron` / `queue` are **DRAFT (v0.2+)** — speculative, gated by
495
- concrete consumer demand. Same appendix § "DRAFT (v0.2+)."
496
-
497
528
  The state-machine "lifecycle" from the Schema atom
498
- (`Schema.spec.lifecycle: simple | editorial`) is a separate domain
529
+ (`Schema.spec.lifecycle: publishing | operational`) is a separate domain
499
530
  that shares the word. The Schema setting governs which states an
500
531
  entry can be in; shipped lifecycle Triggers govern what fires around
501
532
  mutations.
502
533
 
503
534
  **Postgres analogue**: `CREATE TRIGGER ... AFTER INSERT ON posts
504
- EXECUTE FUNCTION ...` (lifecycle); `pg_cron` extension (cron); plus
535
+ EXECUTE FUNCTION ...` (lifecycle), plus
505
536
  `CREATE FUNCTION` exposed via PostgREST routes (http) — Postgres has
506
537
  had all of these via extensions for years, just split across multiple
507
538
  mechanisms. We unify them under one atom.
@@ -553,9 +584,8 @@ That split is intentional and load-bearing.
553
584
  draft-2020-12 JSON Schema documents. This is what AI authors and
554
585
  human authors write, what the admin UI feeds JSON Forms, and what
555
586
  the OpenAPI emitter relays unchanged.
556
- - **Runtime engine** = zod (v3). The `@aotter/mantle-spec`
557
- package ships a JSON-Schema → zod converter
558
- (`src/json-schema-zod.ts`); the runtime calls the converted zod
587
+ - **Runtime engine** = zod 4. The `@aotter/mantle-spec`
588
+ package ships a JSON-Schema → zod converter; the runtime calls the converted zod
559
589
  schema on every Procedure invocation, View parameter parse, and
560
590
  manifest boot check.
561
591
 
@@ -579,7 +609,7 @@ different constraints.
579
609
  | `UNAUTHENTICATED` | `401` | no active session (admin API: missing/expired session cookie) |
580
610
  | `AUTH_DENIED` | `403` | `requires.auth` predicate evaluated false (or admin: caller lacks required staff role) |
581
611
  | `ENTITLEMENT_REQUIRED` | `402` | consumer guard denies current payment/membership/transaction entitlement |
582
- | `NOT_FOUND` | `404` | resource not found — admin: approval id; runtime: View name at `/api/views/<name>` |
612
+ | `NOT_FOUND` | `404` | resource not found — entry id or View name at `/api/views/<name>` |
583
613
  | `HANDLER_NOT_REGISTERED` | `500` | `handler.ref` key not registered at boot |
584
614
  | `DISPATCHER_NOT_BUILT` | `501` | runtime feature not implemented in this SDK build |
585
615
  | `INTERNAL_ERROR` | `500` | uncaught handler exception |
@@ -590,10 +620,7 @@ may also fire in earlier loops (e.g. `HANDLER_NOT_REGISTERED`
590
620
  fires at boot — `phase: "boot"` — and that's where it should be
591
621
  caught; the runtime occurrence is defense-in-depth).
592
622
 
593
- Additional runtime codes activate as future grammar surfaces (e.g.
594
- `QUOTA_EXCEEDED → 429` arrives with `requires.quota`).
595
-
596
- ## RBAC — what v0.1 ships, what's DRAFT
623
+ ## RBAC
597
624
 
598
625
  v0.1 auth gates use `requires.auth.all` with `ctx.user`,
599
626
  `ctx.staff: [<roles>]`, `ctx.auth`, and `ctx.auth.scope` predicates on
@@ -601,16 +628,6 @@ Procedures and Views. This covers staff-only, logged-in-only,
601
628
  credential-protected, and delegated-scope targets. One Procedure-backed guard
602
629
  handles mutable consumer business state without widening the static grammar.
603
630
 
604
- **Not yet shipped** (DRAFT):
605
- - Row-level read visibility (private posts, friend-only audiences)
606
- - Field-level write policies (this user can update body but not
607
- parent_id)
608
- - Cross-Schema policy inheritance (gatedBy)
609
- - Quotas, edit-windows, rate limits
610
-
611
- These all live as `Schema.spec.policies.*` and `Procedure.spec.requires.*`
612
- sub-specs in the DRAFT spec. See "Future grammar" appendix.
613
-
614
631
  ## How to think when extending this CMS
615
632
 
616
633
  1. **What entities does the feature need?** → `Schema` for each.
@@ -619,13 +636,11 @@ sub-specs in the DRAFT spec. See "Future grammar" appendix.
619
636
  external read endpoints).
620
637
  3. **What operations?** → `Procedure` for each. One Procedure = one
621
638
  typed function call. Compose in handler code, not in YAML.
622
- 4. **What invokes them?** → `Trigger` per source. Multiple Triggers
623
- can target the same Procedure (HTTP + MCP + cron, all on one
624
- handler).
639
+ 4. **What invokes them?** → `Trigger` per declared HTTP, MCP, or lifecycle
640
+ source. Site-owned events can call the same Procedure binding directly.
625
641
  5. **Who's allowed?** → `Procedure/View.spec.requires.auth` for static
626
642
  identity/scope; optional `requires.guard.procedure` for one live,
627
- consumer-owned business check. Row-level and field-level rules remain
628
- future grammar.
643
+ consumer-owned business check.
629
644
 
630
645
  If you find yourself wanting a 5th kind, **stop**. Sketch the same
631
646
  thing as a composition of the four; almost always it works.
@@ -643,181 +658,20 @@ thing as a composition of the four; almost always it works.
643
658
  > fold into JSON Schema inside `Schema` rather than becoming new
644
659
  > kinds.
645
660
 
646
- Postgres exposes ~25 object kinds total; an application developer
647
- typically only writes 4–6 of them. The rest are engine internals that
648
- Cloudflare D1 / KV abstract away.
649
-
650
- ## Storage backend — D1 today, Postgres-via-Hyperdrive tomorrow
651
-
652
- > See the `mantle-cloudflare` README for rationale — why D1 for
653
- > v0.1 (no-CC OSS onboarding, platform-native), and the documented
654
- > PG path.
655
-
656
- ### How `Schema` data is stored on D1 today
657
-
658
- All collections share one `entries` table (defined by the
659
- Cloudflare adapter's storage migrations in
660
- `@aotter/mantle-cloudflare`):
661
-
662
- ```sql
663
- CREATE TABLE entries (
664
- id TEXT PRIMARY KEY,
665
- collection TEXT NOT NULL, -- discriminator: 'posts', 'comments', ...
666
- status TEXT NOT NULL, -- 'draft' / 'published' / ...
667
- version INTEGER NOT NULL,
668
- data TEXT NOT NULL, -- JSON blob: every Schema property (incl. locale, per ADR-0010)
669
- created_at INTEGER NOT NULL,
670
- updated_at INTEGER NOT NULL,
671
- author_id TEXT REFERENCES users(id)
672
- );
673
- ```
661
+ Postgres exposes ~25 object kinds total; an application developer typically
662
+ only writes 4–6 of them. The rest belong behind the selected storage adapter.
674
663
 
675
- Reserved metadata are native columns; **everything in your Schema's
676
- `spec.schema.properties` lives inside the `data` JSON blob**. Locale
677
- on localized Schemas lives at `data.locale` (per ADR-0010); for
678
- indexing it surfaces as a JSON-extracted virtual generated column
679
- (`json_extract(data, '$.locale')`) with a partial unique index, not as
680
- a top-level column.
681
-
682
- `uniqueIndexes` and ordered, non-unique `indexes` declarations compile
683
- into affinity-correct virtual generated columns plus partial B-tree
684
- indexes via the spec engine's DDL emitter (`@aotter/mantle-spec`).
685
- Different collections coexist on the same table without colliding
686
- because each generated-column expression is gated by `WHEN collection
687
- = '<name>'`. See [Schema indexes on D1](schema-indexes.md) for the
688
- grammar, leftmost-prefix rules, SQL helper, and query-plan examples.
689
-
690
- ### What works well on D1
691
-
692
- - D1's SQLite has the **JSON1 extension built in**: `json_extract`,
693
- `json_each`, `json_object`, `json_array`, `json_set`, `json_valid`,
694
- plus `->` / `->>` operators.
695
- - Reserved-column queries (`status = 'published'`, `ORDER BY
696
- updated_at`) use native indexes — fast.
697
- - `uniqueIndexes`- and `indexes`-declared paths use virtual columns +
698
- partial indexes. Core-compiled Views and repository lookups reference
699
- those columns instead of repeating `json_extract`.
700
-
701
- ### Where D1's JSON support has limits (vs Postgres)
702
-
703
- | Concern | D1 / SQLite | Postgres |
704
- |---|---|---|
705
- | JSON storage | TEXT — re-parsed on every `json_extract` | JSONB — stored as compact binary; faster repeated extraction |
706
- | Index any JSON path | must declare each path explicitly via virtual column + index | GIN index on JSONB indexes all paths automatically |
707
- | Array containment | `EXISTS (SELECT 1 FROM json_each(...) WHERE value = ?)` — full scan unless indexed | `data @> ARRAY[x]` with GIN — native operator |
708
- | Path operators | `->`, `->>` only | `->`, `->>`, `#>`, `#>>`, `@>`, `?`, `?|`, `?&` |
709
- | `DEFAULT now()` | not at column level — SDK-stamped via `x-mantle-bind: now` | native column default |
710
- | Foreign key on JSON path | not supported on `VIRTUAL` columns; needs `STORED` (extra space) | not natively, but B-tree on expression OK |
711
- | Row-level security | none — SDK-side filter rewriting | native RLS policies |
712
- | Multi-statement transactions | session API serializes; not true ACID across statements | full ACID transactions |
713
-
714
- ### Practical scale envelope on D1
715
-
716
- - **Blog-scale (< 10k entries / collection)**: today's design is
717
- comfortable. Public render hits KV cache, not D1.
718
- - **Mid-scale (10k – 100k entries)**: declare measured list/filter
719
- access paths with ordered `indexes`. Cross-collection JSON-path joins
720
- may eventually gain `x-mantle-ref` auto-lift; that remains DRAFT.
721
- - **Hard D1 limits**: 1 MB max row size; 5,000 rows per query result;
722
- single-writer per database (concurrent writes serialize).
723
- - **Cross-region read**: D1 is region-pinned; first hit from a
724
- far region is ~100–200 ms cold replica. Public reads should hit
725
- the SDK's KV render cache, not D1, so this rarely matters at the
726
- CMS layer.
727
-
728
- ### Scale-up path: D1 → Postgres via Cloudflare Hyperdrive
729
-
730
- Cloudflare does not run a first-party Postgres service. The supported
731
- path when D1 limits bind is **Cloudflare Hyperdrive** (a connection
732
- pooler + query-cache that lets a Worker connect to any external
733
- Postgres at near-edge latency):
734
-
735
- - **Neon** (serverless Postgres with scale-to-zero) — closest
736
- ergonomic match; tightest CF integration.
737
- - **Supabase** (PG + auth + storage) — works out of the box.
738
- - **AWS RDS / Google Cloud SQL / Azure DB / self-hosted PG** — also
739
- supported via Hyperdrive.
740
-
741
- When this path is taken (post-v0.1; not implemented yet), the
742
- **author-facing YAML stays unchanged**. The SDK's compile target swaps:
743
-
744
- | What changes (SDK runtime) | Author impact |
745
- |---|---|
746
- | `data TEXT` → `data JSONB` | none |
747
- | Virtual generated columns → optional (PG can use GIN) | author may stop declaring `indexes` if defaults suffice; the same YAML remains valid |
748
- | `json_each` → array operators (`@>`, `&&`) | none — predicates stay declarative |
749
- | SDK-stamped `now` → `DEFAULT now()` (optional) | none |
750
- | SDK-side policy rewriting → native RLS (optional) | none |
751
- | `Trigger.target.project` single-writer DO → real same-transaction trigger | atomicity guarantee strengthens; YAML grammar unchanged (the `atomicity:` declaration just stops needing the eventual-consistency caveat) |
752
-
753
- Migration shape: `entries` table dumped from D1, restored to PG. Stay
754
- single-table-with-discriminator (drop-in) or split into
755
- table-per-collection (more PG-idiomatic, longer migration). Both shapes
756
- remain valid `cms.mantle.aotter.net/v1` deployments.
757
-
758
- **v0.1 commitment**: D1 only via the Cloudflare adapter. Hyperdrive +
759
- PG path is documented as the upgrade route but not implemented. SDKs
760
- and starters target D1 exclusively. The Netlify adapter package
761
- (`@aotter/mantle-netlify`) is a README stub for v0.2; its
762
- existence is an engineering forcing function ensuring the runtime
763
- package stays portable across adapters.
764
-
765
- ## Roadmap — what's not in v0.1.0
766
-
767
- > See [ADR-0001 §"Future grammar discipline"](adr/0001-four-atom-manifest-model.md#future-grammar-discipline-was-poc-adr-0005)
768
- > for the v0.1 minimum-vs-roadmap discipline, the promotion process,
769
- > and the YAGNI argument that gates speculative shipping.
770
-
771
- The atoms are locked at 4. Their **inner grammar** is intentionally
772
- narrow at v0.1.0 and grows in two tiers:
773
-
774
- 1. **v0.1.0 shipped** — grammar parses and runtime behavior is wired
775
- in the current rebuild.
776
- 2. **v0.1.x committed** — on the patch-release roadmap. Spec is
777
- documented; implementation lands within the v0.1 series. The unsupported
778
- runtime path fails closed with a code naming the feature.
779
- 3. **DRAFT (v0.2+)** — speculative, gated by concrete consumer
780
- demand. Parser/static validation rejects with `DRAFT_KEY_USED`. May or may
781
- not ship — depends on whether real use cases apply pressure.
782
-
783
- ### v0.1.0 shipped
784
-
785
- #### `Trigger.source.kind: lifecycle` — bind a Procedure to a Schema event
664
+ ## Storage adapters
786
665
 
787
- Grammar lives in v0.1.0. Runtime is the
788
- `LifecycleHookingEntryRepository` decorator wrapping the entry-writer
789
- chokepoint so MCP / admin / builtin paths all fire the same hooks.
790
- Full shape lives further down.
666
+ Manifests compile to semantic storage ports; they do not select a database.
667
+ Use the shared SQLite adapter for SQLite-compatible hosts, or implement the
668
+ same ports over PostgreSQL, MongoDB, or existing application repositories.
669
+ See the [adapter guide](adapter-guide.md); SQLite index lowering is documented
670
+ separately in [Schema indexes](schema-indexes.md).
791
671
 
792
- #### `Procedure.handler.kind: builtin` — SDK-supplied CRUD Procedure
672
+ ## Detailed shipped grammar
793
673
 
794
- Grammar lives in v0.1.0. Runtime is the
795
- `InvokeBuiltinUseCase` that dispatches `op: create | update | upsert
796
- | delete | archive` against the entry-writer chokepoint with `x-mantle-bind`
797
- stamping and `input ∩ Schema.properties` projection. Full shape lives
798
- further down.
799
-
800
- #### `Trigger.source.kind: mcp` and shared authorization
801
-
802
- An MCP Trigger binds a declared Procedure to either the public or staff MCP
803
- surface. Procedures/Views share `ctx.auth`/scope predicates and optional guard
804
- orchestration across REST and MCP. Staff role is loaded live for each protected
805
- call; staff Views remain absent and un-callable on public MCP.
806
-
807
- ### Detailed shipped grammar and v0.1.x reservation
808
-
809
- > The `handler.kind: builtin` and `Trigger.source.kind: lifecycle`
810
- > sections below describe shipped v0.1.0 behavior. Only the
811
- > `Schema.spec.lifecycle: editorial` subsection remains v0.1.x-committed.
812
-
813
- #### `Schema.spec.lifecycle: editorial` runtime
814
-
815
- Grammar and boot already accept the key, but `request_publish` emits
816
- `LIFECYCLE_NOT_IN_V010` because the approval-queue runtime ships in v0.1.x.
817
- When that runtime lands, the same manifest can use the publish workflow without
818
- a grammar change.
819
-
820
- #### `handler.kind: builtin` — thin shortcut over the storage adapter for trivial CRUD-shaped Procedures
674
+ ### `handler.kind: builtin` — thin shortcut over the storage adapter for trivial CRUD-shaped Procedures
821
675
 
822
676
  Use when the body is "insert a row" / "update a row" / "delete a
823
677
  row"; reach for `ref` when there is real business logic. Shape:
@@ -834,11 +688,11 @@ spec:
834
688
 
835
689
  | op | Behavior |
836
690
  |---|---|
837
- | `create` | INSERT a new row. Project `input ∩ Schema.spec.schema.properties`; stamp `x-mantle-bind` fields; generated id; status is `draft`, or immediately `published` for `lifecycle: none`. |
691
+ | `create` | INSERT a new row. Project `input ∩ Schema.spec.schema.properties`; stamp `x-mantle-bind` fields; generated id; status is `draft`, or immediately `published` for `lifecycle: operational`. |
838
692
  | `update` | UPDATE in place. `input.id` + `input.expectedVersion` (OCC) required. Bumps version. |
839
693
  | `upsert` | If `input.id` resolves, behaves as `update`; else as `create`. |
840
694
  | `delete` | Hard DELETE by id. |
841
- | `archive` | Soft-archive (status='archived'). The manifest validator permits this builtin only for `editorial` Schemas; the archive transition itself is runtime-wired, while editorial approval/request-publish remains deferred. |
695
+ | `archive` | Soft-archive a publishing entry (`status='archived'`). |
842
696
 
843
697
  The Procedure's `input` is the contract with the *caller*. It MAY
844
698
  declare fields the Schema does not (e.g. a Turnstile token). The
@@ -848,11 +702,10 @@ the side-channel fields pass validation. To act on those fields
848
702
  (read the token, call the vendor), declare a `before_create`
849
703
  lifecycle Trigger — see below.
850
704
 
851
- `request_publish` and `publish` are intentionally not in the
852
- builtin vocabulary. They are editorial-workflow operations, not CRUD
853
- primitives.
705
+ `request_publish` and `publish` are intentionally not in the builtin
706
+ vocabulary. They are lifecycle operations, not CRUD primitives.
854
707
 
855
- #### `Trigger.source.kind: lifecycle` — bind a Procedure to a Schema event
708
+ ### `Trigger.source.kind: lifecycle` — bind a Procedure to a Schema event
856
709
 
857
710
  Shape:
858
711
 
@@ -874,12 +727,15 @@ spec:
874
727
  |---|---|
875
728
  | `before_create` | Before INSERT. Throw cancels. |
876
729
  | `after_create` | After INSERT. Default best-effort. |
877
- | `before_update` | Before UPDATE. Throw cancels. |
878
- | `after_update` | After UPDATE. Default best-effort. |
730
+ | `before_update` | Before UPDATE or a status transition whose target is not `published` (including unpublish and archive). Throw cancels. |
731
+ | `after_update` | After UPDATE or a status transition whose target is not `published` (including unpublish and archive). Default best-effort. |
879
732
  | `before_delete` | Before DELETE. Throw cancels. |
880
733
  | `after_delete` | After DELETE. Default best-effort. |
881
- | `before_publish` | Before any supported status transition to `published` (the shipped workflow is `simple`). |
882
- | `after_publish` | After any supported status transition to `published` (the shipped workflow is `simple`). |
734
+ | `before_publish` | Before any supported status transition to `published` (the shipped workflow is `publishing`). |
735
+ | `after_publish` | After any supported status transition to `published` (the shipped workflow is `publishing`). |
736
+
737
+ v0.1 has no separate unpublish or archive hooks. Do not treat
738
+ `before_update` / `after_update` as edit-only hooks.
883
739
 
884
740
  **Atomicity defaults by phase**:
885
741
  - `before_*`: `errorPolicy: abort`. Handler throw cancels the
@@ -910,9 +766,8 @@ post-mutation row for every `after_*`.
910
766
 
911
767
  **Hook ordering**: when multiple lifecycle Triggers bind the same
912
768
  `(schema, hook)`, the runtime fires them **alphabetically by
913
- `Trigger.metadata.name`**. A `priority: number` key is reserved for
914
- v0.2; today, choose names that sort correctly (`010-bot-check`,
915
- `020-rate-limit`).
769
+ `Trigger.metadata.name`**. Choose names that sort correctly
770
+ (`010-bot-check`, `020-rate-limit`).
916
771
 
917
772
  For deferred delivery, that ordered Trigger-name list is captured in
918
773
  one strict versioned event. Every captured Trigger runs before a
@@ -923,103 +778,7 @@ Cloudflare wiring, the 128 KB platform limit, retry/DLQ configuration,
923
778
  idempotency, and legacy-envelope draining are specified in
924
779
  [Deferred lifecycle hooks on Cloudflare Queues](deferred-lifecycle-queues.md).
925
780
 
926
- `before_publish` and `after_publish` already wrap the shipped simple publish
927
- transition. When editorial approval lands, the same hooks wrap its final
928
- transition to `published`; no second hook grammar is planned.
929
-
930
- ### DRAFT (v0.2+, speculative)
931
-
932
- Each item below lands when the first concrete real-world use case
933
- forces it, not on speculation. Today, do not implement; parser/static
934
- validation rejects it with `DRAFT_KEY_USED`.
935
-
936
- #### Schema future
937
- - **`x-mantle-ref` auto-lift to virtual column** — when a property
938
- carrying `x-mantle-ref: <other-schema>` is referenced in any registered
939
- View's `filter:` or future `join:`, the SDK auto-creates the virtual
940
- column + non-unique index without requiring an explicit `indexes`
941
- declaration. Solves cross-collection JSON-path join
942
- performance without making authors think about it. Pure SDK
943
- behavior; no new YAML grammar.
944
- - **`spec.policies.visible`** — row-level read predicate auto-AND'd
945
- into Views from this Schema. Lands with first private/audience
946
- feature.
947
- - **`spec.policies.readable.fields`** — field-level wire-mask. Lands
948
- when staff-only fields surface (e.g. `threadLength` visible to
949
- editors only).
950
- - **`spec.policies.writable.{fields, create}`** — field-level write
951
- gate auto-applied to all writers.
952
- - **`spec.policies.owner`** — names the ownership column for `owns:`
953
- predicate. Currently implicit (`authorId`).
954
- - **Computed columns via projection Trigger** — see `Trigger.target`.
955
-
956
- #### View future
957
- - **`recursive: { parent, rootWhen, pathBy, depthAs?, pathAs?, maxDepth }`**
958
- — declarative recursive CTE. Lands with first threaded-reads feature
959
- (comments, taxonomies).
960
- - **`params: { <name>: <jsonschema> }`** — caller-bound view parameters.
961
- - **`gatedBy: [{ schema, policy, on }]`** — cross-Schema visibility
962
- inheritance.
963
- - **`join: [{ as, from, on, project?, aggregate?: count|exists, bind? }]`**
964
- — joined relations with optional reducer (per-row count or boolean).
965
- - **`policies: { skip: [<policy-name> | field, ...] }`** — explicit
966
- bypass for admin/audit Views.
967
- - Filter AST extension: `contains` (array containment), `not`, `in`, `like`.
968
-
969
- #### Procedure future
970
- - **`requires.auth.any`** with disjunction; the shipped `all` predicate
971
- vocabulary extends to `owns: { schema, idFrom }`, `contains: {
972
- schema, idFrom, field, valueFrom }`.
973
- - **`requires.window.{withinMinutes, column?}`** — temporal
974
- precondition, sibling-modifier of `owns:`. DRY rule: window MUST
975
- sit beside exactly one `(schema, id)`-binding predicate in the same
976
- any/all group.
977
- - **`requires.quota.{key, limit, per, overrideFrom?}`** — declarative
978
- rate cap. Override Schema lookup keyed by `ctx.user`. Counter
979
- substrate: Durable Object.
980
- - **`errors.{onHandlerThrow, onRecursionDepth, maxDepth}`** + **`retry.{
981
- limit, backoff, retryOn, idempotencyKey?}`** — failure-policy
982
- declared. Retry + `op: create` requires `idempotencyKey:` (parse
983
- error otherwise).
984
-
985
- #### Trigger future
986
- - **`source.kind: cron`** with `expr:` — scheduled invocation.
987
- - **`source.kind: queue`** — async fan-out / message-driven invocation.
988
- - **`source.kind: lifecycle.foo`** — DRAFT extensions to the shipped
989
- lifecycle hooks (e.g. `before_archive`, `after_request_publish`).
990
- The 8 hooks listed in the detailed shipped section are the floor,
991
- not the ceiling.
992
-
993
- (Full lifecycle Trigger spec for the 8 shipped hooks lives
994
- in the detailed section above. The remaining DRAFT items
995
- below are the speculative v0.2+ shapes that haven't yet been promoted
996
- to a committed roadmap.)
997
-
998
- - **`target.project: { schema, where, set: { <col>: { aggregate, from,
999
- where } } }`** — declarative aggregate-projection across Schemas.
1000
- Replaces hand-written counter handlers. Same-transaction on PG;
1001
- bounded-eventual on D1 via single-writer DO.
1002
- - **`atomicity: same-transaction | best-effort`** — explicit guarantee
1003
- declaration when projection Triggers ship.
1004
-
1005
- #### Cross-cutting future
1006
- - **Closed `ctx.*` predicate identity**: v0.1 `{ user, staff, auth,
1007
- auth.scope }`
1008
- extends to `{ ..., system }` when SDK-internal Trigger executor
1009
- paths land. Multi-tenant deployments would add `{ tenant }` via
1010
- grammar-revise round if/when that product shape is pursued. New
1011
- entries require explicit grammar-revise (closed enum is
1012
- load-bearing infrastructure).
1013
- - **Placeholder namespace `$.*`**: `$.input.<f>`, `$.row.<f>`, `$.op`,
1014
- `$.params.<f>`, `$.source` — write-time data-flow bindings inside
1015
- policy ASTs and handler signatures. Distinct from `:ctx.*` identity
1016
- bindings; one-site evaluation rule.
1017
- - **`Schema.spec.staffBypass: [<role>]`** — DRY shortcut once
1018
- `ctx.staff: [editor, owner]` repetition crosses 4+ schemas.
1019
-
1020
- Each extension goes through a 3-agent review (yml-editor proposes /
1021
- code-impler tests buildability / fresh-dev verifies clarity) before
1022
- locking. v0.1 is the floor, not the ceiling.
781
+ `before_publish` and `after_publish` wrap the shipped publishing transition.
1023
782
 
1024
783
  ## Lineage — the academic foundations
1025
784
 
@@ -1032,6 +791,6 @@ locking. v0.1 is the floor, not the ceiling.
1032
791
 
1033
792
  The composite design — declarative resources + ECA-fired procedures +
1034
793
  policy-gated execution — is sometimes labeled **Active Database +
1035
- Policy-Based Management**. Postgres + PostgREST + pg_cron is the
794
+ Policy-Based Management**. Postgres + PostgREST is the
1036
795
  canonical full-stack reference; we abstract that pattern up to the
1037
796
  application layer with K8s-style YAML manifests.