@aotter/mantle 0.2.1-alpha.2 → 0.2.1-alpha.4

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 (33) hide show
  1. package/README.md +16 -177
  2. package/dist/build/compiler.js +3 -3
  3. package/dist/infrastructure/help.js +1 -3
  4. package/dist/usecase/adminDescriptors.js +1 -1
  5. package/dist/usecase/generate.js +20 -4
  6. package/dist/usecase/rest.js +2 -4
  7. package/docs/examples/README.md +2 -2
  8. package/docs/examples/app-shell/README.md +1 -1
  9. package/docs/examples/app-shell/accept.mjs +12 -2
  10. package/docs/examples/app-shell/package.json +3 -3
  11. package/docs/handbook/cloudflare/authentication.md +1 -1
  12. package/docs/handbook/cloudflare/deploy-and-operate.md +20 -8
  13. package/docs/handbook/cloudflare/media-r2.md +31 -0
  14. package/docs/handbook/examples/hub.md +1 -1
  15. package/docs/handbook/guides/agent-setup.md +25 -67
  16. package/docs/handbook/guides/agent-workflow-acceptance.md +33 -4
  17. package/docs/handbook/guides/mcp-apps.md +3 -1
  18. package/docs/{skills/performance/SKILL.md → handbook/guides/performance.md} +55 -32
  19. package/docs/handbook/guides/size-and-startup.md +2 -2
  20. package/docs/handbook/guides/typed-queries.md +12 -0
  21. package/docs/handbook/navigation.json +4 -0
  22. package/docs/handbook/releases/index.md +63 -2
  23. package/docs/{upgrade-0.1-to-0.2.md → handbook/releases/upgrade-0.1-to-0.2.md} +13 -13
  24. package/docs/handbook/start/agents.md +43 -6
  25. package/docs/handbook/start/quickstart-worker.md +1 -1
  26. package/package.json +1 -1
  27. package/skills/README.md +7 -69
  28. package/skills/mantle/SKILL.md +16 -81
  29. package/docs/skills/develop/SKILL.md +0 -100
  30. package/docs/skills/integrate/SKILL.md +0 -51
  31. package/docs/skills/media-gc/SKILL.md +0 -109
  32. package/docs/skills/provision/SKILL.md +0 -44
  33. package/docs/skills/update/SKILL.md +0 -42
@@ -15,11 +15,9 @@ export function topics(root = docsRoot()) {
15
15
  const unlisted = readdirSync(resolve(root, "handbook"), { recursive: true }).filter(path => path.endsWith(".md")).sort()
16
16
  .map(path => resolve(root, "handbook", path)).filter(file => !listed.has(file))
17
17
  .map(file => ({ name: relative(resolve(root, "handbook"), file).replaceAll("\\", "/").slice(0, -3), title: readFileSync(file, "utf8").match(/^# (.+)$/m)?.[1] ?? basename(file), file, group: "More pages" }));
18
- const skills = (existsSync(resolve(root, "skills")) ? readdirSync(resolve(root, "skills")) : []).filter(name => existsSync(resolve(root, "skills", name, "SKILL.md")))
19
- .map(name => ({ name: `skills/${name}`, title: `Package skill: ${name}`, file: resolve(root, "skills", name, "SKILL.md"), group: "Package skills" }));
20
18
  const examples = resolve(root, "examples");
21
19
  const shipped = readdirSync(examples, { withFileTypes: true }).filter(entry => entry.isDirectory() && existsSync(resolve(examples, entry.name, "README.md")));
22
- return [...pages, ...unlisted, ...skills, { name: "examples", title: "Examples and how to combine them", file: resolve(examples, "README.md"), group: "Examples" },
20
+ return [...pages, ...unlisted, { name: "examples", title: "Examples and how to combine them", file: resolve(examples, "README.md"), group: "Examples" },
23
21
  ...shipped.map(entry => ({ name: `examples/${entry.name}`, title: `Example: ${entry.name}`, file: resolve(examples, entry.name, "README.md"), group: "Examples" }))];
24
22
  }
25
23
  function find(all, name) {
@@ -7,7 +7,7 @@ export function emitAdminDescriptors({ plan, admin = false }, diagnostics) {
7
7
  const schemaList = Object.values(plan.schemas);
8
8
  const placed = [...schemaList.map(s => ["schemas/" + s.name, s.uiSchema, s]), ...Object.entries(plan.views).map(([name, v]) => ["views/" + name, v.uiSchema, null])];
9
9
  for (const [path, ui, s] of placed) {
10
- const nav = (ui?.["nav"] ?? {}), group = nav["group"];
10
+ const nav = (ui?.["nav"] ?? {});
11
11
  if (s && !isPrimaryCollection(s, schemaList))
12
12
  for (const key of ["group", "order", "hidden"])
13
13
  if (nav[key] !== undefined)
@@ -76,6 +76,9 @@ export function generate(input) {
76
76
  return fields;
77
77
  };
78
78
  const values = new Set();
79
+ const pool = new Map();
80
+ const pooled = (text) => { let id = pool.get(text); if (!id)
81
+ pool.set(text, id = `\u0000_p${pool.size}\u0000`); return id; };
79
82
  const step = (s, path, fields, row = false) => {
80
83
  const recipes = recipesOf(s, fields, message => refuse(path, message)), probe = row && s.probe ? recipesOf(s.probe, fields, message => refuse(path, message)) : [];
81
84
  for (const r of [...recipes, ...probe]) {
@@ -86,7 +89,7 @@ export function generate(input) {
86
89
  }
87
90
  const locks = Object.fromEntries((plan.schemas[s.schema ?? ""]?.locks ?? []).filter(l => l.on.includes(s.verb)).map(l => [l.name, l.message]));
88
91
  const lock = row && s.probe ? `, lock: ${q({ sql: s.probe.sql, binds: probe, schema: plan.schemas[s.schema].name, verb: s.verb, locks })}` : "";
89
- return `{ sql: ${q(s.sql)}, binds: ${q(recipes)}${row ? ", row: true" : s.onNoRows ? ', row: "some"' : ""}${lock} }`;
92
+ return `{ sql: ${pooled(q(s.sql))}, binds: ${pooled(q(recipes))}${row ? ", row: true" : s.onNoRows ? ', row: "some"' : ""}${lock} }`;
90
93
  };
91
94
  const declarations = [], views = [], procedures = [], refProcedures = [];
92
95
  const entries = {};
@@ -133,7 +136,7 @@ export function generate(input) {
133
136
  const fieldNames = inputFields(procedure.input, path + "/input");
134
137
  const steps = (statements) => `[${statements.map(s => step(s, path, fieldNames, s.kind === "row")).join(", ")}] as const`;
135
138
  const same = steps(modes.caller) === steps(modes.trusted);
136
- declarations.push(`export type Procedure${index}Input = ${queryInputType(procedure.input, [...modes.caller, ...modes.trusted])};`, `const ${fn}Caller = ${steps(modes.caller)};`, ...(same ? [] : [`const ${fn}Trusted = ${steps(modes.trusted)};`]), `async function ${fn}(db: Database, caller: Caller, raw: Procedure${index}Input): Promise<Output> {`, ...(modes.caller.some(s => s.kind !== "read") ? [` if ("authenticatedAs" in caller && caller.authenticatedAs === "snapshot") (await import("@aotter/mantle/host-caller")).checkFresh(caller, false, ${q(path)});`] : []), ...auth(procedure.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ...(procedure.trustedOnly ? [` deny(caller, USER.modeOf(caller) === "trusted", ${q(path)});`] : []), ` return { results: await DRIVER.procedure(db, bound(D, ${user.used ? "USER.scopedAll(" : ""}${same ? "" : `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : `}${fn}Caller${user.used ? ", caller)" : ""}, caller, raw), ${q(path)}) };`, "}");
139
+ declarations.push(`export type Procedure${index}Input = ${queryInputType(procedure.input, [...modes.caller, ...modes.trusted])};`, `const ${fn}Caller = ${steps(modes.caller)};`, ...(same ? [] : [`const ${fn}Trusted = ${steps(modes.trusted)};`]), `async function ${fn}(db: Database, caller: Caller, raw: Procedure${index}Input): Promise<Output> {`, ...(modes.caller.some(s => s.kind !== "read") ? [` await _fresh(caller, ${q(path)});`] : []), ...auth(procedure.requires, path), ...(user.used ? [' USER.modeOf(caller);'] : []), ...(procedure.trustedOnly ? [` deny(caller, USER.modeOf(caller) === "trusted", ${q(path)});`] : []), ` return { results: await DRIVER.procedure(db, bound(D, ${user.used ? "USER.scopedAll(" : ""}${same ? "" : `${user.used ? 'USER.modeOf(caller) === "trusted"' : 'caller.kind === "system"'} ? ${fn}Trusted : `}${fn}Caller${user.used ? ", caller)" : ""}, caller, raw), ${q(path)}) };`, "}");
137
140
  procedures.push(`[${q(name)}]: ${fn}`);
138
141
  declarations.push(`export { ${fn} as ${exportName("procedure", name)} };`);
139
142
  entries[`procedures/${encodeURIComponent(name)}.ts`] = entry("procedure", name, `Procedure${index}Input as Input`, `export type { Output } from "${base}contracts";\n`);
@@ -145,18 +148,31 @@ export function generate(input) {
145
148
  if (diagnostics.length)
146
149
  return { ok: false, diagnostics };
147
150
  const storeProcedures = hasHandlers ? "storeProcedures" : "procedures";
148
- const code = [...(Object.keys(plan.access?.userFields ?? {}).length ? [`export const userFields = ${q(plan.access.userFields)} as const;`] : []), ...declarations, `export const views = { ${views.join(", ")} };`, `export const procedures = { ${procedures.join(", ")} };`, ...(stores ? [
151
+ const code = [...(declarations.some(d => d.includes(" await _fresh(caller, ")) ? ['const _fresh = (caller: Caller, path: string) => "authenticatedAs" in caller && caller.authenticatedAs === "snapshot" ? import("@aotter/mantle/host-caller").then(m => m.checkFresh(caller, false, path)) : undefined;'] : []), ...(Object.keys(plan.access?.userFields ?? {}).length ? [`export const userFields = ${q(plan.access.userFields)} as const;`] : []), ...declarations, `export const views = { ${views.join(", ")} };`, `export const procedures = { ${procedures.join(", ")} };`, ...(stores ? [
149
152
  `export type Tables = ${stores.tables};`, `const RULES = ${stores.rules} as const;`,
150
153
  ...(hasHandlers ? [`const storeProcedures = { ${[...procedures, ...refProcedures].join(", ")} };`] : []),
151
154
  `const initStore = () => createStore<Database, Tables, typeof DRIVER, { views: typeof views; procedures: typeof ${storeProcedures} }${hasHandlers ? ", { handlers: Handlers }" : ""}>(${user.used ? "{...DIALECT.dialect, ...USER_VALUES}" : "DIALECT.dialect"}, DRIVER, RULES, ${stores.facts}, { views, procedures: ${storeProcedures} });`,
152
155
  `let makeStore: ReturnType<typeof initStore>;`,
153
156
  `export function store${hasHandlers ? "<const H extends Handlers>" : ""}(db: Database, caller: Caller${hasHandlers ? ", options: { handlers: H & Record<Exclude<keyof H, keyof Handlers>, never> }" : ", _options?: { handlers?: Handlers }"}): ReturnType<typeof makeStore> { return (makeStore ??= initStore())(db, caller${hasHandlers ? ", options" : ""}); }`,
154
157
  ] : [])].join("\n");
158
+ function unpool(text) {
159
+ const shared = [];
160
+ for (const [value, token] of pool) {
161
+ if (text.split(token).length > 2) {
162
+ const id = token.slice(1, -1);
163
+ shared.push(`const ${id} = ${value} as const;`);
164
+ text = text.replaceAll(token, id);
165
+ }
166
+ else
167
+ text = text.replace(token, () => value);
168
+ }
169
+ return [...shared, text];
170
+ }
155
171
  const dialect = `${base}dialect/${isSqlite ? "sqlite" : "postgres"}`;
156
172
  const contracts = [...(target.endsWith("postgres") ? ["type Postgres as Database", ...(views.length ? ["type Unsafe"] : [])] : []), "type Caller", ...(hasHandlers ? ["ContractError"] : []), ...(/^ deny\(/m.test(code) ? ["deny", ...(/\bisAdmin\(/.test(code) ? ["isAdmin"] : []), ...(/\bisUser\(/.test(code) ? ["isUser"] : [])] : []), ...(procedures.length ? ["type Output"] : [])];
157
173
  const dbType = isSqlite ? databaseImport(target) : "";
158
174
  return { ok: true, refProcedures: refNames, files: generatedFiles({ ...surfaces, ...entries, "generated.ts": ["// Generated native application SQL. No runtime plan or interpreter.", user.code, dbType, `import { ${contracts.join(", ")} } from "${base}contracts";`,
159
175
  ...(values.size || stores ? [`import * as DIALECT from "${dialect}";`] : []), `import * as DRIVER from "${base}driver/${driver}";`, ...((names => names.length ? [`import { ${names.join(", ")} } from "${base}store";`] : [])([...(stores ? ["createStore"] : []), ...(hasHandlers && target !== "cloudflare-d1" ? ["handlerTransaction"] : [])])),
160
176
  `import { ${procedures.length ? "binds, bound" : "binds"} } from "${base}binds";`,
161
- `const D = { ${[...values].sort().map(v => `${v}: DIALECT.${v}`).concat(user.values ? [user.values] : []).join(", ")} };`, code].filter(Boolean).join("\n") + "\n" }) };
177
+ `const D = { ${[...values].sort().map(v => `${v}: DIALECT.${v}`).concat(user.values ? [user.values] : []).join(", ")} };`, ...unpool(code)].filter(Boolean).join("\n") + "\n" }) };
162
178
  }
@@ -22,7 +22,7 @@ export function emitRest(plan, lowered, target, description) {
22
22
  for (const [name, view] of Object.entries(plan.views)) {
23
23
  if (view.surface !== "public")
24
24
  continue;
25
- const id = views.length, encoded = encodeURIComponent(name);
25
+ const id = views.length;
26
26
  const wire = outputConversions(plan, name, target);
27
27
  imports.push(...viewImports(name, id).map(line => wire.some(([, type]) => type === "int") ? line.replaceAll("./paged/views/", "./paged/wire/views/") : line));
28
28
  hasWire ||= wire.length > 0;
@@ -33,14 +33,12 @@ export function emitRest(plan, lowered, target, description) {
33
33
  if (trigger.source.kind !== "http")
34
34
  continue;
35
35
  const { method, path } = trigger.source, name = trigger.procedure, procedure = plan.procedures[name], id = procedures.length, ref = "ref" in procedure.handler;
36
- if (!ref)
37
- imports.push(`import procedure${id} from "./procedures/${encodeURIComponent(name)}.js";`);
38
36
  imports.push(`import { validate as validate${id} } from "./validation/procedures/${encodeURIComponent(name)}.js";`);
39
37
  const params = Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].flatMap(([, key]) => {
40
38
  const type = [procedure.input?.properties?.[key]?.type].flat().find(t => t && t !== "null");
41
39
  return type === "integer" || type === "number" ? [[key, "number"]] : type === "boolean" ? [[key, "boolean"]] : [];
42
40
  }));
43
- procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${template(path)}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `procedure${id}`} }`);
41
+ procedures.push(`{ ${route(method, path)}, id: ${q(`rest:${method} ${template(path)}`)}, target: ${q({ kind: "procedure", name })}${description.targets.find(t => t._meta.id === `procedure:${name}`)?._meta.readOnly ? ", readOnly: true" : ""}${guard(procedure.requires, `procedures/${name}`, lowered.procedures[name]?.caller?.flatMap(s => s.binds), procedure.trustedOnly)}${Object.keys(params).length ? `, params: ${q(params)}` : ""}, validate: validate${id}${convert(conversions(procedure.input?.properties, lowered.procedures[name]?.caller?.flatMap(s => s.binds) ?? [], target))}, run: ${ref ? `(db, caller, input, handlers) => store(db, caller, { handlers }).procedures[${q(name)}](input)` : `async (db, caller, input) => (await import("./procedures/${encodeURIComponent(name)}.js")).default(db, caller, input)`} }`);
44
42
  }
45
43
  if (hasWire)
46
44
  imports.push('import { wireRows } from "@aotter/mantle/contracts";');
@@ -39,5 +39,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
39
39
 
40
40
  Every example uses two roles: `admin` (the admin layer) and `user`.
41
41
 
42
- [Reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.2/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
43
- Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
42
+ [Reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.4/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
43
+ Projects on 0.1.x move with [the upgrade guide](../handbook/releases/upgrade-0.1-to-0.2.md).
@@ -112,7 +112,7 @@ its layered report ("Report done-ness in layers"): smoke and `pnpm test` are lay
112
112
 
113
113
  ### Independent acceptance
114
114
 
115
- `pnpm accept` (optionally `pnpm accept --fixtures fixtures.mjs --user member@example.test`; with npm, `npm run accept -- --fixtures …`) starts the app on a fresh local D1,
115
+ `pnpm accept` (optionally `pnpm accept --fixtures fixtures.mjs --user member@example.test`, and `--open` to open the signed-in admin's Developer UI in your browser; with npm, `npm run accept -- --fixtures …`) starts the app on a fresh local D1,
116
116
  signs the admin in, runs your fixtures module, signs the user in and prints one local URL per role:
117
117
 
118
118
  ```text
@@ -1,5 +1,5 @@
1
1
  // Independent acceptance setup (mantle help agent-workflow-acceptance): hands each role to a fresh evaluator without a credential.
2
- // node accept.mjs [--fixtures fixtures.mjs] [--user member@example.test]
2
+ // node accept.mjs [--fixtures fixtures.mjs] [--user member@example.test] [--open]
3
3
  // Starts the app on a fresh local D1 (schema.sql, then seed.sql), signs the admin in with email OTP, runs the optional
4
4
  // fixtures module (default export async ({ call, mcp, signIn, sql, sessions, user }) => {...}: realistic records through
5
5
  // the app's own REST/MCP as real users, e.g. other members' orders and more than one page), signs the user in, then
@@ -7,8 +7,11 @@
7
7
  // (a visitor gets the app's own origin):
8
8
  // MCP at <proxy>/mcp (user) or <proxy>/mcp/admin (admin), Admin at <proxy>/admin, pages and REST at <proxy>/.
9
9
  // The proxy refuses /api/auth/* so the evaluator cannot sign out, sign in or mint credentials. Ctrl-C stops everything.
10
+ // --open also opens the signed-in admin's Developer UI (<admin proxy>/admin/dev) in the default browser, so a person
11
+ // sees the app's wiring without a one-time code; the app keeps rebuilding on every change under src/ and generated/.
10
12
  // node accept.mjs mcp <endpoint> [tool [json-arguments]]
11
13
  // A minimal MCP client for an evaluator without one: lists the tools, or calls one and prints the raw result.
14
+ import { spawn } from "node:child_process";
12
15
  import { createServer } from "node:http";
13
16
  import { Readable } from "node:stream";
14
17
  import { parseArgs } from "node:util";
@@ -19,7 +22,7 @@ if (process.argv[2] === "mcp") await client(process.argv[3], process.argv[4], pr
19
22
  else await setup();
20
23
 
21
24
  async function setup() {
22
- const { values } = parseArgs({ args: process.argv.slice(2).filter(arg => arg !== "--"), options: { fixtures: { type: "string" }, user: { type: "string", default: "member@example.test" } } });
25
+ const { values } = parseArgs({ args: process.argv.slice(2).filter(arg => arg !== "--"), options: { fixtures: { type: "string" }, user: { type: "string", default: "member@example.test" }, open: { type: "boolean" } } });
23
26
  const { startApp } = await import("./smoke-kit.mjs");
24
27
  const app = await startApp();
25
28
  const stop = async () => { await app.stop(); process.exit(0); };
@@ -33,6 +36,7 @@ async function setup() {
33
36
  const lines = [`visitor (anonymous): ${app.origin}`];
34
37
  for (const [role, cookie] of Object.entries(sessions)) {
35
38
  const url = await proxy(app.origin, cookie);
39
+ if (role === "admin" && values.open) browse(`${url}/admin/dev`);
36
40
  lines.push(`${role} (${role === "admin" ? app.owner : values.user}): ${url} MCP ${url}/mcp${role === "admin" ? "/admin" : ""}${role === "admin" ? ` Admin ${url}/admin` : ""}`);
37
41
  }
38
42
  console.log(lines.join("\n") + "\nReady. Ctrl-C stops the app and deletes its data.");
@@ -43,6 +47,12 @@ async function setup() {
43
47
  }
44
48
  }
45
49
 
50
+ /** Opens a URL in the default browser; without one (CI, a container) the printed URL is all there is. */
51
+ function browse(url) {
52
+ const [command, ...args] = process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
53
+ spawn(command, [...args, url], { stdio: "ignore", detached: true }).on("error", () => console.log(`Open ${url} in a browser.`)).unref();
54
+ }
55
+
46
56
  /** A local origin that forwards to the app with one role's session; sliding Set-Cookie updates stay inside the proxy. */
47
57
  function proxy(origin, cookie) {
48
58
  const jar = new Map(cookie.split("; ").map(pair => [pair.slice(0, pair.indexOf("=")), pair]));
@@ -15,8 +15,8 @@
15
15
  "accept": "node accept.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@aotter/mantle": "0.2.1-alpha.2",
19
- "@aotter/mantle-ui": "0.2.1-alpha.2",
18
+ "@aotter/mantle": "0.2.1-alpha.4",
19
+ "@aotter/mantle-ui": "0.2.1-alpha.4",
20
20
  "@better-auth/cimd": "1.7.7",
21
21
  "@better-auth/core": "1.7.7",
22
22
  "@better-auth/mcp": "1.7.7",
@@ -25,7 +25,7 @@
25
25
  "@modelcontextprotocol/ext-apps": "2.0.0",
26
26
  "@modelcontextprotocol/server": "2.3.1",
27
27
  "better-auth": "1.7.7",
28
- "@aotter/mantle-admin": "0.2.1-alpha.2",
28
+ "@aotter/mantle-admin": "0.2.1-alpha.4",
29
29
  "react": "19.3.0",
30
30
  "react-dom": "19.3.0"
31
31
  },
@@ -91,7 +91,7 @@ Invalid presented bearer credentials fail rather than becoming anonymous. Fresh
91
91
 
92
92
  ## Build: keep Better Auth out of startup
93
93
 
94
- Use the [Workers build instructions](../../../README.md#workers-build).
94
+ Generation configures `mantle bundle` as the Workers build ([outputs](../start/project-and-cli.md#outputs)).
95
95
  The metafile startup guard rejects static Better Auth imports; keep auth behind
96
96
  `authLoader(() => import(...))`.
97
97
 
@@ -5,6 +5,22 @@ description: Install schema explicitly, migrate before deploying, and operate a
5
5
 
6
6
  Run the same generation command with `--check`, typecheck the application and test its selected routes before deploying. Keep generated TypeScript, SQL and Wrangler edits committed; keep bundle `dist/` untracked. The app owns the database, auth secrets, trusted origin and assets.
7
7
 
8
+ Deploy only when the user asks; creating a project locally is not authorization. Confirm the exact account and reuse the
9
+ resources it already has before creating any. For ChatGPT Sites, follow [Mantle on ChatGPT Sites](chatgpt-sites.md).
10
+
11
+ ## Production checklist
12
+
13
+ 1. Migrations are reviewed, rehearsed on a copy and applied before traffic reaches the new code
14
+ ([install and change schemas](#install-and-change-schemas)).
15
+ 2. Secrets live in the host's secret store, never in source, chat or logs: the auth secret, the trusted HTTPS
16
+ `PUBLIC_ORIGIN`, the `ADMIN_EMAIL` bootstrap rule and the production email sender ([authentication](authentication.md)).
17
+ 3. Admin assets are served Worker-first ([Workers assets and secrets](service-entry.md#workers-assets-and-secrets)) and
18
+ both [public-fetch flags](#public-fetch-routing) stay in every environment.
19
+ 4. After deploying, verify on the deployed host: admin sign-in gets role `admin`, a member is denied Admin, public and
20
+ protected reads, a persisted write, a stale OCC write refused, and MCP `initialize` and `tools/list` with a real OAuth
21
+ bearer.
22
+ 5. Keep the previous build and the migration rollback until the release is verified.
23
+
8
24
  ## Public fetch routing
9
25
 
10
26
  Workers use `compatibility_flags: ["nodejs_compat", "global_fetch_strictly_public"]`.
@@ -28,13 +44,7 @@ checks do not verify production public-hostname routing.
28
44
 
29
45
  ## Install and change schemas
30
46
 
31
- The runtime never creates, alters or checks tables. Boot and requests run no DDL, introspection or schema checks.
32
-
33
- For the first install, apply generated `schema.sql`, including selected auth/site DDL, to an empty database. Apply selected seed SQL explicitly.
34
-
35
- For every later schema change, the application's coding agent diffs the regenerated `schema.sql`, writes a migration with the host's native tool (`wrangler d1 migrations` or the app's PostgreSQL migration tool), rehearses it on a copy, and applies it before deploying the new code.
36
-
37
- Destructive changes and type changes need explicit data migration. Mantle drops nothing. Initial `schema.sql` is not an upgrade script.
47
+ The rules are in [schema installation and migrations](../concepts/runtime-and-adapters.md#schema-installation-and-migrations): the runtime runs no DDL, the initial `schema.sql` is only for an empty database, and every later change is a reviewed migration rehearsed on a copy and applied before the new code deploys.
38
48
 
39
49
  For D1, create a migration with `pnpm exec wrangler d1 migrations create <database> <name>`, review its SQL, then rehearse `pnpm exec wrangler d1 migrations apply <database> --local` against copied data. Apply the reviewed migrations to the target before `wrangler deploy`. Initial DDL may be placed in the first migration for a new database.
40
50
 
@@ -79,4 +89,6 @@ Auth configuration and production email delivery are app-owned; follow [authenti
79
89
 
80
90
  Scheduling is app-owned. Wire generated system-only TTL sweeps into `scheduled()` and configure Wrangler crons explicitly. Bound each invocation and retain a cursor if work remains; see [TTL maintenance](../guides/native-execution.md#ttl-maintenance). Expired rows remain stored and hold unique keys until removed.
81
91
 
82
- After deployment, verify public and protected reads, member/admin sign-in, a persisted write, stale OCC and selected MCP/Admin/web routes. An `OUTCOME_UNKNOWN` write may have committed; reconcile before deciding what to do next and never retry automatically.
92
+ An `OUTCOME_UNKNOWN` write may have committed; reconcile before deciding what to do next and never retry automatically. For MCP Apps, verify a newly invoked render tool on the deployed host and keep old
93
+ conversations working with the [retained-page recipe](../guides/mcp-apps.md#keep-old-conversations-working-after-an-upgrade).
94
+ Deploying an application never publishes or bumps the SDK.
@@ -73,3 +73,34 @@ reconciliation and retry id.
73
73
  Admin's `media` option takes a `MediaStorage` value, so build it where `env` is available (for example once per
74
74
  Worker in the fetch handler). Uploads use presigned S3 PUT URLs: `wrangler dev --local` has an R2 binding but no S3
75
75
  endpoint, so test the upload flow against a real bucket; local tests can cover everything up to the signed URL.
76
+
77
+ ## Clean up stale uploads
78
+
79
+ An upload that was created but never committed leaves objects under `uploads/`. Cleaning them up is an operator task:
80
+ audit by default, and delete only the exact objects the user approves for that exact account and bucket. Create no
81
+ Worker, Cron Trigger, lifecycle rule, table or script for it, and never create, request or store credentials.
82
+
83
+ **Layout.** `POST /admin/api/media/uploads` issues presigned PUT URLs (15 minutes) for
84
+ `uploads/<purpose>/<group>/<role>.<ext>`. Commit copies each checked object to `committed/<group>/<attempt>/<role>.<ext>`
85
+ with `committedAt` metadata and removes the upload keys after the database commit; a rejected group also removes them.
86
+ An old upload key may be leftover cleanup from a successful or uncertain commit, so it is not evidence that the media row
87
+ is absent. Objects outside `uploads/` are never candidates; attempt copies under `committed/` are reconciled with live
88
+ database references separately, under their own authorization.
89
+
90
+ **Preflight.** Resolve the bucket `r2MediaStorage` uses and the purposes in `siteDefaults.media.purposes` from
91
+ `wrangler.jsonc` and the app entry. Confirm the exact account and bucket with the user; when either is ambiguous, ask,
92
+ never match by name. Stop when the app passes no `r2MediaStorage`, declares no purpose, or the connected account lacks
93
+ List/Delete Objects access.
94
+
95
+ **Audit.** For each declared purpose, page through `uploads/<purpose>/` until the listing is not truncated. A candidate
96
+ is more than 24 hours old, has no `committedAt`, and matches
97
+ `uploads/<purpose>/<group>/(primary|alternate|fallback).(png|jpg|webp|avif|gif|bin)` with a declared purpose and a
98
+ `<group>` of letters, digits, `_` or `-`. Skip everything else (committed, unknown prefix, malformed, ambiguous) and
99
+ delete single variants, never a whole group. Report the account, bucket, prefixes, UTC cutoff, candidate object and
100
+ group counts, bytes, skipped count, and a SHA-256 digest of the sorted `key + etag` set; print no keys, group ids,
101
+ filenames, URLs or secrets. A page truncated without a cursor stops the run: never delete from a partial audit.
102
+
103
+ **Apply.** After explicit confirmation of the account, bucket, cutoff, count, bytes and digest, re-run the audit with the
104
+ same cutoff and stop if anything changed. Delete exact keys, at most 1,000 per request, and never send a `prefix`: an
105
+ empty prefix can empty the bucket. Do not retry failed keys automatically. Re-list each prefix and report whether the run
106
+ was audit-only or applied, the before/after counts and bytes, and redacted error codes.
@@ -2,4 +2,4 @@
2
2
 
3
3
  Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
4
4
 
5
- The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.2/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
5
+ The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.4/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
@@ -1,97 +1,55 @@
1
1
  ---
2
- description: Install the Mantle agent plugin or its one skill, hand off to the installed package's own docs, and diagnose missing or stale instructions.
2
+ description: Install the Mantle agent skill, hand off to the installed package's `mantle help`, and diagnose missing or stale instructions.
3
3
  ---
4
4
  # Install and verify agent instructions
5
5
 
6
- Two separate things are installed: the agent's `mantle` skill and the
7
- application's `@aotter/mantle` package. Installing one does not install the
8
- other, and the installed package is always the authority for the version it
9
- carries.
6
+ Two separate things are installed: the agent's `mantle` skill and the application's `@aotter/mantle` package. The skill
7
+ holds no contract and does not depend on the SDK version: it sends the agent to `mantle help`, which prints the docs
8
+ shipped inside the installed package. So the skill can come from the latest repository while the application pins any
9
+ version.
10
10
 
11
11
  ## 1. Install the `mantle` skill
12
12
 
13
- The Mantle plugin carries one OSS authoring skill, `mantle`, and local
14
- acceptance references. It does not configure a hosted-service connection.
15
- Install from the Git tag matching the exact SDK version you selected (currently
16
- `v0.2.1-alpha.2`). Pin the tag to keep instructions aligned with the application's
17
- SDK. A skill install does not upgrade the application's SDK.
18
-
19
- For Codex, pin the marketplace checkout:
13
+ Any agent that reads skills:
20
14
 
21
15
  ```sh
22
- codex plugin marketplace add aotter/mantle --ref v0.2.1-alpha.2
23
- codex plugin add mantle@mantle
16
+ npx skills add aotter/mantle@mantle
24
17
  ```
25
18
 
26
- For Claude Code, first clone the release into a directory you will keep:
19
+ The explicit name excludes the repository's maintainer skills. As a plugin, which carries the same one skill and no
20
+ hosted-service connection:
27
21
 
28
22
  ```sh
29
- git clone --depth 1 --branch v0.2.1-alpha.2 https://github.com/aotter/mantle.git mantle-plugin
23
+ codex plugin marketplace add aotter/mantle
24
+ codex plugin add mantle@mantle
30
25
  ```
31
26
 
32
- Then, in Claude Code, use that checkout's absolute path:
33
-
34
27
  ```text
35
- /plugin marketplace add /absolute/path/to/mantle-plugin
28
+ /plugin marketplace add aotter/mantle
36
29
  /plugin install mantle@mantle
37
30
  ```
38
31
 
39
- Cursor and GitHub Copilot use their plugin manifests from the same tagged
40
- checkout. Without a plugin host, install only the consumer skill by name:
41
-
42
- ```sh
43
- npx skills add aotter/mantle@mantle
44
- ```
45
-
46
- Without a version it follows `main`, the latest stable release. To match the
47
- application's SDK exactly, add its release tag: `npx skills add aotter/mantle#v0.2.1-alpha.2@mantle`.
48
-
49
- When upgrading the SDK, update the marketplace ref or local tagged checkout and
50
- refresh/reinstall the plugin using the host's native workflow. Verify the loaded
51
- manifest version and skill source. Do not reset a checkout containing local edits.
52
-
53
- Read the path the installer prints (for project-local Codex,
54
- `.agents/skills/mantle/SKILL.md`). The skill installs no SDK, creates no
32
+ Cursor and GitHub Copilot read their plugin manifests from the same repository. The skill installs no SDK, creates no
55
33
  project and copies no handbook.
56
34
 
57
- ## 2. Install the SDK, then read its own docs
35
+ ## 2. Install the SDK and read its docs
58
36
 
59
- Use the version the project already pins. For a new project, install this
60
- handbook's version exactly. 0.2.1-alpha.2 is a prerelease; stable 0.2.0 remains the
61
- production line, and its own tagged handbook describes it.
37
+ Use the version the project already pins. For a new project, pick one exact version from
38
+ [Releases](../releases/index.md) (only plain `X.Y.Z` versions are for production) and pin every selected
39
+ `@aotter/mantle*` package to it:
62
40
 
63
41
  ```sh
64
- pnpm add --save-exact @aotter/mantle@0.2.1-alpha.2
42
+ pnpm add --save-exact @aotter/mantle@<version>
43
+ npx mantle help
65
44
  ```
66
45
 
67
- Add optional `@aotter/mantle-ui@0.2.1-alpha.2` and `@aotter/mantle-admin@0.2.1-alpha.2` only
68
- when the selected surfaces need them. Every selected `@aotter/mantle*` package
69
- stays at the same exact version. Read, from the application:
70
-
71
- ```text
72
- node_modules/@aotter/mantle/README.md
73
- node_modules/@aotter/mantle/docs/handbook/start/overview.md
74
- node_modules/@aotter/mantle/docs/handbook/reference/features.md
75
- node_modules/@aotter/mantle/docs/skills/<workflow>/SKILL.md
76
- ```
77
-
78
- These ship in the npm tarball and match the installed code. Prefer them over
79
- the skill's GitHub copy and over any online handbook.
80
-
81
- | Package skill | Read it when |
82
- |---|---|
83
- | `docs/skills/develop/SKILL.md` | changing manifests, handlers, the service or its surfaces |
84
- | `docs/skills/integrate/SKILL.md` | adding Mantle to an existing application |
85
- | `docs/skills/update/SKILL.md` | upgrading `@aotter/mantle`, including from 0.1.x |
86
- | `docs/skills/provision/SKILL.md` | deploying, with production sign-in |
87
- | `docs/skills/media-gc/SKILL.md` | auditing or removing stale R2 uploads |
88
- | `docs/skills/performance/SKILL.md` | after acceptance converges, or when a page, first request or query is slow |
89
-
90
- 0.2.0 has no `mantle skills` command: nothing is copied into the project.
91
- An agent reads these files from `node_modules` when the task calls for them.
46
+ `mantle help` prints the installed version's entry page; prefer it over any online copy of this handbook.
92
47
 
93
48
  ## 3. Verify the application
94
49
 
95
- Run the app's exact generation command with `--check`, typecheck, implement every printed handler and typecheck again. Test actual routes and persisted state. With selected Admin, mount `entries(bindings.schemas, bindings.adapter)` and serve assets separately. Verify member/admin OTP, row visibility and OCC. Installing a skill proves none of these flows.
50
+ Run the app's exact generation command with `--check`, typecheck, implement every printed handler and typecheck again.
51
+ Test actual routes and persisted state, then run acceptance as the entry page describes. Installing a skill proves none
52
+ of these flows.
96
53
 
97
- If packaged docs are missing, reinstall the SDK version pinned by the app. If a feature differs from an online page, read the installed docs rather than assuming compatibility.
54
+ If `mantle help` reports missing docs, reinstall the SDK version the app pins. If a feature differs from an online page,
55
+ the installed docs win.
@@ -85,6 +85,20 @@ rounds below before claiming the user-facing work is done. Keep the author's
85
85
  capability map and worked call sequence out of the fresh agent's prompt: an MCP
86
86
  evaluator must discover the workflow from public tool descriptions and results.
87
87
 
88
+ ## Plan the run
89
+
90
+ The plan the user approves before the build ([Build from a product brief](../start/agents.md#build-from-a-product-brief))
91
+ says in plain words who tries the app and what they try to get done. Behind it, the run
92
+ fixes:
93
+
94
+ - each evaluator you will start: its role (`admin`, `user`, a visitor), the surfaces it uses (Admin, member pages,
95
+ REST, MCP, visitor pages) and the goals it is asked to finish, in the user's words;
96
+ - the round limit (at most three) and what triggers a re-run;
97
+ - what the environment needs, such as a browser for a visitor or member page. Provide it yourself (install a headless
98
+ browser if none is there); only what you cannot provide goes to the user now, rather than reported as BLOCKED later.
99
+
100
+ Keep the rubric itself private: the plan names goals, never the checks an evaluator is graded on.
101
+
88
102
  ## Run role-only acceptance rounds until they converge
89
103
 
90
104
  The evaluator gets a role, not a task list. It works out what that role needs
@@ -154,10 +168,11 @@ acceptance as BLOCKED and report the scripted checks on their own
154
168
  [layers](#report-done-ness-in-layers). Do not invent
155
169
  an independent result. Do not deploy or publish merely to obtain acceptance.
156
170
 
157
- Acceptance proves the behavior, not its speed. Once a round converges, the
158
- [performance skill](../../skills/performance/SKILL.md) decides which performance
159
- checks apply and holds them to their targets; a performance repair that changes
160
- what a role sees or can do returns here, scoped by the
171
+ Acceptance proves the behavior, not its speed. For every role and surface a
172
+ round reports CONVERGED, the [performance pass](performance.md)
173
+ decides which performance checks apply and holds them to their targets. BLOCKED
174
+ or UNVERIFIED parts are reported as they are, never optimized. A performance
175
+ repair that changes what a role sees or can do returns here, scoped by the
161
176
  [re-verification rule](#re-verify-what-a-repair-touches).
162
177
 
163
178
  ## Re-verify what a repair touches
@@ -300,6 +315,20 @@ reported, layer by layer; it is not done:
300
315
  | 4. External platform | not applicable: no deployment or external host requested | |
301
316
  ```
302
317
 
318
+ ## Summarise for the user
319
+
320
+ Lead the handoff with what acceptance found and fixed, so the user sees the value of the rounds:
321
+
322
+ ```markdown
323
+ | Evaluator (role, surfaces) | Rounds | Found | Fixed in | Final |
324
+ | --- | --- | --- | --- | --- |
325
+ | admin: Admin, admin MCP | 2 | P1: approving a stale event overwrote a newer edit | `approve-event` checks the version | CONVERGED |
326
+ | user: member pages, MCP | 1 | none | | CONVERGED |
327
+ | visitor: public pages | 1 | P2: no link back from an event to the list | `src/routes.ts` | CONVERGED |
328
+ ```
329
+
330
+ Name each finding by its effect on the user, not the code, then give the layered status below.
331
+
303
332
  ## Record evidence in the existing issue or handoff
304
333
 
305
334
  Use ordinary Markdown; no new receipt schema or framework is required:
@@ -86,7 +86,9 @@ source/origin guard is not a protocol implementation.
86
86
 
87
87
  Applications own domain components and build entries. A frontend MCP App uses its
88
88
  own React entry, official ext-apps and optionally mantle-ui, registered through
89
- Core's generic resources. It needs no Admin package or Admin session. See the
89
+ Core's generic resources. It needs no Admin package or Admin session. Build it
90
+ with `App` from `@modelcontextprotocol/ext-apps`: register `ontoolinput`/`ontoolresult` before `connect()` and use
91
+ `callServerTool` for host-proxied calls; never write a second `postMessage` bridge beside the official client. See the
90
92
  [standalone frontend example](../../examples/frontend-mcp-app/README.md) for a
91
93
  small owner-scoped example. `public` names the frontend endpoint, not anonymous
92
94
  access: use the application's verified Caller and generated authorization rules.