@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) 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/dist/codegen/emitMantleModule.js +8 -0
  9. package/dist/codegen/emitMantleModule.js.map +1 -1
  10. package/docs/adapter-guide.md +23 -26
  11. package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
  12. package/docs/adr/0010-locale-and-translates.md +14 -16
  13. package/docs/adr/0011-adapter-port-spec.md +2 -1
  14. package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
  15. package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
  16. package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +38 -2
  17. package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
  18. package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
  19. package/docs/adr/0022-caller-observed-version-occ.md +212 -0
  20. package/docs/adr/0023-port-failure-contract.md +69 -0
  21. package/docs/adr/0024-manifest-native-schema-tables.md +260 -0
  22. package/docs/adr/README.md +19 -3
  23. package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
  24. package/docs/adr/adr-lite-808-route-readiness.md +47 -0
  25. package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
  26. package/docs/adr/adr-lite-812-native-parity.md +150 -0
  27. package/docs/adr/adr-lite-823-home-statistics.md +64 -0
  28. package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
  29. package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
  30. package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
  31. package/docs/api-mcp-authorization.md +3 -664
  32. package/docs/auth-hosting-model.md +4 -4
  33. package/docs/cloudflare-low-level-composition.md +3 -104
  34. package/docs/deferred-lifecycle-queues.md +2 -243
  35. package/docs/design-atoms.md +16 -796
  36. package/docs/direct-authoring.md +5 -0
  37. package/docs/examples/minimal-worker/README.md +37 -0
  38. package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
  39. package/docs/examples/minimal-worker/package.json +26 -0
  40. package/docs/examples/minimal-worker/smoke.mjs +23 -0
  41. package/docs/examples/minimal-worker/src/index.ts +4 -0
  42. package/docs/examples/minimal-worker/tsconfig.json +17 -0
  43. package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
  44. package/docs/handbook/cloudflare/authentication.md +167 -0
  45. package/docs/handbook/cloudflare/bindings.md +236 -0
  46. package/docs/handbook/cloudflare/conventional-worker.md +162 -0
  47. package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
  48. package/docs/handbook/cloudflare/deploy-and-operate.md +105 -0
  49. package/docs/handbook/cloudflare/low-level-composition.md +169 -0
  50. package/docs/handbook/cloudflare/media-r2.md +216 -0
  51. package/docs/handbook/cloudflare/public-web.md +144 -0
  52. package/docs/handbook/concepts/authorization.md +116 -0
  53. package/docs/handbook/concepts/four-atoms.md +173 -0
  54. package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
  55. package/docs/handbook/concepts/mcp-and-agents.md +109 -0
  56. package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
  57. package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
  58. package/docs/handbook/concepts/views.md +191 -0
  59. package/docs/handbook/examples/commerce-transaction.md +813 -0
  60. package/docs/handbook/examples/guarded-api.md +427 -0
  61. package/docs/handbook/examples/intake-form.md +319 -0
  62. package/docs/handbook/examples/legal-documents.md +218 -0
  63. package/docs/handbook/examples/procurement-approvals.md +240 -0
  64. package/docs/handbook/examples/publication.md +240 -0
  65. package/docs/handbook/examples/reservation.md +220 -0
  66. package/docs/handbook/navigation.json +63 -0
  67. package/docs/handbook/reference/authorization.md +295 -0
  68. package/docs/handbook/reference/diagnostics.md +183 -0
  69. package/docs/handbook/reference/manifest.md +132 -0
  70. package/docs/handbook/reference/procedure.md +263 -0
  71. package/docs/handbook/reference/schema.md +250 -0
  72. package/docs/handbook/reference/site-config.md +233 -0
  73. package/docs/handbook/reference/surface.md +207 -0
  74. package/docs/handbook/reference/trigger.md +193 -0
  75. package/docs/handbook/reference/view.md +256 -0
  76. package/docs/handbook/start/project-and-cli.md +122 -0
  77. package/docs/handbook/start/quickstart-worker.md +193 -0
  78. package/docs/labels.md +3 -1
  79. package/docs/media-uploads.md +3 -184
  80. package/docs/migration-0.1.2.md +60 -3
  81. package/docs/performance-harness.md +127 -10
  82. package/docs/release-process.md +85 -231
  83. package/docs/schema-indexes.md +3 -180
  84. package/docs/sealed-pipeline-ownership.md +6 -5
  85. package/docs/spec-only-host-adoption.md +158 -0
  86. package/docs/transaction-patterns.md +5 -0
  87. package/package.json +21 -25
  88. package/skills/README.md +4 -4
  89. package/skills/develop/SKILL.md +23 -28
  90. package/skills/install/SKILL.md +55 -147
  91. package/skills/provision/SKILL.md +4 -3
  92. package/skills/theme/SKILL.md +6 -6
  93. package/skills/update/SKILL.md +32 -68
  94. package/dist/cli/create.d.ts +0 -2
  95. package/dist/cli/create.d.ts.map +0 -1
  96. package/dist/cli/create.js +0 -243
  97. package/dist/cli/create.js.map +0 -1
  98. package/dist/cli/update.d.ts +0 -2
  99. package/dist/cli/update.d.ts.map +0 -1
  100. package/dist/cli/update.js +0 -413
  101. package/dist/cli/update.js.map +0 -1
  102. package/dist/provision/renderProvisionBundle.d.ts +0 -70
  103. package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
  104. package/dist/provision/renderProvisionBundle.js +0 -367
  105. package/dist/provision/renderProvisionBundle.js.map +0 -1
  106. package/dist/provision.d.ts +0 -2
  107. package/dist/provision.d.ts.map +0 -1
  108. package/dist/provision.js +0 -2
  109. package/dist/provision.js.map +0 -1
@@ -0,0 +1,183 @@
1
+ ---
2
+ description: The Diagnostic shape and the complete closed code catalog — every validate, boot and runtime code, what it means and the HTTP status it maps to.
3
+ ---
4
+ # Diagnostics
5
+
6
+ Every failure in Mantle is a `Diagnostic`: one structured object with a stable code, the phase that produced it and a JSON Pointer to the offending place. The catalog is closed — adding a code is a grammar-revise event — so an agent can group by `code` or filter by `phase` without parsing prose. This page is the whole catalog. The rules that raise each code live on the atom pages: [Manifest](./manifest.md), [Schema](./schema.md), [View](./view.md), [Procedure](./procedure.md), [Trigger](./trigger.md), [Authorization](./authorization.md) and [Site config](./site-config.md).
7
+
8
+ ## Shape
9
+
10
+ ```ts
11
+ interface Diagnostic {
12
+ readonly code: DiagnosticCode;
13
+ readonly phase: "validate" | "test" | "boot" | "runtime";
14
+ readonly severity: "error" | "warning";
15
+ readonly path: string;
16
+ readonly source?: SourceLocation;
17
+ readonly value?: unknown;
18
+ readonly expected?: string;
19
+ readonly candidates?: readonly string[];
20
+ readonly suggestion?: string;
21
+ readonly message: string;
22
+ readonly failure?: {
23
+ readonly outcome: "not-applied" | "partial" | "unknown";
24
+ readonly retry: "never" | "after-change" | "safe" | "reconcile";
25
+ readonly resource?: string;
26
+ };
27
+ }
28
+ ```
29
+
30
+ | Field | Meaning |
31
+ |---|---|
32
+ | `code` | One of the codes below. Unprefixed `UPPER_SNAKE`. |
33
+ | `phase` | Which loop produced it. The same code may appear in more than one phase when it names the same root cause. |
34
+ | `severity` | `error` withholds the result of its stage; `warning` does not. |
35
+ | `path` | Where the problem is. In validate and boot phases a manifest path such as `manifest:Procedure/expire-order#/spec/handler/ref`; in the runtime phase a target path plus a JSON Pointer, such as `manifest:View/my-orders#/params/locale`. |
36
+ | `source` | Authored location: `{ sourceId, documentIndex, path }` plus a line and column span when the YAML node is known. Present on parse diagnostics. |
37
+ | `value` | The offending value, when one can be shown. |
38
+ | `expected` | What would have been accepted, in prose. |
39
+ | `candidates` | The valid alternatives — declared handler keys, enabled locales, declared Procedure names. Security-sensitive. |
40
+ | `suggestion` | Nearest-match hint, when one is computed. |
41
+ | `message` | Human-readable. Call sites may supply their own; otherwise it is derived as `[<phase>/<code>] at <path>; expected <…>; got <…>; (did you mean <…>?)`. The structured fields stay authoritative. |
42
+
43
+ `candidates` is stripped by `redactForWire` before any HTTP egress, because listing valid alternatives to an untrusted caller leaks schema information. Internal phases — validate, test and boot — skip that redaction, so a CLI or boot log keeps the full list. One or more diagnostics travel across a transport boundary inside a `DiagnosticError`; the boundary catch emits the structured payload instead of falling back to the `INTERNAL_ERROR` envelope reserved for genuinely unexpected throws.
44
+
45
+ `failure` describes safe effect and recovery facts for storage and service failures. `safe` means retrying the same idempotent operation is safe, not that Mantle automatically retries it. Unknown write/send outcomes require reconciliation unless the adapter guarantees idempotence. Provider payloads, SQL and credentials belong in `DiagnosticError`’s internal `cause`, never public fields. See the [port operation matrix](../../adr/0023-port-failure-contract.md).
46
+
47
+ ## Validate-only
48
+
49
+ Raised by the parser, the graph validator and the code generator. `mantle validate` and `mantle generate` surface these; parsing is all-or-nothing, so one error-severity diagnostic withholds the whole parsed set.
50
+
51
+ | Code | Meaning | HTTP |
52
+ |---|---|---|
53
+ | `INVALID_MANIFEST_ENVELOPE` | Wrong `apiVersion`, unknown key at a known level, wrong value shape, a YAML syntax or alias-limit failure, or a Schema data property named `expectedVersion` (reserved Procedure OCC token; ADR-0022). | — |
54
+ | `DUPLICATE_NAME` | Two documents of the same kind declare the same `metadata.name`. | — |
55
+ | `VIEW_FROM_UNKNOWN_SCHEMA` | `spec.from` names no declared Schema. | — |
56
+ | `VIEW_FIELD_NOT_IN_SCHEMA` | A `fields` entry or `orderBy[i].field` is neither a Schema property nor a reserved entry column. | — |
57
+ | `VIEW_FILTER_FIELD_NOT_IN_SCHEMA` | A filter comparison names an unknown field. | — |
58
+ | `VIEW_PARAMS_INVALID_SHAPE` | `spec.params` is not an object schema with an object `properties`. | — |
59
+ | `VIEW_PARAMS_RESERVED_NAME` | `params.properties` declares `page`, `show` or `cursor`. | — |
60
+ | `VIEW_FILTER_PARAM_REF_UNKNOWN` | `{ $param: <name> }` names a param that is not declared. | — |
61
+ | `VIEW_FILTER_PARAM_REF_NOT_REQUIRED` | The referenced param is not listed in `params.required`. | — |
62
+ | `VIEW_FILTER_CTX_USER_REF_INVALID` | The `{ "$ctx.user": "id" }` sentinel is malformed or used outside `eq`. | — |
63
+ | `VIEW_FILTER_CTX_USER_REF_REQUIRES_AUTH` | The sentinel is used without `ctx.user` in `requires.auth.all`. | — |
64
+ | `VIEW_FILTER_CTX_USER_REF_REQUIRES_INDEX` | The bound field is not the leftmost field of a declared index. | — |
65
+ | `VIEW_ORDERBY_INVALID` | An `orderBy` entry has the wrong shape or an unknown `direction`. | — |
66
+ | `VIEW_UI_INVALID` | A View `uiSchema` key is unknown, used on a public View, or names an unknown output field. | — |
67
+ | `REQUIRED_FIELD_UNKNOWN` | A `required` entry of `spec.schema` is not declared under `properties`. | — |
68
+ | `INVALID_PATTERN` | A `pattern` does not compile as a JavaScript regular expression. | — |
69
+ | `JSON_SCHEMA_UNSUPPORTED` | A JSON Schema keyword outside the accepted subset. | — |
70
+ | `JSON_SCHEMA_REF_INVALID` | A `$ref` does not begin `#/$defs/` or does not resolve in the same document. | — |
71
+ | `JSON_SCHEMA_LIMIT_EXCEEDED` | Nesting deeper than 100 levels, or more than 10,000 schema nodes. | — |
72
+ | `BIND_VALUE_NOT_IN_ENUM` | `x-mantle-bind` is not `ctx.user`, `ctx.staff` or `now`. | — |
73
+ | `AUTH_PREDICATE_NOT_IN_ENUM` | A `ctx.staff` role is not `owner`, `editor` or `contributor`. | — |
74
+ | `GUARD_PROCEDURE_UNKNOWN` | `requires.guard.procedure` names no declared Procedure. | — |
75
+ | `GUARD_SELF_REFERENCE` | A Procedure names itself as its own guard. | — |
76
+ | `GUARD_PROCEDURE_BUILTIN` | The guard Procedure uses a builtin handler instead of `handler.kind: ref`. | — |
77
+ | `GUARD_CHAIN_NOT_ALLOWED` | The guard Procedure itself declares a guard. | — |
78
+ | `SCHEMA_INDEX_INVALID` | An index tuple breaks a shape, naming, reserved-column, type or duplication rule. | — |
79
+ | `SCHEMA_INDEX_FIELD_UNKNOWN` | An `indexes` field is not a top-level Schema property. | — |
80
+ | `UNIQUE_INDEX_FIELD_UNKNOWN` | A `uniqueIndexes` field is not a top-level Schema property. | — |
81
+ | `SCHEMA_SEARCH_INVALID` | `searchableFields` repeats an entry or names a non-string property. | — |
82
+ | `SCHEMA_SEARCH_FIELD_UNKNOWN` | A `searchableFields` entry is not a Schema property. | — |
83
+ | `SCHEMA_UI_INVALID` | A Schema or Procedure `uiSchema` rule is broken, including unknown roots, invalid `nav.standalone`, or a Schema declaring `uiSchema.collectionAction`. | — |
84
+ | `HANDLER_BUILTIN_NOT_IN_V010` | A builtin Procedure was invoked on a runtime assembled without the builtin collaborator. | — |
85
+ | `MANIFEST_ROOT_NOT_FOUND` | The manifests directory is missing, unreadable, or contains no `.yaml` or `.yml` file. | — |
86
+ | `MANIFEST_READ_FAILED` | A manifest source could not be read. | — |
87
+ | `CODEGEN_IDENTIFIER_COLLISION` | Two names in one group collapse to the same generated lower-camel identifier. | — |
88
+ | `FIXTURE_SCHEMA_VIOLATION` | Reserved for consumer-authored test diagnostics on the `test` phase. Core emits purpose-shaped harness reports instead. | — |
89
+
90
+ ## Cross-phase
91
+
92
+ Named by the same code in validate, boot or runtime, depending on where the condition is detectable.
93
+
94
+ | Code | Meaning | HTTP |
95
+ |---|---|---|
96
+ | `HANDLER_NOT_REGISTERED` | A `handler.kind: ref` key has no function in the `handlers` map. Boot attaches the registered keys as `candidates`; the runtime occurrence is defence in depth. | `500` |
97
+ | `TRIGGER_TARGET_PROCEDURE_UNKNOWN` | `spec.target.procedure` names no declared Procedure. | — |
98
+ | `TRIGGER_PATH_COLLISION` | Two HTTP Triggers claim the same `(method, path)`. | — |
99
+ | `TRIGGER_PATH_INVALID` | An HTTP Trigger path does not start `/api/` (validate), or falls under an adapter-reserved prefix (boot). | — |
100
+ | `MCP_TOOL_NAME_COLLISION` | Two atoms mangle to the same MCP tool name, a Procedure takes a reserved generic name or prefix, or two MCP Triggers share a `(surface, tool name)`. | — |
101
+ | `PROCEDURE_NOT_FOUND` | An invocation names a Procedure that is not in the compiled plan. | — |
102
+ | `NOT_FOUND` | The addressed resource does not exist: an entry id, a View name, a media asset, an operation name. | `404` |
103
+ | `METHOD_NOT_ALLOWED` | The path exists but the method is not bound. | `405` |
104
+ | `VIEW_DIALECT_UNSUPPORTED` | The bound storage adapter does not support a `sql` View's dialect. | — |
105
+
106
+ ## Builtin handlers and lifecycle
107
+
108
+ | Code | Meaning | HTTP |
109
+ |---|---|---|
110
+ | `BUILTIN_HANDLER_SCHEMA_UNKNOWN` | `handler.schema` names no declared Schema. | — |
111
+ | `BUILTIN_HANDLER_CONTRACT_INVALID` | The Procedure's `input` breaks the builtin op's contract, such as a missing `expectedVersion` on `update` or a `match` tuple that is not exactly one `uniqueIndexes` entry. | — |
112
+ | `LIFECYCLE_SCHEMA_UNKNOWN` | A lifecycle Trigger's `source.schema` names no declared Schema. | — |
113
+ | `LIFECYCLE_HOOK_REJECTED` | A `before_*` hook aborted the mutation. The diagnostic names the rejecting hook. | `409` |
114
+
115
+ ## Locale and translates
116
+
117
+ | Code | Meaning | HTTP |
118
+ |---|---|---|
119
+ | `SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES` | A Schema declares `localized: true` while the site has no valid locales. | — |
120
+ | `TRANSLATES_PARENT_UNKNOWN` | `translates.parent` names no declared Schema. | `409` |
121
+ | `TRANSLATES_REQUIRES_LOCALIZED` | A Schema declares `translates` without `localized: true`. | — |
122
+ | `TRANSLATES_REQUIRES_CONTENT_FIELD` | A translation child declares no property besides `locale` and the join field. | — |
123
+ | `TRANSLATES_FIELD_NOT_IN_PARENT` | The join field is not declared in the parent's `properties`. | — |
124
+ | `TRANSLATES_FIELD_NOT_IN_CHILD` | The join field is not declared in the child's own `properties`. | — |
125
+ | `TRANSLATES_PARENT_IS_LOCALIZED` | The named parent is itself `localized: true`. | — |
126
+
127
+ ## Runtime
128
+
129
+ These are the codes that reach a caller. Everything else in this catalog is caught before traffic.
130
+
131
+ | Code | Meaning | HTTP |
132
+ |---|---|---|
133
+ | `RESOURCE_EXHAUSTED` | A known capacity or quota refusal. | `507` |
134
+ | `RESOURCE_UNAVAILABLE` | A required storage or service dependency is unavailable. | `503` |
135
+ | `RATE_LIMITED` | A recognized request-rate refusal. | `429` |
136
+ | `OUTCOME_UNKNOWN` | The operation may have taken effect; reconcile before retry. | `503` |
137
+ | `PARTIAL_FAILURE` | Some effects completed; inspect the operation’s recovery contract. | `503` |
138
+ | `PRECONDITION_FAILED` | A storage or service precondition failed. | `412` |
139
+ | `INPUT_VALIDATION_FAILED` | Procedure input, View params or an entry's `data` failed the compiled schema, including the write-time locale gate. | `400` |
140
+ | `INVALID_LOCALE` | A locale value is not a canonical Mantle v0.1 tag. Also raised at boot against `site_config/locales`. | `400` |
141
+ | `UNAUTHENTICATED` | An auth predicate failed and the caller presented no identity at all. | `401` |
142
+ | `ENTITLEMENT_REQUIRED` | A guard Procedure denied the call on current business state. | `402` |
143
+ | `AUTH_DENIED` | An auth predicate failed for a caller that is authenticated in some way, or an Admin caller lacks the required staff role. | `403` |
144
+ | `CONFLICT` | Optimistic-concurrency mismatch, unique-index violation, an illegal lifecycle transition, or a generic write against a read-only Schema. Not retryable as sent. | `409` |
145
+ | `OUTPUT_VALIDATION_FAILED` | A handler returned a value that does not match its declared `output`. A handler bug. | `500` |
146
+ | `INTERNAL_ERROR` | An uncaught handler exception. | `500` |
147
+ | `DISPATCHER_NOT_BUILT` | The runtime feature is not implemented in this build. | `501` |
148
+ | `MEDIA_NOT_CONFIGURED` | Media uploads are not enabled: no `mediaStorage` port is bound. | `501` |
149
+ | `MEDIA_PURPOSE_REJECTED` | The requested `purpose` is not declared in `siteDefaults.media.purposes`. The declared set is returned in `expected`. | `400` |
150
+ | `MEDIA_MIME_REJECTED` | The declared mime is outside the accepted image set. | `400` |
151
+ | `MEDIA_SVG_REJECTED` | An SVG upload was attempted while the adapter has SVG disabled; object storage does not sanitize SVG payloads. | `400` |
152
+ | `MEDIA_SIZE_EXCEEDED` | A declared `byteSize` exceeds the cap. | `400` |
153
+ | `MEDIA_VARIANTS_INCOMPLETE` | The variants manifest does not cover every mime the purpose requires. | `400` |
154
+ | `MEDIA_VARIANT_SIZE_EXCEEDED` | One variant's `byteSize` exceeds its mime's `maxBytes`. | `400` |
155
+ | `MEDIA_VARIANTS_SUSPICIOUS_SIZE` | A modern format is larger than its fallback, so the uploader skipped optimization for that variant. | `400` |
156
+ | `MEDIA_UPLOAD_EXPIRED` | The upload capability's TTL elapsed, or it never existed. | `410` |
157
+ | `MEDIA_OBJECT_NOT_FOUND` | Commit ran before every variant's bytes reached the storage backend. | `409` |
158
+ | `MEDIA_CHECKSUM_MISMATCH` | Uploaded bytes do not match the declared checksum. | `409` |
159
+ | `MEDIA_ASSET_NOT_FOUND` | No `media_assets` row matches the id. | `404` |
160
+
161
+ ## How diagnostics surface
162
+
163
+ Only the runtime phase maps to HTTP. `httpStatusFor` reads the status table above; **a runtime code that is not in that table becomes `500`**. Validate, test and boot phases have no HTTP mapping at all — they surface through:
164
+
165
+ - **CLI exit codes.** `mantle validate`, `mantle generate` and `mantle-harness` print each diagnostic as `<code> <path>: <message>` in text mode or as JSON otherwise. Exit `0` means no errors (warnings are allowed), `1` means one or more errors, `2` means a CLI invocation problem.
166
+ - **A thrown `BootValidationError`.** Deployment preparation collects boot diagnostics and throws them together; the error carries the full `diagnostics` array.
167
+ - **Worker init logs.** On Cloudflare the facade boots the runtime once per isolate. A boot failure is logged and the request boundary returns a redacted `{ ok: false, error: "internal_error" }` with `500`, so the diagnostic detail stays in the log rather than on the wire.
168
+
169
+ Five codes are declared in the catalog but not emitted anywhere in the shipped source at this version: `FIXTURE_SCHEMA_VIOLATION` (reserved for consumer tests by design), `MANIFEST_READ_FAILED`, `METHOD_NOT_ALLOWED`, `DISPATCHER_NOT_BUILT` and `MEDIA_CHECKSUM_MISMATCH`. They remain part of the public contract because the catalog, not the current set of throw sites, is the contract.
170
+
171
+ ## Source
172
+
173
+ - [`packages/mantle-spec/src/kernel/diagnostic.ts`](../../../packages/mantle-spec/src/kernel/diagnostic.ts)
174
+ - [`packages/mantle-spec/src/domain/service/ManifestParser.ts`](../../../packages/mantle-spec/src/domain/service/ManifestParser.ts)
175
+ - [`packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts`](../../../packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts)
176
+ - [`packages/mantle-spec/src/domain/service/CrossSchemaChecker.ts`](../../../packages/mantle-spec/src/domain/service/CrossSchemaChecker.ts)
177
+ - [`packages/mantle-spec/src/infrastructure/cli/ValidateCommand.ts`](../../../packages/mantle-spec/src/infrastructure/cli/ValidateCommand.ts)
178
+ - [`packages/mantle-spec/src/infrastructure/cli/loadManifests.ts`](../../../packages/mantle-spec/src/infrastructure/cli/loadManifests.ts)
179
+ - [`packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts`](../../../packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts)
180
+ - [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts)
181
+ - [`packages/mantle-runtime/src/usecase/media/diagnostics.ts`](../../../packages/mantle-runtime/src/usecase/media/diagnostics.ts)
182
+ - [`packages/mantle-runtime/src/domain/service/EntryMutationDiagnostics.ts`](../../../packages/mantle-runtime/src/domain/service/EntryMutationDiagnostics.ts)
183
+ - [`packages/adapters/cloudflare/src/worker/createMantleWorker.ts`](../../../packages/adapters/cloudflare/src/worker/createMantleWorker.ts)
@@ -0,0 +1,132 @@
1
+ ---
2
+ description: Envelope fields, unknown-key policy, multi-document YAML, LocalizedText, naming rules and reserved names shared by every Manifest kind.
3
+ ---
4
+ # Manifest envelope and conventions
5
+
6
+ This page covers the rules that apply to every Manifest document before kind-specific validation runs. Read it once; the four atom pages ([Schema](./schema.md), [View](./view.md), [Procedure](./procedure.md), [Trigger](./trigger.md)) assume it. Diagnostic codes named here are catalogued in [Diagnostics](./diagnostics.md).
7
+
8
+ ## Envelope
9
+
10
+ Every document is a YAML mapping with exactly four top-level keys.
11
+
12
+ ```yaml
13
+ apiVersion: cms.mantle.aotter.net/v1
14
+ kind: Schema | View | Procedure | Trigger
15
+ metadata:
16
+ name: posts
17
+ spec:
18
+ # kind-specific
19
+ ```
20
+
21
+ | Field | Type | Required | Rules |
22
+ |---|---|---|---|
23
+ | `apiVersion` | literal | yes | Exactly `cms.mantle.aotter.net/v1`. Anything else is `INVALID_MANIFEST_ENVELOPE` at `/apiVersion`. |
24
+ | `kind` | enum | yes | `Schema`, `View`, `Procedure` or `Trigger`. |
25
+ | `metadata` | mapping | yes | Only the key `name` is accepted. There is no `namespace`. |
26
+ | `metadata.name` | string | yes | Non-empty. Unique within its kind (`DUPLICATE_NAME`, one diagnostic per occurrence). A Schema and a View may share a name. |
27
+ | `spec` | mapping | yes | Kind-specific; see the atom pages. |
28
+
29
+ A document that is not a mapping is rejected with `manifest must be a YAML mapping`.
30
+
31
+ ## Unknown-key policy
32
+
33
+ The parser rejects keys outside the shipped grammar at every level it knows. The diagnostic is `INVALID_MANIFEST_ENVELOPE` with the message `<dotted.path> is not supported` and a JSON Pointer to the offending key (for example `spec.foo is not supported` at `/spec/foo`). Enum values outside the grammar are rejected the same way.
34
+
35
+ | Pointer | Allowed keys |
36
+ |---|---|
37
+ | `/` | `apiVersion`, `kind`, `metadata`, `spec` |
38
+ | `/metadata` | `name` |
39
+ | `/spec` (Schema) | `title`, `description`, `schema`, `uiSchema`, `uniqueIndexes`, `indexes`, `searchableFields`, `localized`, `translates`, `lifecycle` |
40
+ | `/spec` (View) | `title`, `uiSchema`, `from`, `sql`, `surface`, `requires`, `filter`, `fields`, `orderBy`, `limit`, `params` |
41
+ | `/spec` (Procedure) | `title`, `description`, `requires`, `input`, `uiSchema`, `output`, `handler` |
42
+ | `/spec` (Trigger) | `source`, `target` |
43
+ | `/spec/translates` | `parent`, `on` |
44
+ | `/spec/requires` | `auth`, `guard` |
45
+ | `/spec/requires/auth` | `all` |
46
+ | `/spec/requires/guard` | `procedure` |
47
+ | `/spec/requires/auth/all/<i>` (object form) | exactly one of `ctx.staff`, `ctx.auth.scope` |
48
+ | `/spec/handler` (`kind: ref`) | `kind`, `ref` |
49
+ | `/spec/handler` (`kind: builtin`) | `kind`, `op`, `schema`, `match` |
50
+ | `/spec/filter/<op>` | `field`, `value` |
51
+ | `/spec/orderBy/<i>` | `field`, `direction` |
52
+ | `/spec/cache` (View) | `sharedMaxAge` |
53
+ | `/spec/source` (`kind: http`) | `kind`, `method`, `path` |
54
+ | `/spec/source` (`kind: lifecycle`) | `kind`, `schema`, `on`, `errorPolicy` |
55
+ | `/spec/source` (`kind: mcp`) | `kind`, `surface` |
56
+ | `/spec/target` | `procedure` |
57
+ | `/spec/uiSchema` (View) | `list` (only on `surface: staff`; violations are `VIEW_UI_INVALID`) |
58
+ | `/spec/uiSchema/list` (View) | `columns`, `searchFields`, `filterFields` |
59
+ | `/spec/uiSchema` (Schema) | `fields`, `list`, `nav` |
60
+ | `/spec/uiSchema/list` (Schema) | `filterField`, `primaryField`, `columns` |
61
+ | `/spec/uiSchema/nav` (Schema) | `standalone`, `parentField` |
62
+ | `/spec/uiSchema` (Procedure) | `collectionAction`, `fields` |
63
+
64
+ `uiSchema` roots are closed. Schema accepts `fields`, `list`, and `nav`; Procedure accepts `collectionAction` and `fields`; View accepts `list` on `surface: staff`. Nested keys are closed. A Schema that declares `uiSchema.collectionAction` is rejected with `SCHEMA_UI_INVALID`. JSON Schema documents inside `spec.schema`, `spec.input`, `spec.output` and `spec.params` follow the [JSON Schema subset](./schema.md#json-schema-subset) instead of an allowlist.
65
+
66
+ ## Multi-document YAML and sources
67
+
68
+ The parser consumes a set of sources, each `{ sourceId, text }`. The CLI builds this set from the immediate `.yaml` and `.yml` files of the manifests directory, sorted lexicographically, with the file path as `sourceId`; an unreadable or empty directory is `MANIFEST_ROOT_NOT_FOUND`. Nested directories are not read.
69
+
70
+ Within one source:
71
+
72
+ - `---` separates documents. A feature commonly bundles a Procedure and its Triggers in one file.
73
+ - YAML merge keys (`<<`) are disabled.
74
+ - Alias expansion is capped at 100 aliases per document. Exceeding the cap is `INVALID_MANIFEST_ENVELOPE` with the message `YAML alias-expansion limit exceeded`.
75
+ - Empty or `null` documents are skipped.
76
+ - A YAML syntax error is `INVALID_MANIFEST_ENVELOPE` at `/`, prefixed `[doc <index>]`.
77
+
78
+ Parsing is all-or-nothing. Any error-severity diagnostic in any document withholds the whole parsed set; later stages never see a partial graph. Every diagnostic carries `source: { sourceId, documentIndex, path }` plus a line and column span when the YAML node is known.
79
+
80
+ ## LocalizedText
81
+
82
+ `Schema.spec.title` (required), `Schema.spec.description`, `View.spec.title`, `Procedure.spec.title` and `Procedure.spec.description` accept either a plain string or a locale map.
83
+
84
+ ```yaml
85
+ title: Products
86
+ # or
87
+ title: { en: Products, "zh-TW": 商品 }
88
+ ```
89
+
90
+ The parser rejects an empty string, an empty map `{}`, an array, an empty locale key and any non-string or empty value. JSON Schema property `title` and `description` keywords accept the same shape for Admin labels and help text; MCP tool schemas collapse them to the `en` value.
91
+
92
+ Resolution order (`resolveLocalizedText`): the viewer's preferred locale, then the site's canonical locale, then the first key in insertion order. A value that was never set resolves to `null`.
93
+
94
+ ## Naming rules for `metadata.name`
95
+
96
+ | Rule | Applies to | Diagnostic |
97
+ |---|---|---|
98
+ | Non-empty string, unique within the kind. | all kinds | `DUPLICATE_NAME` |
99
+ | Must match `/^[A-Za-z][A-Za-z0-9_.-]*$/` when the Schema declares any `indexes` or `uniqueIndexes`. | Schema | `SCHEMA_INDEX_INVALID` at `/metadata/name` |
100
+ | Unique after MCP mangling (`mcpToolNameSegment`: lower-case, `-` becomes `_`). Two Schemas, or two Views, that mangle to the same segment collide. | Schema, View | `MCP_TOOL_NAME_COLLISION` |
101
+ | A Procedure's mangled name must not equal a reserved generic tool name, start with a reserved tool prefix, equal a Schema's mangled segment, or equal another Procedure's mangled name. | Procedure | `MCP_TOOL_NAME_COLLISION` |
102
+ | One MCP Trigger per `(surface, tool name)`. | Trigger | `MCP_TOOL_NAME_COLLISION` |
103
+ | Unique lower-camel identifier within each group (`entries`, `views`, `procedures`, `triggers`) when running `mantle generate`. The identifier joins the `[A-Za-z0-9]+` runs of the name, lower-casing the first and capitalising the rest; `my-orders` and `My Orders` both become `myOrders`. | all kinds | `CODEGEN_IDENTIFIER_COLLISION` |
104
+
105
+ Names containing `-` must be double-quoted when used as tables in a `sql` View (`"post-translations"`).
106
+
107
+ ## Reserved names
108
+
109
+ | Namespace | Reserved | Effect |
110
+ |---|---|---|
111
+ | Entry columns | `id`, `status`, `version`, `createdAt`, `updatedAt`, `authorId` | Native on every Schema. Cannot appear in `indexes` or `uniqueIndexes` (`SCHEMA_INDEX_INVALID`). Valid in View `fields`, `filter`, `orderBy` and `uiSchema.list`. Avoid declaring data properties with these names; SQL Views project the native column, not the data field. |
112
+ | Data field | `locale` | A non-localized Schema that declares `properties.locale` is rejected; use a domain name such as `orderLocale`. On a localized Schema the runtime requires `data.locale` on writes. |
113
+ | Data field | `expectedVersion` | Reserved Procedure OCC wire name. A Schema that declares `spec.schema.properties.expectedVersion` is `INVALID_MANIFEST_ENVELOPE` (ADR-0022). New reserved names need an ADR. |
114
+ | View params | `page`, `show`, `cursor` | Owned by the runtime for pagination. Declaring them under `params.properties` is `VIEW_PARAMS_RESERVED_NAME`. |
115
+ | Builtin input | `id`, `expectedVersion` | Contract fields for builtin `update`, `delete`, `archive`, and version-checked `upsert`. `expectedVersion` is the observed native `entry.version` at read time (not `version+1`). Matched upsert must declare it as a strict number and must not declare `id`. New reserved wire names need an ADR. |
116
+ | MCP tool names | `request_publish`, `unpublish_entry`, `archive_entry`, `delete_entry`, `create_media_upload`, `commit_media_upload` | A Procedure that mangles to one of these is `MCP_TOOL_NAME_COLLISION`. |
117
+ | MCP tool prefixes | `create_draft_`, `update_draft_`, `create_record_`, `update_record_`, `query_view_` | Same as above. |
118
+ | HTTP paths | Every `http` Trigger path must start with `/api/` (`TRIGGER_PATH_INVALID` at validate time). The Cloudflare Worker additionally reserves `/admin`, `/_mantle`, `/api/auth`, `/api/views`, `/oauth`, `/mcp`, any path starting `/.well-known/oauth`, and the exact registrations `*` and `/*`; a Trigger under one of these fails at boot with `TRIGGER_PATH_INVALID`. | See [Trigger](./trigger.md#http-source). |
119
+
120
+ ## Source
121
+
122
+ - [`packages/mantle-spec/src/domain/model/ManifestGrammar.ts`](../../../packages/mantle-spec/src/domain/model/ManifestGrammar.ts)
123
+ - [`packages/mantle-spec/src/domain/service/ManifestParser.ts`](../../../packages/mantle-spec/src/domain/service/ManifestParser.ts)
124
+ - [`packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts`](../../../packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts)
125
+ - [`packages/mantle-spec/src/domain/service/SchemaIndexChecker.ts`](../../../packages/mantle-spec/src/domain/service/SchemaIndexChecker.ts)
126
+ - [`packages/mantle-spec/src/domain/service/SchemaAdminUiChecker.ts`](../../../packages/mantle-spec/src/domain/service/SchemaAdminUiChecker.ts)
127
+ - [`packages/mantle-spec/src/domain/service/McpToolNaming.ts`](../../../packages/mantle-spec/src/domain/service/McpToolNaming.ts)
128
+ - [`packages/mantle-spec/src/infrastructure/cli/loadManifests.ts`](../../../packages/mantle-spec/src/infrastructure/cli/loadManifests.ts)
129
+ - [`packages/mantle-spec/src/kernel/diagnostic.ts`](../../../packages/mantle-spec/src/kernel/diagnostic.ts)
130
+ - [`packages/mantle/src/codegen/emitMantleModule.ts`](../../../packages/mantle/src/codegen/emitMantleModule.ts)
131
+ - [`packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts`](../../../packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts)
132
+ - [`packages/adapters/cloudflare/src/worker/createMantleWorker.ts`](../../../packages/adapters/cloudflare/src/worker/createMantleWorker.ts)
@@ -0,0 +1,263 @@
1
+ ---
2
+ description: Procedure field reference — runtime order of operations, ref and builtin handlers, the builtin op contract, EntryRow responses and Admin operations.
3
+ ---
4
+ # Procedure
5
+
6
+ A Procedure is a typed callable: input schema, output schema, authorization requirement, and one handler binding. It is the only atom with a code seam, and it is never exposed on its own — a [Trigger](./trigger.md) is what makes it reachable. This page is the field-level contract; the concepts are in [Procedures and Triggers](../concepts/procedures-and-triggers.md). Envelope rules are in [Manifest envelope and conventions](./manifest.md), and diagnostic codes are catalogued in [Diagnostics](./diagnostics.md).
7
+
8
+ ## Fields
9
+
10
+ | Field | Type | Required | Rules |
11
+ |---|---|---|---|
12
+ | `title` | LocalizedText | no | Admin label for the staff-operations surface. Absent falls back to a Title-Cased `metadata.name`. |
13
+ | `description` | LocalizedText | no | The MCP tool description and the `description` field of `GET /admin/api/operations`. |
14
+ | `requires` | AuthorizationRequirements | no | `auth.all` predicates plus one optional `guard.procedure`. See [Authorization](./authorization.md). |
15
+ | `input` | JSON Schema | yes | Must be an object. Becomes the MCP tool `inputSchema` and the OpenAPI request body. |
16
+ | `uiSchema` | object | no | Admin-only. Accepts `collectionAction` and `fields`. Violations are `SCHEMA_UI_INVALID`. |
17
+ | `output` | JSON Schema | yes | Checked after the handler returns. Failure is `OUTPUT_VALIDATION_FAILED` (500). |
18
+ | `handler` | `ref` \| `builtin` | yes | Exactly one binding shape; see below. |
19
+
20
+ Both `input` and `output` are walked by the [JSON Schema subset](./schema.md#json-schema-subset) validator, so the same recognized and rejected keywords apply.
21
+
22
+ ## Order of operations
23
+
24
+ Every invocation — HTTP Trigger, MCP tool call, lifecycle hook, Admin operation — runs the same pipeline.
25
+
26
+ | Step | Behavior | Failure |
27
+ |---|---|---|
28
+ | 1. Authorize | Evaluate every `requires.auth.all` predicate against the caller context. | `UNAUTHENTICATED` (401) when the caller carried no credential, `AUTH_DENIED` (403) when an authenticated caller falls short. |
29
+ | 2. Validate input | Compile `input` to zod and parse the request. | `INPUT_VALIDATION_FAILED` (400), pointing at the first failing property. |
30
+ | 3. Guard | Invoke `requires.guard.procedure` with the validated input and the same context. | Any guard failure denies the target. Guards fail closed. |
31
+ | 4. Dispatch | `ref`: look up the registration key and call the function. `builtin`: run the op. | See the two handler sections. |
32
+ | 5. Validate output | Parse the handler result against `output`. | `OUTPUT_VALIDATION_FAILED` (500) — this is a handler bug, not a caller error. |
33
+
34
+ The value returned to the caller is the handler's own result. Output validation checks it; it does not strip unexpected fields.
35
+
36
+ ## `handler.kind: ref`
37
+
38
+ ```yaml
39
+ handler:
40
+ kind: ref
41
+ ref: approve-purchase-order
42
+ ```
43
+
44
+ `ref` is an **opaque registration key, not a path**. It never names a file, module or export. The consumer passes a matching key in the `handlers` map given to the runtime or Worker, and the key is the whole contract between manifest and code.
45
+
46
+ | Rule | Effect |
47
+ |---|---|
48
+ | `ref` is a non-empty string; only `kind` and `ref` are accepted under `handler`. | `INVALID_MANIFEST_ENVELOPE` |
49
+ | Every declared `ref` resolves to a registered handler. | `HANDLER_NOT_REGISTERED` at boot, listing the registered keys as candidates. |
50
+ | An unregistered key reached at request time. | The same `HANDLER_NOT_REGISTERED` code, mapped to 500 — defense in depth for embeddings that skipped boot validation. |
51
+ | A handler throws. | Anything other than a structured error becomes `INTERNAL_ERROR` (500) with a safe generic message; exception details remain in internal logs. |
52
+
53
+ To return a structured error instead, throw `InvokeFailure` carrying a diagnostic; the runtime unwraps it and returns that diagnostic with its own status. This is how a handler reports `CONFLICT`, `ENTITLEMENT_REQUIRED` or a domain-specific `INPUT_VALIDATION_FAILED` rather than a generic 500.
54
+
55
+ ### `ref` example
56
+
57
+ ```yaml
58
+ apiVersion: cms.mantle.aotter.net/v1
59
+ kind: Procedure
60
+ metadata:
61
+ name: approve-purchase-order
62
+ spec:
63
+ title: { en: Approve purchase order, "zh-TW": 核准採購單 }
64
+ description: Approve a submitted order and record the approver.
65
+ requires:
66
+ auth:
67
+ all:
68
+ - { "ctx.staff": [owner, editor] }
69
+ input:
70
+ type: object
71
+ additionalProperties: false
72
+ required: [orderId, decision]
73
+ properties:
74
+ orderId: { type: string, x-mantle-ref: purchase-orders }
75
+ decision: { type: string, enum: [approve, reject] }
76
+ note: { type: string, maxLength: 2000 }
77
+ requestId: { type: string, x-mcp-hint: idempotency-key }
78
+ uiSchema:
79
+ fields:
80
+ note: { widget: textarea }
81
+ output:
82
+ type: object
83
+ required: [orderId, status]
84
+ properties:
85
+ orderId: { type: string }
86
+ status: { type: string, enum: [approved, rejected] }
87
+ handler:
88
+ kind: ref
89
+ ref: approve-purchase-order
90
+ ```
91
+
92
+ ```ts
93
+ // src/mantle/config.ts
94
+ import { approvePurchaseOrder } from "./handlers/approve-purchase-order";
95
+
96
+ export const handlers = {
97
+ "approve-purchase-order": approvePurchaseOrder,
98
+ };
99
+ ```
100
+
101
+ ## `handler.kind: builtin`
102
+
103
+ A shortcut over the entry-writer chokepoint for Procedures whose body is "write a row". Reach for `ref` as soon as there is real business logic.
104
+
105
+ ```yaml
106
+ handler:
107
+ kind: builtin
108
+ op: create | update | upsert | delete | archive
109
+ schema: <Schema metadata.name>
110
+ match: [<field>, ...] # only with op: upsert
111
+ ```
112
+
113
+ | Rule | Diagnostic |
114
+ |---|---|
115
+ | Only `kind`, `op`, `schema` and `match` are accepted; `ref` alongside `builtin` is rejected. | `INVALID_MANIFEST_ENVELOPE` |
116
+ | `op` is one of the five; `schema` is a non-empty string. | `INVALID_MANIFEST_ENVELOPE` |
117
+ | `match` appears only with `op: upsert`, and is a non-empty array of unique non-empty strings. | `INVALID_MANIFEST_ENVELOPE` |
118
+ | `schema` names a declared Schema. | `BUILTIN_HANDLER_SCHEMA_UNKNOWN` |
119
+ | `input` is an object schema. | `BUILTIN_HANDLER_CONTRACT_INVALID` |
120
+ | The runtime was built without the builtin dispatcher. | `HANDLER_BUILTIN_NOT_IN_V010` at request time. |
121
+
122
+ `request_publish` and `publish` are deliberately absent: they are lifecycle operations, not CRUD primitives.
123
+
124
+ ### Ops
125
+
126
+ | `op` | Runtime behavior | Input contract |
127
+ |---|---|---|
128
+ | `create` | Projects `input ∩ Schema.properties` into `data`, stamps every `x-mantle-bind` property, generates an id and writes. `status` is `draft`, or `published` on a `lifecycle: operational` Schema. `authorId` is `ctx.user?.id ?? null`. Returns the created row. | `input` is an object schema. No other required properties. |
129
+ | `update` | Loads the row (`NOT_FOUND` if absent), merges the patch over the stored `data` so omitted fields and existing stamps survive, writes under optimistic concurrency against the caller's `expectedVersion` (observed native `entry.version` at read time, not `version+1`), bumps `version`. | `id` (strict `type: string`) and `expectedVersion` (strict `type: number`) declared under `properties` **and** listed in `required`. |
130
+ | `upsert` with `match` | Reads the matched fields off the validated input and looks the row up by those data values. Found: the update path, using the **caller's** `expectedVersion` (never the preloaded row's version). Not found: the create path only when `expectedVersion` is omitted; a versioned write for a missing row is `NOT_FOUND` and does not recreate. | `match` equals one declared `uniqueIndexes` tuple exactly, in order. Every matched field is a Schema property, is declared in `input.properties`, and appears in `input.required`. `input` must **not** declare `id`. `expectedVersion` **must** be declared as strict `number`; it is not globally required so create can omit it. |
131
+ | `upsert` without `match` | Legacy form. Create when the caller omits `expectedVersion` (and either omits `id` or the id is unknown). Update when a resolved `id` is present — the caller token is required and is the OCC check. A versioned write for a missing id is `NOT_FOUND`. | `expectedVersion` must be declared as strict `number`. If `id` is declared it must be strict `string`. Neither is in `required`. |
132
+ | `delete` | Loads the row (`NOT_FOUND` if absent), runs the delete guard, then hard-deletes pinned to the row's status and version. Returns `{ removed }`. | `id` (strict `type: string`) declared and in `required`. |
133
+ | `archive` | Loads the row, checks the lifecycle state machine (`CONFLICT` on an illegal transition), then transitions to `archived` pinned to the version just read. | `id` (strict `type: string`) declared and in `required`. The target Schema must be `lifecycle: publishing`; an operational target is rejected. |
134
+
135
+ Every contract violation in the right-hand column is `BUILTIN_HANDLER_CONTRACT_INVALID`, reported at the offending pointer. *Strict* means a single scalar type — an array-valued `type` or `nullable: true` does not satisfy it.
136
+
137
+ All five ops write through the same chokepoint, which validates the projected `data` against the Schema, runs the [write-time locale gate](./schema.md#write-time-locale-gate) and performs a unique-index preflight before the write.
138
+
139
+ ### Side-channel input fields
140
+
141
+ `input` is the contract with the *caller*, not with the Schema. It may declare fields the collection has no column for — a CAPTCHA token, an idempotency key, a routing hint. JSON Schema's default `additionalProperties: true` lets them validate, and the builtin op projects `input ∩ Schema.properties`, so they never reach `data`.
142
+
143
+ They are not lost. The pre-projection input travels to the chokepoint as `originalInput`, and synchronous `before_*` lifecycle hooks receive it as their handler input. A `before_create` hook can therefore verify a token the row never stores. See [Trigger](./trigger.md#lifecycle-source).
144
+
145
+ ### Builtin `upsert` example
146
+
147
+ ```yaml
148
+ apiVersion: cms.mantle.aotter.net/v1
149
+ kind: Procedure
150
+ metadata:
151
+ name: sync-inventory-level
152
+ spec:
153
+ title: Sync inventory level
154
+ description: Insert or update the stock level for one SKU in one warehouse.
155
+ requires:
156
+ auth:
157
+ all:
158
+ - ctx.auth
159
+ - { "ctx.auth.scope": "inventory:write" }
160
+ input:
161
+ type: object
162
+ required: [sku, warehouse, onHand]
163
+ properties:
164
+ sku: { type: string, minLength: 1 }
165
+ warehouse: { type: string, minLength: 1 }
166
+ onHand: { type: integer, minimum: 0 }
167
+ countedAt: { type: integer, x-mcp-hint: timestamp-ms }
168
+ requestId: { type: string, x-mcp-hint: idempotency-key }
169
+ expectedVersion: { type: number }
170
+ output:
171
+ type: object
172
+ required: [id, version]
173
+ properties:
174
+ id: { type: string }
175
+ version: { type: number }
176
+ handler:
177
+ kind: builtin
178
+ op: upsert
179
+ schema: inventory-levels
180
+ match: [sku, warehouse]
181
+ ```
182
+
183
+ This requires `inventory-levels` to declare `uniqueIndexes: [[sku, warehouse]]` — the same fields, in the same order. `requestId` is a side-channel field: it validates, reaches `before_*` hooks, and is never written to `data`. `expectedVersion` is the observed native `entry.version` at read time (not `version+1`). Omit it to create; send it to update. First-party Admin binds and hides it; HTTP and MCP callers supply it themselves.
184
+
185
+ ## The response shape
186
+
187
+ Every builtin op except `delete` returns the persisted `EntryRow`.
188
+
189
+ | Field | Type | Notes |
190
+ |---|---|---|
191
+ | `id` | string | Generated on create. |
192
+ | `collection` | string | The Schema's `metadata.name`. |
193
+ | `status` | `draft` \| `published` \| `archived` | |
194
+ | `version` | number | Optimistic-concurrency counter; bumps on every persisted update. |
195
+ | `data` | object | The projected, stamped Schema fields. |
196
+ | `authorId` | string \| null | |
197
+ | `createdAt`, `updatedAt` | number | Unix epoch milliseconds. |
198
+ | `locale` | string | Present only when the row carries `data.locale`. |
199
+
200
+ An HTTP Trigger wraps a success as `{ "ok": true, "data": <EntryRow> }` with status 200.
201
+
202
+ > **Warning**
203
+ > Declare `output` against the **row**, not the envelope. `output: { type: object, required: [id], properties: { id: { type: string } } }` checks that an id came back. Output validation does not strip the other fields, so a caller still receives the whole row; use a `ref` handler with an explicit projection when the response must be smaller.
204
+
205
+ `delete` returns `{ removed: boolean }` instead.
206
+
207
+ ## Conflicts and idempotency
208
+
209
+ A unique-index preflight runs before every write, and the database's own constraints catch the races the preflight misses. Both surface as `CONFLICT` (409), as do a stale `expectedVersion` and an illegal lifecycle transition. **There is no automatic retry** — the caller decides whether to re-read and try again.
210
+
211
+ Idempotency has no grammar key. The convention is an `input` property marked `x-mcp-hint: idempotency-key`: Admin generates and hides one UUID per form submission, and other callers generate one and reuse it across retries of the same logical request. The handler is responsible for acting on it.
212
+
213
+ Optimistic concurrency uses the reserved input name `expectedVersion` — the version the caller **read**, not the next version. First-party Admin and SDK bind-and-hide that property from the OCC target row; other callers send it themselves. There is no `x-mcp-hint` for OCC. On `CONFLICT` (409) Admin keeps the operator's business fields and requires an explicit re-read; it does not retry with the latest version. New reserved Procedure input names need an ADR.
214
+
215
+ Deferred lifecycle hooks have a stronger guarantee to work with: delivery is at-least-once, and handlers key on `${ctx.event.id}:${ctx.event.trigger}` — stable across enqueue fallback, queue retries and replay. See [Deferred hooks on Queues](../cloudflare/deferred-hooks-queues.md).
216
+
217
+ ## `uiSchema`
218
+
219
+ Admin presentation only. It never affects input validation, the MCP tool schema or the OpenAPI document. Roots are closed: `collectionAction` and `fields`.
220
+
221
+ | Key | Rule |
222
+ |---|---|
223
+ | `collectionAction` | A declared Schema name. Admin offers the Procedure as an action on that collection's list page. A non-empty string that names no Schema is `SCHEMA_UI_INVALID`; a Schema that declares `collectionAction` is rejected outright. |
224
+ | `fields.<field>.widget` | Only `textarea`. `<field>` must be a top-level property of `input` with a string type. Anything else is `SCHEMA_UI_INVALID`. |
225
+
226
+ ## Staff operations in Admin
227
+
228
+ Admin derives its operations surface from the manifest graph — there is no extra grammar. A Procedure is staff-operable when **either** condition holds:
229
+
230
+ 1. Some Trigger targets it with `source.kind: mcp` and `source.surface: staff` — the same predicate that builds the `/mcp/staff` tool catalog.
231
+ 2. Some Trigger targets it with `source.kind: http` **and** the Procedure's `requires.auth.all` includes a `ctx.staff` predicate.
232
+
233
+ | Endpoint | Behavior |
234
+ |---|---|
235
+ | `GET /admin/api/operations` | Lists the staff-operable Procedures the calling staff member may actually run. |
236
+ | `POST /admin/api/operations/:name` | Invokes one, through the same pipeline as any other caller. |
237
+
238
+ Each listed operation carries `name`, `title`, `description`, `input`, `uiSchema`, `triggers` (the distinct kinds that qualified it, so a Procedure can be both), `rowBindings`, and `targetCollection` (the builtin handler schema, or `null`).
239
+
240
+ `rowBindings` come from `x-mantle-ref` on the Procedure's input properties. An input property referencing a declared, non-`translates` Schema produces `{ collection, inputField, rowField }`, and Admin offers the operation from that collection's row menu with the field pre-filled and read-only. `rowField` is the target Schema's same-named property when it has one, otherwise the lone field of a single single-field unique index, otherwise the reserved `id` column. Refs to unknown collections or to translation children produce no binding and no error.
241
+
242
+ When `input` declares `expectedVersion`, Admin treats that reserved name as magic: it captures the OCC target's current `version` at read time, submits it, and does not render an editable version field. Changing the selected target rebinds version (an organization row must not supply a membership mutation's version). A resolvable OCC target or a row-bound dialog must have that captured version before Run is enabled, including matched upsert where the field is declared but not globally required. If `expectedVersion` is in `input.required` and no target can be resolved, submit stays disabled. Collection create / no-row dialogs may omit it when it is not required. Builtin operations also expose `targetCollection` (the handler schema) so Admin can pick the mutated collection over a contextual parent.
243
+
244
+ Worked end-to-end examples live in [Commerce transaction](../examples/commerce-transaction.md) and [Procurement approvals](../examples/procurement-approvals.md).
245
+
246
+ ## Source
247
+
248
+ - [`packages/mantle-spec/src/domain/model/ManifestGrammar.ts`](../../../packages/mantle-spec/src/domain/model/ManifestGrammar.ts)
249
+ - [`packages/mantle-spec/src/domain/service/ManifestParser.ts`](../../../packages/mantle-spec/src/domain/service/ManifestParser.ts)
250
+ - [`packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts`](../../../packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts)
251
+ - [`packages/mantle-spec/src/domain/service/SchemaAdminUiChecker.ts`](../../../packages/mantle-spec/src/domain/service/SchemaAdminUiChecker.ts)
252
+ - [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts)
253
+ - [`packages/mantle-runtime/src/usecase/procedure/InvokeBuiltinUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeBuiltinUseCase.ts)
254
+ - [`docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md`](../../adr/0020-builtin-handler-contracts-and-matched-upsert.md)
255
+ - [`docs/adr/0022-caller-observed-version-occ.md`](../../adr/0022-caller-observed-version-occ.md)
256
+ - [`packages/mantle-runtime/src/domain/service/BuiltinProjector.ts`](../../../packages/mantle-runtime/src/domain/service/BuiltinProjector.ts)
257
+ - [`packages/mantle-runtime/src/domain/model/EntryRow.ts`](../../../packages/mantle-runtime/src/domain/model/EntryRow.ts)
258
+ - [`packages/mantle-runtime/src/domain/service/io/EntryWriteGuard.ts`](../../../packages/mantle-runtime/src/domain/service/io/EntryWriteGuard.ts)
259
+ - [`packages/mantle-runtime/src/domain/service/io/EntryDeleteGuard.ts`](../../../packages/mantle-runtime/src/domain/service/io/EntryDeleteGuard.ts)
260
+ - [`packages/mantle-runtime/src/domain/service/CallableCapabilityProjector.ts`](../../../packages/mantle-runtime/src/domain/service/CallableCapabilityProjector.ts)
261
+ - [`packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts`](../../../packages/mantle-runtime/src/usecase/boot/ValidateBootUseCase.ts)
262
+ - [`packages/mantle-runtime/src/infrastructure/http/createMantleRequestHandler.ts`](../../../packages/mantle-runtime/src/infrastructure/http/createMantleRequestHandler.ts)
263
+ - [`packages/mantle-admin/src/mountMantleAdmin.ts`](../../../packages/mantle-admin/src/mountMantleAdmin.ts)