@aotter/mantle 0.0.11-alpha.63 → 0.0.11-alpha.64

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 (41) hide show
  1. package/README.md +87 -12
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +52 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/generate.d.ts +2 -0
  7. package/dist/generate.d.ts.map +1 -0
  8. package/dist/generate.js +181 -0
  9. package/dist/generate.js.map +1 -0
  10. package/dist/skills.d.ts +2 -0
  11. package/dist/skills.d.ts.map +1 -0
  12. package/dist/skills.js +80 -0
  13. package/dist/skills.js.map +1 -0
  14. package/dist/update.d.ts +2 -0
  15. package/dist/update.d.ts.map +1 -0
  16. package/dist/update.js +387 -0
  17. package/dist/update.js.map +1 -0
  18. package/docs/adr/0001-four-atom-manifest-model.md +6 -7
  19. package/docs/adr/0007-ai-as-primary-author.md +100 -138
  20. package/docs/adr/0008-structured-diagnostic-shape.md +79 -99
  21. package/docs/adr/0009-consumer-supplied-manifests.md +101 -228
  22. package/docs/adr/0012-views-as-public-rest.md +43 -15
  23. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +43 -17
  24. package/docs/adr/0018-core-starters-repository-boundary.md +155 -0
  25. package/docs/adr/README.md +8 -6
  26. package/docs/cloudflare-low-level-composition.md +94 -0
  27. package/docs/design-atoms.md +59 -57
  28. package/docs/design-references/editorial-blog-2026-05-05.md +7 -7
  29. package/docs/labels.md +1 -1
  30. package/docs/media-uploads.md +1 -1
  31. package/docs/release-process.md +156 -523
  32. package/package.json +9 -6
  33. package/skills/README.md +20 -16
  34. package/skills/develop/SKILL.md +4 -4
  35. package/skills/install/SKILL.md +16 -2
  36. package/skills/plugin/SKILL.md +1 -1
  37. package/skills/provision/SKILL.md +1 -1
  38. package/skills/theme/SKILL.md +12 -10
  39. package/skills/update/SKILL.md +31 -19
  40. package/skills/customize-design/SKILL.md +0 -215
  41. package/skills/extend/SKILL.md +0 -257
@@ -1,270 +1,143 @@
1
1
  # ADR-0009: Consumer-supplied manifests at SDK boot
2
2
 
3
- **Status:** Carried over from POC v0.0.x; refreshed for v0.1.0.
3
+ **Status:** Carried over from POC v0.0.x; amended for the parser-free v0.1
4
+ consumer boundary.
4
5
 
5
- **Date**: 2026-05-01 (POC); refreshed 2026-05-03 for v0.1.0 rebuild
6
+ **Date:** 2026-05-01 (POC); last amended 2026-08-03
6
7
 
7
- **Deciders**: phsu
8
-
9
- **Related**: [ADR-0001](0001-four-atom-manifest-model.md) (the manifest model whose authoring path this ADR opens to consumers), [ADR-0007](0007-ai-as-primary-author.md) (the AI-author DX this unblocks at the per-project layer)
10
-
11
- ---
8
+ **Related:** [ADR-0001](0001-four-atom-manifest-model.md),
9
+ [ADR-0007](0007-ai-as-primary-author.md),
10
+ [ADR-0018](0018-core-starters-repository-boundary.md)
12
11
 
13
12
  ## Context
14
13
 
15
- The 4-atom manifest model ([ADR-0001](0001-four-atom-manifest-model.md))
16
- promises that "anything more domain-shaped is composed in the
17
- consumer's project." For that promise to hold, the SDK has to accept
18
- manifest content from the consumer rather than baking any in itself.
19
-
20
- The natural failure mode — and the one this ADR forecloses — is the
21
- SDK shipping with a fixed manifest set hand-maintained inside the
22
- package (e.g. a TS string mirror of starter YAML files compiled into
23
- the SDK bundle). A consumer who wants a `comments` Schema, a
24
- `like-post` Procedure, or a cron Trigger then has two bad options:
25
-
26
- 1. Edit the embedded set inside the SDK (forks the SDK; loses upgrades), or
27
- 2. Wait for a future SDK release that ships the manifest they want.
28
-
29
- Either option is incompatible with the AI-as-primary-author contract
30
- ([ADR-0007](0007-ai-as-primary-author.md)): the AI is
31
- working *inside the consumer's project*, not inside the SDK monorepo.
32
- It cannot extend the SDK without a fork-and-publish loop that has none
33
- of the three feedback loops the contract guarantees.
14
+ The four-atom model promises that domain shape is authored in the consumer's
15
+ project. Core therefore cannot ship a fixed manifest set: doing so would force
16
+ every new Schema, View, Procedure, or Trigger through an SDK fork/release.
34
17
 
35
- Consumer-supplied manifests are the path that keeps the 4-atom model
36
- a property of *the SDK* rather than of *whatever starter the SDK
37
- ships*.
18
+ The first v0.1 implementation imported YAML as Wrangler Text modules and parsed
19
+ it during Worker startup. That kept ownership correct but leaked authoring-only
20
+ YAML machinery into the runtime bundle and coupled consumers to Wrangler
21
+ `[[rules]]`. The public CLI now provides one build-time compilation boundary.
38
22
 
39
23
  ## Decision
40
24
 
41
- The SDK's mount factory accepts manifest YAML text passed in by the
42
- consumer. The SDK ships **zero** embedded manifests; the registry is
43
- built exclusively from what the consumer passes. The resulting
44
- registry feeds `createCmsRuntime(...).bootInit()`, the HTTP Trigger
45
- mounts, the View executor, and the MCP tool catalog.
25
+ Consumers own YAML under `manifests/`. The installed `mantle` CLI parses and
26
+ validates that YAML, then writes the machine-owned runtime module and handler
27
+ types:
46
28
 
47
- ```ts
48
- export interface CmsConfig {
49
- /** Procedure handlers keyed by `Procedure.spec.handler.ref`. */
50
- readonly handlers?: Readonly<Record<string, AnyHandler>>;
51
- /** Parsed consumer-authored manifests. Starters usually import YAML
52
- * as text via Wrangler rules and call `parseManifestsOrThrow()`
53
- * before passing them to the adapter. */
54
- readonly manifests: readonly Manifest[];
55
- }
29
+ ```bash
30
+ pnpm exec mantle generate
31
+ pnpm exec mantle generate --check
56
32
  ```
57
33
 
58
- The consumer's config file remains `src/mantleConfig.ts` (unchanged
59
- from v0.0.x). YAML manifests are imported as **Text modules** via
60
- Wrangler's `[[rules]]` block — the standard CF Workers mechanism for
61
- bundling text assets into a Worker without a codegen step:
34
+ The outputs are:
35
+
36
+ - `.mantle/generated/site.ts` — a parser-free `readonly Manifest[]` export;
37
+ - `.mantle/generated/types.d.ts` — handler declarations derived from the same
38
+ validated manifest set.
39
+
40
+ The Worker imports the generated array rather than YAML text:
62
41
 
63
42
  ```ts
64
- // consumer's src/mantleConfig.ts
65
- import postsYaml from "../manifests/posts.yaml";
66
- import contactYaml from "../manifests/contact.yaml";
67
- import { sendContactMessage } from "./handlers/send-contact-message.js";
68
-
69
- export const cmsConfig: CmsConfig = {
70
- manifests: [postsYaml, contactYaml],
71
- handlers: { "send-contact-message": sendContactMessage },
72
- };
73
- ```
43
+ import { createMantleWorker } from "@aotter/mantle/cloudflare";
44
+ import { manifest } from "../.mantle/generated/site.js";
74
45
 
75
- ```toml
76
- # wrangler.toml — bundle every manifests/*.yaml as text
77
- [[rules]]
78
- type = "Text"
79
- globs = ["**/*.yaml"]
80
- fallthrough = true
46
+ export default createMantleWorker({ manifest });
81
47
  ```
82
48
 
83
- A small ambient `yaml.d.ts` declaration tells TypeScript that
84
- `*.yaml` imports resolve to a string. The contract the SDK consumes
85
- is just "an array of YAML strings"; how the consumer's bundler
86
- produces them is their choice.
49
+ Low-level composition passes the same array as `CmsConfig.manifests`. Core and
50
+ adapters ship zero application manifests. The registry, boot validator, View
51
+ executor, HTTP/MCP mounts, and tool catalog are built only from the consumer's
52
+ generated array.
87
53
 
88
- ### Adapter scope for v0.1.0
54
+ `mantle generate --check` must gate validation/deploy so authored YAML cannot
55
+ drift from checked-in generated output. Generation never edits YAML, handlers,
56
+ skills, package metadata, styles, or provider configuration.
89
57
 
90
- The Cloudflare adapter (`@aotter/mantle-cloudflare`) is the
91
- only shipping adapter in v0.1.0, so the Text-import-via-`[[rules]]`
92
- pattern is the canonical path. When other adapters (Netlify, etc.)
93
- ship, they will need their own equivalent text-import mechanism, but
94
- the SDK boundary remains unchanged: an array of YAML strings in,
95
- parsed registry out.
58
+ ### Adapter scope
96
59
 
97
- ### Single-slice ship
60
+ Compilation is adapter-independent and uses Node at author/build time. The
61
+ Worker runtime receives plain typed objects, so Cloudflare needs no YAML Text
62
+ module rule and a future adapter needs no equivalent bundler hook.
98
63
 
99
- The grammar lock applies to the spec shape, not to how the SDK ships
100
- internally. v0.1.0 lands consumer-supplied manifests as the only
101
- path from day one — no embedded fallback, no deprecation window, no
102
- override semantics. This is a fresh build with no existing v0.1.x
103
- deployments to migrate.
64
+ ### Single authority
104
65
 
105
- This means:
106
- - The mount factory requires `manifests` to be passed in for any
107
- Schema / View / Trigger functionality. Calling it without manifests
108
- yields an admin-only Worker (no Schema, no View, no Trigger), with
109
- the boot validator surfacing the empty set as a warning.
110
- - No conflict policy is needed: the SDK ships no manifests, so there
111
- is no merge surface where an override could disagree with a default.
66
+ YAML remains the authored source of truth. Generated TypeScript is a
67
+ deterministic transport artifact and must not be hand-edited. Parser
68
+ diagnostics point to the consumer manifest document/pointer before generation;
69
+ runtime boot validation covers cross-manifest and registered-handler facts.
112
70
 
113
71
  ## Worked example
114
72
 
115
- The v0.1.0 starter ships at `starters/blog/manifests/` and is the
116
- canonical example consumers copy from. It demonstrates the full
117
- pattern: a `posts` Schema, a `contact` multi-doc Procedure + Trigger,
118
- the `[[rules]] type = "Text"` block in `wrangler.toml`, the ambient
119
- `yaml.d.ts`, and the `src/mantleConfig.ts` file that wires manifests
120
- + handlers into the SDK. The blog `SKILL.md` walks AI install agents
121
- through the sequence.
73
+ The external [`aotter/mantle-starters`](https://github.com/aotter/mantle-starters)
74
+ repository owns the blank project and typed overlays. Materialized projects
75
+ carry their own `manifests/`, generated module, handlers, and package scripts;
76
+ they consume the exact packed Core artifact rather than a workspace link.
122
77
 
123
78
  ## Consequences
124
79
 
125
- ### Pros
126
-
127
- - Consumers can compose all four atoms in their own project per the
128
- ADR-0001 promise. The 4-atom model is a property of the SDK, not
129
- of the starters.
130
- - The static-validation feedback loop (`mantle validate`) reads
131
- from a `manifests/` directory in the consumer's project; the
132
- validate path is uniformly applicable to consumer-authored
133
- manifests across all four feedback loops.
134
- - The SDK bundle ships no manifest content. Consumers who don't need
135
- a given starter's atoms (e.g. a docs site that only uses
136
- `posts`-shaped content) don't pay for unrelated atoms.
137
- - Text-imported YAML keeps manifests as the YAML source-of-truth on
138
- disk — no codegen step in the build, no parser drift between
139
- author-time YAML and runtime parse. Multi-doc YAML grouping
140
- ([ADR-0001](0001-four-atom-manifest-model.md) §"Authoring shape: multi-doc YAML")
141
- is preserved end-to-end.
142
- - The MCP tool catalog automatically picks up the consumer's atoms —
143
- no separate registration call. The consumer adds a `comments` Schema
144
- and the operator agent sees `create_draft_comments` /
145
- `update_draft_comments` on the next deploy.
80
+ ### Benefits
81
+
82
+ - Consumers can compose all four atoms without forking or publishing Core.
83
+ - One CLI parse/validation path drives runtime objects and handler types.
84
+ - Worker bundles do not include the YAML parser or consumer YAML text imports.
85
+ - Every REST/MCP catalog automatically reflects the generated consumer set.
86
+ - Other adapters consume the same `readonly Manifest[]` with no filesystem or
87
+ bundler-specific manifest loader.
146
88
 
147
89
  ### Costs
148
90
 
149
- - The consumer project carries a `manifests/` directory, a
150
- `wrangler.toml` `[[rules]]` block, and a `src/yaml.d.ts` ambient
151
- declaration. The install agent has these files to copy and one
152
- more import to wire (the blog `SKILL.md` covers the sequence).
153
- - Text imports for YAML are a property of the consumer's build
154
- system, not the SDK. Consumers using a non-standard build need to
155
- ensure their bundler resolves `*.yaml` to a string. This is a
156
- documentation issue, not a contract change — alternative paths
157
- (codegen, embedded literal, fetch from KV) all work the same way
158
- at the SDK boundary as long as they yield a string.
159
- - Boot validation needs to surface "the consumer wrote invalid YAML"
160
- clearly. The diagnostic's `path` field references the consumer
161
- file (e.g. `consumer-manifest:[2]#/spec/...`) so deploy logs point
162
- at the right file.
163
-
164
- ### Risks
165
-
166
- - **Build-step coupling.** Text imports depend on the consumer's
167
- bundler. If a future Wrangler version changes the `[[rules]]`
168
- surface the docs need updating. Mitigation: spec the import
169
- contract loosely — "a string carrying the YAML text" — so any
170
- other path (codegen, embedded literal, `fetch` from KV) works the
171
- same way at the SDK boundary.
172
- - **Manifest drift between dev and prod.** The consumer edits a YAML
173
- file; until they re-run `wrangler deploy`, the runtime keeps
174
- serving the previous bundle. This is the existing Worker dev-loop;
175
- the validate CLI catches the static checks pre-deploy and the
176
- boot validator catches handler-ref mismatches at deploy. No new
177
- risk; just call out in the docs that "rebuild required after
178
- manifest edit."
179
- - **Multi-tenant deployments amplifying conflict.** A single Worker
180
- running multiple tenants would want each tenant's manifests
181
- isolated. The atom model has no `namespace` field
182
- ([ADR-0001](0001-four-atom-manifest-model.md)) on purpose —
183
- multi-tenancy lives in the consumer app layer with `tenant_id`
184
- columns. Consumer-supplied manifests don't change this; the SDK
185
- still sees one flat manifest set per Worker. Multi-tenant
186
- SaaS-on-this-CMS is a separate design question.
187
- - **Manifest set growth.** A large consumer (50+ Procedures, 20+
188
- Schemas) makes per-file imports verbose. Mitigation: a future
189
- ergonomic helper (`loadManifestsFromGlob`) can be added without
190
- changing the SDK contract — the contract is "pass an array of YAML
191
- strings"; how the consumer assembles the array is their choice.
91
+ - Projects keep deterministic generated files and must run `generate` after
92
+ editing YAML.
93
+ - CI/deploy must run `generate --check`; otherwise an old generated module can
94
+ outlive its source YAML.
95
+ - Merge conflicts in generated output are resolved by re-running the installed
96
+ command, never by editing the artifact.
97
+
98
+ ### Risks and controls
99
+
100
+ - **Version skew:** always run the generator from the installed package and
101
+ read its embedded docs. Do not generate a versioned project from `develop`.
102
+ - **Hidden stale output:** keep `mantle generate --check` in the normal project
103
+ validation gate.
104
+ - **Runtime/parser divergence:** generated objects come from the same parser
105
+ used by `validate`; focused packed-package tests compare the consumer output.
106
+ - **Multi-tenancy pressure:** Core still sees one flat manifest set per Worker.
107
+ Tenant isolation remains an application concern; this ADR adds no namespace.
192
108
 
193
109
  ## Alternatives considered
194
110
 
195
- **(A) `manifests: readonly Manifest[]`** — pre-parsed manifest
196
- objects instead of YAML strings. Rejected: pre-parsing means the
197
- consumer must call `parseManifests` themselves, losing multi-doc
198
- YAML grouping unless they call it once per file. The SDK already
199
- parses YAML inside its boot pipeline; pushing parse responsibility
200
- to the consumer is unforced complexity. Diagnostic paths also
201
- degrade: parse errors surface as JS exceptions in consumer code,
202
- not as Diagnostic JSON tied to a file path.
203
-
204
- **(B) Filesystem-based manifest discovery** — SDK reads manifests
205
- from `process.cwd()/manifests/*.yaml` at boot. Rejected: Workers do
206
- not have a runtime filesystem. Even at build time, Wrangler doesn't
207
- provide a hook for the SDK to read consumer files — that's the
208
- consumer's bundler's job, which is exactly what Text-imported YAML
209
- is for.
210
-
211
- **(C) Separate `@aotter/mantle-manifests-<consumer>` package** —
212
- each consumer ships their manifests as an npm package; the SDK
213
- imports from `mantle-manifests-blog` etc. Rejected: 1:N package
214
- overhead for what should be a directory of YAML files. Tooling pain
215
- (versioning, publishing) for a layer that is fundamentally
216
- consumer-internal. Useful if a community emerges around shared
217
- manifest sets ("here's a forum schema as a package"), but YAGNI for
218
- the v0.1 baseline — that ergonomic can be added later by a wrapper
219
- that calls the mount factory with `pkg.manifests`.
220
-
221
- **(D) Embed manifests in the SDK and ignore the question** — ship
222
- the SDK with a fixed manifest set baked in. Rejected: fails the
223
- ADR-0001 promise and the AI-author contract. The SDK is supposed to
224
- be content-agnostic; making it content-prescriptive permanently is
225
- a strategic mistake.
226
-
227
- **(E) Override semantics for conflicts** — last-write-wins, with the
228
- consumer's manifest beating the SDK's. Rejected: only relevant if
229
- the SDK ships embedded manifests alongside consumer ones. With the
230
- SDK shipping zero manifests there is no conflict surface.
111
+ **Runtime YAML Text imports.** Replaced. They require Wrangler-specific rules,
112
+ ambient YAML modules, and ship authoring-only parse machinery in the Worker.
113
+
114
+ **Runtime filesystem discovery.** Rejected. Workers have no runtime filesystem,
115
+ and hidden discovery would make inputs and diagnostics less deterministic.
116
+
117
+ **Application-specific manifest packages.** Rejected for the baseline. It adds
118
+ publish/version overhead to files that belong in one consumer repo.
119
+
120
+ **SDK-embedded manifests or override precedence.** Rejected. Core is
121
+ content-agnostic, so there is no default manifest set and no merge policy.
231
122
 
232
123
  ## How to apply
233
124
 
234
- When proposing a new SDK feature that depends on knowing the
235
- manifest set (e.g. a new admin UI screen, a new MCP reflection
236
- method), assume the manifest set is consumer-owned. Don't hardcode
237
- collection names; iterate `getRegistry().schemas`. Don't assume the
238
- SDK knows about `posts`; it doesn't.
239
-
240
- When writing or reviewing the install agent's path, the agent must:
241
- 1. Copy the relevant `starters/<name>/` contents into the consumer's
242
- project. The starter is self-contained — `manifests/`,
243
- `src/yaml.d.ts`, the `[[rules]] type = "Text"` block in
244
- `wrangler.toml`, and `src/mantleConfig.ts` all come along.
245
- 2. Wire `import postsYaml from "../manifests/posts.yaml"` (and
246
- peers) in `src/mantleConfig.ts`. No `?raw` suffix needed —
247
- Wrangler's text rule covers plain imports.
248
- 3. Pass `manifests: [postsYaml, contactYaml, ...]` in the exported
249
- `cmsConfig` object.
250
-
251
- The blog `SKILL.md` walks this sequence.
125
+ When a Core feature needs the manifest set, accept the parsed consumer-owned
126
+ array; never hardcode starter collection names or read consumer files at
127
+ runtime.
128
+
129
+ When authoring or reviewing a generated project:
130
+
131
+ 1. Edit YAML only under `manifests/`.
132
+ 2. Run the installed `pnpm exec mantle generate`.
133
+ 3. Import `manifest` from `.mantle/generated/site.js` into the conventional
134
+ Worker, or pass it as `CmsConfig.manifests` in low-level composition.
135
+ 4. Run `pnpm exec mantle generate --check` in validation and before deploy.
136
+ 5. Reject Wrangler YAML Text rules, runtime `parseManifests*` calls, or a second
137
+ manifest loader in a new generated project.
252
138
 
253
139
  ## Implementation status
254
140
 
255
- Accepted for v0.1.0. The implementation slice:
256
-
257
- - `CmsConfig` carries `manifests?: readonly string[]`.
258
- - The mount factory builds the registry from the consumer-supplied
259
- YAML; no SDK fallback.
260
- - The `@aotter/mantle-cloudflare` package ships zero embedded
261
- manifests.
262
- - The starter at `starters/blog/` is self-contained: `manifests/`,
263
- `src/yaml.d.ts`, `wrangler.toml` `[[rules]]` block, and
264
- `src/mantleConfig.ts` wiring it together.
265
- - Boot validator's `path` field on `INVALID_MANIFEST_ENVELOPE`-class
266
- errors uses the consumer-supplied YAML index
267
- (e.g. `consumer-manifest:[2]#/spec/...`) so deploy logs point at
268
- the right file.
269
-
270
- Tracking: aotter/mantle.
141
+ Implemented by the umbrella `mantle generate` command and the conventional
142
+ Cloudflare Worker facade. Exact commands and output paths are documented in the
143
+ version-matched `@aotter/mantle` README and embedded Core skills.
@@ -1,20 +1,26 @@
1
- # ADR-0012: Views as the public REST surface
1
+ # ADR-0012: Views as named REST and MCP read surfaces
2
2
 
3
- **Status:** Accepted for v0.1.0. New ADR.
3
+ **Status:** Accepted for v0.1.0. Amended to match the shipped public/staff
4
+ surface and authorization contract.
4
5
 
5
- **Date:** 2026-05-05
6
+ **Date:** 2026-05-05; amended 2026-08-03
6
7
 
7
8
  ## Context
8
9
 
9
10
  mantle ships two read-side surfaces and one write-side surface:
10
11
 
11
12
  - **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
+ - **MCP tools** — the staff surface exposes entry authoring and lifecycle
14
+ tools; both public and staff surfaces expose `query_view_<name>` for Views
15
+ assigned to that surface.
13
16
  - **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
17
 
15
18
  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
19
 
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.
20
+ This ADR answers that question: **every parsed View is a named read surface**.
21
+ Views default to public and auto-expose `GET /api/views/<view-name>` plus a
22
+ matching public MCP tool. `surface: staff` moves both transports to the guarded
23
+ staff surfaces. Schemas do not get a public REST surface at all.
18
24
 
19
25
  (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
26
 
@@ -34,13 +40,30 @@ No version prefix. `apiVersion: cms.mantle.aotter.net/v1` is the manifest-gramma
34
40
 
35
41
  `<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
42
 
37
- ### 3. Views auto-expose; opt-out is "don't write a View"
43
+ ### 3. Views auto-expose on one declared surface
38
44
 
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.
45
+ `View.spec.surface` uses the closed `public | staff` vocabulary and defaults to
46
+ `public`:
47
+
48
+ - public Views mount at `GET /api/views/<name>` and appear as
49
+ `query_view_<name>` on `/mcp`;
50
+ - staff Views mount at `GET /admin/api/views/<name>` behind the live staff-role
51
+ gate and appear only on `/mcp/staff`.
52
+
53
+ The adapter filters the View set before constructing each MCP dispatcher, so a
54
+ guessed public tool call cannot reach a staff View. `View.spec.requires` then
55
+ applies the same static predicates and optional guard Procedure on REST and MCP
56
+ calls. Surface selects transport visibility; authorization decides whether the
57
+ verified caller may execute the View.
58
+
59
+ We still reject a second `expose.rest` switch. A View is externally queryable
60
+ on exactly one surface; internal-only helpers remain TypeScript code.
40
61
 
41
62
  ### 4. Pagination knobs are reserved query-string names
42
63
 
43
- Public callers pass `?page=<1-indexed>&show=<page-size>`. Internally the runtime emits `LIMIT show OFFSET (page-1)*show`.
64
+ REST callers pass `?page=<1-indexed>&show=<page-size>`. MCP callers pass the
65
+ same reserved names as tool arguments. Internally the runtime emits
66
+ `LIMIT show OFFSET (page-1)*show`.
44
67
 
45
68
  `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
69
 
@@ -93,7 +116,7 @@ The required-only rule is a v0.1.0 simplification. v0.1.x will promote optional-
93
116
 
94
117
  `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
118
 
96
- ### 7. Param coercion happens at the adapter boundary
119
+ ### 7. Param coercion happens at the transport boundary
97
120
 
98
121
  Query strings arrive as strings; `View.spec.params` declares the JSON Schema type. The Cloudflare adapter (`coerceViewParams` in `mountServerEndpoints.ts`) coerces per-property:
99
122
 
@@ -109,29 +132,34 @@ Required params not present → `400 INPUT_VALIDATION_FAILED`. Coercion failure
109
132
 
110
133
  ## Out of scope (deferred)
111
134
 
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
135
  - **`Trigger.target.view`** (lifecycle/projection triggers fired by Views). Tracked separately as a v0.2 grammar move.
114
136
  - **`spec.output.kind`** (declaring scalar / tree / tabular result shape per View). Lands with join + group-by support in v0.1.x.
115
137
  - **Optional param-ref drop semantics in the parser.** Runtime is already implemented; parser promotes when v0.1.x lands.
116
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.
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.
139
+ - **Row-level policy rewriting.** `requires` authorizes the whole View; it does
140
+ not inject per-row visibility predicates. Consumer-specific membership,
141
+ payment, or entitlement checks belong in the optional guard Procedure.
118
142
 
119
143
  ## Consequences
120
144
 
121
145
  **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.
146
+ - REST and MCP read surfaces for free — declare a View and choose public or
147
+ staff visibility once.
148
+ - Single mental model for "how do consumers and agents read?": always Views.
124
149
  - Cheap pagination + dynamic filters without hand-writing handlers.
125
150
 
126
151
  **Authors lose:**
127
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.
128
- - No way to write a "private" named query — moves to a TS helper.
153
+ - No internal-only View surface; choose public/staff or keep the query in a TS
154
+ helper.
129
155
 
130
156
  **Runtime gains:**
131
- - One auto-mount path covers every public-read use case for v0.1.0.
157
+ - One executor and response shape cover public/staff REST and MCP reads.
132
158
  - Forward-compat for join / group-by / aggregation: envelope generalises by Views declaring `output.kind` later.
133
159
 
134
160
  **Reviewers / future contributors should:**
135
161
  - Reject any PR adding `Schema.spec.expose.rest` or a similar Schema-level public-read flag.
136
162
  - Reject any PR introducing a second public read surface (e.g. `/api/<collection>` shortcut).
163
+ - Require REST and MCP mounts to filter by the same `View.spec.surface` value,
164
+ and keep authorization in the shared `ExecuteViewUseCase` path.
137
165
  - Reject any PR that lets `filter` reference state outside the declared `params` (e.g. `{ $env: ... }`, `{ $cookie: ... }`) without a matching ADR amendment.
@@ -1,12 +1,18 @@
1
- # ADR-0014: Better Auth as the auth + MCP authorization server, scope-derived multi-tenant MCP
1
+ # ADR-0014: Adapter-owned identity and MCP authorization
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted (new). Amended 2026-05-14 — formalize "Better Auth as default implementation, `Auth` interface as the SDK contract" (see § "Auth as contract, Better Auth as default").
5
+ Accepted. Amended 2026-05-14, 2026-05-15, 2026-06-30, 2026-07-15, and
6
+ 2026-08-03.
6
7
 
7
8
  ## Date
8
9
 
9
- 2026-05-09 (amended 2026-05-14)
10
+ 2026-05-09 (last amended 2026-08-03)
11
+
12
+ > **Current authority:** the original decision below records the rejected
13
+ > Better-Auth-for-MCP design. The 2026-05-15 carve-out and 2026-07-15 unified
14
+ > authorization amendment are authoritative where they conflict. Operational
15
+ > guidance in "How to apply" is maintained against the current adapter/runtime.
10
16
 
11
17
  ## Context
12
18
 
@@ -40,7 +46,7 @@ A 2026 Workers-friendly auth library — [Better Auth](https://better-auth.com)
40
46
 
41
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.
42
48
 
43
- ## Decision
49
+ ## Decision (historical baseline; amended below)
44
50
 
45
51
  ### 1. Better Auth replaces both layers
46
52
 
@@ -310,7 +316,7 @@ Keep our `staff` overlay and `D1StaffRepository`. Use Better Auth only for ident
310
316
 
311
317
  **Rejected** — duplicate role data (Better Auth `admin` plugin + our staff overlay) is worse than picking one. Audit trail is the only thing the standalone overlay buys, and v0.1.0 doesn't need it.
312
318
 
313
- ## Implementation status
319
+ ## Implementation status (historical snapshot)
314
320
 
315
321
  Phase 0 (spike, 0.5–1d) — pending:
316
322
 
@@ -351,13 +357,31 @@ Phase 3 (v0.2+, with community / fan-club):
351
357
 
352
358
  When reviewing or implementing a change that touches auth, MCP routing, or roles:
353
359
 
354
- 1. **Identity / session / account state** — Better Auth API. Don't hand-write D1 reads against `user` / `session` / `account`. Use `auth.api.*`.
355
- 2. **Role check** — read `session.user.role` (from `auth.api.getSession()` or `auth.api.getMcpSession()`). Don't query a `staff` table; it doesn't exist.
356
- 3. **MCP tool routing** — let the dispatcher derive surface from `Procedure.requires.auth.all`. Don't add a per-tool `surface: 'staff' | 'public'` field; the predicate is the source of truth.
357
- 4. **DCR consent gating** — scope-based via Better Auth `oauthProvider` config. `mcp:staff` requires admin role; `mcp:read` accepts any signed-in user. Don't add a separate consent path or config flag.
358
- 5. **Token props** — minimal. If you need role in the token payload for caller convenience, add via `customAccessTokenClaims`, but always re-validate fresh on the server side.
359
- 6. **Email** — call `EmailSender` port. CF adapter binds Resend; consumer can swap.
360
- 7. **Adapter portability** — the auth surface is platform-agnostic. A new adapter (Netlify / Bun / Deno) constructs Better Auth with its preferred DB adapter and passes the instance to the runtime. No port re-implementation needed.
360
+ 1. **Identity and local sessions** — depend on the adapter's public `Auth`
361
+ interface. `createAuth()` is the curated Better Auth-backed Cloudflare
362
+ default; do not import Better Auth internals outside that implementation or
363
+ add an un-curated passthrough.
364
+ 2. **Mutable staff privilege** — call `auth.getUserRole(userId)` on every
365
+ protected REST/MCP invocation. Do not trust a role captured in an OAuth
366
+ grant or long-lived token.
367
+ 3. **MCP transport** — export `createOAuthProvider(...)` at the Worker top
368
+ level. It owns DCR/PKCE/token verification and dispatches `/mcp` and
369
+ `/mcp/staff`; the consent handler uses the current local `Auth` session.
370
+ 4. **MCP surface** — use explicit `View.spec.surface` and
371
+ `Trigger.source.surface`. The adapter pre-filters each catalog; the shared
372
+ runtime evaluator then enforces the target's `requires.auth.all` and
373
+ optional guard on every call.
374
+ 5. **Scopes and props** — advertise the compatibility scope `mcp`. Store only
375
+ immutable grant identity (`userId`, `clientId`, scopes); never store mutable
376
+ staff role or raw/refresh tokens in runtime context.
377
+ 6. **REST credentials** — normalize sessions, OAuth JWTs, and an optional
378
+ consumer `credentialResolver` into `HandlerContext`. A recognized invalid
379
+ API key/PAT fails closed and never falls back to a cookie. Standard remote
380
+ MCP does not promise raw REST key/PAT support.
381
+ 7. **Adapter portability** — auth remains adapter-owned. A future adapter
382
+ verifies its platform's credentials and supplies the same normalized
383
+ runtime context; `mantle-runtime` does not gain a Better Auth dependency or
384
+ an auth storage port.
361
385
 
362
386
  ## Sources
363
387
 
@@ -408,17 +432,19 @@ The original ADR-0014 §"Auth as contract, Better Auth as default" framing stays
408
432
 
409
433
  ### What didn't change
410
434
 
411
- - The auth port is still removed (the runtime takes the Better Auth instance directly).
435
+ - The auth storage port remains removed; the adapter owns `Auth` and passes
436
+ verified, normalized caller context into runtime dispatchers.
412
437
  - Apple's `trustedOrigins` auto-append (`https://appleid.apple.com`) and `sameSite=none` cookie injection for cross-site `form_post` callback stay.
413
438
  - `appleClientSecret()` helper (from PR #173) stays.
414
439
  - All non-OAuth admin endpoints (`/api/auth/*`, `/api/auth/methods`, admin SPA mount) stay on Better Auth.
415
440
  - The `bootstrapOwner` + email-OTP + magic-link + `methods[]` carve-out stay.
416
441
 
417
- ### Future work
442
+ ### Compatibility constraints to re-test before changing
418
443
 
419
- - A `@cloudflare/vitest-pool-workers`-based integration test covering the full OAuth flow (DCR → consent → token → MCP RPC). Node-vitest can't load `@cloudflare/workers-oauth-provider` because it imports from `cloudflare:workers`.
420
- - Starters (`aotter/mantle-starters`) migration to the same top-level OAuthProvider shape. All 8 archetypes currently use the pre-carve-out `mountMcp` API and need updating before the next starter tag.
421
- - Track whether Anthropic relaxes (1) the `/mcp` resource-path-prefix requirement and (2) the no-colon-in-scope requirement. Both are de-facto MCP client behaviors, not RFC requirements; if upstream relaxes them, the SDK can re-introduce `mcp:read` / `mcp:staff` scopes for finer-grained delegation.
444
+ - Anthropic clients required the `/mcp` resource-path prefix during the
445
+ carve-out verification.
446
+ - Colon-shaped advertised scopes broke the same clients; Mantle therefore
447
+ advertises one `mcp` scope and re-evaluates target authorization server-side.
422
448
 
423
449
  ## Amendment — 2026-06-30: Hosted-auth boundary and first-party cookie fields
424
450