@aotter/mantle 0.1.0-alpha.8 → 0.1.2-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 (104) hide show
  1. package/README.md +38 -38
  2. package/dist/cli/generate.d.ts +10 -1
  3. package/dist/cli/generate.d.ts.map +1 -1
  4. package/dist/cli/generate.js +49 -4
  5. package/dist/cli/generate.js.map +1 -1
  6. package/dist/cli/main.js +0 -8
  7. package/dist/cli/main.js.map +1 -1
  8. package/docs/adapter-guide.md +20 -24
  9. package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
  10. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
  11. package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
  12. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +37 -1
  13. package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
  14. package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
  15. package/docs/adr/0022-caller-observed-version-occ.md +212 -0
  16. package/docs/adr/0023-port-failure-contract.md +69 -0
  17. package/docs/adr/README.md +18 -3
  18. package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
  19. package/docs/adr/adr-lite-808-route-readiness.md +47 -0
  20. package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
  21. package/docs/adr/adr-lite-812-native-parity.md +149 -0
  22. package/docs/adr/adr-lite-823-home-statistics.md +63 -0
  23. package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
  24. package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
  25. package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
  26. package/docs/api-mcp-authorization.md +3 -664
  27. package/docs/auth-hosting-model.md +4 -4
  28. package/docs/cloudflare-low-level-composition.md +3 -104
  29. package/docs/deferred-lifecycle-queues.md +2 -243
  30. package/docs/design-atoms.md +16 -796
  31. package/docs/direct-authoring.md +5 -0
  32. package/docs/examples/minimal-worker/README.md +37 -0
  33. package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
  34. package/docs/examples/minimal-worker/package.json +26 -0
  35. package/docs/examples/minimal-worker/smoke.mjs +23 -0
  36. package/docs/examples/minimal-worker/src/index.ts +4 -0
  37. package/docs/examples/minimal-worker/tsconfig.json +17 -0
  38. package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
  39. package/docs/handbook/cloudflare/authentication.md +167 -0
  40. package/docs/handbook/cloudflare/bindings.md +233 -0
  41. package/docs/handbook/cloudflare/conventional-worker.md +162 -0
  42. package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
  43. package/docs/handbook/cloudflare/deploy-and-operate.md +98 -0
  44. package/docs/handbook/cloudflare/low-level-composition.md +169 -0
  45. package/docs/handbook/cloudflare/media-r2.md +216 -0
  46. package/docs/handbook/cloudflare/public-web.md +144 -0
  47. package/docs/handbook/concepts/authorization.md +116 -0
  48. package/docs/handbook/concepts/four-atoms.md +173 -0
  49. package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
  50. package/docs/handbook/concepts/mcp-and-agents.md +109 -0
  51. package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
  52. package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
  53. package/docs/handbook/concepts/views.md +183 -0
  54. package/docs/handbook/examples/commerce-transaction.md +813 -0
  55. package/docs/handbook/examples/guarded-api.md +427 -0
  56. package/docs/handbook/examples/intake-form.md +319 -0
  57. package/docs/handbook/examples/legal-documents.md +218 -0
  58. package/docs/handbook/examples/procurement-approvals.md +240 -0
  59. package/docs/handbook/examples/publication.md +240 -0
  60. package/docs/handbook/examples/reservation.md +220 -0
  61. package/docs/handbook/navigation.json +63 -0
  62. package/docs/handbook/reference/authorization.md +295 -0
  63. package/docs/handbook/reference/diagnostics.md +183 -0
  64. package/docs/handbook/reference/manifest.md +132 -0
  65. package/docs/handbook/reference/procedure.md +263 -0
  66. package/docs/handbook/reference/schema.md +247 -0
  67. package/docs/handbook/reference/site-config.md +233 -0
  68. package/docs/handbook/reference/surface.md +207 -0
  69. package/docs/handbook/reference/trigger.md +193 -0
  70. package/docs/handbook/reference/view.md +256 -0
  71. package/docs/handbook/start/project-and-cli.md +122 -0
  72. package/docs/handbook/start/quickstart-worker.md +193 -0
  73. package/docs/labels.md +3 -1
  74. package/docs/media-uploads.md +3 -184
  75. package/docs/migration-0.1.2.md +34 -1
  76. package/docs/performance-harness.md +125 -8
  77. package/docs/release-process.md +85 -231
  78. package/docs/schema-indexes.md +3 -180
  79. package/docs/sealed-pipeline-ownership.md +5 -4
  80. package/docs/spec-only-host-adoption.md +158 -0
  81. package/docs/transaction-patterns.md +5 -0
  82. package/package.json +21 -25
  83. package/skills/README.md +4 -4
  84. package/skills/develop/SKILL.md +23 -28
  85. package/skills/install/SKILL.md +55 -147
  86. package/skills/provision/SKILL.md +4 -3
  87. package/skills/theme/SKILL.md +6 -6
  88. package/skills/update/SKILL.md +32 -68
  89. package/dist/cli/create.d.ts +0 -2
  90. package/dist/cli/create.d.ts.map +0 -1
  91. package/dist/cli/create.js +0 -243
  92. package/dist/cli/create.js.map +0 -1
  93. package/dist/cli/update.d.ts +0 -2
  94. package/dist/cli/update.d.ts.map +0 -1
  95. package/dist/cli/update.js +0 -413
  96. package/dist/cli/update.js.map +0 -1
  97. package/dist/provision/renderProvisionBundle.d.ts +0 -70
  98. package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
  99. package/dist/provision/renderProvisionBundle.js +0 -367
  100. package/dist/provision/renderProvisionBundle.js.map +0 -1
  101. package/dist/provision.d.ts +0 -2
  102. package/dist/provision.d.ts.map +0 -1
  103. package/dist/provision.js +0 -2
  104. package/dist/provision.js.map +0 -1
@@ -0,0 +1,199 @@
1
+ # ADR-0020: Builtin Handler Contracts and Matched Upsert
2
+
3
+ **Status:** Accepted + amended by [ADR-0022](0022-caller-observed-version-occ.md)
4
+
5
+ **Date:** 2026-09-01
6
+
7
+ **Related:** [#765](https://github.com/aotter/mantle/issues/765),
8
+ [#766](https://github.com/aotter/mantle/issues/766),
9
+ [#850](https://github.com/aotter/mantle/issues/850),
10
+ ADR-0008, ADR-0010, ADR-0014, ADR-0019, ADR-0022
11
+
12
+ > **Amendment (ADR-0022):** matched-upsert OCC no longer forbids caller
13
+ > `expectedVersion` or substitutes `preloaded.version`. Keep this ADR for
14
+ > builtin static contracts, `handler.match` / uniqueIndexes, `CONFLICT`
15
+ > without auto-retry, and natural-key lookup without system `id`. Read
16
+ > ADR-0022 for caller-observed version on the update branch, create vs
17
+ > update, reserved wire names, and first-party Admin bind.
18
+
19
+ ## Context
20
+
21
+ Mantle Procedures can bind to standard entry-mutation operations using `handler.kind: builtin` (e.g. `create`, `update`, `delete`, `archive`, `upsert`).
22
+
23
+ Before this ADR:
24
+ 1. **No static contract validation**: Procedure input JSON Schema contracts were not statically verified against the requirements of their builtin operations during manifest linking (`ValidateManifestsUseCase`). Invalid contracts (e.g. `update` missing `id` or `expectedVersion`, nullable union types like `type: ["string", "null"]`, or `archive` on operational Schemas) were only caught at runtime via `requireField` or runtime assertions, causing valid-looking manifests to fail during invocation.
25
+ 2. **Missing natural key upsert (`handler.match`)**: Built-in `upsert` only supported legacy ID-based lookup (`id` + `expectedVersion`), requiring client knowledge of internal system IDs. Real-world domains require idempotent upserts matching natural keys (such as `slug`, `siteKey`, or composite unique keys like `[slug, locale]`).
26
+ 3. **Concurrency and race conditions**: Matched upsert must handle concurrent writers safely. When two concurrent writers miss preflight lookup, storage-level unique constraints must catch the conflict and surface structured `CONFLICT` diagnostics without retrying or corrupting entries.
27
+
28
+ ## Decision
29
+
30
+ ### 1. Static Contract Validation for Builtin Handlers
31
+
32
+ Manifest linking (`ManifestGraphValidator.ts`) now validates Procedure `input` schemas against the requirements of their builtin `op`:
33
+
34
+ - **Diagnostic Code**: `BUILTIN_HANDLER_CONTRACT_INVALID` (phase: `validate`).
35
+ - **Common rule**: `input` must be an `object` schema (`type: "object"`).
36
+ - **`update`**:
37
+ - `properties.id` must be strict, non-nullable `string` (`type: "string"`, not union array, not `nullable: true`).
38
+ - `properties.expectedVersion` must be strict, non-nullable `number` (`type: "number"`, not union array, not `nullable: true`).
39
+ - `required` must include both `"id"` and `"expectedVersion"`.
40
+ - **`delete`**:
41
+ - `properties.id` must be strict, non-nullable `string`.
42
+ - `required` must include `"id"`.
43
+ - **`archive`**:
44
+ - Target Schema must have `lifecycle: "publishing"` (operational Schemas have no lifecycle transitions and cannot be archived).
45
+ - `properties.id` must be strict, non-nullable `string`.
46
+ - `required` must include `"id"`.
47
+ - **`upsert` (legacy mode without `match`)** (ADR-0022):
48
+ - `properties.expectedVersion` must be declared as strict `number`.
49
+ - If `id` is declared it must be strict `string`. Neither is in `required`
50
+ because the create branch accepts new entries without IDs or versions.
51
+
52
+ ### 2. Matched Upsert Grammar and Static Contract (`handler.match`)
53
+
54
+ Procedures with `op: "upsert"` may declare `match`:
55
+
56
+ ```yaml
57
+ apiVersion: cms.mantle.aotter.net/v1
58
+ kind: Procedure
59
+ metadata:
60
+ name: upsertArticleBySlug
61
+ spec:
62
+ input:
63
+ type: object
64
+ properties:
65
+ slug:
66
+ type: string
67
+ title:
68
+ type: string
69
+ body:
70
+ type: string
71
+ expectedVersion:
72
+ type: number
73
+ required:
74
+ - slug
75
+ - title
76
+ output:
77
+ type: object
78
+ handler:
79
+ kind: builtin
80
+ op: upsert
81
+ schema: articles
82
+ match:
83
+ - slug
84
+ ```
85
+
86
+ **Grammar and parser validation rules (`ManifestParser.ts`)**:
87
+ - `match` is allowed in `spec.handler` only when `handler.kind === "builtin"` and `op === "upsert"`.
88
+ - `match` must be a non-empty array of unique, non-empty field names.
89
+
90
+ **Static manifest validation rules (`ManifestGraphValidator.ts`)**:
91
+ - `handler.match` must exactly match one declared unique index in the target Schema's `spec.uniqueIndexes` (matching length, field names, and order).
92
+ - Every field in `handler.match` must be declared in the target Schema's `spec.schema.properties`.
93
+ - Every field in `handler.match` must be declared in Procedure `input.properties` and listed in `input.required`.
94
+ - Procedure `input` must NOT declare `id` when using `match`.
95
+ - Procedure `input` **must** declare `expectedVersion` as strict `number`
96
+ (ADR-0022). It is not globally required: create has no version; update
97
+ requires the caller token at runtime. Do not overwrite that token with
98
+ `preloaded.version`.
99
+
100
+ ### 3. Runtime Semantics and Atomic Conflict Handling (`InvokeBuiltinUseCase.ts`)
101
+
102
+ When executing matched upsert:
103
+ 1. Extract matching field values from `input`.
104
+ 2. Query existing entry using `entries.findByDataFields({ collection, fields })`.
105
+ 3. **If found**: Execute update path (`opUpdate`):
106
+ - Merge input into existing data using `projectUpdateAndStamp`, preserving omitted fields and system bindings.
107
+ - Require the caller's `expectedVersion` and pass **that** token to the
108
+ atomic repository OCC check. Do not use `existing.version` as the token
109
+ (ADR-0022). Missing token → `INPUT_VALIDATION_FAILED` (do not overwrite).
110
+ - Pass caller's original input to lifecycle hooks (`before_update` / `after_update`).
111
+ 4. **If not found**: Execute create path (`opCreate`) only when the caller
112
+ omitted `expectedVersion`. A versioned write for a missing row is
113
+ `NOT_FOUND` (do not recreate).
114
+ - Project and stamp data using `projectAndStamp`.
115
+ - Set status to `"published"` (if `lifecycle: "operational"`) or `"draft"` (if `lifecycle: "publishing"`).
116
+ - Pass caller's original input to lifecycle hooks (`before_create` / `after_create`).
117
+ 5. **Concurrency & Race Conditions**:
118
+ - Both update and create operations are wrapped in `withConflictDiagnostic`.
119
+ - Storage-level unique constraint violations (e.g. SQLite `SQLITE_CONSTRAINT_UNIQUE`, Postgres `23505`) and version/status mismatches are converted into structured `CONFLICT` diagnostics (HTTP 409).
120
+ - The runtime does not automatically retry matched upsert on conflict, providing deterministic failure semantics under race conditions.
121
+
122
+ ## Consequences
123
+
124
+ - **Fail-fast authoring**: Schema and Procedure mismatches are detected during `mantle validate` or test time rather than failing unpredictably at runtime.
125
+ - **Strict type safety**: Nullable union types cannot bypass static contract validation.
126
+ - **Natural key idempotency**: Clients can author natural-key upsert procedures for ingestion and sync pipelines without managing internal mantle IDs.
127
+ - **Safe concurrency across all adapters**: Concurrent writes are guarded by storage-level unique constraints (SQLite/D1 unique indexes, IndexedDB readwrite transaction assertions) and translated to standard `CONFLICT` diagnostics via typed `EntryUniqueConflict`.
128
+
129
+ ## Alternatives considered
130
+
131
+ 1. **Automatic retry loops on conflict**: Considered having `InvokeBuiltinUseCase` automatically re-query and retry upon catching unique collisions. Rejected because repeated attempts could fire lifecycle hooks (`before_create` / `before_update`) multiple times with unintended side effects (e.g. duplicate webhook notifications or rate limits) and mask true contention. Failing fast with a structured `CONFLICT` diagnostic gives caller control.
132
+ 2. **Arbitrary field matching without Schema unique indexes**: Considered allowing `handler.match` on arbitrary Schema properties. Rejected because storage engines cannot enforce uniqueness without dedicated unique constraints/indexes, which would result in race conditions and duplicate entries under concurrent traffic.
133
+ 3. **Permissive nullable unions**: Considered allowing `type: ["string", "null"]` in Procedure input schemas for `id`. Rejected because runtime entry mutations strictly require non-null scalar identifiers; allowing nullable schemas would let schema-valid requests crash inside builtin handlers.
134
+
135
+ ## How to apply
136
+
137
+ 1. **Declare Schema Unique Indexes**:
138
+ ```yaml
139
+ apiVersion: cms.mantle.aotter.net/v1
140
+ kind: Schema
141
+ metadata:
142
+ name: site-settings
143
+ spec:
144
+ title: Site Settings
145
+ lifecycle: publishing
146
+ schema:
147
+ type: object
148
+ required: [siteKey, theme]
149
+ properties:
150
+ siteKey: { type: string }
151
+ theme: { type: string }
152
+ uniqueIndexes:
153
+ - [siteKey]
154
+ ```
155
+
156
+ 2. **Author a Matched Upsert Procedure**:
157
+ ```yaml
158
+ apiVersion: cms.mantle.aotter.net/v1
159
+ kind: Procedure
160
+ metadata:
161
+ name: setSiteSetting
162
+ spec:
163
+ input:
164
+ type: object
165
+ required: [siteKey, theme]
166
+ properties:
167
+ siteKey: { type: string }
168
+ theme: { type: string }
169
+ expectedVersion: { type: number }
170
+ output:
171
+ type: object
172
+ handler:
173
+ kind: builtin
174
+ op: upsert
175
+ schema: site-settings
176
+ match: [siteKey]
177
+ ```
178
+
179
+ 3. **Validate**:
180
+ Run `mantle validate` to verify input contracts against the declared unique indexes and schema properties.
181
+
182
+ ## Implementation status
183
+
184
+ - **`@aotter/mantle-spec`**:
185
+ - `ManifestGrammar.ts`: Added `match?: readonly string[]` to `HandlerBuiltinBinding`.
186
+ - `diagnostic.ts`: Added `BUILTIN_HANDLER_CONTRACT_INVALID` to `DIAGNOSTIC_CODES`.
187
+ - `ManifestParser.ts`: Parser-level validation for `handler.match`.
188
+ - `ManifestGraphValidator.ts`: Static input contract validation in `checkBuiltinHandler`.
189
+ - **`@aotter/mantle-runtime`**:
190
+ - `EntryRow.ts`: Added `EntryUniqueConflict` domain error.
191
+ - `EntryMutationDiagnostics.ts`: `withConflictDiagnostic` converts `EntryUniqueConflict` to `CONFLICT` diagnostic (HTTP 409).
192
+ - `DatabaseEntryRepository.ts`: SQLite/Postgres unique constraint violation translation to `EntryUniqueConflict`.
193
+ - `InvokeBuiltinUseCase.ts`: Matched upsert execution via `findByDataFields` and `projectUpdateAndStamp`.
194
+ - **`@aotter/mantle-indexeddb`**:
195
+ - `IndexedDbEntryRepository.ts`: Atomic transaction-level unique index enforcement and `EntryUniqueConflict` emission.
196
+ - **Documentation**:
197
+ - `docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md` (this document).
198
+ - `docs/adr/README.md`.
199
+ - `docs/design-atoms.md`.
@@ -0,0 +1,30 @@
1
+ # ADR-0021: Author applications directly; retire Starter scaffolding
2
+
3
+ Status: Accepted by owner, 2026-09-08 (#786). Supersedes ADR-0018 for the 0.1.2 line.
4
+
5
+ Core is an embeddable manifest engine. Applications own package manifests,
6
+ Worker entries, provider configuration, business manifests and visitor UI.
7
+ Core no longer creates those files from a Starter bundle. Remove `create` and
8
+ the Starter three-way `update` CLI, their bundle renderer/export, and the
9
+ cross-repository Starter/Landing release gates. Legacy consumers retain the
10
+ immutable 0.1.0-alpha.17 packages, tags and URLs.
11
+
12
+ `generate` remains an explicit compiler for existing manifests and optional
13
+ installed Admin UI assets. It must not initialize a missing project or invent
14
+ a default Schema, frontend or home route. Web remains optional runtime
15
+ composition, with consumer-provided templates and routes.
16
+
17
+ Version-matched install/update skills describe direct authoring and reviewed
18
+ SDK dependency upgrades. Existing project source, provider identity, secrets
19
+ and legacy metadata remain user-owned. No replacement init/template command,
20
+ new preset registry or Builder/Platform implementation belongs to this change.
21
+
22
+ A small Core-owned reference consumer proves generation and a running Worker
23
+ against exact packed packages and public registry candidates. It is a test and
24
+ worked example, not a downloadable scaffolder or another launch product.
25
+ Useful transaction Queue/DO patterns remain documented with immutable sources.
26
+
27
+ Release ownership stays in the existing Core controller: source and packed
28
+ consumer gates → immutable tag → verified registry candidates → public-registry
29
+ consumer gate → monotonic channels → GitHub release. No downstream repo writer
30
+ is required. Wrong public artifacts still require fixing forward; no retagging.
@@ -0,0 +1,212 @@
1
+ # ADR-0022: Caller-observed `expectedVersion` for Admin and builtin upsert
2
+
3
+ **Status:** Accepted
4
+
5
+ **Date:** 2026-09-14
6
+
7
+ **Related:** [#850](https://github.com/aotter/mantle/issues/850),
8
+ [ADR-0020](0020-builtin-handler-contracts-and-matched-upsert.md)
9
+
10
+ ## Context
11
+
12
+ ADR-0020 remains the authority for builtin static contracts, `handler.match`
13
+ matching a declared unique index, natural-key lookup without a system `id`,
14
+ and `CONFLICT` without automatic retry.
15
+
16
+ Its **matched-upsert OCC** rule is outdated. Matched upsert forbade
17
+ `expectedVersion` on the Procedure input and, at runtime, substituted the
18
+ preloaded row's `version` for the caller's token. ID-based upsert did the same
19
+ whenever a preloaded row was supplied. The repository still rejected races
20
+ *after* the handler read, but a change made since the **client** read the row
21
+ was overwritten.
22
+
23
+ Admin rendered `expectedVersion` as an editable number. Operators had to type
24
+ a technical version, or a handler that re-read at submit time quietly dropped
25
+ read-time protection. Downstream (mantle-home organization quota) reproduced
26
+ this.
27
+
28
+ The intended contract: callers POST the **observed** native `entry.version`
29
+ from read time. Mantle enforces that token atomically. A successful write
30
+ still bumps storage to `expectedVersion + 1`. The token is **not**
31
+ `version + 1`.
32
+
33
+ ## Decision
34
+
35
+ ### 1. Wire semantics
36
+
37
+ `expectedVersion` is the observed native `entry.version` at read time.
38
+
39
+ - Successful repository writes still persist `expectedVersion + 1`.
40
+ - Callers must not send the next version.
41
+ - Core does **not** auto-inject `expectedVersion` into Manifests. Authors
42
+ declare it. Omit → fail-closed `BUILTIN_HANDLER_CONTRACT_INVALID`.
43
+
44
+ ### 2. Closed reserved wire names
45
+
46
+ The reserved Procedure input wire name for OCC is `expectedVersion`. Business
47
+ fields must not collide with it. Schema `spec.schema.properties` must not
48
+ declare `expectedVersion`; validate fails closed (`INVALID_MANIFEST_ENVELOPE`)
49
+ so `projectUpdateAndStamp` cannot copy the OCC token into `data`. New reserved
50
+ wire names require an ADR. OCC behaviour must not depend on `x-mcp-hint`. The
51
+ magic word is the property name only.
52
+
53
+ ### 3. Static contracts (amends ADR-0020 §2 matched upsert)
54
+
55
+ Keep ADR-0020 for `update` (`id` + `expectedVersion` both strict and
56
+ required), `delete`/`archive` (`id` required), `handler.match` vs
57
+ `uniqueIndexes`, and forbidding `id` on matched upsert.
58
+
59
+ **Matched upsert (`op: upsert` + `match`):**
60
+
61
+ - `input.properties.expectedVersion` **must** be a strict, non-nullable
62
+ `number`.
63
+ - `expectedVersion` is **not** globally required: the create branch has no
64
+ version. Authors may list it in `required` only when the Procedure is
65
+ update-only.
66
+ - `input` must still **not** declare `id`.
67
+
68
+ **ID-based upsert (`op: upsert` without `match`):**
69
+
70
+ - `input.properties.expectedVersion` **must** be a strict `number` (fail-closed
71
+ if omitted). It is not in `required`.
72
+ - If `id` is declared, it must be a strict `string`. Neither `id` nor
73
+ `expectedVersion` is required, so create can omit both.
74
+
75
+ ### 4. Runtime (amends ADR-0020 §3)
76
+
77
+ Intent is the presence of a finite numeric `expectedVersion`, not the
78
+ presence of an id or a preload hit.
79
+
80
+ | Branch | Caller token | Existing row | Result |
81
+ |---|---|---|---|
82
+ | Create | absent | none | `create` (no version) |
83
+ | Create race | absent | unique collision after miss | `CONFLICT`; no silent overwrite |
84
+ | Update | present | found | atomic OCC with **caller** token; never `preloaded.version` |
85
+ | Update, stale | present | found, version ≠ token | `CONFLICT`; no retry |
86
+ | Update, missing | present | none (deleted / unknown) | `NOT_FOUND`; do **not** create |
87
+ | Create-intent vs existing | absent | found | `INPUT_VALIDATION_FAILED`; do **not** overwrite |
88
+
89
+ `opUpdate` always reads `expectedVersion` from caller input when a versioned
90
+ write is attempted. Preload supplies identity (`id`) and PATCH base data only.
91
+
92
+ There is still **no automatic retry**.
93
+
94
+ ### 5. First-party Admin / SDK bind (not grammar)
95
+
96
+ When Procedure `input` declares `expectedVersion`, first-party Admin (and any
97
+ first-party SDK helper) treats the name as magic:
98
+
99
+ - Auto-bind the OCC target row's current `version` captured at read.
100
+ - Hide the field from the editable form (same UX idea as
101
+ `x-mcp-hint: idempotency-key`, keyed by reserved **name**).
102
+ - A resolvable OCC target (row menu, `id` in the form, or
103
+ `targetCollection` binding) **must** be bound before submit is enabled,
104
+ including matched upsert where `expectedVersion` is declared but not
105
+ globally required (opening from a row is update-path intent).
106
+ - If `expectedVersion` is in `input.required` and no OCC target can be
107
+ resolved, submit stays disabled (do not invent a version).
108
+ - Collection create / no-row dialogs may omit the token when it is not
109
+ required (create branch).
110
+ - Rebind when the selected target changes. An organization row must not
111
+ supply `expectedVersion` for a membership mutation.
112
+ - On `CONFLICT` / HTTP 409: keep operator business inputs; require an
113
+ explicit re-read; **no** auto-retry with the latest version.
114
+ - Background refetches of the same target must not replace a captured
115
+ token while the form still holds older values.
116
+
117
+ Other callers (HTTP Trigger, MCP, custom clients) supply the observed
118
+ version themselves. Magic bind/hide is first-party only.
119
+
120
+ OCC target for Admin bind:
121
+
122
+ 1. If `input` declares `id`, the target is `form.id` (the membership / row
123
+ being mutated), never a merely contextual parent ref.
124
+ 2. Else if the Procedure is builtin, prefer the `rowBindings` entry whose
125
+ `collection` equals `handler.schema`.
126
+ 3. Else if another row binding (not the launch collection) identifies an
127
+ entry id, use that once filled.
128
+ 4. Else use the launch row (quota-style actions bound only to that row).
129
+
130
+ A not-yet-existing target has no version: omit `expectedVersion` so upsert
131
+ takes the create branch.
132
+
133
+ ### 6. Compatibility
134
+
135
+ This is a **breaking** change for:
136
+
137
+ - Matched-upsert Manifests that omitted `expectedVersion` (now fail
138
+ validate).
139
+ - Callers that updated via matched or ID-based upsert without sending an
140
+ observed version (preload OCC). Those writes now fail closed instead of
141
+ overwriting with the version read inside the handler.
142
+ - Matched-upsert Manifests that declared `expectedVersion` (previously
143
+ rejected; now required as a property).
144
+
145
+ Callers that already sent the observed version on `op: update` are
146
+ unchanged. HTTP and MCP share the Procedure input contract; only Admin/SDK
147
+ auto-fill the reserved name.
148
+
149
+ ## Consequences
150
+
151
+ - Read-time OCC works through Admin forms, HTTP Triggers, and MCP for both
152
+ `update` and version-checked upsert.
153
+ - Create remains possible without a version; a versioned write cannot
154
+ silently recreate a deleted row.
155
+ - AI authors must declare `expectedVersion` as a strict number on OCC
156
+ builtins. Tool descriptions state **observed** version, not `+1`.
157
+
158
+ ## Alternatives considered
159
+
160
+ 1. **`x-mcp-hint: entry-version`.** Rejected. OCC must not depend on hints;
161
+ the reserved property name is the contract.
162
+ 2. **Keep preload OCC for matched upsert.** Rejected. Accepting
163
+ `expectedVersion` and ignoring it is worse than forbidding it.
164
+ 3. **Require `expectedVersion` in `input.required` for every upsert.**
165
+ Rejected: that makes create impossible at validate time.
166
+ 4. **Auto-retry upsert on `CONFLICT`.** Rejected in ADR-0020; unchanged.
167
+ 5. **Auto-inject the property into Manifests.** Rejected: fail-closed
168
+ grammar; authors (often agents) must see the field.
169
+
170
+ ## How to apply
171
+
172
+ ```yaml
173
+ apiVersion: cms.mantle.aotter.net/v1
174
+ kind: Procedure
175
+ metadata:
176
+ name: upsert-membership
177
+ spec:
178
+ input:
179
+ type: object
180
+ required: [organizationId, userId, role]
181
+ properties:
182
+ organizationId: { type: string, x-mantle-ref: organizations }
183
+ userId: { type: string }
184
+ role: { type: string }
185
+ expectedVersion: { type: number }
186
+ output:
187
+ type: object
188
+ handler:
189
+ kind: builtin
190
+ op: upsert
191
+ schema: organization-members
192
+ match: [organizationId, userId]
193
+ ```
194
+
195
+ - Create: `{ organizationId, userId, role }` — no version.
196
+ - Update: the same fields plus `expectedVersion` equal to the membership
197
+ row's current `version`.
198
+ - Stale token → `CONFLICT` (409). Deleted membership + token → `NOT_FOUND`.
199
+
200
+ ## Implementation status
201
+
202
+ - `@aotter/mantle-spec`: `ManifestGraphValidator` / parser fail-closed
203
+ `expectedVersion` on upsert; Schema `spec.schema.properties` must not
204
+ declare reserved Procedure input names; `EXPECTED_VERSION_PROPERTY`
205
+ reserved name.
206
+ - `@aotter/mantle-runtime`: `InvokeBuiltinUseCase` uses the caller token;
207
+ MCP catalog copy states observed version.
208
+ - `@aotter/mantle-admin` / `@aotter/mantle-admin-ui`: operations expose
209
+ builtin `targetCollection`; Admin binds and hides `expectedVersion`,
210
+ and does not enable submit on a resolvable OCC target until the
211
+ observed version is captured.
212
+ - Docs: this ADR, ADR-0020 amendment pointer, design-atoms, handbook.
@@ -0,0 +1,69 @@
1
+ # ADR-0023: Storage and service failure contracts
2
+
3
+ Status: Accepted for alpha.17 under #857 (amends ADR-0008 and media deletion behavior).
4
+
5
+ This is the diagnostic grammar-revise record: add RESOURCE_EXHAUSTED (507),
6
+ RESOURCE_UNAVAILABLE (503), RATE_LIMITED (429), OUTCOME_UNKNOWN (503),
7
+ PARTIAL_FAILURE (503), PRECONDITION_FAILED (412), and optional safe `failure`
8
+ facts to Diagnostic. No Manifest keys, new ports, or parallel wire format.
9
+ Existing codes and envelopes remain compatible. DiagnosticError accepts internal
10
+ ErrorOptions.cause; causes never enter Diagnostic. Unexpected exceptions must not
11
+ be copied into public messages. Core owns no tenant, plan or provider quota policy.
12
+
13
+ ## Contract
14
+
15
+ A port rejects with DiagnosticError for recognized failures. The diagnostic
16
+ contains stable code, safe message and optional failure facts: outcome is
17
+ not-applied/partial/unknown; retry is never/after-change/safe/reconcile; resource
18
+ is an optional non-sensitive logical resource name, not credentials or SQL.
19
+ A provider adapter or host policy owns classification. Unknown errors stay
20
+ unexpected; never guess quota from arbitrary exception prose. Existing specific
21
+ validation, not-found and atomic OCC diagnostics remain authoritative.
22
+
23
+ `safe` requires idempotence of the same operation against the same resource.
24
+ It does not request automatic retry. Timeouts after write/send default to unknown
25
+ outcome and reconciliation, not unconditional retry. Do not downgrade unknown
26
+ outcome to not-applied merely because no reply arrived. Not-found on an
27
+ idempotent object delete is success. Read absence may be null per the port.
28
+
29
+ ## Operation matrix (public contracts)
30
+
31
+ | Contract | Success | Failure / effect / retry |
32
+ | --- | --- | --- |
33
+ | EntryReader / ViewQueryExecutor | documented rows/null/page | denied/unavailable/rate limit; reads have no business mutation; explicit retry after transient failure |
34
+ | EntryRepository | semantic mutation with atomic expectedVersion | existing NOT_FOUND/CONFLICT; host capacity/denial; unknown write requires readback by stable id/version, never latest-version substitution |
35
+ | SiteConfigRepository | validated settings/locale | reads as above; writes same conditional and uncertain-effect rules as entries |
36
+ | MediaAssetRepository / PendingUploadRepository | metadata persisted/deleted | capacity/availability/unknown write; reconcile by asset/upload id; removal of missing metadata is idempotent where specified |
37
+ | MantleStorageAdapter.prepare / migrations | ready prepared storage | invalid plan/readiness; completed migration steps may persist; journal/readback before retry, no cross-provider rollback promise |
38
+ | DatabaseDriver | driver rows/batch result | constraint/precondition or recognized provider failure; transaction guarantees remain driver-specific; ambiguous writes require readback, SQL is never public |
39
+ | MediaStorage.createUpload | authorized capabilities, not uploaded bytes | policy/size/denied/unavailable; adapter must state any reservations created; no general single-use guarantee |
40
+ | MediaStorage.commitUpload | verified asset, then use case stores metadata | missing/type/size/precondition or partial/unknown provider effect; reconcile same upload group and objects before retry |
41
+ | MediaStorage.deleteObject | object absent | idempotent same-key retry; unavailable/unknown; use case retains metadata if any variant fails and returns PARTIAL_FAILURE |
42
+ | EmailSender | provider accepted, not guaranteed recipient delivery | known rejection not-applied; lost acknowledgement unknown/reconcile; do not resend automatically without provider idempotency/readback |
43
+ | DeferredHookDispatcher | accepted for at-least-once delivery | ambiguous enqueue may duplicate; existing fallback preserves eventId + trigger idempotency identity; consumers must deduplicate |
44
+ | HandlerRegistry / custom handler | application output | DiagnosticError/InvokeFailure preserved; unknown exceptions internal only; handler owns side-effect semantics |
45
+ | Clock / IdGenerator | timestamp / unpredictable unique id | unexpected environmental/programming errors; never silently substitute insecure ids |
46
+ | selected Auth contract (outside Runtime) | authentication/session/admin operation | preserve supported validation/denial; provider/email/storage failure handled at Auth adapter boundary; no assumption that Procedure catches Auth errors |
47
+
48
+ HTTP and Admin use the existing central mapping; MCP retains the diagnostic
49
+ code/failure facts rather than deriving application recovery from HTTP status.
50
+ Admin OCC recovery applies to structured CONFLICT only, not all 409 responses.
51
+ Unknown provider bodies, SQL and causes remain internal. Host-supplied messages
52
+ and resource labels are explicitly public and must not include sensitive data.
53
+
54
+ ## Media partial deletion change
55
+
56
+ Previously object errors were logged and the asset row removed. Now every
57
+ variant deletion is attempted; any failure retains the row and returns partial
58
+ failure. A retry deletes the same keys (including already absent objects), then
59
+ removes metadata. If metadata deletion itself has an uncertain result, reconcile
60
+ by asset id; no new object identities are created. No cross-store transaction is
61
+ implied. Remaining metadata may reference already removed bytes during recovery.
62
+
63
+ ## Compatibility / verification
64
+
65
+ New failure fields are additive; old consumers still have code/message. Media
66
+ partial-delete clients now receive failure rather than misleading success.
67
+ Focused tests cover diagnostic serialization, transport redaction, host rejection
68
+ and retry of partial deletion. Concrete adapter conformance must preserve
69
+ provider constraints and no automatic retry of uncertain writes/sends.
@@ -18,8 +18,11 @@ Records of *why* mantle ended up shaped this way. The numbering preserves POC AD
18
18
  | [0014](0014-auth-better-auth-and-multi-tenant-mcp.md) | The Cloudflare adapter owns one curated Better Auth 1.7 identity/OAuth/MCP authority with CIMD discovery. Verified callers are normalized into runtime context; mutable staff role and target authorization are re-evaluated per call. | Accepted + amended |
19
19
  | [0016](0016-site-semantic-layer.md) | Site semantic layer: `AGENTS.md` (cross-tool entry) + `.mantle/launch-state.json` (deterministic install context). The older `mantle/site.md` letter surface is suspended from first-run scaffolds. | Accepted (slimmed) |
20
20
  | [0017](0017-media-multi-variant-agent-side-optimization.md) | Multi-variant media assets with agent-side optimization and asset-id entry references. | Accepted |
21
- | [0018](0018-core-starters-repository-boundary.md) | Core produces published SDK artifacts; the separate starters repository validates them as an external consumer. Revisit after release-contract simplification. | Accepted for now |
21
+ | [0018](0018-core-starters-repository-boundary.md) | Core produces published SDK artifacts; the separate starters repository validates them as an external consumer. Revisit after release-contract simplification. | Superseded by 0021 |
22
22
  | [0019](0019-sealed-manifest-runtime-pipeline.md) | One sealed source-to-runtime pipeline, semantic storage seam, and optional Web/Admin/platform dependency direction. | Accepted |
23
+ | [0020](0020-builtin-handler-contracts-and-matched-upsert.md) | Static builtin handler contracts and natural-key matched upsert (`handler.match`). | Accepted + amended by 0022 |
24
+ | [0021](0021-retire-starter-scaffolding.md) | Direct authoring; retire Starter scaffolding. | Accepted; supersedes 0018 |
25
+ | [0022](0022-caller-observed-version-occ.md) | Caller-observed `expectedVersion` for Admin bind and builtin upsert OCC. | Accepted |
23
26
 
24
27
  ## Reading order
25
28
 
@@ -31,7 +34,7 @@ If you're new to the codebase:
31
34
  4. **0007** — what running the SDK feels like as an AI author (and as the operator agent).
32
35
  5. **0011** — the boundary between the runtime and the adapter.
33
36
  6. **0010** — how locale flows through the system.
34
- 7. **0013** — historical install-session context; current first launch is landing provision bundles plus repo-local handoff.
37
+ 7. **0021** — current direct-authoring boundary; **0013** is historical install-session context.
35
38
  8. **0002, 0008** — the two ADRs that touch every diagnostic and every binding.
36
39
 
37
40
  ## What's NOT here (and why)
@@ -54,8 +57,20 @@ The rebuild's ADR-0011 (new) is the most load-bearing addition — the POC accum
54
57
 
55
58
  ## Contributing a new ADR
56
59
 
57
- 1. Pick the next number (currently 0020).
60
+ 1. Pick the next unused number after the highest existing ADR.
58
61
  2. File: `docs/adr/<NNNN>-<kebab-title>.md`.
59
62
  3. Sections: Status, Date, Context, Decision, Consequences, Alternatives, How to apply, Implementation status.
60
63
  4. Link from this README's table.
61
64
  5. Land it in a PR alongside (or before) the implementation it documents — ADR-as-design-artifact, not ADR-as-archaeology.
65
+
66
+ ## ADR-lite records
67
+
68
+ These retain their issue-based filenames and original decision status.
69
+
70
+ | Record | Status |
71
+ |---|---|
72
+ | [#803 Request diagnostics](adr-lite-803-request-diagnostics.md) | Implemented |
73
+ | [#808 Route readiness](adr-lite-808-route-readiness.md) | Accepted |
74
+ | [#809 Bounded public content](adr-lite-809-bounded-public-content.md) | Implemented |
75
+ | [#812 Native parity](adr-lite-812-native-parity.md) | Implemented; deployment evidence linked in record |
76
+ | [#823 Home statistics](adr-lite-823-home-statistics.md) | Proposed; unmerged demo |
@@ -0,0 +1,38 @@
1
+ # ADR-lite: request-scoped Cloudflare diagnostics
2
+
3
+ Status: implemented diagnostic record for #803; real-auth controls and measured
4
+ comparison acceptance belong to the #812 harness. This does not close the
5
+ separate starter provisioning/Smart Placement portion of #803.
6
+
7
+ Use native AsyncLocalStorage and a small versioned testing entry point instead of
8
+ a global current-request counter or a configurable production telemetry system.
9
+ Production mounts only check for an existing test context. With none, there are
10
+ no diagnostic clock reads, binding wrappers, records, observers or response headers.
11
+ Runtime, auth, role, catalog, dispatcher-build and dispatch retain their ownership
12
+ and authorization order. The ordinary runtime-ready promise keeps its identity.
13
+
14
+ Native D1 instrumentation sits beneath both Auth and Runtime and is idempotent.
15
+ It preserves receivers, bind chains, first-column behavior and native objects in
16
+ batches; no second driver observer is added for counting. Provider failures and
17
+ unknown metadata remain visible. Request context is captured when an operation
18
+ starts, so shared work belongs to its initiator. Catalog waiters inherit source
19
+ classification and wait time without inheriting the owner's binding counts.
20
+
21
+ The record freezes at response creation. Inclusive spans can overlap, and partial
22
+ metadata or deferred work must not be presented as a full total. A test-only cloned
23
+ MCP response supplies the JSON-RPC outcome without exposing its content; that
24
+ inspection is outside totalMs but remains instrumentation overhead for HTTP timing.
25
+ Observer errors never replace application results. D1/KV/R2 operations emit only
26
+ counts, sizes with their source/coverage, and durations; object keys, SQL, caller
27
+ identities and credentials never enter the record.
28
+
29
+ R2 GET bodies stay native. Successful direct stream-to-PUT completion confirms
30
+ payload bytes; canceled, incomplete and unconsumed reads remain unknown. This
31
+ avoids losing R2's known-length stream property or hiding buffering overhead.
32
+
33
+ Checks deliberately overlap MCP requests and share KV hit/failure loads, then
34
+ retry after failure. Native I/O is counted once per owner, and denied requests
35
+ leave unreached phases null. Additional checks preserve native D1 private receiver
36
+ and batch semantics, sync/async observer failure, immutable deferred snapshots,
37
+ R2 stream identity and partial-transfer uncertainty. The collector fixture labels
38
+ its deterministic auth explicitly; it is not evidence of native-auth parity.
@@ -0,0 +1,47 @@
1
+ # Route-owned Cloudflare readiness (#808)
2
+
3
+ Status: accepted for the unreleased #806–812 implementation. Follows ADR-0019
4
+ (preparation owns migrations) and amends ADR-0014's unconditional HTTP boot rule.
5
+
6
+ The facade assembles immutable route projections once, then prepares only for
7
+ routes that consume canonical storage. It retains one retryable preparation
8
+ promise, the sealed plan and Better Auth's existing global AsyncLocalStorage,
9
+ `ready` observation, failed-assembly eviction and HTTP `waitUntil` ownership.
10
+
11
+ | Surface | Readiness, including an empty database |
12
+ | --- | --- |
13
+ | Consumer constant health, `/api/views` catalog | No content preparation. |
14
+ | Static Admin shell and assets | No content preparation; incomplete Auth still fails closed as before. |
15
+ | Manifest View/Trigger | Prepare before credential resolution, then use the same runtime invocation. |
16
+ | Admin API, configured Auth base path, OAuth UI/discovery | Prepare before session/provider work. Auth tables share canonical migrations. |
17
+ | MCP, including missing-token challenge | Prepare before the selected Auth verifier. A custom Auth implementation may require D1 even for a denied request; the facade cannot assume otherwise. |
18
+ | Public page/list/discovery and favicon fallback | Their existing `ref.get()` starts preparation when content is needed. |
19
+ | Consumer extension handlers | Call the supplied `getRuntime()` or `ref.get()` before using Mantle content or database-backed Auth. Pure handlers need neither. |
20
+ | Queue/scheduled `getRuntime(env)` | Await both runtime preparation and Auth initialization, unchanged. |
21
+
22
+ No deployment-time migration flag or KV readiness authority is introduced. A
23
+ current database's first protected route needs one fingerprint SELECT; an empty
24
+ or changed database performs canonical setup there. Concurrent first uses share
25
+ that work. A failed preparation returns the facade's redacted 500, and later
26
+ requests retry. Static availability is not a promise that data is ready.
27
+
28
+ The pre-change full-facade fixture recorded 106 SQL operations for its empty-DB
29
+ health request (its four Schemas include indexes), then 1 fingerprint operation
30
+ for each new-state health/catalog/shell/challenge. These are statement counts,
31
+ not network round trips. Its dry-run bundle was 3,921.40 KiB / 690.43 KiB gzip
32
+ with Wrangler 4.124.0 and compatibility date 2026-07-08. Esbuild attributed
33
+ 1,342,428 emitted bytes to Better Auth, 76,078 to Admin, 24,624 to Web and
34
+ 203,130 to Runtime; the rest includes transitive libraries and adapter code.
35
+ Byte attribution does not measure startup CPU. We retain static imports and
36
+ immutable mount-time projections: deferring those modules has not yet been
37
+ justified by a startup profile. The final #812 report owns the separate startup,
38
+ preparation-wall and warm-dispatch measurements.
39
+
40
+ Post-change full-facade workerd checks pass all 13 operation gates: empty-DB
41
+ health and fresh-state health/catalog/shell perform zero **content** queries;
42
+ the challenge retains its one fingerprint query. The fixture Auth is a stated
43
+ stub. A separate real Better Auth regression records one eager oauthResource
44
+ lookup while `ready` initializes; Better Auth 1.7.2 defers missing-table resource
45
+ seeding to first access. Subsequent static requests perform no additional Auth
46
+ queries. This background initialization is retained, not counted as eliminated
47
+ content preparation or hidden behind a zero-total-SQL claim.