@aotter/mantle 0.1.0-alpha.8 → 0.1.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +38 -38
  2. package/dist/cli/generate.d.ts +10 -1
  3. package/dist/cli/generate.d.ts.map +1 -1
  4. package/dist/cli/generate.js +49 -4
  5. package/dist/cli/generate.js.map +1 -1
  6. package/dist/cli/main.js +0 -8
  7. package/dist/cli/main.js.map +1 -1
  8. package/docs/adapter-guide.md +20 -24
  9. package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
  10. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
  11. package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
  12. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +37 -1
  13. package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
  14. package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
  15. package/docs/adr/0022-caller-observed-version-occ.md +212 -0
  16. package/docs/adr/0023-port-failure-contract.md +69 -0
  17. package/docs/adr/README.md +18 -3
  18. package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
  19. package/docs/adr/adr-lite-808-route-readiness.md +47 -0
  20. package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
  21. package/docs/adr/adr-lite-812-native-parity.md +149 -0
  22. package/docs/adr/adr-lite-823-home-statistics.md +63 -0
  23. package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
  24. package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
  25. package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
  26. package/docs/api-mcp-authorization.md +3 -664
  27. package/docs/auth-hosting-model.md +4 -4
  28. package/docs/cloudflare-low-level-composition.md +3 -104
  29. package/docs/deferred-lifecycle-queues.md +2 -243
  30. package/docs/design-atoms.md +16 -796
  31. package/docs/direct-authoring.md +5 -0
  32. package/docs/examples/minimal-worker/README.md +37 -0
  33. package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
  34. package/docs/examples/minimal-worker/package.json +26 -0
  35. package/docs/examples/minimal-worker/smoke.mjs +23 -0
  36. package/docs/examples/minimal-worker/src/index.ts +4 -0
  37. package/docs/examples/minimal-worker/tsconfig.json +17 -0
  38. package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
  39. package/docs/handbook/cloudflare/authentication.md +167 -0
  40. package/docs/handbook/cloudflare/bindings.md +233 -0
  41. package/docs/handbook/cloudflare/conventional-worker.md +162 -0
  42. package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
  43. package/docs/handbook/cloudflare/deploy-and-operate.md +98 -0
  44. package/docs/handbook/cloudflare/low-level-composition.md +169 -0
  45. package/docs/handbook/cloudflare/media-r2.md +216 -0
  46. package/docs/handbook/cloudflare/public-web.md +144 -0
  47. package/docs/handbook/concepts/authorization.md +116 -0
  48. package/docs/handbook/concepts/four-atoms.md +173 -0
  49. package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
  50. package/docs/handbook/concepts/mcp-and-agents.md +109 -0
  51. package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
  52. package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
  53. package/docs/handbook/concepts/views.md +183 -0
  54. package/docs/handbook/examples/commerce-transaction.md +813 -0
  55. package/docs/handbook/examples/guarded-api.md +427 -0
  56. package/docs/handbook/examples/intake-form.md +319 -0
  57. package/docs/handbook/examples/legal-documents.md +218 -0
  58. package/docs/handbook/examples/procurement-approvals.md +240 -0
  59. package/docs/handbook/examples/publication.md +240 -0
  60. package/docs/handbook/examples/reservation.md +220 -0
  61. package/docs/handbook/navigation.json +63 -0
  62. package/docs/handbook/reference/authorization.md +295 -0
  63. package/docs/handbook/reference/diagnostics.md +183 -0
  64. package/docs/handbook/reference/manifest.md +132 -0
  65. package/docs/handbook/reference/procedure.md +263 -0
  66. package/docs/handbook/reference/schema.md +247 -0
  67. package/docs/handbook/reference/site-config.md +233 -0
  68. package/docs/handbook/reference/surface.md +207 -0
  69. package/docs/handbook/reference/trigger.md +193 -0
  70. package/docs/handbook/reference/view.md +256 -0
  71. package/docs/handbook/start/project-and-cli.md +122 -0
  72. package/docs/handbook/start/quickstart-worker.md +193 -0
  73. package/docs/labels.md +3 -1
  74. package/docs/media-uploads.md +3 -184
  75. package/docs/migration-0.1.2.md +34 -1
  76. package/docs/performance-harness.md +125 -8
  77. package/docs/release-process.md +85 -231
  78. package/docs/schema-indexes.md +3 -180
  79. package/docs/sealed-pipeline-ownership.md +5 -4
  80. package/docs/spec-only-host-adoption.md +158 -0
  81. package/docs/transaction-patterns.md +5 -0
  82. package/package.json +21 -25
  83. package/skills/README.md +4 -4
  84. package/skills/develop/SKILL.md +23 -28
  85. package/skills/install/SKILL.md +55 -147
  86. package/skills/provision/SKILL.md +4 -3
  87. package/skills/theme/SKILL.md +6 -6
  88. package/skills/update/SKILL.md +32 -68
  89. package/dist/cli/create.d.ts +0 -2
  90. package/dist/cli/create.d.ts.map +0 -1
  91. package/dist/cli/create.js +0 -243
  92. package/dist/cli/create.js.map +0 -1
  93. package/dist/cli/update.d.ts +0 -2
  94. package/dist/cli/update.d.ts.map +0 -1
  95. package/dist/cli/update.js +0 -413
  96. package/dist/cli/update.js.map +0 -1
  97. package/dist/provision/renderProvisionBundle.d.ts +0 -70
  98. package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
  99. package/dist/provision/renderProvisionBundle.js +0 -367
  100. package/dist/provision/renderProvisionBundle.js.map +0 -1
  101. package/dist/provision.d.ts +0 -2
  102. package/dist/provision.d.ts.map +0 -1
  103. package/dist/provision.js +0 -2
  104. package/dist/provision.js.map +0 -1
@@ -0,0 +1,216 @@
1
+ ---
2
+ description: Enable staff media uploads on R2 — presigned PUT flow through Staff MCP, media purposes, wrangler config, commit cost and cleanup.
3
+ ---
4
+ # Media uploads with R2
5
+
6
+ R2-backed media is an optional post-launch capability for Workers whose staff or agents need to upload images and files. This page covers the upload flow, the Cloudflare and Worker configuration it needs, how a Schema references an asset, what a commit costs, and how to clean up.
7
+
8
+ ## When to enable it
9
+
10
+ Turn this on only after the site already has a working deploy, staff auth configured, and a real need for staff-managed media. R2 setup may require billing, so it is deliberately not part of the first-deploy path. Everything on this page is inert until the bucket, credentials and at least one media purpose all exist.
11
+
12
+ Media maintenance also needs an agent that can read local files, process images and make outbound `PUT` requests to `*.r2.cloudflarestorage.com`. A sandboxed agent without egress cannot finish the flow.
13
+
14
+ ## The upload flow
15
+
16
+ ```txt
17
+ 1. create_media_upload staff MCP -> purpose, variants, mime types, byte sizes
18
+ 2. Mantle -> signed upload URLs + required headers
19
+ 3. agent or browser -> PUT bytes directly to R2
20
+ 4. commit_media_upload staff MCP -> validate, stamp, save the asset row
21
+ ```
22
+
23
+ Bytes travel from the agent to R2 over a signed URL; the Worker sees policy, not payload. Never pass image bytes or base64 payloads through MCP tool arguments — the tool dispatchers count streamed JSON bytes and return `413` above the 1 MiB control-plane limit. The Worker validates; the agent runtime does the file work.
24
+
25
+ ## Cloudflare setup
26
+
27
+ ```sh
28
+ wrangler r2 bucket create <project>-media
29
+ wrangler r2 bucket dev-url enable <project>-media
30
+ ```
31
+
32
+ Then create an R2 S3 API token in the dashboard: open **R2**, open **Manage R2 API Tokens**, create an **Object Read & Write** token, and copy the Access Key ID and Secret Access Key.
33
+
34
+ The R2 binding alone cannot issue presigned `PUT` URLs: presigning is an S3-protocol operation, so Mantle needs these credentials in addition to the binding. A deployment with the bucket bound but no credentials never registers the upload tools.
35
+
36
+ ## Wrangler configuration
37
+
38
+ ```toml
39
+ [vars]
40
+ R2_ACCOUNT_ID = "<account-id>"
41
+ MEDIA_PUBLIC_URL_BASE = "https://pub-<hash>.r2.dev"
42
+
43
+ [[r2_buckets]]
44
+ binding = "MEDIA_BUCKET"
45
+ bucket_name = "<project>-media"
46
+ ```
47
+
48
+ A `wrangler.jsonc` project writes the same binding as an `"r2_buckets"` array; see [Bindings and primitives](./bindings.md#r2-media-bucket-optional) for the JSON form and the matching `Env` fields.
49
+
50
+ ```sh
51
+ wrangler secret put R2_ACCESS_KEY_ID
52
+ wrangler secret put R2_SECRET_ACCESS_KEY
53
+ ```
54
+
55
+ `MEDIA_PUBLIC_URL_BASE` is the origin public URLs are built from — the `r2.dev` development URL, or a custom domain in production. `R2_ACCOUNT_ID` builds the S3 endpoint, which is a different host from the public one.
56
+
57
+ ## Wiring `R2MediaStorage`
58
+
59
+ Build the adapter from `env`, returning `undefined` when any part is missing so a half-configured environment degrades to no media rather than failing to boot:
60
+
61
+ ```ts
62
+ import { R2MediaStorage } from "@aotter/mantle/cloudflare";
63
+ import { AwsClient } from "aws4fetch";
64
+
65
+ function buildMediaStorage(env: Env) {
66
+ if (
67
+ !env.MEDIA_BUCKET ||
68
+ !env.R2_ACCOUNT_ID ||
69
+ !env.R2_ACCESS_KEY_ID ||
70
+ !env.R2_SECRET_ACCESS_KEY ||
71
+ !env.MEDIA_PUBLIC_URL_BASE
72
+ ) {
73
+ return undefined;
74
+ }
75
+
76
+ const s3 = new AwsClient({
77
+ accessKeyId: env.R2_ACCESS_KEY_ID,
78
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
79
+ region: "auto",
80
+ service: "s3",
81
+ });
82
+
83
+ return new R2MediaStorage(
84
+ env.MEDIA_BUCKET,
85
+ s3,
86
+ `https://<project>-media.${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
87
+ env.MEDIA_PUBLIC_URL_BASE,
88
+ );
89
+ }
90
+ ```
91
+
92
+ Pass it through the `bindings` hook, spreading the conventional set:
93
+
94
+ ```ts
95
+ export default createMantleWorker<Env>({
96
+ plan,
97
+ bindings: (env, conventional) => ({
98
+ ...conventional,
99
+ mediaStorage: buildMediaStorage(env),
100
+ }),
101
+ });
102
+ ```
103
+
104
+ `aws4fetch` is an application dependency; the adapter takes the signing client rather than embedding one. See [The conventional Worker](./conventional-worker.md#a-bindings-hook).
105
+
106
+ ## Declaring media purposes
107
+
108
+ A purpose is the upload policy: which variants an asset must have, and how large each may be.
109
+
110
+ ```ts
111
+ siteDefaults: {
112
+ media: {
113
+ purposes: [
114
+ {
115
+ name: "page-image",
116
+ required: ["image/jpeg,image/png", "image/webp", "image/avif"],
117
+ maxBytes: {
118
+ "image/jpeg": 5_000_000,
119
+ "image/png": 5_000_000,
120
+ "image/webp": 3_000_000,
121
+ "image/avif": 2_000_000,
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ ```
128
+
129
+ `required` is slot-based, in the grammar of an `<input accept>` list. The example declares three slots: slot 0 takes exactly one fallback mime preserving source semantics (`image/jpeg` for opaque photos, `image/png` when alpha must survive), slot 1 a WebP variant, slot 2 an AVIF variant. That models a multi-variant `<picture>`. A narrower purpose may declare a single slot such as `["image/jpeg,image/png,image/webp,image/gif"]`, in which case the agent picks exactly one mime from that list and must not upload one variant per listed mime.
130
+
131
+ Purpose names are slugs (`^[a-z0-9]+(-[a-z0-9]+)*$`), every slot must be non-empty, slots may not overlap on a mime, and every `maxBytes` key must be a mime some slot accepts with a positive value. Violations fail at boot. SVG uploads are rejected unless the Worker sets `mediaAllowSvg`. See [Site defaults and site_config](../reference/site-config.md).
132
+
133
+ The runtime emits the policy summary into the `create_media_upload` tool description, so an agent reads the contract straight from `tools/list` without an extra round trip.
134
+
135
+ ## Tool visibility
136
+
137
+ `create_media_upload` and `commit_media_upload` are registered only when **both** hold:
138
+
139
+ - `bindings.mediaStorage` is set;
140
+ - `siteDefaults.media.purposes` contains at least one purpose.
141
+
142
+ If either is missing the tools never appear in `tools/list`. That is the intended diagnostic: an agent that cannot see the tools is looking at a deployment where media is not configured, not at a permissions problem. A call that reaches a runtime without media storage answers `MEDIA_NOT_CONFIGURED`.
143
+
144
+ ## Referencing an asset from a Schema
145
+
146
+ Entries store asset ids, not URLs. Mark the field with both existing v0.1 grammar extensions:
147
+
148
+ ```yaml
149
+ apiVersion: cms.mantle.aotter.net/v1
150
+ kind: Schema
151
+ metadata:
152
+ name: pages
153
+ spec:
154
+ title: Pages
155
+ schema:
156
+ type: object
157
+ additionalProperties: false
158
+ required: [slug, title]
159
+ properties:
160
+ slug: { type: string, pattern: "^[a-z0-9-]+$" }
161
+ title: { type: string, minLength: 1 }
162
+ coverAssetId:
163
+ type: string
164
+ x-mantle-ref: media_assets
165
+ x-mcp-hint: media-image
166
+ ```
167
+
168
+ `x-mantle-ref: media_assets` marks the string as an id in the media asset collection, which is what makes Admin show a media picker instead of a text box. `x-mcp-hint: media-image` tells an MCP client the field expects an image asset. Neither key is validated as a foreign key; they are informational. At render time, resolve ids to their variants with `runtime.media.resolve(id)` or `resolveMany(ids)`, which batches a render pass into one database round trip. No new manifest keys are involved; see [Schema](../reference/schema.md).
169
+
170
+ ## What a commit costs
171
+
172
+ R2 has no metadata-only patch. To stamp `committedAt`, `role`, `uploadGroupId` and the filename markers, Mantle streams each uploaded object through a `GET` and a `PUT`. So a successful N-variant commit performs N `GET`s and N `PUT`s and rewrites the sum of the variant sizes.
173
+
174
+ - Commits validate the bundle shape before any I/O.
175
+ - Variants are processed in batches of at most three; a batch settles before the next starts or an error returns.
176
+ - A mime or size failure cancels the unused `GET` stream; a failed `PUT` also attempts cancellation while preserving the original error.
177
+ - The asset row is saved only after every variant succeeds. All-or-nothing.
178
+
179
+ Parallelism reduces waiting, not operation count or bytes. Keep variant counts and byte caps deliberate. A partial R2 failure leaves the pending D1 record in place so the commit can be retried before it expires; already stamped objects stay stamped.
180
+
181
+ ## Cleanup
182
+
183
+ An upload can reach R2 and never be committed — an interrupted agent, a rejected commit. Remove those objects with the version-matched `media-gc` skill. It is opt-in and deliberately not projected into a repository by `mantle skills`, because it deletes remote objects.
184
+
185
+ Its posture is audit first, delete only what an operator confirmed. It treats an object as a candidate only when it is over 24 hours old, has no `committedAt` custom metadata, and matches the exact key layout `<purpose>/<group>/(primary|alternate|fallback).<ext>` for a purpose this project declares. It reports account, bucket, prefixes, cutoff, counts, bytes and a digest of the candidate set without printing keys or URLs; on confirmation it re-runs the same audit and stops if anything changed.
186
+
187
+ > **Do not use an R2 lifecycle rule**
188
+ > Committed and uncommitted media share the same purpose prefix, so an age-based rule deletes live assets. After a pending record expires, partially stamped orphans require an operator audit against D1 references: a missing pending record does not mean an object is unused.
189
+
190
+ ## Diagnostics
191
+
192
+ | Code | HTTP | Raised when |
193
+ |---|---|---|
194
+ | `MEDIA_NOT_CONFIGURED` | 501 | No `mediaStorage` port is bound on this deployment |
195
+ | `MEDIA_PURPOSE_REJECTED` | 400 | The requested purpose is not declared; the declared set is returned so an agent can self-correct |
196
+ | `MEDIA_MIME_REJECTED` | 400 | A mime outside `image/png`, `image/jpeg`, `image/webp`, `image/avif`, `image/gif` |
197
+ | `MEDIA_SIZE_EXCEEDED` | 400 | A declared byte size exceeds its cap |
198
+ | `MEDIA_SVG_REJECTED` | 400 | An SVG was offered while SVG is not allowed; object storage does not sanitize SVG payloads |
199
+ | `MEDIA_VARIANTS_INCOMPLETE` | 400 | The bundle misses a mime the purpose requires; the missing set is returned |
200
+ | `MEDIA_VARIANT_SIZE_EXCEEDED` | 400 | One variant exceeds the cap declared for its mime |
201
+ | `MEDIA_VARIANTS_SUSPICIOUS_SIZE` | 400 | A modern variant is larger than its fallback (AVIF above JPEG), so it looks unoptimized |
202
+ | `MEDIA_ASSET_NOT_FOUND` | 404 | No `media_assets` row matches the id |
203
+ | `MEDIA_UPLOAD_EXPIRED` | 410 | The upload capability's TTL elapsed, or it was never created |
204
+ | `MEDIA_OBJECT_NOT_FOUND` | 409 | A variant's bytes were never PUT before commit |
205
+
206
+ The full catalog is in [Diagnostic codes](../reference/diagnostics.md).
207
+
208
+ ## Source
209
+ - [`docs/media-uploads.md`](../../../docs/media-uploads.md)
210
+ - [`docs/adr/0017-media-multi-variant-agent-side-optimization.md`](../../../docs/adr/0017-media-multi-variant-agent-side-optimization.md)
211
+ - [`packages/mantle-runtime/src/domain/port/MediaStorage.ts`](../../../packages/mantle-runtime/src/domain/port/MediaStorage.ts)
212
+ - [`packages/mantle-runtime/src/domain/port/MediaAssetRepository.ts`](../../../packages/mantle-runtime/src/domain/port/MediaAssetRepository.ts)
213
+ - [`packages/mantle-spec/src/domain/service/SiteDefaultsValidator.ts`](../../../packages/mantle-spec/src/domain/service/SiteDefaultsValidator.ts)
214
+ - [`packages/mantle-spec/src/kernel/diagnostic.ts`](../../../packages/mantle-spec/src/kernel/diagnostic.ts)
215
+ - [`packages/adapters/cloudflare/README.md`](../../../packages/adapters/cloudflare/README.md)
216
+ - [`skills/media-gc/SKILL.md`](../../../skills/media-gc/SKILL.md)
@@ -0,0 +1,144 @@
1
+ ---
2
+ description: Opt a Worker into public HTML, Markdown mirrors, llms.txt and sitemap with mountPublicRoutes, templates and a path resolver.
3
+ ---
4
+ # Public web, SEO and cache
5
+
6
+ Public rendering is opt-in. A headless Worker serves Views and Triggers only; this page shows how to add server-rendered pages, their agent-readable mirrors and discovery files, and what the cache contract guarantees.
7
+
8
+ ## Three inputs that must agree
9
+
10
+ 1. `mountPublicRoutes(app, ref, { collectionRoutes, ... })` inside `extend.mount` declares which collections have URLs.
11
+ 2. A `TemplateRegistry` with an entry template (and optionally a list template) for each of those collections, passed as `templates`.
12
+ 3. A `PublicPathResolver` from `createPublicPathResolver({ collectionRoutes })`, passed as `publicPathResolver`, so canonical URLs, sitemap entries and hreflang siblings match the mounted routes.
13
+
14
+ Mounting every Schema automatically is not supported: some collections are private even when they carry a slug. Without a `publicPathResolver`, `/sitemap.xml` returns `500`.
15
+
16
+ ## Worked example: a blog
17
+
18
+ ```ts
19
+ // src/web/templates.ts
20
+ import { createPublicPathResolver, TemplateRegistry, renderSeoTagsHtml } from "@aotter/mantle-web";
21
+
22
+ export const publicPathResolver = createPublicPathResolver({
23
+ collectionRoutes: { posts: { segment: "posts" } },
24
+ });
25
+
26
+ export const collectionRoutes = [
27
+ { collection: "posts", segment: "posts", listRoute: true },
28
+ ] as const;
29
+
30
+ export function createBlogTemplates(): TemplateRegistry {
31
+ const templates = new TemplateRegistry();
32
+
33
+ templates.registerEntryTemplate("posts", ({ entry, site, seo }) => {
34
+ const title = String(entry.data.title ?? site.title);
35
+ const meta = seo && {
36
+ ...seo,
37
+ jsonLd: { "@context": "https://schema.org", "@type": "BlogPosting", headline: title, url: seo.canonical },
38
+ };
39
+ return `<html lang="${entry.locale ?? "en"}"><head><title>${escape(title)}</title>
40
+ ${meta ? renderSeoTagsHtml(meta) : ""}<link rel="stylesheet" href="/blog.css"></head>
41
+ <body><article><h1>${escape(title)}</h1>
42
+ ${seo?.alternateMarkdown ? `<a href="${seo.alternateMarkdown}">Read as Markdown</a>` : ""}
43
+ ${renderMarkdown(String(entry.data.body ?? ""))}</article></body></html>`;
44
+ });
45
+
46
+ templates.registerListTemplate("posts", ({ entries, locale, site, seo }) => {
47
+ const items = entries.map((entry) => {
48
+ const href = publicPathResolver.forEntry(entry) ?? `/${locale}/posts/${entry.id}`;
49
+ return `<li><a href="${href}">${escape(String(entry.data.title ?? entry.id))}</a></li>`;
50
+ }).join("");
51
+ return `<html lang="${locale}"><head><title>Blog · ${escape(site.brand)}</title>
52
+ ${seo ? renderSeoTagsHtml(seo) : ""}</head><body><ul>${items}</ul></body></html>`;
53
+ });
54
+
55
+ return templates;
56
+ }
57
+ ```
58
+
59
+ ```ts
60
+ // worker entry
61
+ export default createMantleWorker<Env>({
62
+ plan,
63
+ templates: createBlogTemplates(),
64
+ publicPathResolver,
65
+ siteDefaults: (env) => ({ /* ... */ origin: env.PUBLIC_ORIGIN, locales: ["en", "zh-TW"] }),
66
+ extend: ({ env }) => ({
67
+ mount({ app, ref }) {
68
+ mountPublicRoutes(app as never, ref, {
69
+ collectionRoutes: [...collectionRoutes],
70
+ notFoundRenderer: async () => htmlNotFound(),
71
+ liveDev: new URL(env.PUBLIC_ORIGIN).hostname === "localhost",
72
+ });
73
+ },
74
+ }),
75
+ });
76
+ ```
77
+
78
+ Templates return complete HTML strings; the renderer prepends the doctype. Escape every value you interpolate.
79
+
80
+ ## Template signatures
81
+
82
+ ```ts
83
+ templates.registerEntryTemplate(collection, ({ entry, site, mediaAssets?, seo? }) => string);
84
+ templates.registerListTemplate(collection, ({ collection, locale, entries, site, mediaAssets?, seo? }) => string);
85
+ ```
86
+
87
+ `seo` is a composed `SeoMeta`:
88
+
89
+ | Field | Content |
90
+ |---|---|
91
+ | `canonical` | Absolute canonical URL from `site.origin` and the resolved path |
92
+ | `alternateMarkdown` | Absolute `.md` mirror URL, or `null` when the entry has no Markdown body |
93
+ | `hreflangs` | One row per locale plus `x-default` on multi-locale sites |
94
+ | `description`, `og`, `twitter` | Description, Open Graph and Twitter card blocks |
95
+ | `jsonLd` | A default object; replace it before rendering, as the example does |
96
+
97
+ `renderSeoTagsHtml(seo)` emits the corresponding tags. Entry pages also get `<link rel="alternate" type="text/markdown">` so crawlers and agents can fetch clean Markdown.
98
+
99
+ ## Routes produced
100
+
101
+ | Route | Condition |
102
+ |---|---|
103
+ | `GET /` → `302` to `/<canonicalLocale>` | `homeRenderer` set |
104
+ | `GET /:locale`, `GET /:locale.md` | `homeRenderer` set; body from `homeMarkdown` or the `homeSlug` entry |
105
+ | `GET /:locale/<segment>`, `GET /:locale/<segment>.md` | `listRoute: true` |
106
+ | `GET /:locale/<segment>/:slug` | always |
107
+ | `GET /:locale/<segment>/:slug.md` | `markdownMirror` not `false` (default on) |
108
+ | `GET /:locale/<segment>/:slug?preview=1` | staff session; renders live, `private, no-store` |
109
+ | `GET /llms.txt`, `GET /:locale/llms.txt` | always |
110
+ | `GET /sitemap.xml` | always; a urlset, or a sitemap index linking `/sitemap.xml?part=1&cursor=...` parts |
111
+ | `GET /robots.txt` | always; `Allow: /` plus the sitemap URL |
112
+
113
+ `CollectionRouteConfig` fields: `collection`, `segment` (empty string mounts entries directly under `/:locale/`), `listRoute` (default `false`), `markdownMirror` (default `true`), `homeSlug` (collapse one slug to `/:locale`). `slugOverrides` serve one `(collection, slug)` pair from your own renderer and take precedence over preview and standard rendering. `notFoundRenderer` is required; every miss falls through it. `liveDev` switches entry and list responses to `private, no-store` for local work.
114
+
115
+ Only entries with `status: published` render. Drafts never appear on pages, mirrors, `llms.txt` or the sitemap; preview needs a staff session and answers `401` or `403` otherwise.
116
+
117
+ ## Pagination
118
+
119
+ Lists and `llms.txt` return 50 entries per page, ordered `updatedAt DESC, id DESC`, with a forward `cursor`. A continuation adds `Link: <...?cursor=...>; rel="next"`; HTML lists also get a visible `<nav aria-label="Pagination">` Next link, and `llms.txt` appends a `## Continue` section. Sitemap parts hold up to 2,000 URLs divided by the locale count. See [Views](../concepts/views.md) for View-level paging.
120
+
121
+ ## Cache contract
122
+
123
+ With a valid `cacheScope`, public responses carry `Cache-Control: public, max-age=0, s-maxage=300` and a deployment-scoped cache tag. The facade's final policy keeps that only for anonymous `200` `GET`/`HEAD` responses with no request `Cookie` or `Authorization` and no `Set-Cookie`, and adds `Vary: Cookie, Authorization`. Everything else becomes `private, no-store`, and CDN override headers are removed.
124
+
125
+ Publishing-content and site-setting writes purge the scoped tag through the native Workers cache API. Purge is best effort after the canonical write. Operational records and static assets are outside that boundary. Enable the cache with `"cache": { "enabled": true }`; see [Bindings](./bindings.md#workers-cache).
126
+
127
+ > **Warning**
128
+ > The local emulator does not simulate the entrypoint Workers Cache or its purge API. Verify `cf-cache-status` and post-publish invalidation on a deployed environment, not with `wrangler dev`.
129
+
130
+ ## Static assets for templates
131
+
132
+ Reference CSS, JS and icons by root path (`/blog.css`, `/site-icon.svg`); the `ASSETS` binding serves them from `public/`. Keep public content prefixes in `run_worker_first` so a static file never shadows a rendered route, and leave asset paths out of it so they bypass the Worker.
133
+
134
+ ## Source
135
+ - [`packages/adapters/cloudflare/src/mount/mountPublicRoutes.ts`](../../../packages/adapters/cloudflare/src/mount/mountPublicRoutes.ts)
136
+ - [`packages/adapters/cloudflare/src/oauth/cachePolicy.ts`](../../../packages/adapters/cloudflare/src/oauth/cachePolicy.ts)
137
+ - [`packages/adapters/cloudflare/src/worker/createMantleWorker.ts`](../../../packages/adapters/cloudflare/src/worker/createMantleWorker.ts)
138
+ - [`packages/mantle-web/README.md`](../../../packages/mantle-web/README.md)
139
+ - [`packages/mantle-web/src/model/TemplateRegistry.ts`](../../../packages/mantle-web/src/model/TemplateRegistry.ts)
140
+ - [`packages/mantle-web/src/model/SeoMeta.ts`](../../../packages/mantle-web/src/model/SeoMeta.ts)
141
+ - [`packages/mantle-web/src/service/SeoMetaComposer.ts`](../../../packages/mantle-web/src/service/SeoMetaComposer.ts)
142
+ - [`packages/mantle-web/src/service/PublicPathResolver.ts`](../../../packages/mantle-web/src/service/PublicPathResolver.ts)
143
+ - [`docs/adapter-guide.md`](../../../docs/adapter-guide.md)
144
+ - [`docs/performance-harness.md`](../../../docs/performance-harness.md)
@@ -0,0 +1,116 @@
1
+ ---
2
+ description: One authorization pipeline for REST, MCP and Admin — identity kinds, static predicates, the dynamic guard, staff roles and 401/403/402.
3
+ ---
4
+ # Authorization
5
+
6
+ Mantle has one authorization pipeline. A manifest HTTP Trigger, a View over REST, an MCP `tools/call` and an Admin operation all reach the same evaluator, so a rule written once holds on every surface. This page explains the model; the exact `requires` grammar is in the [authorization reference](../reference/authorization.md), and a worked ladder of four access levels is in [Guarded API access](../examples/guarded-api.md).
7
+
8
+ ## The fixed order
9
+
10
+ Every protected invocation runs the same five steps:
11
+
12
+ 1. Verify and normalize the transport credential.
13
+ 2. Evaluate the static predicates in `requires.auth.all`, before any input-schema detail is exposed.
14
+ 3. Validate and coerce the target input, or the View params.
15
+ 4. Invoke the guard Procedure with that validated value and the same context.
16
+ 5. Invoke the target only after the guard succeeds.
17
+
18
+ Static predicates run before validation so an unauthorized caller cannot probe a schema by reading its error messages. The guard runs after validation so it can decide on the actual arguments. Every stage fails closed.
19
+
20
+ ## Identity kinds
21
+
22
+ The adapter verifies the transport, then hands the runtime normalized, non-secret metadata. Handlers see this and nothing else:
23
+
24
+ ```ts
25
+ interface HandlerContext {
26
+ readonly user: { readonly id: string } | null;
27
+ readonly staff: { readonly id: string; readonly role: StaffRole } | null;
28
+ readonly auth?: {
29
+ readonly credential: "session" | "oauth" | "api-key" | "personal-token";
30
+ readonly credentialId: string | null;
31
+ readonly clientId: string | null;
32
+ readonly scopes: readonly string[];
33
+ };
34
+ }
35
+ ```
36
+
37
+ Three things are distinct:
38
+
39
+ - **`ctx.user`** is the end-user identity: a row in the site's own Better Auth user table. A service API key may have no user at all.
40
+ - **`ctx.staff`** is a role overlay on top of a user, not a separate account. A user with no overlay is `null` here.
41
+ - **`ctx.auth`** is the verified credential itself, of kind `session`, `oauth`, `api-key` or `personal-token`. Raw keys, refresh tokens and cookies never enter the runtime; `credentialId` is an opaque record id.
42
+
43
+ The predicate vocabulary is closed and maps onto those three: `ctx.user`, `ctx.auth`, `{ "ctx.auth.scope": "<scope>" }` (repeat it to require several), and `{ "ctx.staff": [roles] }`. There is no credential-kind predicate — a target that must reject browser sessions does so by requiring a scope no session grant carries.
44
+
45
+ ## Predicates versus the guard
46
+
47
+ | | Static predicates | Guard Procedure |
48
+ |---|---|---|
49
+ | Declared as | `requires.auth.all` | `requires.guard.procedure` |
50
+ | Answers | Is this caller of the right kind, with the right role and scopes? | Is this verified caller allowed to do this business action right now? |
51
+ | Evaluated | Before input validation | After input validation, on every call, never cached |
52
+ | Reads | The compiled plan only | Your tables, your provider state, `ctx.env` |
53
+ | Typical failure | `401` or `403` | `402` |
54
+
55
+ Anything that changes independently of the credential belongs in the guard: payment state, subscription or membership status, seat counts, per-tenant entitlement. A key stays valid while a subscription lapses, so the fact that a caller paid is not something a token can carry. The guard is an ordinary unguarded `handler.kind: ref` Procedure — not a fifth atom — and it throws a structured diagnostic to deny:
56
+
57
+ ```ts
58
+ throw new DiagnosticError(
59
+ runtimeDiagnostic({
60
+ code: "ENTITLEMENT_REQUIRED",
61
+ severity: "error",
62
+ path: `site:membership/${ctx.user!.id}`,
63
+ message: "Active membership is required.",
64
+ }),
65
+ );
66
+ ```
67
+
68
+ ## 401, 403 and 402
69
+
70
+ | Status | Diagnostic | Meaning |
71
+ |---|---|---|
72
+ | `401` | `UNAUTHENTICATED` | No credential, or a recognized credential that is bad, revoked or expired. |
73
+ | `403` | `AUTH_DENIED` | Verified caller, but a required predicate failed — a missing scope, a missing user subject, an insufficient staff role. |
74
+ | `402` | `ENTITLEMENT_REQUIRED` | Verified and permitted caller whose current business state does not allow the action. |
75
+
76
+ The split is the point: `401` says *who are you*, `403` says *you may not*, `402` says *not until you settle something*. Only the guard produces `402`, and on `402` the target handler is never invoked. See [Diagnostic codes](../reference/diagnostics.md) for the full mapping.
77
+
78
+ ## Staff roles
79
+
80
+ Staff roles are `owner`, `editor` and `contributor`. Owners manage staff and site settings; editors publish, approve and manage entries; contributors work on drafts.
81
+
82
+ A manifest predicate tests **exact membership**, not rank: `{ "ctx.staff": [editor] }` admits editors and nobody else, an owner included. List every role you mean, as in `{ "ctx.staff": [owner, editor] }`. Rank ordering does exist, but only Admin's own route table uses it, which is why an owner passes an Admin route gated at editor. See [Authorization requirements](../reference/authorization.md).
83
+
84
+ The role is re-read from the database on every protected REST and MCP call. A token snapshot or a consent-time role is not an authorization boundary, so demoting or revoking a user in Admin locks them out on their very next request — no token revocation, no cache flush, no waiting for expiry.
85
+
86
+ ## The credential resolver seam
87
+
88
+ Sites that issue their own API keys or personal tokens supply one `ConsumerCredentialResolver` through the Worker's `extend` seam. It returns exactly one of three outcomes:
89
+
90
+ | Outcome | Meaning |
91
+ |---|---|
92
+ | `not-handled` | The request carries none of the site's credential formats. Resolution moves on. |
93
+ | `invalid` | The request carries a recognized format that is bad, revoked or malformed. |
94
+ | `verified` | The site's authoritative record was checked; normalized metadata is returned. |
95
+
96
+ Resolution precedence is site resolver, then configured OAuth bearer, then cookie session. A recognized-but-invalid credential never falls back to a valid cookie: presenting a revoked key is a failure, not an invitation to be treated as an anonymous browser. Return `not-handled`, never `invalid`, for a request your resolver simply does not recognize.
97
+
98
+ Scopes are opaque strings the site defines and grants. Mantle compares them; it does not interpret `catalog:read`, publish a scope catalog, or infer a hierarchy. Correspondingly, Mantle issues and stores no API keys or personal tokens, and holds no payment or subscription state. Those tables, their issuance, hashing, rotation and revocation, and whatever fills them from a billing provider, are application code. Mantle owns verification, normalization and enforcement.
99
+
100
+ ## MCP is the same pipeline
101
+
102
+ An MCP `tools/call` runs the identical evaluator, in the identical order, with the identical diagnostics — surfaced as JSON-RPC errors carrying `error.data.code` instead of an HTTP status. A Trigger's `source.surface` selects which catalog lists a tool, and `tools/list` hides what the caller cannot see. That filtering is discovery UX, not enforcement: a client that guesses a tool name still meets every predicate and the guard. Discovery is never the authorization boundary. See [MCP and agents](./mcp-and-agents.md).
103
+
104
+ ## Related
105
+
106
+ - [Authorization requirements](../reference/authorization.md) — the `requires` shape, predicate forms, guard rules, OpenAPI projection.
107
+ - [Guarded API access](../examples/guarded-api.md) — a full resolver, guards and the REST/MCP outcome tables.
108
+ - [Reads: Views, REST and MCP](./views.md) — the `$ctx.user` identity-View sentinel.
109
+ - [Authentication](../cloudflare/authentication.md) — sessions, first owner, role management routes.
110
+
111
+ ## Source
112
+ - [`docs/adapter-guide.md`](../../../docs/adapter-guide.md)
113
+ - [`packages/mantle-runtime/src/domain/model/HandlerContext.ts`](../../../packages/mantle-runtime/src/domain/model/HandlerContext.ts)
114
+ - [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts)
115
+ - [`packages/adapters/cloudflare/src/mount/resolveCaller.ts`](../../../packages/adapters/cloudflare/src/mount/resolveCaller.ts)
116
+ - [`packages/mantle-spec/src/domain/service/StaffRoleHierarchy.ts`](../../../packages/mantle-spec/src/domain/service/StaffRoleHierarchy.ts)
@@ -0,0 +1,173 @@
1
+ ---
2
+ description: Schema, View, Procedure and Trigger — what each atom owns, why reads and writes are asymmetric, and how to compose everything else from four kinds.
3
+ ---
4
+ # The four atoms
5
+
6
+ A Mantle Manifest declares exactly four resource kinds under the `cms.mantle.aotter.net/v1` API group. This page explains what each atom owns, how they compose, and how to decide where a new feature belongs. Field-level rules live in the [Reference](../reference/manifest.md) section.
7
+
8
+ ## The four kinds
9
+
10
+ They map one-to-one onto the primitives Postgres has shipped for thirty years.
11
+
12
+ | Atom | Postgres equivalent | Externally exposed by itself? | Has user code? |
13
+ |---|---|---|---|
14
+ | `Schema` | `CREATE TABLE` | No. Reached only through a View or a Procedure. | No |
15
+ | `View` | `CREATE VIEW` | Yes. Auto-mounted on its declared `public` or `staff` REST and MCP surface. | No |
16
+ | `Procedure` | `CREATE FUNCTION` | No. Transport-agnostic; needs a Trigger to gain a surface. | Yes — a handler in your project's registry. |
17
+ | `Trigger` | `CREATE TRIGGER` plus route and tool binding | Yes. It is the binding atom. | No |
18
+
19
+ A four-word gloss covers most questions: Schema is state, View is the read API, Procedure is the write API, Trigger is the binding.
20
+
21
+ ## Reads and writes are asymmetric on purpose
22
+
23
+ Views mount themselves; Procedures do not. That asymmetry matches HTTP safe-versus-unsafe semantics. A View is a named, read-only query: idempotent, cacheable, and safe to expose the moment its `surface` is declared. A Procedure changes state, so its path, method, tool name and authorization are decisions an author must make explicitly, one Trigger at a time. There is no `Schema.spec.expose.rest` switch and no `/api/<collection>` shortcut; exposing collections directly was considered and rejected, because a Schema stores drafts, server-stamped fields and rows the author never meant to publish.
24
+
25
+ The consequence for authors: a Procedure that has no Trigger is unreachable from outside the runtime, and calling its name as a URL returns `404`.
26
+
27
+ ## The composition rule
28
+
29
+ Anything more domain-shaped than these four is not an atom. A Form, Membership, Email, Webhook, Workflow or ScheduledJob is a composition of Schemas, Views, Procedures and Triggers plus your own TypeScript. An earlier iteration of this grammar shipped eleven domain-shaped kinds; authors could not decide whether a contact form was a Form, a Workflow or an Email, and each kind grew to subsume its neighbors.
30
+
31
+ If you find yourself wanting a fifth kind, sketch the same thing as a composition of the four first. Almost always it works.
32
+
33
+ ## The Manifest envelope
34
+
35
+ Every document carries the same four top-level keys. Unknown keys at any known level are rejected with `INVALID_MANIFEST_ENVELOPE`.
36
+
37
+ ```yaml
38
+ apiVersion: cms.mantle.aotter.net/v1
39
+ kind: Schema | View | Procedure | Trigger
40
+ metadata:
41
+ name: posts
42
+ spec: {}
43
+ ```
44
+
45
+ There is no `namespace` field. Names are unique within a kind, so a Schema and a View may share a name; two Schemas may not. Multi-tenancy belongs in your application layer, not in manifest metadata.
46
+
47
+ One feature usually needs several atoms, so YAML's `---` separator keeps the file count down. Core reads every immediate `.yaml` and `.yml` file in the manifest directory and parses each document independently:
48
+
49
+ ```yaml
50
+ # manifests/contact.yaml
51
+ apiVersion: cms.mantle.aotter.net/v1
52
+ kind: Procedure
53
+ metadata:
54
+ name: send-contact-message
55
+ spec:
56
+ input:
57
+ type: object
58
+ additionalProperties: false
59
+ required: [name, message]
60
+ properties:
61
+ name: { type: string, minLength: 1, maxLength: 80 }
62
+ message: { type: string, minLength: 1, maxLength: 4000 }
63
+ output: { type: object }
64
+ handler: { kind: ref, ref: send-contact-message }
65
+ ---
66
+ apiVersion: cms.mantle.aotter.net/v1
67
+ kind: Trigger
68
+ metadata:
69
+ name: contact-http
70
+ spec:
71
+ source: { kind: http, method: POST, path: /api/contact }
72
+ target: { procedure: send-contact-message }
73
+ ```
74
+
75
+ Parsing is all-or-nothing: one error in one document means no `ParsedManifestSet` at all.
76
+
77
+ ## Schema — the entity
78
+
79
+ A Schema declares one collection: the JSON Schema for each entry's `data`, its indexes, its Admin presentation and its [lifecycle mode](./lifecycle-and-locales.md). Entries also carry the native columns `id`, `status`, `version`, `createdAt`, `updatedAt` and `authorId` outside `data`.
80
+
81
+ ```yaml
82
+ apiVersion: cms.mantle.aotter.net/v1
83
+ kind: Schema
84
+ metadata:
85
+ name: notes
86
+ spec:
87
+ title: Notes
88
+ lifecycle: publishing
89
+ schema:
90
+ type: object
91
+ additionalProperties: false
92
+ required: [title, body]
93
+ properties:
94
+ title: { type: string, minLength: 1, maxLength: 200 }
95
+ body: { type: string, x-mcp-hint: markdown }
96
+ searchableFields: [title]
97
+ ```
98
+
99
+ Full field rules, the JSON Schema subset and the `x-mantle-bind` / `x-mantle-ref` / `x-mcp-hint` keywords are in the [Schema reference](../reference/schema.md).
100
+
101
+ ## View — the read surface
102
+
103
+ A View is a named read-only query over Schemas, mounted on exactly one surface. No Trigger is involved.
104
+
105
+ ```yaml
106
+ apiVersion: cms.mantle.aotter.net/v1
107
+ kind: View
108
+ metadata:
109
+ name: published-notes
110
+ spec:
111
+ title: Published notes
112
+ surface: public
113
+ from: notes
114
+ fields: [id, title, updatedAt]
115
+ filter:
116
+ eq: { field: status, value: published }
117
+ orderBy:
118
+ - { field: updatedAt, direction: desc }
119
+ limit: 20
120
+ ```
121
+
122
+ `surface: public` serves `GET /api/views/published-notes` and the `query_view_published_notes` MCP tool on `/mcp`; `surface: staff` moves both behind the staff gate. See [Reads: Views, REST and MCP](./views.md) and the [View reference](../reference/view.md).
123
+
124
+ ## Procedure — the typed callable
125
+
126
+ A Procedure declares typed `input`, typed `output`, optional `requires`, and one `handler`. It is the only atom with a code seam. The handler is either `kind: builtin` — a CRUD shortcut over one Schema — or `kind: ref`, an opaque key into the `handlers` map your project registers.
127
+
128
+ ```yaml
129
+ spec:
130
+ handler: { kind: builtin, op: create, schema: notes }
131
+ ```
132
+
133
+ Builtin ops are `create`, `update`, `upsert`, `delete` and `archive`. `request_publish` and `publish` are deliberately absent: they are lifecycle operations, not CRUD primitives. See [Writes: Procedures, Triggers and lifecycle hooks](./procedures-and-triggers.md) and the [Procedure reference](../reference/procedure.md).
134
+
135
+ ## Trigger — the binding
136
+
137
+ A Trigger says "when X happens, run Procedure Y". Its `source` is one of three kinds, and its `target.procedure` names a declared Procedure.
138
+
139
+ ```yaml
140
+ spec:
141
+ source: { kind: http, method: POST, path: /api/contact }
142
+ # or: { kind: mcp, surface: public }
143
+ # or: { kind: lifecycle, schema: notes, on: [before_create] }
144
+ target: { procedure: send-contact-message }
145
+ ```
146
+
147
+ Several Triggers may target one Procedure, which is how the same handler becomes an HTTP endpoint, an MCP tool and a lifecycle hook without duplicated logic. Adding a transport is additive; the Procedure never changes. See the [Trigger reference](../reference/trigger.md).
148
+
149
+ ## How to think when extending
150
+
151
+ 1. **What entities does the feature need?** One `Schema` each.
152
+ 2. **What named queries?** One `View` each. Ad-hoc reads stay inside handler code; only sanctioned queries become Views, because a View is an external read endpoint.
153
+ 3. **What operations?** One `Procedure` each. One Procedure equals one typed function call. Compose in handler code, not in YAML.
154
+ 4. **What invokes them?** One `Trigger` per declared HTTP, MCP or lifecycle source.
155
+ 5. **Who is allowed?** `requires.auth` for static identity and scope, plus one optional `requires.guard.procedure` for a live business check. See [Authorization](./authorization.md).
156
+
157
+ ## The Postgres heuristic
158
+
159
+ When you are unsure whether something deserves to be an atom, ask what it would be in Postgres.
160
+
161
+ - If it is a `CREATE X` an **application developer** writes — `TABLE`, `VIEW`, `FUNCTION`, `TRIGGER` — it maps to one of the four.
162
+ - If it is a `CREATE X` Postgres only needs because it is a database engine — `INDEX`, `MATERIALIZED VIEW`, `TABLESPACE`, `STATISTICS`, `EVENT TRIGGER`, `EXTENSION`, `LANGUAGE`, `PUBLICATION`, `FOREIGN TABLE`, `RULE` — it does not map, and it belongs behind the storage adapter.
163
+ - If it is a type-system extension — `TYPE`, `DOMAIN`, `SEQUENCE`, `COLLATION`, `CAST`, `AGGREGATE`, `OPERATOR` — it folds into the JSON Schema inside a `Schema`.
164
+
165
+ Postgres exposes roughly twenty-five object kinds; an application developer writes four to six of them. That is the same ratio Mantle ships.
166
+
167
+ ## Source
168
+
169
+ - [`docs/adr/0001-four-atom-manifest-model.md`](../../../docs/adr/0001-four-atom-manifest-model.md)
170
+ - [`docs/adr/0012-views-as-public-rest.md`](../../../docs/adr/0012-views-as-public-rest.md)
171
+ - [`packages/mantle-spec/src/domain/model/ManifestGrammar.ts`](../../../packages/mantle-spec/src/domain/model/ManifestGrammar.ts)
172
+ - [`packages/mantle-spec/src/domain/service/ManifestParser.ts`](../../../packages/mantle-spec/src/domain/service/ManifestParser.ts)
173
+ - [`skills/develop/SKILL.md`](../../../skills/develop/SKILL.md)