@aotter/mantle 0.0.11-alpha.16 → 0.0.11-alpha.19

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.
@@ -0,0 +1,272 @@
1
+ # ADR-0011: Adapter port spec
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).
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).
6
+
7
+ ## Context
8
+
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
+
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.
14
+
15
+ This ADR fixes the contract so:
16
+ - Future adapter authors have a stable target.
17
+ - PR reviewers can mechanically check "does this commit add a CF-specific type to runtime?"
18
+ - The runtime can refactor freely as long as the port shapes stay stable.
19
+
20
+ The POC accumulated multiple half-decisions about this seam (POC ADR-0015 documented an aspirational `cms-astro`-internal discipline; POC ADR-0029 retired Astro and dissolved the seam; the rebuild closes it properly).
21
+
22
+ ## Decision
23
+
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`.
25
+
26
+ | Port | Surface |
27
+ |---|---|
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. |
30
+ | `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
+
32
+ Optional feature ports may also live in `domain/port/`, but they are
33
+ not part of the first-run adapter contract until a feature is enabled.
34
+ For v0.1.x media hosting and durable lifecycle dispatch:
35
+
36
+ | Optional port | Surface |
37
+ |---|---|
38
+ | `MediaStorage` | Object-storage-shaped media upload/commit/public URL/delete contract for **public** media. Cloudflare may implement with R2, but runtime must not import R2 types. |
39
+ | `DeferredHookDispatcher` | Queue-shaped dispatcher for durable `after_*` lifecycle hooks. Cloudflare may implement with Workers Queues; other adapters may use a queue, job runner, or leave it unset. |
40
+
41
+ These optional ports must not force first-run provisioning to create R2
42
+ resources. Publication starters can carry external image URLs without a
43
+ media storage implementation.
44
+
45
+ Identity, session, OAuth, and role enforcement are adapter-owned per
46
+ ADR-0014. The runtime does not define `SessionRepository`,
47
+ `OAuthVerifier`, `UserRepository`, or `StaffRepository` ports. Adapters
48
+ must provide an auth surface compatible with their HTTP framework and
49
+ must pass authenticated user/staff context into runtime dispatchers.
50
+
51
+ ### Public vs private media — two buckets, two ports
52
+
53
+ `MediaStorage` deliberately models **public-only** semantics:
54
+
55
+ - `getPublicUrl()` returns an unconditional public URL. Reads bypass
56
+ the Worker entirely (`MEDIA_PUBLIC_URL_BASE` → CDN → R2).
57
+ - `MediaAsset.publicUrl` is frozen at commit time and embedded directly
58
+ into entry data (e.g. `posts.coverUrl`). This is intentional: for
59
+ public assets the URL is permanent, the read path is hot, and adding
60
+ a Worker round-trip on every render would defeat the cost / latency
61
+ model.
62
+ - The CORS config on the underlying R2 bucket scopes browser PUTs to
63
+ the admin origin only.
64
+
65
+ **Private content (subscription-gated, fan-club, signed-GET, etc.)
66
+ will be a *separate* port and a *separate* R2 bucket in v0.2.** Two
67
+ buckets, two ports — not one port with a `visibility` flag. Reasons:
68
+
69
+ 1. **Bucket-level isolation.** Private bucket disables public access
70
+ at the bucket level, so the worst-case "leaked private object" bug
71
+ is structurally impossible.
72
+ 2. **Different read paths.** Private reads MUST go through a Worker
73
+ route (`/api/media/private/<key>` or similar) that runs the policy
74
+ gate (staff predicate, subscription check, signed cookie, etc.)
75
+ before resolving the object. The Worker either streams via
76
+ `bucket.get()` or 302s to a short-lived signed GET URL.
77
+ 3. **Different cost models.** Public bucket is CDN-cached, near-zero
78
+ marginal cost. Private bucket charges Worker invocations on every
79
+ read. Operator should opt into the cost knowingly, not by accident.
80
+ 4. **Different MCP tool surface.** `create_private_media_upload` /
81
+ `commit_private_media_upload` keeps the closed-list semantics of
82
+ each tool tight. Agents pick the upload type explicitly.
83
+ 5. **No migration debt.** Public assets stay public forever; their
84
+ `coverUrl` strings remain valid. Private fields use a different
85
+ schema field shape (`x-mcp-hint: private-media-image` over an
86
+ opaque `assetId`, resolved at render time through the policy gate).
87
+ No batch update over already-published entries.
88
+
89
+ Adding `PrivateMediaStorage` in v0.2 is a purely additive change to
90
+ the port set and the runtime. Current `MediaStorage` callers are
91
+ untouched. The Worker route, the use cases, the MCP tools, and the
92
+ adapter all live in their own module — they compose alongside the
93
+ public path rather than retrofitting it.
94
+
95
+ ### `DatabaseDriver`
96
+
97
+ ```ts
98
+ // packages/mantle-runtime/src/domain/port/DatabaseDriver.ts
99
+ export interface DatabaseDriver {
100
+ /** Run a parameterised query. Returns a typed result-set object — adapters
101
+ * normalise their native driver into this shape. */
102
+ prepare(sql: string): PreparedStatement;
103
+ /** Multi-statement transaction. Adapters guarantee atomicity. */
104
+ batch(stmts: ReadonlyArray<PreparedStatement>): Promise<BatchResult[]>;
105
+ /** Migration runner — invoked by `bootInit` once per isolate. */
106
+ migrations: MigrationRunner;
107
+ }
108
+ ```
109
+
110
+ 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.
111
+
112
+ 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.
113
+
114
+ ### `KvCache`
115
+
116
+ ```ts
117
+ export interface KvCache {
118
+ get(key: string): Promise<string | null>;
119
+ put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
120
+ delete(key: string): Promise<void>;
121
+ /** List keys with a prefix — used by sitemap / llms.txt aggregation. */
122
+ list(prefix: string, cursor?: string | null): Promise<{ keys: string[]; cursor: string | null }>;
123
+ }
124
+ ```
125
+
126
+ CF adapter: Workers KV. Future: Redis, FS, S3-compatible store.
127
+
128
+ ### `AssetServer`
129
+
130
+ ```ts
131
+ export interface AssetServer {
132
+ /** Resolve a request to a static asset (typically under `/admin/assets/*`).
133
+ * Returns null if the asset doesn't exist — the adapter's HTTP layer
134
+ * then falls back to the SPA catchall. */
135
+ fetch(req: Request): Promise<Response | null>;
136
+ }
137
+ ```
138
+
139
+ CF adapter: wraps `env.ASSETS.fetch(req)`. Future: filesystem read, S3+CDN, Netlify static-publish dir.
140
+
141
+ 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."
142
+
143
+ ## How adapters wire ports
144
+
145
+ ```ts
146
+ // simplified Cloudflare adapter wiring (post-ADR-0014, amended
147
+ // 2026-05-15 by PR #193's OAuth carve-out).
148
+ import {
149
+ createAuth,
150
+ createCmsRef,
151
+ createMcpApiHandler,
152
+ createOAuthProvider,
153
+ mountAuthorize,
154
+ mountServerEndpoints,
155
+ AssetsAssetServer,
156
+ D1DatabaseDriver,
157
+ KvCacheBinding,
158
+ } from "@aotter/mantle-cloudflare";
159
+
160
+ const auth = createAuth({
161
+ database: env.DB,
162
+ baseURL: env.PUBLIC_ORIGIN,
163
+ secret: env.BETTER_AUTH_SECRET,
164
+ methods: [
165
+ {
166
+ kind: "social",
167
+ provider: "github",
168
+ clientId: env.GITHUB_CLIENT_ID,
169
+ clientSecret: env.GITHUB_CLIENT_SECRET,
170
+ },
171
+ ],
172
+ bootstrapOwner: env.ADMIN_GITHUB_LOGIN
173
+ ? { match: "github-login", value: env.ADMIN_GITHUB_LOGIN }
174
+ : undefined,
175
+ });
176
+ const cms = createCmsRef({
177
+ manifests,
178
+ handlers,
179
+ bindings: {
180
+ db: new D1DatabaseDriver(env.DB),
181
+ kv: new KvCacheBinding(env.KV),
182
+ assets: new AssetsAssetServer(env.ASSETS),
183
+ },
184
+ auth,
185
+ });
186
+ const app = new Hono();
187
+ app.all("/api/auth/*", (c) => auth.handler(c.req.raw));
188
+ mountServerEndpoints(app, cms);
189
+ mountAuthorize(app, { auth }); // /oauth/authorize consent gate
190
+
191
+ // `OAuthProvider` must be the top-level worker entry — it injects
192
+ // `env.OAUTH_PROVIDER` helpers the consent handler needs, and the
193
+ // claude.ai MCP client requires it to discover AS metadata.
194
+ export default createOAuthProvider({
195
+ defaultHandler: app,
196
+ apiHandlers: {
197
+ "/mcp/staff": createMcpApiHandler({ ref: cms, surface: "staff" }),
198
+ "/mcp": createMcpApiHandler({ ref: cms, surface: "public" }),
199
+ },
200
+ });
201
+ ```
202
+
203
+ The runtime gets three required adapter ports (`db`, `kv`, `assets`)
204
+ alongside manifests, handlers, templates, and site defaults. Auth is
205
+ owned by the adapter layer that mounts HTTP/MCP surfaces; the runtime
206
+ receives authenticated context when the adapter dispatches requests.
207
+ There's no module-global state holding adapter-specific bindings.
208
+
209
+ ## Consequences
210
+
211
+ **Hard-enforced boundaries**:
212
+ - `@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.
213
+ - 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.
214
+ - Removing a port is also possible (if a port is found to overlap or be unnecessary), again by amending this ADR.
215
+
216
+ **Discoverability for adapter authors**:
217
+ - 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.
218
+
219
+ **Test ergonomics**:
220
+ - Each port is small and isolated. Tests can mock individual ports without spinning up D1 / KV / OAuth provider.
221
+ - 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.
222
+
223
+ **The Netlify stub's job**:
224
+ - 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.
225
+
226
+ ## Alternatives considered
227
+
228
+ **(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.
229
+
230
+ **(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.
231
+
232
+ **(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.
233
+
234
+ **(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.
235
+
236
+ **(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.
237
+
238
+ ## How to apply
239
+
240
+ When you're authoring `@aotter/mantle-runtime` code:
241
+
242
+ 1. If you reach for a CF-specific type, **stop**. Define a method on a port instead.
243
+ 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.
244
+ 3. Tests must use port mocks (in-memory implementations) — never reach into a real D1 / KV from runtime tests.
245
+
246
+ When you're authoring an adapter (`@aotter/mantle-cloudflare` for v0.1.0; future `mantle-netlify`, `mantle-bun`, …):
247
+
248
+ 1. Read `mantle-runtime/src/domain/port/`. Implement each required port against your runtime's primitives.
249
+ 2. Compose the runtime via `createCmsRuntime({ db, kv, assets, manifests, handlers, templates, siteDefaults, ... })`.
250
+ 3. Call `runtime.bootInit()` once before serving CMS traffic.
251
+ 4. Bind to your HTTP framework — Hono on CF, Netlify Functions handler, raw `fetch` Worker, …
252
+ 5. Provide adapter-owned auth and map sessions/scopes/roles into runtime handler context.
253
+ 6. Bundle `@aotter/mantle-admin-ui`'s `dist/` via your runtime's static-asset surface and bind `AssetServer` to it.
254
+
255
+ When you're reviewing a PR:
256
+
257
+ 1. Grep the diff for `@cloudflare`, `D1Database`, `KVNamespace`, `Fetcher` — flag any occurrence in `mantle-runtime/`.
258
+ 2. If a new port method shows up, check it's also reflected in this ADR + the Netlify stub README.
259
+ 3. If a port shape changed, all 2 adapters (CF real, Netlify stub) get updated in the same PR.
260
+
261
+ ## Implementation status
262
+
263
+ - [x] Required port interface files live in `packages/mantle-runtime/src/domain/port/*.ts`.
264
+ - [x] Cloudflare required port implementations live in `packages/adapters/cloudflare/src/bindings/*.ts`.
265
+ - [x] Optional feature port `MediaStorage` (public bucket) is declared but not required by first-run adapters. `PrivateMediaStorage` is v0.2.
266
+ - [x] Netlify stub README references this ADR.
267
+ - [ ] CI lint: forbid `@cloudflare/*` / `D1Database` / `KVNamespace` imports in `mantle-runtime/` (post-v0.1.0; manual review until then)
268
+
269
+ ## See also
270
+
271
+ - ADR-0007 — AI as primary author. Adapter port discipline serves the "AI debuggable" loop: a missing port method surfaces as a structured Diagnostic at boot, not a runtime 500 in production.
272
+ - ADR-0009 — consumer-supplied manifests. Manifests don't care about ports; ports don't care about manifests. The two abstractions compose cleanly.
@@ -0,0 +1,137 @@
1
+ # ADR-0012: Views as the public REST surface
2
+
3
+ **Status:** Accepted for v0.1.0. New ADR.
4
+
5
+ **Date:** 2026-05-05
6
+
7
+ ## Context
8
+
9
+ mantle ships two read-side surfaces and one write-side surface:
10
+
11
+ - **Templates** (rendered HTML / Markdown / `llms.txt`) — composed by the consumer's `TemplateRegistry` from runtime APIs. The starter blog uses these for `/{locale}/posts/{slug}` etc.
12
+ - **MCP tools** — agent-facing CRUD over the entry chokepoint. Every Schema gets `create_draft_<n>` / `update_draft_<n>` per-collection authoring tools plus generic tools (`list_entries`, `get_entry`, `request_publish`, `unpublish_entry`, `archive_entry`).
13
+ - **HTTP Triggers** — write-side endpoints declared by the consumer (`Trigger.source.kind: http`, methods `POST | PUT | PATCH | DELETE` only). Per ADR-0001 grammar, **`GET` is intentionally absent** because read endpoints belong to Views, not Procedures.
14
+
15
+ What was missing: a stable, consumer-facing **public REST read surface**. The starter blog had no JSON API at all. The CMS needed an answer to "I'm a downstream service that wants `posts` filtered by locale — how do I read?" without forcing every consumer to hand-write a route handler that re-implements filtering.
16
+
17
+ This ADR answers that question: **every parsed View auto-exposes `GET /api/views/<view-name>`**, and Schemas do not get a public REST surface at all. Public reads always go through a named query.
18
+
19
+ (Schemas remain available on the **admin** REST surface — `/admin/api/*` — which lands with the admin UI commit and is auth-gated to staff. That's a separate cut and out of scope here.)
20
+
21
+ ## Decision
22
+
23
+ ### 1. Public reads route through Views, never Schemas
24
+
25
+ Adding `Schema.spec.expose.rest` and letting collections leak directly to public REST was the alternative considered. We rejected it because:
26
+
27
+ - `Schema` declares the **storage shape**; entries carry `draft` / internal status, server-stamped fields, and per-row data the author may not have intended for the public. `contact-messages` is the canonical example — direct `GET /api/contact-messages` would be a privacy bug by default.
28
+ - `View` already has the right semantics: a named query with explicit `filter` / `fields` / `orderBy` / `limit`. Auto-exposing it as REST ratifies what the manifest already declares.
29
+ - One way to do public reads is simpler than two. The Mantle thesis ("agents write config; runtime carries complexity") gets stronger when the contract surface stays narrow.
30
+
31
+ ### 2. URL shape: `/api/views/<view-name>`
32
+
33
+ No version prefix. `apiVersion: cms.mantle.aotter.net/v1` is the manifest-grammar version, locked under v0.1 grammar discipline; the public REST URL doesn't need to repeat it.
34
+
35
+ `<view-name>` is `View.metadata.name` verbatim. Authors are free to pick kebab-case (`recent-posts`) or any URL-safe identifier; the runtime mounts the route as-is.
36
+
37
+ ### 3. Views auto-expose; opt-out is "don't write a View"
38
+
39
+ We considered adding `View.spec.expose: { rest: false }`. Rejected — the shape `View` already has IS "public read API". Authors who want a private named query write a TypeScript helper and call `runtime` directly from their template.
40
+
41
+ ### 4. Pagination knobs are reserved query-string names
42
+
43
+ Public callers pass `?page=<1-indexed>&show=<page-size>`. Internally the runtime emits `LIMIT show OFFSET (page-1)*show`.
44
+
45
+ `page` / `show` / `cursor` are reserved names. The parser rejects any `View.spec.params.properties.<name>` colliding with these (`VIEW_PARAMS_RESERVED_NAME`). The author owns the rest of the query-string namespace.
46
+
47
+ The alternative ("let `filter` express arithmetic so the author writes their own page→offset math") was rejected as a grammar-discipline violation: v0.1 filter is a closed AST (`eq` / `gt` / `gte` / `lt` / `lte` plus boolean `and` / `or`) and adding arithmetic would drag in operator precedence, type coercion, and a closed-enum-vs-expression debate that distracts from shipping.
48
+
49
+ `View.spec.limit` is the **server-enforced cap** on `show`; if the caller passes `?show=10000` and the View declares `limit: 50`, the runtime trims to 50.
50
+
51
+ ### 5. Filter comparison `value` accepts a `{ $param: <name> }` sentinel
52
+
53
+ Static Views are useful (`recent-posts`); param-driven Views are essential (`posts-by-locale?locale=zh-TW`). Until this ADR, filter values were literals, which forced one View per parameter combination — unworkable for `tag` / `locale` / `author` queries.
54
+
55
+ The grammar gains:
56
+
57
+ ```yaml
58
+ spec:
59
+ params:
60
+ type: object # required, must be type: object
61
+ properties:
62
+ locale: { type: string } # scalar leaf types
63
+ required: [locale] # v0.1.0 enforces required-only
64
+ filter:
65
+ eq: { field: locale, value: { $param: locale } }
66
+ ```
67
+
68
+ 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.
69
+
70
+ Boot validator gates:
71
+ - `View.spec.params` MUST be `type: object` with `properties` declared (`VIEW_PARAMS_INVALID_SHAPE`).
72
+ - Reserved names rejected (`VIEW_PARAMS_RESERVED_NAME`).
73
+ - Every `{ $param: <name> }` ref MUST resolve to a declared param (`VIEW_FILTER_PARAM_REF_UNKNOWN`).
74
+ - Every `{ $param: <name> }` ref MUST appear in `params.required` (`VIEW_FILTER_PARAM_REF_NOT_REQUIRED`).
75
+
76
+ 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.
77
+
78
+ ### 6. Response envelope is `{ rows, page, show, hasMore }`
79
+
80
+ ```json
81
+ {
82
+ "ok": true,
83
+ "data": {
84
+ "rows": [...],
85
+ "page": 1,
86
+ "show": 20,
87
+ "hasMore": true
88
+ }
89
+ }
90
+ ```
91
+
92
+ `rows` is intentionally a generic name (matches OLAP family conventions in `aotter-mantle`). v0.1.0 Views are list-of-projected-entries, but join + group-by results land in v0.1.x and may use a different envelope (`data.value` for scalars, `data.tree` for hierarchies). The choice of `rows` over `entries` keeps the door open without forcing a v0.1.0 manifest declaration of output shape.
93
+
94
+ `hasMore = (rows.length === effectiveShow)` — the lazy semantics. We do **not** issue a separate `COUNT(*)` query, and we do not pull `LIMIT n+1` to probe. If the server returns exactly `show` rows, the caller may or may not have more; if fewer, we know definitively. The trade is one false-positive on the boundary case (caller asks for next page, gets empty) in exchange for no extra round-trip per request.
95
+
96
+ ### 7. Param coercion happens at the adapter boundary
97
+
98
+ Query strings arrive as strings; `View.spec.params` declares the JSON Schema type. The Cloudflare adapter (`coerceViewParams` in `mountServerEndpoints.ts`) coerces per-property:
99
+
100
+ | `params.<name>.type` | Coercion |
101
+ |---|---|
102
+ | `string` | identity |
103
+ | `integer` | `parseInt` (rejects non-canonical / float-like input) |
104
+ | `number` | `Number()` |
105
+ | `boolean` | `"true"` / `"false"` only |
106
+ | `enum` | string matched against the enum array |
107
+
108
+ 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).
109
+
110
+ ## Out of scope (deferred)
111
+
112
+ - **MCP tools for Views.** MCP is for agents doing ops; readers don't need a separate MCP tool when they have a stable REST endpoint. Reconsider in v0.2 if downstream agent tooling demands it.
113
+ - **`Trigger.target.view`** (lifecycle/projection triggers fired by Views). Tracked separately as a v0.2 grammar move.
114
+ - **`spec.output.kind`** (declaring scalar / tree / tabular result shape per View). Lands with join + group-by support in v0.1.x.
115
+ - **Optional param-ref drop semantics in the parser.** Runtime is already implemented; parser promotes when v0.1.x lands.
116
+ - **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.
117
+ - **Auth on the public View REST surface.** v0.1.0 Views are public-read by definition. Member-gated reads land with the member system in v0.2.
118
+
119
+ ## Consequences
120
+
121
+ **Authors gain:**
122
+ - Public REST surface for free — declare a View, get an endpoint.
123
+ - Single mental model for "how do consumers read?": always Views.
124
+ - Cheap pagination + dynamic filters without hand-writing handlers.
125
+
126
+ **Authors lose:**
127
+ - 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.
128
+ - No way to write a "private" named query — moves to a TS helper.
129
+
130
+ **Runtime gains:**
131
+ - One auto-mount path covers every public-read use case for v0.1.0.
132
+ - Forward-compat for join / group-by / aggregation: envelope generalises by Views declaring `output.kind` later.
133
+
134
+ **Reviewers / future contributors should:**
135
+ - Reject any PR adding `Schema.spec.expose.rest` or a similar Schema-level public-read flag.
136
+ - Reject any PR introducing a second public read surface (e.g. `/api/<collection>` shortcut).
137
+ - Reject any PR that lets `filter` reference state outside the declared `params` (e.g. `{ $env: ... }`, `{ $cookie: ... }`) without a matching ADR amendment.
@@ -0,0 +1,147 @@
1
+ # ADR-0013: Agent-provisioned consumer projects
2
+
3
+ ## Status
4
+
5
+ Accepted (new)
6
+
7
+ ## Date
8
+
9
+ 2026-05-09
10
+
11
+ ## Context
12
+
13
+ mantle is not optimized for a human developer reading a long
14
+ installation guide and hand-editing boilerplate. The intended v0.1.0
15
+ entry path is:
16
+
17
+ 1. The official site asks for a small set of launch choices: site name,
18
+ project/repo slug, starter/theme, locale(s), visibility, and GitHub
19
+ account context supplied through the Mantle GitHub App when present.
20
+ 2. The site creates a short-lived launch session and produces a one-line
21
+ `create-mantle launch --session ...` command for the user's agent.
22
+ The older localized starting prompt plus pinned Skill URL remains a
23
+ fallback when the session path is unavailable.
24
+ 3. A coder agent runs in the user's chosen parent directory, claims the
25
+ session, scaffolds immediately, initializes local launch state, installs
26
+ npm packages, validates it, and then proceeds toward Cloudflare
27
+ provision.
28
+ 4. The deployed site gives the owner an admin sign-in path and an MCP
29
+ URL for ongoing content operations.
30
+
31
+ This is a different architecture from "SDK docs plus templates." The
32
+ template must be safe for agents to mutate, the package dependency
33
+ story must work outside this monorepo, and first-run content cannot
34
+ depend on an operator MCP session that does not exist yet.
35
+
36
+ ## Decision
37
+
38
+ Consumer projects are provisioned by an agent, using repo-hosted
39
+ Skills as the operational contract.
40
+
41
+ The source of truth for first-run scaffold intent is the website-created
42
+ launch session. It carries structured values such as `archetype`,
43
+ `theme`, `features`, `locales`, `project_name`, `site_name`,
44
+ `github_owner`, `admin_github_login`, and `repo` intent. The install
45
+ Skill should consume a valid launch session instead of running a long
46
+ generic interview before scaffold. Prompt-first install remains the
47
+ fallback when no valid launch session is available.
48
+
49
+ The launch session is not a general provider credential. It authorizes
50
+ the scaffold values the website just collected, and the agent writes
51
+ only non-secret resumable state such as `.mantle/launch-state.json`.
52
+ Cloudflare API tokens, GitHub OAuth App client secrets, paid-provider
53
+ features, and custom domains still require explicit user authorization
54
+ during provision.
55
+
56
+ Published npm packages are the runtime dependency source. Starter files
57
+ may still be copied from the GitHub repo or a release tag, but
58
+ `setup:site` rewrites `workspace:*` dependencies to the requested
59
+ `@aotter/mantle-*` npm version before `pnpm install`. This keeps
60
+ consumer projects independent from the monorepo and avoids private-repo
61
+ token friction in the install path.
62
+
63
+ Starter-owned setup scripts are the only supported way to rewrite
64
+ standard template fields. Agents should not hand-edit `wrangler.toml`,
65
+ `src/mantleConfig.ts`, `package.json`, or similar standard fields when a
66
+ starter script exists. Hand edits are reserved for actual product
67
+ customization.
68
+
69
+ Starter source is copied from pinned public GitHub refs by downloading
70
+ source tarballs, not by leaving a normal clone in place. The extracted
71
+ project initializes its own Git repo and should only set `origin` after
72
+ the user creates or selects a user-owned repository. This avoids agents
73
+ accidentally pushing back to the template source.
74
+
75
+ First-run content is not seeded during real-user install/provision.
76
+ Provision creates the operating surface first: public site, owner
77
+ sign-in, and Staff/User MCP URLs. After owner sign-in, the operating
78
+ agent interviews the owner and asks whether to create initial
79
+ pages/posts through Staff MCP or admin authoring. `seed:initial` and
80
+ fixture data are reserved for tests and contributor local dev.
81
+
82
+ Cloudflare provisioning is owned by the starter's provision script. It
83
+ creates the required free-path resources, writes bindings/secrets,
84
+ deploys once with the real origin, and returns the handoff URLs.
85
+ Agents should not decompose this into ad-hoc Wrangler steps unless
86
+ they are debugging a failed provision.
87
+
88
+ ## Consequences
89
+
90
+ - Skills become part of the compatibility surface. Updating install or
91
+ provision behavior requires updating the relevant Skill in the same
92
+ change.
93
+ - Starter templates must remain script-configurable. Adding a new
94
+ standard field without updating `setup:site` is a regression for
95
+ agent provisioning.
96
+ - npm publishing must precede realistic end-user provisioning tests.
97
+ `file:` or workspace dependencies are acceptable only for local SDK
98
+ development.
99
+ - The first-run success criterion is product-level: public site,
100
+ owner sign-in, MCP URL, and an owner-approved path to create initial
101
+ content through MCP/admin authoring. A deployed but unauthored site
102
+ can be a valid intermediate checkpoint, not a direct-seed failure.
103
+ - Provisioning must preserve the no-credit-card path. Optional paid or
104
+ billing-gated features must be opt-in after the site is already live.
105
+
106
+ ## Alternatives
107
+
108
+ - **Human-first docs with manual edits.** Rejected because it makes the
109
+ non-coder path fragile and forces agents to infer boilerplate edits
110
+ from prose.
111
+ - **Git dependency only.** Rejected for the default path because private
112
+ repos and Git authentication add friction. Git refs remain useful for
113
+ copying starter files, not for installed runtime dependencies.
114
+ - **Direct seed creates the initial content.** Rejected for real-user
115
+ onboarding because it bypasses the same operating path we want to
116
+ validate. MCP/admin authoring after owner sign-in is the product
117
+ loop; fixtures and seed utilities remain for tests/local dev.
118
+ - **One universal starter.** Rejected. The current publication starter
119
+ is intentionally fixed-manifest during bootstrap; custom workflows
120
+ belong in `blank` or future dedicated starters.
121
+
122
+ ## How to apply
123
+
124
+ - If a website launch session supplies structured install values, run
125
+ `create-mantle launch --session ...` first and ask content/voice
126
+ questions later.
127
+ - If only a website prompt supplies structured install values, consume
128
+ them directly and only confirm public/resource-name-impacting details.
129
+ - Run the starter's `setup:site` script before installing
130
+ dependencies.
131
+ - Download starter source as a pinned tarball/zip, extract it, run
132
+ `git init`, and only add a remote that belongs to the user.
133
+ - Keep first-run provision scripts on the free Cloudflare path unless
134
+ the user explicitly opts into a paid feature.
135
+ - After provisioning, hand the user to: view the site, sign in as
136
+ owner, then connect an MCP-capable agent to create/operate content.
137
+
138
+ ## Implementation status
139
+
140
+ - `skills/install/SKILL.md` and `skills/provision/SKILL.md` encode the
141
+ current agent workflow, including the launch-session fast path.
142
+ - `aotter/mantle-starters` hosts `packages/create-mantle`, whose
143
+ `launch --session` mode validates the session before writing files.
144
+ - Starters ship `provision:up` where supported; `seed:initial` remains
145
+ a test/contributor utility, not part of real-user provisioning.
146
+ - Current prerelease package versions are documented in
147
+ `docs/release-process.md`.