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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,694 @@
1
+ # ADR-0001: 4-atom YAML manifest model under `cms.mantle.aotter.net/v1`
2
+
3
+ **Status:** Carried over from POC v0.0.x; refreshed and folded for v0.1.0 (incorporates POC ADRs 0005 + 0006).
4
+
5
+ **Date**: 2026-04-30 (POC origin), refreshed 2026-05-03 for v0.1.0 rebuild
6
+
7
+ **Deciders**: phsu
8
+
9
+ **Related**: ADR-0002 (closed enums for bindings)
10
+
11
+ ---
12
+
13
+ ## Context
14
+
15
+ The SDK's surface area to consumers is YAML manifests. The
16
+ question is: what set of resource kinds does that surface
17
+ expose? Too few and authors find themselves cramming concepts
18
+ into the wrong atom; too many and consumers face a long enum of
19
+ "what kind do I write today" with overlapping semantics.
20
+
21
+ Earlier iterations of this CMS experimented with 11 domain-shaped
22
+ kinds (`ContentSchema`, `ContentView`, `Form`, `Membership`,
23
+ `Email`, `Webhook`, `Workflow`, `ScheduledJob`, etc.). Two
24
+ problems surfaced:
25
+
26
+ 1. **Authors couldn't find the right atom for a new feature.**
27
+ "Is a contact form a `Form`, a `Workflow`, an `Email`, or all
28
+ three?" Real features cross domain boundaries.
29
+ 2. **Atoms drifted toward overlap.** A `Form` developed an
30
+ `email:` block; a `Webhook` developed a `validation:` block;
31
+ each new kind grew to subsume neighbors.
32
+
33
+ A multi-round design experiment (6 rounds + R6.5 falsification +
34
+ R7 reframing, run as a 4-agent debate over a blog feature
35
+ progression: like button → threaded comments → private posts →
36
+ rate-limited writes → cross-Schema invariants) reduced the kind
37
+ count to 4 and tested those 4 against 6 increasingly demanding
38
+ features without needing a 5th.
39
+
40
+ The relevant history that this ADR records (so future readers
41
+ don't re-litigate it):
42
+
43
+ - **R0 — "atoms ≡ PG primitives" claim**: the experiment opened
44
+ with the heuristic "4 kinds because Postgres has 4
45
+ application-developer primitives." Held for 6 rounds without
46
+ challenge.
47
+ - **R6.5 — falsification round**: an agent proposed cutting to
48
+ 3 atoms by folding `Procedure` into `Schema.operations` (PG
49
+ functions attach to schemas in pg_proc). 92→58 LOC reduction
50
+ in the worked example by adding a default-CRUD shortcut.
51
+ Accepted by the round's judge.
52
+ - **R7 — user reframing**: reverted to 4 atoms, but for a
53
+ different reason than R0. The right axis isn't "PG
54
+ primitive"; it's "contract vs implementation × state vs
55
+ event":
56
+
57
+ | | request | event |
58
+ |---|---|---|
59
+ | external API | View (read) / write-API atom | (empty in v0.1) |
60
+ | internal | (Schema's writes are via the write-API atom) | Trigger (reaction) / Schema (state) |
61
+
62
+ **Design lesson** (worth surfacing because it shaped both this
63
+ ADR and the multi-doc YAML decision below): in multi-round
64
+ agent-team debates, decisions ratified in early rounds tend to
65
+ inherit forward through later rounds even when the local
66
+ conditions that justified them have changed. The orchestrator
67
+ must re-test prior naming / structural decisions whenever the
68
+ spec surface materially shifts; agent-team consensus is a strong
69
+ signal but not authoritative.
70
+
71
+ ## Decision
72
+
73
+ The SDK exposes exactly **four declarative resource kinds** under
74
+ the `cms.mantle.aotter.net/v1` API group:
75
+
76
+ | Atom | Postgres equivalent | Externally exposed by itself? | Has user code? |
77
+ |---|---|---|---|
78
+ | `Schema` | `CREATE TABLE` | no | no |
79
+ | `View` | `CREATE VIEW` | yes (auto-mounted at `GET /api/views/<name>`; see ADR-0012) | no |
80
+ | `Procedure` | `CREATE FUNCTION ... LANGUAGE plpgsql` | **no** (transport-agnostic; needs a Trigger to bind) | **yes — handler ref to consumer's TS** |
81
+ | `Trigger` | `CREATE TRIGGER` + `pg_cron` + PostgREST route + `LISTEN/NOTIFY` | yes (the binding atom) | no |
82
+
83
+ ### Path A: Trigger does all binding
84
+
85
+ A Procedure is **not externally exposed by itself**. To make it
86
+ callable over HTTP / MCP / cron / lifecycle / queue, declare a
87
+ `Trigger` whose `target.procedure` points at it. The same
88
+ Procedure can be bound by multiple Triggers (HTTP + cron + MCP,
89
+ all sharing one handler).
90
+
91
+ This is "Path A" relative to an alternative considered (and
92
+ rejected — see below) where Procedures carried an inline
93
+ `expose:` block.
94
+
95
+ ### PG-1:1 mapping is the primary onboarding pitch
96
+
97
+ The mapping above is what new authors see first. Authors with
98
+ Postgres background orient instantly. Authors without it get a
99
+ mental anchor with 30 years of Codd/SQL/PostgREST literature
100
+ behind it.
101
+
102
+ ### Composition rule
103
+
104
+ Anything more domain-shaped than these 4 (a `Form`,
105
+ `Membership`, `Email`, `Webhook`, `Workflow`, `ScheduledJob`)
106
+ is **not** an atom. It is composed in the consumer project by
107
+ combining the 4 atoms with consumer TypeScript. If a proposed
108
+ new feature wants a 5th kind, the proposer must show the 4
109
+ existing atoms cannot express it.
110
+
111
+ ## Consequences
112
+
113
+ ### Pros
114
+
115
+ - One question — "is it state, a query, an operation, or an
116
+ event-binding?" — picks the atom every time. No "form vs
117
+ workflow" gray zone.
118
+ - Read/write asymmetry (Views auto-mount, Procedures require
119
+ Triggers) matches HTTP safe-vs-unsafe semantics. Authors don't
120
+ have to remember which kinds the SDK auto-routes; the rule is
121
+ derivable from the atom's nature.
122
+ - PG mapping gives the spec 30 years of literature and battle
123
+ testing as background. Postgres has shipped these primitives
124
+ in the same shape since SQL-92 (Schema, View, Procedure) plus
125
+ Active Database / ECA rules from the 1990s (Trigger).
126
+ - Closed atom count makes the SDK's testing surface tractable:
127
+ every code path can be enumerated as
128
+ Schema-creation / View-query / Procedure-invocation /
129
+ Trigger-firing combinations.
130
+
131
+ ### Costs
132
+
133
+ - Atoms are coarse. A "form-with-validation-and-email" feature
134
+ is 3 manifests (Schema + Procedure + Trigger), not 1. Mitigated
135
+ by multi-doc YAML grouping (see § Authoring shape below).
136
+ - "Procedure is not directly exposed" is a teaching point; new
137
+ authors initially try to call a Procedure URL and get 404.
138
+ Mitigated by the dispatcher emitting a hint in the 404 if a
139
+ Procedure exists with no Trigger binding (DRAFT — not yet
140
+ spec'd as a code).
141
+ - The PG-1:1 pitch breaks down once authors look at the actual
142
+ storage layer (D1 today). The mapping is conceptual; the
143
+ runtime is SQLite + JSON.
144
+
145
+ ### Risks
146
+
147
+ - **Authors try to subsume domain concepts into the 4 atoms by
148
+ cramming.** A `Form` becomes "a Schema + a Procedure + an
149
+ ad-hoc validator block in the handler." Mitigated by
150
+ multi-doc YAML keeping atoms separate while the file count
151
+ stays low; reviewers should push back when a single Procedure's
152
+ handler becomes a workflow engine.
153
+ - **Trigger.source.kind expansion pressure.** v0.1 only ships
154
+ `http`. As MCP / cron / lifecycle / queue source kinds land
155
+ (DRAFT — see § Future grammar discipline below), the same
156
+ atom has to accommodate radically different invocation shapes.
157
+ The contract holds (one binding atom, many source kinds) but
158
+ each new kind is a design pass.
159
+ - **The PG-1:1 pitch may become a constraint.** If a future
160
+ capability has no PG analogue, framing pressure will push to
161
+ shoehorn it into the table or to break the pitch. Either is
162
+ defensible; the discipline is to flag the choice consciously.
163
+
164
+ ## Alternatives considered
165
+
166
+ **(a) 11 domain-shaped kinds (early scaffolding)**.
167
+ Rejected: cross-cutting features didn't fit single kinds;
168
+ adjacent kinds drifted toward overlap. Authors couldn't pick.
169
+
170
+ **(b) 5 kinds — keep `Policy` as a separate atom**.
171
+ Rejected: row/field-level policy is sub-spec on the atom whose
172
+ data it concerns (Schema-attached visibility, Procedure-attached
173
+ auth gate). Lifting Policy to a kind invented a 5th locator
174
+ authors had to consult; folding it kept the locator count down.
175
+
176
+ **(c) 3 kinds — fold Procedure into `Schema.operations`** (R6.5).
177
+ Rejected: under R7's reframing, the right axis is contract /
178
+ implementation × state / event, not "PG attachment." Schema
179
+ holds state; Procedure holds operations against state; folding
180
+ them conflates "what data exists" with "what can happen to data."
181
+ Also the LOC win that motivated the fold (92→58 via default-CRUD)
182
+ was recovered without folding — captured in the DRAFT
183
+ `handler.kind: builtin` shortcut.
184
+
185
+ **(d) 4 kinds with `Procedure` retaining inline
186
+ `expose: { http: ... }` for single-source bindings**.
187
+ Rejected: created two ways to bind HTTP (inline `expose:` for
188
+ single-source, separate `Trigger` for multi-source). Doctrine
189
+ tax forever. Multi-doc YAML solves the file-count complaint
190
+ that motivated `expose:` without inventing redundant grammar.
191
+ See § Authoring shape below for the full retirement rationale.
192
+
193
+ ## How to apply
194
+
195
+ - Adding a new feature: ask which of the 4 atoms holds each
196
+ piece. Use the "external by itself?" column to settle "do I
197
+ need a Trigger here?"
198
+ - Adding a new top-level kind: do not. First express as a
199
+ composition of the 4 + consumer TS. If the composition is
200
+ awkward, look for sub-spec on an existing atom before adding
201
+ a 5th kind.
202
+ - Renaming an atom: do not. The names are public surface; even
203
+ internally-motivated renames tend to drag schema-revise effort
204
+ across consumers, code, docs, and agent training data. Bias
205
+ toward keeping the names.
206
+ - Reading the spec: Schema = state, View = read API,
207
+ Procedure = write API (internal), Trigger = binding. That
208
+ 4-word gloss covers ~90% of the questions authors ask.
209
+
210
+ ## Implementation status
211
+
212
+ Names + PG-1:1 framing are documented in this ADR and the
213
+ manifest grammar reference. Manifest TS types live in
214
+ `packages/mantle-spec/`. Starter manifests under
215
+ `starters/` declare instances of each. Runtime dispatcher /
216
+ View executor / Procedure dispatcher are tracked under the
217
+ v0.1.0 ship list in [aotter/mantle](https://github.com/aotter/mantle).
218
+
219
+ ---
220
+
221
+ ## Future grammar discipline (was POC ADR-0005)
222
+
223
+ > Folded in 2026-05-03 from POC ADR-0005 ("v0.1 minimum essential
224
+ > grammar; rich grammar reserved as DRAFT"). The discipline below
225
+ > applies to every key inside every atom defined above.
226
+
227
+ ### Context
228
+
229
+ The 4-atom design experiment (same 6 rounds + R6.5 + R7 cited
230
+ above) produced a rich inner grammar:
231
+
232
+ - 18 grammar keys across the 4 atoms
233
+ - 7 meta-rules (DRY across `requires` siblings; one-site
234
+ placeholder evaluation; SDK helper doctrine; closed
235
+ `x-mantle-bind` enum; list-shape for cross-resource refs;
236
+ singleton-fallback exception; ctx.system origin invariant)
237
+ - 2 closed enums (`x-mantle-bind`, `ctx.*` predicate identity)
238
+ - 2 placeholder namespaces (`:ctx.*` identity bindings, `$.*`
239
+ data-flow bindings)
240
+ - ~36 cognitive surface units (consumer-cc count)
241
+
242
+ The pressure-tested grammar covered features as ambitious as
243
+ `Trigger.target.project` (declarative cross-Schema aggregate
244
+ projection in same-tx), `Schema.spec.policies.{visible,
245
+ readable, writable}` (PG-RLS-style row/field policy),
246
+ `View.recursive` (declarative recursive CTEs),
247
+ `requires.window/quota/owns/contains` (temporal predicates,
248
+ rate caps, row ownership, array containment), and
249
+ `handler.kind: builtin` (default-CRUD shortcut).
250
+
251
+ Shipping all of that in v0.1 would mean roughly 5–10× the
252
+ dispatcher / validator / type-system surface v0.1 actually
253
+ needs, plus an OpenAPI emission story for keys (recursive CTEs,
254
+ RLS) that have no clean OpenAPI mapping.
255
+
256
+ The user principle, articulated 2026-04-30 EOD:
257
+
258
+ > 本次 iteration 只匡出四個 yml 最 minimum essential 的 grammar,
259
+ > 四個都蓋出來且能動,之後當應用場景擴充真的有需要再往上蓋。
260
+
261
+ (Translation: this iteration only ships the absolute minimum
262
+ grammar to make the 4 atoms work end-to-end; each extension
263
+ arrives when a real application use case demands it.)
264
+
265
+ This is YAGNI applied to grammar surface, motivated by:
266
+
267
+ - The runtime cost above.
268
+ - The risk that speculative grammar locks the wrong shape —
269
+ features designed in the abstract often look different once a
270
+ real use case applies pressure (R3-R5 of the design experiment
271
+ showed multiple grammar shapes proposed and revised as
272
+ features unfolded).
273
+ - The maintenance cost of keys nobody uses but everybody must
274
+ understand.
275
+
276
+ ### Decision
277
+
278
+ The v0.1 ship targets **minimum essential grammar only**. Rich
279
+ grammar from the design experiment is preserved as DRAFT in the
280
+ manifest grammar reference; it lands when a concrete use case
281
+ forces it.
282
+
283
+ #### v0.1 grammar lock per atom
284
+
285
+ **Schema (v0.1)**:
286
+ - `spec.schema:` (JSON Schema 2020-12 body)
287
+ - `spec.uniqueIndexes:` (composite uniques)
288
+ - Property extensions: `x-mantle-bind`, `x-mantle-ref`, `x-mcp-hint`
289
+
290
+ **View (v0.1)**:
291
+ - `spec.from:` (source Schema)
292
+ - `spec.fields:` (projected fields)
293
+ - `spec.filter:` AST — `eq`, `gt`, `gte`, `lt`, `lte`, `and`, `or`
294
+ - `spec.orderBy:`
295
+ - `spec.limit:`
296
+ - `spec.params:` (required query params referenced by filter values)
297
+
298
+ **Procedure (v0.1)**:
299
+ - `spec.requires.auth.all:` (closed predicate vocabulary)
300
+ - `spec.input:` (JSON Schema 2020-12)
301
+ - `spec.output:` (JSON Schema 2020-12)
302
+ - `spec.handler.{kind: ref, ref: <opaque-key>}` — author-supplied
303
+ handler function
304
+ - `spec.handler.{kind: builtin, op: <create | update | upsert |
305
+ delete>, schema: <Schema name>}` — SDK-supplied CRUD shortcut
306
+ (promoted to v0.1.0; runtime implemented by `InvokeBuiltinUseCase`)
307
+
308
+ **Trigger (v0.1)**:
309
+ - `spec.source.kind: http` — public HTTP endpoint
310
+ - `spec.source.{method, path}` (when `kind: http`)
311
+ - `spec.source.kind: lifecycle` — entry-writer hook (promoted to
312
+ v0.1.0; runtime implemented by `LifecycleHookingEntryRepository`)
313
+ - `spec.source.{schema, on, errorPolicy}` (when `kind: lifecycle`)
314
+ - `spec.target.procedure:`
315
+
316
+ #### v0.1 closed enums
317
+
318
+ - `x-mantle-bind: {ctx.user, ctx.staff, now}`
319
+ - `ctx.*` predicate identity: `{user, staff}` (no `system` until a
320
+ use case forces it)
321
+ - `Trigger.source.kind: {http, lifecycle}`
322
+ - `Procedure.handler.kind: {ref, builtin}`
323
+ - `BuiltinOp: {create, update, upsert, delete}`
324
+ - `LifecycleHook: {before_create, after_create, before_update,
325
+ after_update, before_delete, after_delete, before_publish,
326
+ after_publish}`
327
+
328
+ #### What's DRAFT (do not implement, do not type)
329
+
330
+ - Schema: `policies.{visible, readable, writable, owner}`,
331
+ `indexedFields:`, `x-mantle-ref` auto-lift to virtual column,
332
+ computed columns via projection Trigger
333
+ - View: `recursive`, `gatedBy`, `join`, `policies.skip`,
334
+ filter AST extensions (`contains`, `not`, `in`, `like`)
335
+ - Procedure: `requires.auth.{any | all}` with disjunction;
336
+ `owns:`, `contains:`, `withinMinutes:`, `requires.window`,
337
+ `requires.quota`, `errors`, `retry`
338
+ - Trigger: `source.kind: {cron, queue}`,
339
+ `target.project`, `atomicity`
340
+ (`source.kind: mcp` was promoted in alpha.16 — #281)
341
+ - Cross-cutting: `ctx.system`, `$.*` placeholder namespace,
342
+ `staffBypass:`
343
+
344
+ #### Discipline
345
+
346
+ - The v0.1 validator **rejects DRAFT keys at parse time** with a
347
+ `DRAFT_KEY_USED` warning (not error — see the
348
+ AI-as-primary-author contract on permissive bias). The
349
+ diagnostic explicitly references the future-grammar reference
350
+ so authors know the key is reserved, not
351
+ unsupported-and-forgotten.
352
+ - Each DRAFT key has a documented **landing condition** (the
353
+ use case that surfaces it). Promoting a key to v0.1+ requires
354
+ showing that condition has been met — not just "it would be
355
+ nice to have."
356
+ - Each promotion goes through a 3-agent design review
357
+ (yml-editor proposes / code-impler tests buildability /
358
+ fresh-dev verifies clarity) before locking.
359
+ - The v0.1 floor is the floor, not the ceiling. The atom set
360
+ (4) is locked; the grammar inside each atom is permitted to
361
+ grow.
362
+
363
+ ### Consequences
364
+
365
+ #### Pros
366
+
367
+ - v0.1 dispatcher / validator / types are tractable in a small
368
+ number of weeks, not many months.
369
+ - Author cognitive surface is small: 4 atoms × ~5 keys each ≈
370
+ 20 things to learn, instead of 18 keys with ~36 cognitive
371
+ surface units.
372
+ - DRAFT keys document where the spec is **going**, so authors
373
+ with future requirements can tell whether the project is on
374
+ trajectory or not — without those features being prematurely
375
+ committed.
376
+ - Each DRAFT promotion gets a real use case attached, so the
377
+ grammar evolves under empirical pressure rather than
378
+ speculation. This was the design experiment's strongest
379
+ signal: features like `View.recursive` and
380
+ `Trigger.target.project` looked very different once a real
381
+ feature (threaded comments, cross-Schema invariants) applied
382
+ pressure.
383
+
384
+ #### Costs
385
+
386
+ - Authors with ambitions beyond v0.1 (private posts, role-gated
387
+ reads, rate limits, recursive views) must wait or write
388
+ handler-side TS. For some projects this is enough to shop
389
+ elsewhere; that's acceptable for an OSS v0.1.
390
+ - DRAFT documentation is itself a maintenance burden — the
391
+ reference has to stay coherent as the spec evolves; stale
392
+ DRAFT entries (features no longer planned) need pruning.
393
+ - The "warning, not error, on DRAFT keys" rule means an author
394
+ who copies a DRAFT example into a manifest doesn't fail —
395
+ they get a warning. This requires the validator to know the
396
+ full DRAFT vocabulary, not just the v0.1 vocabulary.
397
+
398
+ #### Risks
399
+
400
+ - **Grammar surface grows ad-hoc.** Each DRAFT promotion looks
401
+ reasonable; together they reproduce the over-grammar v0.1
402
+ was avoiding. Mitigation: 3-agent review per promotion;
403
+ promotions land in batches with documented use cases, not
404
+ one-by-one.
405
+ - **DRAFT becomes vaporware.** Some DRAFT features are listed
406
+ but never land because their use case never surfaces.
407
+ Mitigation: this is fine — the appendix is documenting
408
+ shapes, not commitments. If a DRAFT entry stays cold for >12
409
+ months, prune it from the appendix and let the future
410
+ proposer re-derive it.
411
+ - **Authors hit walls and route around the SDK.** "I need
412
+ `View.recursive` so I'll just write raw SQL in a Procedure
413
+ handler" — over time this drifts the actual extension shape
414
+ away from what the SDK eventually builds. Mitigation: when
415
+ ad-hoc handler patterns repeat, that's the use case that
416
+ promotes the DRAFT key. Watch handler implementations for
417
+ recurring patterns.
418
+
419
+ ### Alternatives considered
420
+
421
+ **(a) Ship the full experiment grammar in v0.1**.
422
+ Rejected: 5–10× the dispatcher work; locks shapes in the
423
+ abstract before real use cases apply pressure; author surface
424
+ explodes.
425
+
426
+ **(b) Don't document DRAFT at all; ship v0.1 spec only**.
427
+ Rejected: authors with future requirements have no way to
428
+ evaluate trajectory; the design experiment's results would be
429
+ lost institutionally; future proposals would re-litigate solved
430
+ questions. The DRAFT reference is a forward-looking invariant.
431
+
432
+ **(c) Ship a subset of DRAFT in v0.1 (e.g. `policies.visible`
433
+ and `requires.auth.any`)**.
434
+ Rejected: any line we draw between v0.1 and DRAFT has the same
435
+ litigation problem. The principled cut is "minimum essential to
436
+ make 4 atoms work end-to-end"; anything richer needs a specific
437
+ motivation. None of the DRAFT features had that motivation in
438
+ the v0.1 ship list.
439
+
440
+ **(d) Promote DRAFT keys lazily — implement at runtime when
441
+ authors ask, no spec lock**.
442
+ Rejected: spec coherence demands keys are spec'd before they
443
+ ship. Authors writing against runtime-only features get
444
+ silently broken on SDK upgrades.
445
+
446
+ ### How to apply
447
+
448
+ - Authoring v0.1 manifests: stick to the v0.1 vocabulary above.
449
+ If something seems missing, check the DRAFT reference; if it's
450
+ there and you have a concrete use case, file an issue
451
+ describing the use case.
452
+ - SDK code: implement only v0.1 keys in the dispatcher /
453
+ validator / types. DRAFT keys are documented but not handled.
454
+ - DRAFT promotion: needs a documented use case + 3-agent design
455
+ review + ADR landing the promotion (with cross-link to the
456
+ use case).
457
+ - Pruning DRAFT: if an entry has stayed cold for 12+ months,
458
+ propose removal in a small ADR. Removal is reversible (the
459
+ shape is in the experiment transcripts and prior ADR
460
+ history).
461
+
462
+ ---
463
+
464
+ ## Authoring shape: multi-doc YAML (was POC ADR-0006)
465
+
466
+ > Folded in 2026-05-03 from POC ADR-0006 ("Multi-doc YAML over
467
+ > inline shortcuts for atom co-location"). The decision below
468
+ > defines how authors lay manifests out on disk; it is the
469
+ > direct corollary of "Trigger does all binding" above.
470
+
471
+ ### Context
472
+
473
+ The 4-atom model means a single user-visible feature commonly
474
+ bundles multiple atoms. A contact form is at minimum:
475
+
476
+ - A `Schema` for `contact-messages`
477
+ - A `Procedure` `send-contact-message` (handler validates +
478
+ inserts)
479
+ - A `Trigger` binding the Procedure to `POST /api/contact`
480
+
481
+ Three atoms, three manifests. With one file per manifest, a
482
+ small site with 10 features has 30+ manifest files. An early
483
+ design-experiment vibe-user called this out:
484
+
485
+ > 4 個 manifest 為了一顆 like 按鈕?要寫成這樣才能用?
486
+
487
+ (Translation: 4 manifests just for a like button? You have to
488
+ write all this to make it work?)
489
+
490
+ Two paths surfaced to address the file count:
491
+
492
+ #### Path A — Multi-doc YAML
493
+
494
+ YAML's standard `---` separator allows multiple documents in
495
+ one file. A "feature file" carries all atoms for that feature:
496
+
497
+ ```yaml
498
+ # starters/<starter>/manifests/contact.yaml
499
+ apiVersion: cms.mantle.aotter.net/v1
500
+ kind: Schema
501
+ metadata: { name: contact-messages }
502
+ spec:
503
+ schema: { ... }
504
+
505
+ ---
506
+ apiVersion: cms.mantle.aotter.net/v1
507
+ kind: Procedure
508
+ metadata: { name: send-contact-message }
509
+ spec:
510
+ input: { ... }
511
+ output: { ... }
512
+ handler: { kind: ref, ref: send-contact-message }
513
+
514
+ ---
515
+ apiVersion: cms.mantle.aotter.net/v1
516
+ kind: Trigger
517
+ metadata: { name: contact-http }
518
+ spec:
519
+ source: { kind: http, method: POST, path: /api/contact }
520
+ target: { procedure: send-contact-message }
521
+ ```
522
+
523
+ One file, three atoms, conceptual separation preserved. The
524
+ loader walks files and parses each `---` block as a separate
525
+ manifest.
526
+
527
+ #### Path B — Inline shortcut on Procedure (`expose:`)
528
+
529
+ Add a `Procedure.expose:` block that absorbs single-source HTTP
530
+ binding inline, eliminating the Trigger for the 1:1 case:
531
+
532
+ ```yaml
533
+ apiVersion: cms.mantle.aotter.net/v1
534
+ kind: Procedure
535
+ metadata: { name: send-contact-message }
536
+ spec:
537
+ expose:
538
+ http: { method: POST, path: /api/contact }
539
+ input: { ... }
540
+ output: { ... }
541
+ handler: { kind: ref, ref: send-contact-message }
542
+ ```
543
+
544
+ The two paths solve the same surface concern (file count) but
545
+ have very different structural implications.
546
+
547
+ ### Decision
548
+
549
+ **Path A: Multi-doc YAML.** `Procedure.expose:` is permanently
550
+ retired. Trigger does ALL transport binding. Co-locate related
551
+ atoms in one file via `---`.
552
+
553
+ #### Why Path B was retired
554
+
555
+ Path B created **two ways to bind a Procedure to HTTP**:
556
+
557
+ - For a Procedure with one HTTP transport: inline `expose:` on
558
+ Procedure
559
+ - For a Procedure with multiple transports (HTTP + cron + MCP):
560
+ separate `Trigger` per transport
561
+
562
+ This is the exact "two ways to do the same thing" failure mode.
563
+ Doctrine resolves it on paper ("expose for single, Trigger for
564
+ multi") but creates permanent tax:
565
+
566
+ - Authors must remember which form to use, and switch when
567
+ cardinality changes (a single-HTTP Procedure that later
568
+ needs an MCP binding has to be refactored from `expose:` to
569
+ `Trigger`).
570
+ - The validator must understand both forms; OpenAPI emission
571
+ has two source paths to walk.
572
+ - The PG-1:1 mapping is muddied: "Procedure ≡ pg_proc" is clean
573
+ only if Procedure is internal-callable (no transport surface);
574
+ `expose:` gives Procedure transport semantics, weakening the
575
+ analogy.
576
+ - AI authors trying to introspect the manifest model get
577
+ conflicting signals about what the atom does.
578
+
579
+ #### What multi-doc YAML accomplishes
580
+
581
+ - File count drops without inventing redundancy. A feature →
582
+ one file, regardless of how many atoms it requires.
583
+ - Atom separation stays honest. Every atom has its own
584
+ envelope (`apiVersion`, `kind`, `metadata`, `spec`); there
585
+ is no "shortcut form."
586
+ - Related atoms sit next to each other for readers reviewing a
587
+ feature.
588
+ - The PG-1:1 framing stays clean: Procedure is
589
+ internal-callable, Trigger is the binding atom.
590
+
591
+ #### File organization conventions (suggested, not enforced)
592
+
593
+ - One feature → one file. Name the file by feature
594
+ (`contact.yaml`, `posts.yaml`, `comments.yaml`), not by atom
595
+ type.
596
+ - The shared file holds the Procedure + its Trigger + (when
597
+ feature-specific) the Schema and View.
598
+ - Schemas that are referenced by many features can live in
599
+ their own `<schema-name>.schema.yaml` file.
600
+ - Views with no Procedure / Trigger neighbors live in
601
+ `<view-name>.view.yaml`.
602
+
603
+ The validator does not enforce naming conventions; it walks
604
+ all `*.yaml` under the manifest root and parses every `---`
605
+ block as a manifest.
606
+
607
+ ### Consequences
608
+
609
+ #### Pros
610
+
611
+ - Single mechanism for HTTP binding (Trigger). Validator,
612
+ OpenAPI emitter, runtime dispatcher all walk one shape.
613
+ - Conceptual atom separation preserved. The 4-atom story holds
614
+ for every feature.
615
+ - File count tractable. A typical small site is 5–20 manifest
616
+ files, not 30+.
617
+ - Cardinality changes (1 HTTP → HTTP + cron) are additive (add
618
+ a Trigger); no refactor needed.
619
+ - AI authors learn one mental model, not two.
620
+
621
+ #### Costs
622
+
623
+ - Author must learn YAML's multi-doc syntax (`---`). This is
624
+ standard YAML 1.2 and supported by every loader, but it is
625
+ one more piece of syntax than "one file = one document."
626
+ - Some loaders (older / non-compliant) don't handle multi-doc.
627
+ Standard `js-yaml` / `yaml` libraries do; the SDK uses
628
+ `yaml` (already a dependency).
629
+ - File-discovery patterns (`find . -name "send-contact-message.trigger.yaml"`)
630
+ no longer work for atoms inside multi-doc files. Authors
631
+ must `grep` instead.
632
+ - Editor support: YAML language servers handle multi-doc, but
633
+ some IDE features (e.g. JSON Schema validation per document
634
+ in a multi-doc file) work less smoothly than in single-doc
635
+ files. Acceptable for v0.1.
636
+
637
+ #### Risks
638
+
639
+ - **Author convenience pressure to reintroduce `expose:`.**
640
+ Someone shows up with "but for the 80% case where I have one
641
+ HTTP binding, the inline form is so much shorter!" The
642
+ argument is real; the answer is: doctrine that creates two
643
+ ways is permanent tax that grows with the codebase.
644
+ Mitigation: this section is the answer to that argument; flag
645
+ attempts and link here.
646
+ - **Multi-doc files grow unwieldy.** A "shop" feature might
647
+ have a Schema + 3 Procedures + 5 Triggers + 2 Views. One
648
+ file with 11 atoms is harder to navigate than 11 files.
649
+ Mitigation: file-organization convention is suggestion, not
650
+ rule; authors are free to split when it stops helping.
651
+ - **Co-location breaks discoverability for shared atoms.**
652
+ A Schema referenced by 4 features can live in any of those
653
+ 4 files (or its own). Without grep / IDE go-to-def, finding
654
+ the Schema definition requires knowing where the author
655
+ put it. Mitigation: Loop 1 (`mantle validate`) emits the
656
+ filesystem path in `VIEW_FROM_UNKNOWN_SCHEMA` diagnostics;
657
+ IDE-shaped tooling (LSP — DRAFT) closes the rest.
658
+
659
+ ### Alternatives considered
660
+
661
+ **(a) Path B: `Procedure.expose:`**.
662
+ Rejected (this section's main subject). Doctrine tax forever; PG
663
+ analogy weakened; cardinality refactor required when
664
+ single-source becomes multi-source.
665
+
666
+ **(b) One file per atom (early scaffolding default)**.
667
+ Rejected: file count complaint is real for multi-atom features.
668
+ Path A solves it without inventing redundancy.
669
+
670
+ **(c) New top-level kind `Feature` that bundles atoms**.
671
+ Rejected: turns a YAML file-organization concern into a spec
672
+ question. The 4-atom contract holds; an envelope-around-atoms
673
+ is structural noise.
674
+
675
+ **(d) Make Trigger optional with a default of "auto-mount the
676
+ Procedure at /api/p/<name>"**.
677
+ Rejected: hides the binding decision from the author, breaks
678
+ the PG-1:1 mapping (Procedures are not externally exposed by
679
+ themselves), and creates a third way to bind (this default +
680
+ explicit `Trigger` + explicit `expose:`).
681
+
682
+ ### How to apply
683
+
684
+ - Authoring: one feature → one file with `---` separators.
685
+ - Reviewing: when an author writes a Procedure with an inline
686
+ HTTP binding, push back with this section.
687
+ - Refactoring: a feature that grew from one HTTP binding to
688
+ one HTTP + one cron + one MCP doesn't change the Procedure;
689
+ it adds two more Triggers in the same file.
690
+ - Tooling: SDK loader walks `*.yaml` under manifest root and
691
+ parses each `---` block; static validator emits diagnostics
692
+ with file path + manifest pointer
693
+ (`feature.yaml#/2/spec/from`) so multi-doc location stays
694
+ precise.