@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.2

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 (109) 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/dist/codegen/emitMantleModule.js +8 -0
  9. package/dist/codegen/emitMantleModule.js.map +1 -1
  10. package/docs/adapter-guide.md +23 -26
  11. package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
  12. package/docs/adr/0010-locale-and-translates.md +14 -16
  13. package/docs/adr/0011-adapter-port-spec.md +2 -1
  14. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
  15. package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
  16. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +38 -2
  17. package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
  18. package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
  19. package/docs/adr/0022-caller-observed-version-occ.md +212 -0
  20. package/docs/adr/0023-port-failure-contract.md +69 -0
  21. package/docs/adr/0024-manifest-native-schema-tables.md +260 -0
  22. package/docs/adr/README.md +19 -3
  23. package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
  24. package/docs/adr/adr-lite-808-route-readiness.md +47 -0
  25. package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
  26. package/docs/adr/adr-lite-812-native-parity.md +150 -0
  27. package/docs/adr/adr-lite-823-home-statistics.md +64 -0
  28. package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
  29. package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
  30. package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
  31. package/docs/api-mcp-authorization.md +3 -664
  32. package/docs/auth-hosting-model.md +4 -4
  33. package/docs/cloudflare-low-level-composition.md +3 -104
  34. package/docs/deferred-lifecycle-queues.md +2 -243
  35. package/docs/design-atoms.md +16 -796
  36. package/docs/direct-authoring.md +5 -0
  37. package/docs/examples/minimal-worker/README.md +37 -0
  38. package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
  39. package/docs/examples/minimal-worker/package.json +26 -0
  40. package/docs/examples/minimal-worker/smoke.mjs +23 -0
  41. package/docs/examples/minimal-worker/src/index.ts +4 -0
  42. package/docs/examples/minimal-worker/tsconfig.json +17 -0
  43. package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
  44. package/docs/handbook/cloudflare/authentication.md +167 -0
  45. package/docs/handbook/cloudflare/bindings.md +236 -0
  46. package/docs/handbook/cloudflare/conventional-worker.md +162 -0
  47. package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
  48. package/docs/handbook/cloudflare/deploy-and-operate.md +105 -0
  49. package/docs/handbook/cloudflare/low-level-composition.md +169 -0
  50. package/docs/handbook/cloudflare/media-r2.md +216 -0
  51. package/docs/handbook/cloudflare/public-web.md +144 -0
  52. package/docs/handbook/concepts/authorization.md +116 -0
  53. package/docs/handbook/concepts/four-atoms.md +173 -0
  54. package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
  55. package/docs/handbook/concepts/mcp-and-agents.md +109 -0
  56. package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
  57. package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
  58. package/docs/handbook/concepts/views.md +191 -0
  59. package/docs/handbook/examples/commerce-transaction.md +813 -0
  60. package/docs/handbook/examples/guarded-api.md +427 -0
  61. package/docs/handbook/examples/intake-form.md +319 -0
  62. package/docs/handbook/examples/legal-documents.md +218 -0
  63. package/docs/handbook/examples/procurement-approvals.md +240 -0
  64. package/docs/handbook/examples/publication.md +240 -0
  65. package/docs/handbook/examples/reservation.md +220 -0
  66. package/docs/handbook/navigation.json +63 -0
  67. package/docs/handbook/reference/authorization.md +295 -0
  68. package/docs/handbook/reference/diagnostics.md +183 -0
  69. package/docs/handbook/reference/manifest.md +132 -0
  70. package/docs/handbook/reference/procedure.md +263 -0
  71. package/docs/handbook/reference/schema.md +250 -0
  72. package/docs/handbook/reference/site-config.md +233 -0
  73. package/docs/handbook/reference/surface.md +207 -0
  74. package/docs/handbook/reference/trigger.md +193 -0
  75. package/docs/handbook/reference/view.md +256 -0
  76. package/docs/handbook/start/project-and-cli.md +122 -0
  77. package/docs/handbook/start/quickstart-worker.md +193 -0
  78. package/docs/labels.md +3 -1
  79. package/docs/media-uploads.md +3 -184
  80. package/docs/migration-0.1.2.md +60 -3
  81. package/docs/performance-harness.md +127 -10
  82. package/docs/release-process.md +85 -231
  83. package/docs/schema-indexes.md +3 -180
  84. package/docs/sealed-pipeline-ownership.md +6 -5
  85. package/docs/spec-only-host-adoption.md +158 -0
  86. package/docs/transaction-patterns.md +5 -0
  87. package/package.json +21 -25
  88. package/skills/README.md +4 -4
  89. package/skills/develop/SKILL.md +23 -28
  90. package/skills/install/SKILL.md +55 -147
  91. package/skills/provision/SKILL.md +4 -3
  92. package/skills/theme/SKILL.md +6 -6
  93. package/skills/update/SKILL.md +32 -68
  94. package/dist/cli/create.d.ts +0 -2
  95. package/dist/cli/create.d.ts.map +0 -1
  96. package/dist/cli/create.js +0 -243
  97. package/dist/cli/create.js.map +0 -1
  98. package/dist/cli/update.d.ts +0 -2
  99. package/dist/cli/update.d.ts.map +0 -1
  100. package/dist/cli/update.js +0 -413
  101. package/dist/cli/update.js.map +0 -1
  102. package/dist/provision/renderProvisionBundle.d.ts +0 -70
  103. package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
  104. package/dist/provision/renderProvisionBundle.js +0 -367
  105. package/dist/provision/renderProvisionBundle.js.map +0 -1
  106. package/dist/provision.d.ts +0 -2
  107. package/dist/provision.d.ts.map +0 -1
  108. package/dist/provision.js +0 -2
  109. package/dist/provision.js.map +0 -1
@@ -625,3 +625,103 @@ OAuth store and is not renamed by this decision.
625
625
  This amendment adopts the 2026-07-28 CIMD authorization profile only. Updating
626
626
  Mantle's JSON-RPC dispatcher to the complete MCP 2026-07-28 transport revision
627
627
  is a separate decision.
628
+
629
+ ## 2026-09-07 amendment — shared OAuth surfaces and revocation
630
+
631
+ OAuth product UI is not owned by a runtime adapter. `@aotter/mantle-admin`
632
+ owns `MantleOAuthAuth`, the consent/connected-app view models, and
633
+ `handleMantleOAuth(Request) -> Response | null`; `mountMantleOAuth` is a thin
634
+ Hono bridge. The Cloudflare adapter implements protocol actions using the
635
+ same Better Auth instance and D1. Future adapters reuse this contract, not
636
+ Cloudflare-specific UI glue. The old `mountAuthorize` export remains an alias.
637
+
638
+ `@aotter/mantle-admin-ui` owns the React/shadcn sign-in, consent and connected
639
+ apps surfaces, including i18n, theme and submit state. Auth pages initially
640
+ follow the system theme; the light/dark toggle persists an explicit override.
641
+ Connected apps has an Admin page, but managing one's own grants requires only
642
+ a session, never a staff role. The no-assets HTML fallback uses native forms
643
+ without JavaScript or a second implementation of the Admin design system.
644
+ Both mounts retain same-origin mutation checks and private/no-store responses.
645
+ The consent document's CSP permits only the provider-validated callback
646
+ origin for the browser's form redirect; it never trusts an unsigned query.
647
+
648
+ MCP authorization remains session-bound: the JWT's original Better Auth
649
+ session must still exist and be unexpired. Admin sign-out/session expiration
650
+ therefore also ends that session's MCP access. A refresh token is not an
651
+ independent authorization to bypass this check. Staff roles are still read
652
+ fresh on every protected request.
653
+
654
+ Disconnect is scoped to the authenticated user and the selected client. It
655
+ revokes refresh/opaque access tokens, removes pending authorization codes and
656
+ consent rows, and prevents existing JWTs from becoming valid when the client
657
+ is connected again. The verification-create hook captures the consent row ID
658
+ in Better Auth's existing authorization `referenceId`; Better Auth carries it
659
+ through the authorization code and every refresh rotation. MCP JWTs copy that
660
+ reference into `mantle_consent_id`, which must match the active consent row.
661
+ Never look up a new consent at token mint time: doing so could revive a refresh
662
+ lineage whose insertion raced the revoke batch. Same-second reconnect and a
663
+ delayed old refresh row are required regression cases, not clock delays.
664
+
665
+ MCP mode reserves authorization `referenceId` for this grant identity; future
666
+ curated configuration must not also expose Better Auth's `postLogin` reference
667
+ hooks. MCP access requires a persisted user consent, so `skipConsent` and
668
+ `cachedTrustedClients` must not bypass consent for MCP clients.
669
+
670
+ This alpha hotfix requires existing MCP clients to reconnect once: pre-hotfix
671
+ JWTs without the grant claim are rejected immediately. No account/session reset
672
+ is required. The unshipped watermark migration 0008 is removed; its unused
673
+ table in the phsu development database is harmless and is not queried or
674
+ deleted during deployment.
675
+
676
+ ## 2026-09-08 amendment — request security boundaries
677
+
678
+ All Admin session mutations use the existing same-origin guard, including
679
+ same-site sibling origins. Admin HTML forbids framing and is private/no-store.
680
+ The SPA also refuses to render in frames, covering direct static-asset URLs
681
+ that bypass the server mount.
682
+ The Admin/auth mounts cap request bodies at 1 MiB using Hono's body limiter;
683
+ HTTP trigger and MCP dispatchers count streamed JSON bytes before parsing and
684
+ return 413 above the same limit. Media bytes continue through direct uploads.
685
+ This intentionally rejects previously accepted larger control-plane payloads.
686
+
687
+ Cloudflare `createAuth` explicitly enables Better Auth rate limits regardless
688
+ of `NODE_ENV`, including the OAuth provider's anonymous registration limit of
689
+ five requests per minute. Only `CF-Connecting-IP` supplies the client key.
690
+ The upstream memory store limits each isolate; deployments needing a shared
691
+ abuse quota must additionally enforce it at ingress. No D1 migration or new
692
+ runtime platform dependency is introduced.
693
+
694
+ Workers must retain initialization work through `ExecutionContext.waitUntil`
695
+ even when the initial challenge finishes or its client disconnects. Schema
696
+ boot precedes OAuth handling. The adapter's static AsyncLocalStorage seeding
697
+ is a version-pinned Better Auth 1.7.2 integration, with accessor-identity
698
+ regression coverage; it does not replace Better Auth's request context.
699
+ Failed Auth initialization evicts only the failed Worker assembly so a later
700
+ request can retry. Non-HTTP callers await Auth initialization with runtime boot;
701
+ HTTP requests anchor it without making public responses depend on Auth health.
702
+
703
+ The conventional `/favicon.ico` reflects the configured site icon, but is a
704
+ fallback after consumer routes, not a newly reserved namespace. Existing
705
+ consumer icon routes must continue to work after a package update.
706
+
707
+ ## Amendment — 2026-09-14: Host-only control-plane cookies
708
+
709
+ `CreateAuthConfig.hostOnlyCookies` is an opt-in, curated auth field for a
710
+ control plane on a parent domain shared with tenants. It uses native `__Host-`
711
+ cookie names, `Secure`, `Path=/` and no `Domain`; HTTPS is required and enabling
712
+ cross-subdomain sharing at the same time is rejected. Existing defaults and
713
+ Apple's `SameSite=None` requirement remain unchanged. Consumers must treat
714
+ switching cookie names as an explicit sign-in migration, not rewrite request
715
+ or response cookies. This keeps cookie semantics in the auth adapter rather
716
+ than requiring each consumer to wrap Admin, member and OAuth routes.
717
+
718
+ ## Amendment — 2026-09-16: deployment KV session reads
719
+
720
+ Cloudflare deployments may pass their deployment-owned KV namespace as
721
+ `CreateAuthConfig.sessionCacheKv`. Better Auth reads sessions from KV while
722
+ keeping the canonical session row in D1. OTP verification remains D1-backed
723
+ and rate limiting remains isolate-local because Workers KV does not provide
724
+ the atomic consume or increment operations those paths require. Auth keys use
725
+ the `better-auth:` prefix so the namespace can also hold Mantle projections.
726
+ Session revocation and user updates use Better Auth's cache invalidation and
727
+ therefore follow Workers KV's propagation model.
@@ -1,5 +1,8 @@
1
1
  # ADR-0018: Keep Core and public starters in separate repositories
2
2
 
3
+ > Superseded for 0.1.2 by [ADR-0021](0021-retire-starter-scaffolding.md).
4
+ > The contract below is retained as alpha.17 history, not current launch/release guidance.
5
+
3
6
  **Status:** Accepted for now; revisit only under the triggers below
4
7
 
5
8
  **Date:** 2026-08-02
@@ -2,10 +2,12 @@
2
2
 
3
3
  **Status:** Accepted
4
4
 
5
- **Date:** 2026-08-16
5
+ **Date:** 2026-08-16; last amended 2026-09-01
6
6
 
7
7
  **Related:** [#656](https://github.com/aotter/mantle/issues/656),
8
8
  [#662](https://github.com/aotter/mantle/issues/662),
9
+ [#759](https://github.com/aotter/mantle/issues/759),
10
+ [#760](https://github.com/aotter/mantle/issues/760),
9
11
  [#546](https://github.com/aotter/mantle/issues/546), ADR-0008, ADR-0009,
10
12
  ADR-0011, ADR-0018
11
13
 
@@ -67,7 +69,7 @@ semantic fingerprint.
67
69
  | Parse + normalize | YAML syntax/alias limits, closed four-atom shape, primitive and atom-local rules, behavior-affecting defaults, source metadata | Cross-atom references, handlers, storage, optional routes |
68
70
  | Link | Duplicate symbols, cross-atom references, guard graphs, translations, manifest-owned route/tool collisions | I/O, selected modules, handler availability |
69
71
  | Compile | Immutable lookup records, authorization plans, Trigger indices, Procedure descriptors, logical View plans, semantic fingerprint | Connections, repositories, handlers, requests, templates, assets |
70
- | Prepare | Selected storage migrations/indexes/native Views, handler availability, selected capability/route checks, readiness revision | YAML interpretation or request execution |
72
+ | Prepare | Selected storage migrations/native Schema tables and indexes/prepared Views, handler availability, selected capability/route checks, readiness revision | YAML interpretation or request execution |
71
73
  | Bind/invoke | Semantic ports, handler dispatch, parameter binding, centralized authorization, content/View/Procedure/Trigger/lifecycle operations | DDL, route mounting, assets, HTTP/session/cache policy |
72
74
  | Optional modules/adapters | Web/Admin composition and request/session/cache/platform translation | Re-parsing, re-linking, or a second authorization/runtime stack |
73
75
 
@@ -106,11 +108,45 @@ Concrete D1/SQLite drivers and SQL repositories remain implementation details.
106
108
  An existing application may either pass its already-owned database/client to
107
109
  an official adapter or implement the semantic ports over its own tables.
108
110
 
111
+ `@aotter/mantle-indexeddb` is the optional browser storage implementation of
112
+ the same semantic ports. One adapter exclusively owns one application-selected
113
+ database name, uses structured-clone persistence and IndexedDB transaction
114
+ completion as its write boundary, and exposes database deletion only on the
115
+ concrete adapter. Runtime Core gains no browser global, browser facade,
116
+ namespace, persistence request, reactive binding, or synchronization port.
117
+ Declarative browser Views use a documented O(n) scan until measured use proves
118
+ an IndexedDB query planner necessary.
119
+
109
120
  Declarative Views compile to logical plans once. Storage preparation lowers
110
121
  those plans to native queries. The v0.1 `View.spec.sql` form remains explicitly
111
122
  SQLite-only and is rejected by unsupported storage during preparation; Mantle
112
123
  does not guess a translation and does not add a universal query driver.
113
124
 
125
+ ### Callable capability projection
126
+
127
+ Optional callable transports consume `projectCallableCapabilities(RuntimePlan)`.
128
+ Each Procedure capability retains the owning MCP Trigger name while its
129
+ input/output/title/description remain owned by the target Procedure; View
130
+ capabilities retain their read-only View contract. Discovery and invocation use
131
+ the same projected descriptor, and invocation routes through `invokeTrigger`
132
+ rather than bypassing Trigger identity with a direct Procedure call. Hosts may
133
+ select a surface and bind transport or browser lifecycle, but do not rescan raw
134
+ manifests or maintain a second registry.
135
+
136
+ `@aotter/mantle-web/webmcp` is an opt-in host binding over that projection. It
137
+ feature-detects `document.modelContext`, adds public capabilities without
138
+ replacing existing host tools, and uses the registration `AbortSignal` only for
139
+ teardown. A browser-local host supplies the projected capabilities and a
140
+ late-bound invoker; Procedure calls retain their explicit MCP Trigger identity.
141
+ For a server-backed host, the Cloudflare adapter publishes a minimal public View
142
+ catalog at `GET /api/views`; the binding validates that wire catalog and invokes
143
+ the existing same-origin `GET /api/views/<name>` route. The catalog contains no
144
+ raw manifest or staff capability.
145
+ Invocation hooks may drive host UI effects but cannot replace Runtime
146
+ validation, authorization, dispatch, or domain results. The binding does not
147
+ add browser globals to Runtime Core, expose staff capabilities, or introduce a
148
+ second manifest/runtime path.
149
+
114
150
  ### Naming and code generation
115
151
 
116
152
  The Core execution unit is `MantleRuntime`, not a site. Optional TypeScript
@@ -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.