@aotter/mantle 0.0.11-alpha.73 → 0.1.0-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.
@@ -1,16 +1,14 @@
1
1
  # ADR-0011: Adapter port spec
2
2
 
3
- **Status:** Accepted for v0.1.0. New ADR. Replaces the architectural concerns previously tracked in POC ADR-0015 (`cms-astro` internal seam discipline).
3
+ **Status:** Accepted for v0.1.0. Amended 2026-08-13 to remove the unimplemented adapter stub.
4
4
 
5
- **Date:** 2026-05-04 (revised 2026-05-09 to absorb ADR-0014; revised 2026-05-10 to remove obsolete pre-ADR-0014 port text).
5
+ **Date:** 2026-05-04 (revised 2026-05-09, 2026-05-10, 2026-08-11, and 2026-08-13).
6
6
 
7
7
  ## Context
8
8
 
9
9
  `@aotter/mantle-runtime` is adapter-agnostic. It owns dispatcher, entry-writer, view executor, content-ops, render pipeline, boot validation, and MCP JSON-RPC dispatch. It depends only on `@aotter/mantle-spec` and a small set of TypeScript interfaces it defines itself.
10
10
 
11
- `@aotter/mantle-cloudflare` is the only adapter shipping in v0.1.0. It binds the runtime's interfaces against Cloudflare Workers' D1, KV, ASSETS, and supplies a Better Auth instance (per ADR-0014) for sign-in + MCP bearer validation.
12
-
13
- `@aotter/mantle-netlify` is a v0.2 stub — README only. It exists in the package layout as an engineering forcing function: with N=1 adapter, "adapter-agnostic" silently rots in PR review (a `D1Database` import slips into runtime, then a second, then five). With a second adapter visible in the workspace (even if its impl is a TODO), reviewers have somewhere to point when blocking the slip.
11
+ `@aotter/mantle-cloudflare` is the only adapter shipping in v0.1.0. It binds the runtime's interfaces against Cloudflare Workers' D1 and ASSETS, and supplies a Better Auth instance (per ADR-0014) for sign-in + MCP bearer validation. OAuth grant KV remains adapter-owned infrastructure and is not a runtime port.
14
12
 
15
13
  This ADR fixes the contract so:
16
14
  - Future adapter authors have a stable target.
@@ -21,14 +19,18 @@ The POC accumulated multiple half-decisions about this seam (POC ADR-0015 docume
21
19
 
22
20
  ## Decision
23
21
 
24
- **Three required adapter ports**, defined as TypeScript interfaces in `@aotter/mantle-runtime/src/domain/port/`. Concrete adapters provide implementations and inject them into `createCmsRuntime`.
22
+ **Two required adapter ports**, defined as TypeScript interfaces in `@aotter/mantle-runtime/src/domain/port/`. Concrete adapters provide implementations and inject them into `createCmsRuntime`.
25
23
 
26
24
  | Port | Surface |
27
25
  |---|---|
28
- | `DatabaseDriver` | All persistent state — `entries`, `site_config`, `staff`, `users`, `approvals`, plus migrations. |
29
- | `KvCache` | Publish-pipeline cache — pre-rendered HTML, `.md` mirrors, `llms.txt` per locale. Read-mostly, written by the publish pipeline. |
26
+ | `DatabaseDriver` | All persistent state — `entries`, `site_config`, `staff`, `users`, plus migrations. |
30
27
  | `AssetServer` | Static-asset serving for the admin SPA. The runtime hands the adapter an asset path + `Request`; the adapter returns a `Response` with the right MIME and caching. |
31
28
 
29
+ Rendered public artifacts are not a second storage model. D1 stays canonical;
30
+ adapters render on origin misses and may use their native HTTP response cache.
31
+ The Cloudflare adapter uses version-local Workers Cache, which runs before the
32
+ Worker for eligible anonymous responses.
33
+
32
34
  Optional feature ports may also live in `domain/port/`, but they are
33
35
  not part of the first-run adapter contract until a feature is enabled.
34
36
  For v0.1.x media hosting and deferred lifecycle dispatch:
@@ -119,21 +121,7 @@ export interface DatabaseDriver {
119
121
 
120
122
  The runtime never sees `D1Database`, `Pool` (postgres), or any concrete driver. The `prepare` / `batch` shape is intentionally close to D1's surface (which is itself close to the SQLite C API) — that's the smallest common denominator. Adapters wrap their native driver to this shape.
121
123
 
122
- The CF adapter's impl is a thin proxy over `env.DB` (D1). A future Postgres-via-Hyperdrive adapter wraps `pg` to the same shape; a Netlify adapter could wrap Neon, Supabase, or PlanetScale.
123
-
124
- ### `KvCache`
125
-
126
- ```ts
127
- export interface KvCache {
128
- get(key: string): Promise<string | null>;
129
- put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
130
- delete(key: string): Promise<void>;
131
- /** List keys with a prefix — used by sitemap / llms.txt aggregation. */
132
- list(prefix: string, cursor?: string | null): Promise<{ keys: string[]; cursor: string | null }>;
133
- }
134
- ```
135
-
136
- CF adapter: Workers KV. Future: Redis, FS, S3-compatible store.
124
+ The CF adapter's impl is a thin proxy over `env.DB` (D1). A future Postgres adapter can wrap its driver to the same shape.
137
125
 
138
126
  ### `AssetServer`
139
127
 
@@ -146,7 +134,7 @@ export interface AssetServer {
146
134
  }
147
135
  ```
148
136
 
149
- CF adapter: wraps `env.ASSETS.fetch(req)`. Future: filesystem read, S3+CDN, Netlify static-publish dir.
137
+ CF adapter: wraps `env.ASSETS.fetch(req)`. Other adapters can use a filesystem or object storage.
150
138
 
151
139
  The admin SPA itself lives in `@aotter/mantle-admin-ui` as a pre-built `dist/`. The adapter binds `AssetServer` to whatever serves that `dist/`; the runtime knows nothing about static asset serving except "ask the port and pass through the response."
152
140
 
@@ -164,7 +152,6 @@ import {
164
152
  mountServerEndpoints,
165
153
  AssetsAssetServer,
166
154
  D1DatabaseDriver,
167
- KvCacheBinding,
168
155
  } from "@aotter/mantle-cloudflare";
169
156
 
170
157
  const auth = createAuth({
@@ -188,7 +175,6 @@ const cms = createCmsRef({
188
175
  handlers,
189
176
  bindings: {
190
177
  db: new D1DatabaseDriver(env.DB),
191
- kv: new KvCacheBinding(env.KV),
192
178
  assets: new AssetsAssetServer(env.ASSETS),
193
179
  },
194
180
  auth,
@@ -210,7 +196,7 @@ export default createOAuthProvider({
210
196
  });
211
197
  ```
212
198
 
213
- The runtime gets three required adapter ports (`db`, `kv`, `assets`)
199
+ The runtime gets two required adapter ports (`db`, `assets`)
214
200
  alongside manifests, handlers, templates, and site defaults. Auth is
215
201
  owned by the adapter layer that mounts HTTP/MCP surfaces; the runtime
216
202
  receives authenticated context when the adapter dispatches requests.
@@ -220,28 +206,25 @@ There's no module-global state holding adapter-specific bindings.
220
206
 
221
207
  **Hard-enforced boundaries**:
222
208
  - `@aotter/mantle-runtime` MUST NOT import `D1Database`, `KVNamespace`, `Fetcher` (CF Workers ASSETS), `@cloudflare/*`, or any other adapter-specific type. CI will lint for this; PR reviewers can grep.
223
- - A new required port can be added only by amending this ADR and updating ALL adapters (CF + Netlify stub) in the same change. Optional feature ports must be documented here and must state when adapters are required to implement them.
209
+ - A new required port can be added only by amending this ADR and updating every shipping adapter in the same change. Optional feature ports must be documented here and must state when adapters are required to implement them.
224
210
  - Removing a port is also possible (if a port is found to overlap or be unnecessary), again by amending this ADR.
225
211
 
226
212
  **Discoverability for adapter authors**:
227
- - A future Bun/Deno/Vercel/Netlify port author reads this ADR + [`docs/adapter-guide.md`](../adapter-guide.md), implements the three required ports, then wires boot and HTTP/MCP surfaces. That's the contract. No hidden state, no implicit assumptions about the HTTP framework.
213
+ - A future adapter author reads this ADR + [`docs/adapter-guide.md`](../adapter-guide.md), implements the two required ports, then wires boot and HTTP/MCP surfaces. That's the contract. No hidden state, no implicit assumptions about the HTTP framework.
228
214
 
229
215
  **Test ergonomics**:
230
- - Each port is small and isolated. Tests can mock individual ports without spinning up D1 / KV / OAuth provider.
216
+ - Each port is small and isolated. Tests can mock individual ports without spinning up D1 or an OAuth provider.
231
217
  - The runtime's test suite exercises against in-memory port impls; the adapter's test suite exercises the binding against real CF resources via `wrangler dev` or live deploy.
232
218
 
233
- **The Netlify stub's job**:
234
- - The `@aotter/mantle-netlify` package's README declares a public commitment to an N>=2 adapter world. If a PR adds CF-specific code to runtime, reviewers point at the stub README and reject. The stub doesn't have to ship code to perform its function — its existence is the constraint.
235
-
236
219
  ## Alternatives considered
237
220
 
238
- **(a) Single mega-port** — One `RuntimePorts` interface containing every method (db.prepare, kv.get, assets.fetch, media.createUpload, …). **Rejected**: leaks the entire surface onto every adapter. Adapter authors who only want to swap KV would have to touch the mega-port impl. Discrete ports keep change blast radius per port.
221
+ **(a) Single mega-port** — One `RuntimePorts` interface containing every method (db.prepare, assets.fetch, media.createUpload, …). **Rejected**: leaks the entire surface onto every adapter. Discrete ports keep change blast radius per port.
239
222
 
240
223
  **(b) Concrete CF types in runtime** — Just `import type { D1Database } from "@cloudflare/workers-types"` directly into `mantle-runtime`. Treat "CF-only" as a v0.1.0 reality, defer the abstraction. **Rejected**: this is what the POC did (via `cms-server` having implicit assumptions about D1 shape) and it's the trap the rebuild exists to escape. Once concrete CF types land in runtime, removing them is a multi-PR uplift later. Cheaper to do it right at v0.1.0.
241
224
 
242
- **(c) Function-injection (no interfaces, just functions)** — Runtime accepts a record of functions: `{ dbPrepare, kvGet, kvPut, sessionRead, … }`. **Rejected**: TypeScript interfaces are more discoverable (an adapter author IDE-jumps from `DatabaseDriver` to its surface; jumping from `dbPrepare` is harder). Interfaces also document grouping; functions don't.
225
+ **(c) Function-injection (no interfaces, just functions)** — Runtime accepts a record of functions such as `{ dbPrepare, assetFetch, sessionRead, … }`. **Rejected**: TypeScript interfaces are more discoverable and document grouping.
243
226
 
244
- **(d) Plugin pattern (each port is a separate package)** — `@aotter/mantle-port-database`, `@aotter/mantle-port-kv`, etc., and runtime depends on one package per port. **Rejected**: the port set is too small to warrant per-port packages. The current 5-package structure (spec / runtime / admin-ui / cloudflare / netlify) is already at the boundary of "too many"; splitting further increases the maintenance tax without useful benefit. Ports are TS interfaces in `mantle-runtime`'s `src/domain/port/` directory — that's enough.
227
+ **(d) Plugin pattern (each port is a separate package)** — `@aotter/mantle-port-database`, `@aotter/mantle-port-kv`, etc., and runtime depends on one package per port. **Rejected**: the port set is too small to warrant per-port packages. Ports are TypeScript interfaces in `mantle-runtime`'s `src/domain/port/` directory — that's enough.
245
228
 
246
229
  **(e) gRPC / wire-protocol seam** — Make ports a network protocol so adapters can be in any language. **Rejected**: the runtime is not an external service, it's a TypeScript library that adapters compose into a single Worker / Function. Network seam adds latency, deployment complexity, and operational surface for zero authoring benefit. The ports are in-process; they always will be.
247
230
 
@@ -251,29 +234,28 @@ When you're authoring `@aotter/mantle-runtime` code:
251
234
 
252
235
  1. If you reach for a CF-specific type, **stop**. Define a method on a port instead.
253
236
  2. If a port is missing the method you need, **amend this ADR first** in the same PR, then add the method. Adapters in the same PR.
254
- 3. Tests must use port mocks (in-memory implementations) — never reach into a real D1 / KV from runtime tests.
237
+ 3. Tests must use port mocks (in-memory implementations) — never reach into a real D1 from runtime tests.
255
238
 
256
- When you're authoring an adapter (`@aotter/mantle-cloudflare` for v0.1.0; future `mantle-netlify`, `mantle-bun`, …):
239
+ When you're authoring an adapter:
257
240
 
258
241
  1. Read `mantle-runtime/src/domain/port/`. Implement each required port against your runtime's primitives.
259
- 2. Compose the runtime via `createCmsRuntime({ db, kv, assets, manifests, handlers, templates, siteDefaults, ... })`.
242
+ 2. Compose the runtime via `createCmsRuntime({ db, assets, manifests, handlers, templates, siteDefaults, ... })`.
260
243
  3. Call `runtime.bootInit()` once before serving CMS traffic.
261
- 4. Bind to your HTTP framework — Hono on CF, Netlify Functions handler, raw `fetch` Worker, …
244
+ 4. Bind to your HTTP framework.
262
245
  5. Provide adapter-owned auth and map sessions/scopes/roles into runtime handler context.
263
246
  6. Bundle `@aotter/mantle-admin-ui`'s `dist/` via your runtime's static-asset surface and bind `AssetServer` to it.
264
247
 
265
248
  When you're reviewing a PR:
266
249
 
267
250
  1. Grep the diff for `@cloudflare`, `D1Database`, `KVNamespace`, `Fetcher` — flag any occurrence in `mantle-runtime/`.
268
- 2. If a new port method shows up, check it's also reflected in this ADR + the Netlify stub README.
269
- 3. If a port shape changed, all 2 adapters (CF real, Netlify stub) get updated in the same PR.
251
+ 2. If a new port method shows up, check it is also reflected in this ADR.
252
+ 3. If a port shape changed, every shipping adapter gets updated in the same PR.
270
253
 
271
254
  ## Implementation status
272
255
 
273
256
  - [x] Required port interface files live in `packages/mantle-runtime/src/domain/port/*.ts`.
274
257
  - [x] Cloudflare required port implementations live in `packages/adapters/cloudflare/src/bindings/*.ts`.
275
258
  - [x] Optional feature port `MediaStorage` (public bucket) is declared but not required by first-run adapters. `PrivateMediaStorage` is v0.2.
276
- - [x] Netlify stub README references this ADR.
277
259
  - [ ] CI lint: forbid `@cloudflare/*` / `D1Database` / `KVNamespace` imports in `mantle-runtime/` (post-v0.1.0; manual review until then)
278
260
 
279
261
  ## See also
@@ -88,7 +88,7 @@ spec:
88
88
  eq: { field: locale, value: { $param: locale } }
89
89
  ```
90
90
 
91
- The `$param` discriminator key was chosen to match the JSON Schema `$ref` convention. Future sentinels (`$now`, `$ctx.user`) follow the same `$<name>` shape; this ADR adds none of them.
91
+ The `$param` discriminator key was chosen to match the JSON Schema `$ref` convention. The later closed `{ "$ctx.user": "id" }` sentinel follows the same `$<name>` shape; `$now` remains unimplemented.
92
92
 
93
93
  Boot validator gates:
94
94
  - `View.spec.params` MUST be `type: object` with `properties` declared (`VIEW_PARAMS_INVALID_SHAPE`).
@@ -96,8 +96,6 @@ Boot validator gates:
96
96
  - Every `{ $param: <name> }` ref MUST resolve to a declared param (`VIEW_FILTER_PARAM_REF_UNKNOWN`).
97
97
  - Every `{ $param: <name> }` ref MUST appear in `params.required` (`VIEW_FILTER_PARAM_REF_NOT_REQUIRED`).
98
98
 
99
- The required-only rule is a v0.1.0 simplification. v0.1.x will promote optional-with-skip semantics (filter clauses referencing missing optional params evaluate to TRUE / no-op) — the runtime compiler already implements drop semantics for forward compatibility, but the parser rejects it today so authors get a clear "not yet" diagnostic.
100
-
101
99
  ### 6. Response envelope is `{ rows, page, show, hasMore }`
102
100
 
103
101
  ```json
@@ -130,12 +128,11 @@ Query strings arrive as strings; `View.spec.params` declares the JSON Schema typ
130
128
 
131
129
  Required params not present → `400 INPUT_VALIDATION_FAILED`. Coercion failure → `400 INPUT_VALIDATION_FAILED`. Unknown query-string keys are silently ignored (lenient v0.1.0; strict mode is a candidate v0.1.x flag).
132
130
 
133
- ## Out of scope (deferred)
131
+ ## Current limits
134
132
 
135
- - **`Trigger.target.view`** (lifecycle/projection triggers fired by Views). Tracked separately as a v0.2 grammar move.
136
- - **`spec.output.kind`** (declaring scalar / tree / tabular result shape per View). Lands with join + group-by support in v0.1.x.
137
- - **Optional param-ref drop semantics in the parser.** Runtime is already implemented; parser promotes when v0.1.x lands.
138
- - **DRAFT filter operators** (`contains` / `in` / `like` / `not`). v0.1 keeps comparison operators closed to `eq` / `gt` / `gte` / `lt` / `lte`; field-to-field comparisons remain out of scope.
133
+ - Param refs must be required.
134
+ - Filter operators are closed to `eq` / `gt` / `gte` / `lt` / `lte`;
135
+ field-to-field comparisons are unsupported.
139
136
  - **Row-level policy rewriting.** `requires` authorizes the whole View; it does
140
137
  not inject per-row visibility predicates. Consumer-specific membership,
141
138
  payment, or entitlement checks belong in the optional guard Procedure.
@@ -149,13 +146,12 @@ Required params not present → `400 INPUT_VALIDATION_FAILED`. Coercion failure
149
146
  - Cheap pagination + dynamic filters without hand-writing handlers.
150
147
 
151
148
  **Authors lose:**
152
- - A View per filter combination (until DRAFT operators land). `posts-by-locale` plus `posts-by-tag` plus `posts-by-locale-and-tag` would be three Views in v0.1.0.
149
+ - Each named query shape remains an explicit View.
153
150
  - No internal-only View surface; choose public/staff or keep the query in a TS
154
151
  helper.
155
152
 
156
153
  **Runtime gains:**
157
154
  - One executor and response shape cover public/staff REST and MCP reads.
158
- - Forward-compat for join / group-by / aggregation: envelope generalises by Views declaring `output.kind` later.
159
155
 
160
156
  **Reviewers / future contributors should:**
161
157
  - Reject any PR adding `Schema.spec.expose.rest` or a similar Schema-level public-read flag.
@@ -44,7 +44,7 @@ A 2026 Workers-friendly auth library — [Better Auth](https://better-auth.com)
44
44
  - **MCP plugin (`mcp`)** — purpose-built on top of the OAuth 2.1 provider for MCP DCR; auto-mounts `.well-known/oauth-authorization-server` + `.well-known/oauth-protected-resource`, exposes `auth.api.getMcpSession()` for protected-resource validation
45
45
  - Account linking with policies (verified-email match + reauth requirement)
46
46
 
47
- Better Auth depends on a Kysely / Drizzle / Prisma adapter for the database, not on any Cloudflare-specific service. The auth machinery becomes platform-agnostic — porting to Netlify / Bun / Deno is config-only.
47
+ Better Auth depends on a Kysely / Drizzle / Prisma adapter for the database, not on any Cloudflare-specific service. The auth machinery remains platform-agnostic.
48
48
 
49
49
  ## Decision (historical baseline; amended below)
50
50
 
@@ -64,7 +64,7 @@ Adopt Better Auth as the SDK's full auth surface. It owns:
64
64
 
65
65
  The auth runtime stops being an adapter port. `OAuthVerifier` port + `WorkersOAuthVerifier` adapter are deleted. Validating bearer tokens at `/mcp` and `/staff/mcp` becomes `auth.api.getMcpSession(req.raw)` — a direct Better Auth API call, no port indirection.
66
66
 
67
- This makes the runtime more platform-agnostic, not less: Better Auth runs on Workers (D1 via Kysely), Bun (sqlite), Node (postgres) without code changes. Future Netlify / partner adapters get the auth surface for free.
67
+ This makes the runtime more platform-agnostic, not less: Better Auth runs on Workers (D1 via Kysely), Bun (sqlite), and Node (postgres) without runtime changes.
68
68
 
69
69
  ### 2. `staff` table → `user.role` via Better Auth admin plugin
70
70
 
@@ -81,23 +81,18 @@ admin({
81
81
 
82
82
  The manifest grammar predicate `requires.auth.all: [{ "ctx.staff": ["editor"] }]` evaluates against `session.user.role` at runtime. Closed enum membership unchanged.
83
83
 
84
- What we lose: `grantedBy` / `grantedAt` audit trail. v0.1.0 doesn't need this; v0.1.x can re-add via `additionalFields` on user, or via a separate append-only `staff_audit_log` table.
84
+ ### 3. Two explicit MCP surfaces
85
85
 
86
- ### 3. Two MCP routes, surface-derived from manifest predicate
87
-
88
- `/mcp` and `/staff/mcp` are mounted side-by-side from boot. v0.1.0 ships the conservative partition: `/staff/mcp` exposes all staff authoring/lifecycle tools and requires `mcp:staff` plus an admin role; `/mcp` exposes only read-only `query_view_<name>` tools and requires `mcp:read`. The v0.2+ extension point is **automatic** surface partition derived from each Procedure's `requires.auth.all` predicate:
89
-
90
- ```
91
- predicate contains ctx.staff: [...] → tool exposed on /staff/mcp only
92
- predicate only ctx.user / no predicate → tool exposed on /mcp only
93
- ```
86
+ `/mcp` and `/staff/mcp` are mounted side-by-side from boot. `/staff/mcp`
87
+ exposes staff authoring/lifecycle tools and `/mcp` exposes declared public
88
+ Views and Procedures. An MCP Trigger explicitly chooses `surface: public |
89
+ staff`; the target's `requires.auth` still gates every call.
94
90
 
95
91
  Tool partition rules:
96
92
 
97
93
  - Per-collection auto-emitted authoring tools (`create_draft_<schema>`, `update_draft_<schema>`) — predicate baked-in to require `ctx.staff: [contributor+]`; route to `/staff/mcp`
98
94
  - `list_entries` / `get_entry` / `request_publish` / `archive_entry` / `unpublish_entry` — staff-only (return drafts, mutate state); `/staff/mcp` only
99
- - `query_view_<name>` (auto-emitted from each parsed View, mirroring the existing `/api/views/<name>` REST shape) — public; `/mcp` only
100
- - v0.2 community / v0.2.x fan-club user-facing writes (comment, reaction, subscribe, ...) — predicate `ctx.user` or `ctx.user.subscription`; `/mcp`
95
+ - `query_view_<name>` follows `View.spec.surface`.
101
96
 
102
97
  ### 4. Scope-aware DCR via Better Auth `oauthProvider`
103
98
 
@@ -140,7 +135,7 @@ The token can carry `role` via `customAccessTokenClaims` for caller convenience,
140
135
 
141
136
  ### 6. Single auth surface, no port indirection
142
137
 
143
- Auth is no longer an adapter port. `mantle-runtime` does NOT define an auth port and `createCmsRuntime()` does not accept auth. Adapter packages (`mantle-cloudflare`, future `mantle-netlify`) construct the Better Auth instance with the right database adapter for their platform and keep it in the adapter-owned HTTP/MCP mount layer.
138
+ Auth is no longer an adapter port. `mantle-runtime` does NOT define an auth port and `createCmsRuntime()` does not accept auth. Adapter packages construct the Better Auth instance with the right database adapter for their platform and keep it in the adapter-owned HTTP/MCP mount layer.
144
139
 
145
140
  The adapter uses Better Auth to validate sessions, MCP bearer tokens, scopes, and roles, then passes authenticated user/staff context into runtime dispatchers. Better Auth remains platform-agnostic, but it is not a runtime dependency.
146
141
 
@@ -188,7 +183,7 @@ This makes the implicit explicit. The SDK's auth surface is committee-curated; u
188
183
 
189
184
  ### 8. Path to `@aotter/mantle-better-auth` separate package (deferred)
190
185
 
191
- When `mantle-netlify` lands, the Better Auth wiring moves to its own package. Today the seam is in place:
186
+ Each adapter owns its Better Auth wiring. Today the seam is:
192
187
 
193
188
  - `Auth` interface lives in the adapter (could move to runtime or a separate package without breaking the contract — adapters consume the type, not the implementation).
194
189
  - `createAuth.ts` is the only file with `import { betterAuth }` (~290 LOC, no Cloudflare-binding-specific code outside `config.database: D1Database`).
@@ -201,10 +196,10 @@ The future split looks like:
201
196
  @aotter/mantle-runtime ← ports + use cases (today)
202
197
  @aotter/mantle-better-auth ← createAuth + EmailSender impls + appleClientSecret (new, when needed)
203
198
  @aotter/mantle-cloudflare ← Workers adapter; depends on (or accepts) Auth-shape (today)
204
- @aotter/mantle-netlify ← Netlify adapter; same shape (v0.2)
199
+ future adapter ← same contract, implemented when needed
205
200
  ```
206
201
 
207
- The pivot point — when to extract — is when the second adapter (`mantle-netlify`) needs the same wiring. Until then, in-place co-location is cheaper than a new package boundary.
202
+ The pivot point — when to extract — is when a second adapter needs the same wiring. Until then, in-place co-location is cheaper than a new package boundary.
208
203
 
209
204
  ## Consequences
210
205
 
@@ -241,7 +236,6 @@ The pivot point — when to extract — is when the second adapter (`mantle-netl
241
236
  - `databaseHooks.user.create.after` for `ensureBootstrapOwner` semantics
242
237
  - Two `/.well-known/oauth-protected-resource/*` metadata endpoints (Better Auth helpers)
243
238
  - Public View MCP tools: dispatcher emits `query_view_<name>` on `/mcp`.
244
- - Future manifest grammar tools: dispatcher will read `Procedure.requires.auth.all` to route user-facing tools to `/mcp` or `/staff/mcp`.
245
239
  - Skills + docs updates for the dual MCP URL handoff
246
240
 
247
241
  ### Backward compatibility
@@ -263,20 +257,9 @@ User MCP URL: https://<worker>.workers.dev/mcp (give to visitors / t
263
257
  The publication starter repo's production smoke recipe uses `/mcp/staff`
264
258
  for the MCP operator smoke step.
265
259
 
266
- ### Future-proof for v0.2
267
-
268
- The end-user MCP via DCR + role-gated content (community / fan-club) requires no architectural change — just:
269
-
270
- - Enable Better Auth `socialProviders.google` / `.apple` (config-only)
271
- - Enable `magicLink` and `emailOTP` plugins (config + `EmailSender` wiring already in place)
272
- - Promote DRAFT manifest grammar from POC ADR-0005 — `Schema.spec.policies.readable: ctx.user` and `requires.auth.all: [{ ctx.user.subscription: [premium] }]`
273
- - Add `additionalFields: { subscriptionTier: ... }` on user when Stripe entitlement lands
274
-
275
- No config flag flips, no surface migration. The dispatcher partition rule (predicate → surface) handles new tool emission automatically.
276
-
277
260
  ### Platform agnosticism
278
261
 
279
- By removing `@cloudflare/workers-oauth-provider` and routing auth through Better Auth, the SDK no longer depends on any CF-specific auth service. A future Netlify adapter constructs a Better Auth instance backed by a Netlify-compatible D1 / postgres / sqlite database; the rest of the runtime + dispatcher + skills + prompts work unchanged. ADR-0011 (adapter port spec) is amended: the `OAuthVerifier` port disappears; auth becomes a direct constructor argument with platform-agnostic Better Auth as the type.
262
+ By removing `@cloudflare/workers-oauth-provider` and routing auth through Better Auth, the SDK no longer depends on any CF-specific auth service. A future adapter can construct Better Auth against its database while the runtime + dispatcher + skills + prompts stay unchanged. ADR-0011 (adapter port spec) is amended: the `OAuthVerifier` port disappears; auth becomes adapter-owned, with platform-agnostic Better Auth as the type.
280
263
 
281
264
  ## Alternatives considered
282
265
 
@@ -346,13 +329,6 @@ Phase 2 (v0.1.x):
346
329
  - Magic-link + email-OTP plugins enabled (need `ResendEmailSender` wired)
347
330
  - Account-linking with reauth UI in publication starter
348
331
 
349
- Phase 3 (v0.2+, with community / fan-club):
350
-
351
- - POC ADR-0005 DRAFT grammar promotion: `Schema.spec.policies.readable`, `requires.auth.all: ctx.user.subscription[*]`
352
- - Subscription tier on user (`additionalFields`)
353
- - Stripe webhook → entitlement updater
354
- - Community / fan-club starter manifests
355
-
356
332
  ## How to apply
357
333
 
358
334
  When reviewing or implementing a change that touches auth, MCP routing, or roles:
@@ -12,7 +12,7 @@ Records of *why* mantle ended up shaped this way. The numbering preserves POC AD
12
12
  | [0008](0008-structured-diagnostic-shape.md) | Diagnostic shape for validate/boot/runtime failures, with a reserved consumer-test phase; measured harnesses keep purpose-shaped reports. | Accepted + amended |
13
13
  | [0009](0009-consumer-supplied-manifests.md) | Consumers own manifest YAML; the installed CLI emits the parser-free runtime module and handler types. Core ships no application manifests. | Accepted + amended |
14
14
  | [0010](0010-locale-and-translates.md) | Locale 3-layer (manifest / D1 site_config / data field) + translates pattern. Boot decoupled from `site_config` (issue #60 fix). | Accepted (refreshed) |
15
- | [0011](0011-adapter-port-spec.md) | Adapter port spec. Required runtime ports plus optional feature ports. CF impl + Netlify stub. | Accepted (new) |
15
+ | [0011](0011-adapter-port-spec.md) | Adapter port spec. Required runtime ports plus optional feature ports. | Accepted (new) |
16
16
  | [0012](0012-views-as-public-rest.md) | Views auto-expose matching REST and `query_view_*` MCP reads on their declared `public` or `staff` surface. Schemas never get a public REST endpoint. | Accepted + amended |
17
17
  | [0013](0013-agent-provisioned-consumer-projects.md) | Historical agent-provisioned consumer projects path. Superseded for first launch by landing provision bundles. | Superseded |
18
18
  | [0014](0014-auth-better-auth-and-multi-tenant-mcp.md) | The Cloudflare adapter owns the curated Better Auth identity/session facade and top-level `@cloudflare/workers-oauth-provider` MCP transport. Both normalize verified callers into runtime context; mutable staff role and target authorization are re-evaluated per call. | Accepted + amended |
@@ -39,12 +39,12 @@ for new v0.1.0 boundaries, and folds / drops the rest:
39
39
 
40
40
  - **POC ADR-0003** OpenAPI emission → folded into `mantle-spec` README (the *what* is implementation; the *why* was already captured by ADR-0001's grammar lock).
41
41
  - **POC ADR-0004** D1 today, Hyperdrive PG tomorrow → folded into `mantle-cloudflare` README (now a v0.2 roadmap item, not an architectural decision).
42
- - **POC ADR-0005** v0.1 minimum vs DRAFT discipline → folded into ADR-0001 §"Future grammar discipline."
42
+ - **POC ADR-0005** v0.1 minimum grammar → folded into ADR-0001's fail-closed grammar policy.
43
43
  - **POC ADR-0006** multi-doc YAML → folded into ADR-0001 §"Authoring shape: multi-doc YAML."
44
- - **POC ADR-0011** lifecycle binary opt-in → distilled to a §"Lifecycle" subsection in `docs/design-atoms.md`. v0.1.0 ships `simple` only; `editorial` is a v0.1.x feature.
44
+ - **POC ADR-0011** lifecycle binary opt-in → distilled to a §"Lifecycle" subsection in `docs/design-atoms.md`. v0.1.0 ships `publishing` and `operational`.
45
45
  - **POC ADR-0012** strategic posture vs adjacent CMS designs → strategic / marketing material, lives in `README.md` if anywhere.
46
46
  - **POC ADR-0013** role-split surfaces (coder agent vs operator agent) → folded into ADR-0007 (Part B).
47
- - **POC ADR-0014** builtin handlers and lifecycle Triggers → promoted to v0.1.0 and implemented in the rebuild via `LifecycleHookingEntryRepository` and `InvokeBuiltinUseCase`. Editorial lifecycle remains v0.1.x-gated. Full shape spec lives in `docs/design-atoms.md`.
47
+ - **POC ADR-0014** builtin handlers and lifecycle Triggers → promoted to v0.1.0 and implemented in the rebuild via `LifecycleHookingEntryRepository` and `InvokeBuiltinUseCase`. Full shape spec lives in `docs/design-atoms.md`.
48
48
  - **POC ADR-0015** cms-astro internal seam discipline → POC-specific to a package that no longer exists; replaced by ADR-0011 (adapter port spec).
49
49
  - **POC ADR-0029** drop Astro from cms-cloudflare → POC-specific historical record; the rebuild starts post-Astro.
50
50
 
@@ -78,6 +78,63 @@ Missing/invalid credentials return `401`; a verified caller missing a required
78
78
  role or scope returns `403`; a site guard may return
79
79
  `ENTITLEMENT_REQUIRED`/`402`. Guards run on every call and are not cached.
80
80
 
81
+ ### Identity-bound Views
82
+
83
+ Use the closed `{ "$ctx.user": "id" }` filter sentinel for rows owned by the
84
+ current site-local Better Auth user. The caller never supplies this value, so
85
+ the same View is safe on both REST and public MCP:
86
+
87
+ ```yaml
88
+ apiVersion: cms.mantle.aotter.net/v1
89
+ kind: Schema
90
+ metadata: { name: orders }
91
+ spec:
92
+ schema:
93
+ type: object
94
+ properties:
95
+ userId: { type: string, x-mantle-bind: ctx.user }
96
+ orderNumber: { type: string }
97
+ orderStatus: { type: string }
98
+ totalMinor: { type: integer }
99
+ placedAt: { type: integer }
100
+ indexes: [[userId, placedAt]]
101
+ ---
102
+ apiVersion: cms.mantle.aotter.net/v1
103
+ kind: View
104
+ metadata: { name: my-orders }
105
+ spec:
106
+ surface: public
107
+ from: orders
108
+ requires:
109
+ auth:
110
+ all: [ctx.user]
111
+ filter:
112
+ and:
113
+ - { eq: { field: status, value: published } }
114
+ - { eq: { field: userId, value: { "$ctx.user": id } } }
115
+ fields: [orderNumber, orderStatus, totalMinor, placedAt]
116
+ orderBy: [{ field: placedAt, direction: desc }]
117
+ limit: 50
118
+ ```
119
+
120
+ Core rejects this sentinel unless the View requires `ctx.user` and the bound
121
+ field is the leftmost field of a declared Schema index. Missing identity fails
122
+ with `401`; it never drops the filter or falls back to all rows. REST exposes
123
+ `GET /api/views/my-orders`; public MCP exposes `query_view_my_orders`. Both
124
+ call `ExecuteViewUseCase` and bind the same `ctx.user.id`.
125
+
126
+ The id belongs to the customer site's Better Auth user row. It is not a
127
+ Mantle Platform user id, Hosted Auth upstream subject, email, or provider id.
128
+ Hosted Auth may establish the site session, but Platform is not part of the
129
+ View query path.
130
+
131
+ ## Site OAuth symmetry
132
+
133
+ A site-issued OAuth access token represents the same caller on public MCP and
134
+ manifest HTTP routes. Both surfaces populate `ctx.user` and `ctx.auth` from the
135
+ same token grant; expiry, revocation, scope, client, and resource audience are
136
+ enforced before the Procedure or View runs.
137
+
81
138
  ## Cloudflare consumer wiring
82
139
 
83
140
  Pass one site-owned resolver to `createCmsRef`. Return `not-handled` when the
@@ -173,7 +230,6 @@ import {
173
230
  createMcpApiHandler,
174
231
  createOAuthProvider,
175
232
  D1DatabaseDriver,
176
- KvCacheBinding,
177
233
  mountServerEndpoints,
178
234
  } from "@aotter/mantle/cloudflare";
179
235
 
@@ -182,7 +238,6 @@ const runtimeRef = createCmsRef({
182
238
  handlers,
183
239
  bindings: {
184
240
  db: new D1DatabaseDriver(env.DB),
185
- kv: new KvCacheBinding(env.KV),
186
241
  assets: env.ASSETS
187
242
  ? new AssetsAssetServer(env.ASSETS)
188
243
  : { fetch: async () => null },
@@ -72,7 +72,7 @@ function assemble(env: Env) {
72
72
  }
73
73
  ```
74
74
 
75
- Keep the conventional `DB`, `KV` and `OAUTH_KV` bindings and
75
+ Keep the conventional `DB` and `OAUTH_KV` bindings and
76
76
  `nodejs_compat`; add the Queue producer in `wrangler.jsonc`:
77
77
 
78
78
  ```jsonc
@@ -103,7 +103,6 @@ import type { DeferredHookEnvelope } from "@aotter/mantle/runtime";
103
103
  import {
104
104
  AssetsAssetServer,
105
105
  D1DatabaseDriver,
106
- KvCacheBinding,
107
106
  WorkersQueueHookDispatcher,
108
107
  createCmsRef,
109
108
  createQueueHandler,
@@ -112,7 +111,6 @@ import {
112
111
 
113
112
  interface Env {
114
113
  DB: D1Database;
115
- KV: KVNamespace;
116
114
  ASSETS: Fetcher;
117
115
  MANTLE_INTERNAL_QUEUE: Queue<DeferredHookEnvelope>;
118
116
  }
@@ -124,7 +122,6 @@ function buildWorker(env: Env) {
124
122
  auth: createSiteAuth(env),
125
123
  bindings: {
126
124
  db: new D1DatabaseDriver(env.DB),
127
- kv: new KvCacheBinding(env.KV),
128
125
  assets: new AssetsAssetServer(env.ASSETS),
129
126
  deferredHookDispatcher: new WorkersQueueHookDispatcher(
130
127
  env.MANTLE_INTERNAL_QUEUE,