@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,226 @@
1
+ # ADR-0002: Closed enums for identity/time bindings
2
+
3
+ **Status:** Carried over from POC v0.0.x; refreshed for v0.1.0.
4
+
5
+ **Date**: 2026-04-30 (POC); refreshed 2026-05-03
6
+
7
+ **Deciders**: phsu
8
+
9
+ **Related**: [ADR-0001](0001-four-atom-manifest-model.md)
10
+
11
+ ---
12
+
13
+ ## Context
14
+
15
+ Two places in the v0.1 grammar accept identity-or-time-shaped
16
+ values:
17
+
18
+ 1. **`x-mantle-bind`** (Schema property extension) — declares that
19
+ a field is server-stamped at write. The SDK fills the value;
20
+ the caller must not supply it.
21
+ 2. **`requires.auth.all` predicates** (Procedure auth gate) —
22
+ names who must be acting for the call to proceed.
23
+
24
+ The straightforward design would let authors put expressions in
25
+ these slots:
26
+
27
+ ```yaml
28
+ authorId: { x-mantle-bind: "${session.user.id}" }
29
+ createdAt: { x-mantle-bind: "${request.timestamp}" }
30
+ ```
31
+
32
+ ```yaml
33
+ requires:
34
+ auth:
35
+ all:
36
+ - "${session.user.id == row.authorId}"
37
+ - "${session.user.permissions includes 'write:posts'}"
38
+ ```
39
+
40
+ This is the path most CMS-shaped products go down. It immediately
41
+ becomes a problem:
42
+
43
+ - Each new use case stretches the expression language (string
44
+ ops, array containment, date math, lookups). The language
45
+ compounds.
46
+ - The expression evaluator becomes a dependency footprint
47
+ (Liquid, Jinja, JEXL, custom DSL). Each is a security surface
48
+ (server-side template injection, sandbox escape, ReDoS).
49
+ - Static validation becomes intractable — to know whether a
50
+ manifest is correct, the validator would need to type-check the
51
+ expression language, including its host-environment access
52
+ shape.
53
+ - Author tooling (autocomplete, type inference) does not survive
54
+ free-form strings.
55
+ - AI authors writing manifests are particularly bad at templating
56
+ languages — model output drifts into a similar-looking but
57
+ subtly invalid syntax (Liquid filter where Jinja was expected,
58
+ etc.).
59
+
60
+ The same product class has already invented several of these
61
+ languages and inherited their footguns (Hasura's permissions
62
+ expressions, Strapi's policies, PostgREST's `PRE_REQUEST` hooks).
63
+ Every one of them ends up either gradually closing the
64
+ expression set or wearing the support cost of an open-grammar
65
+ DSL.
66
+
67
+ ## Decision
68
+
69
+ **Both binding slots use closed enums.** Any value not in the
70
+ enum is a parse error.
71
+
72
+ ### `x-mantle-bind` — closed enum (v0.1)
73
+
74
+ ```ts
75
+ type MantleBindValue = "ctx.user" | "ctx.staff" | "now";
76
+ ```
77
+
78
+ | Value | Resolves to | Typical use |
79
+ |---|---|---|
80
+ | `ctx.user` | UUID of the signed-in end-user (from session); `null` if anonymous | row ownership: `authorId`, `submittedBy`, `creatorId` |
81
+ | `ctx.staff` | UUID of the staff member acting; `null` for end-user-only paths | audit trail: `approvedBy`, `moderatedBy`, `grantedBy` |
82
+ | `now` | Server timestamp at write (ISO-8601 with timezone) | `createdAt`, `submittedAt`, `grantedAt` |
83
+
84
+ ### `requires.auth.all` predicate — closed vocabulary (v0.1)
85
+
86
+ ```yaml
87
+ requires:
88
+ auth:
89
+ all:
90
+ - ctx.user # caller is any signed-in end-user
91
+ - ctx.staff: [editor, owner] # caller is staff in one of these roles
92
+ ```
93
+
94
+ The vocabulary is exactly:
95
+
96
+ - `ctx.user` — caller is signed in as an end-user
97
+ - `ctx.staff: [<role>, ...]` — caller is signed in as staff in one of these roles
98
+
99
+ Anything beyond (`any:` disjunction; `owns:`, `withinMinutes:`,
100
+ `contains:`, quota predicates) is DRAFT — see
101
+ [ADR-0001](0001-four-atom-manifest-model.md) § Future grammar
102
+ discipline.
103
+
104
+ ### Adding a new entry is an explicit grammar-revise round
105
+
106
+ New `x-mantle-bind` values or `ctx.*` predicates do not get added
107
+ ad-hoc. They go through the discipline gate documented in
108
+ [ADR-0001](0001-four-atom-manifest-model.md) § Future grammar
109
+ discipline:
110
+
111
+ 1. A documented use case showing the existing closed set cannot
112
+ express the requirement.
113
+ 2. A design pass on what the new value's runtime semantics are
114
+ (where does it come from? when is it null? what happens at
115
+ the storage layer?).
116
+ 3. A spec doc revision, including the v0.1-vs-DRAFT
117
+ classification.
118
+ 4. Code that updates the validator to accept the new value.
119
+
120
+ This treats the closed set as load-bearing infrastructure, not
121
+ configuration.
122
+
123
+ ## Consequences
124
+
125
+ ### Pros
126
+
127
+ - Validator can statically know all legal values for a binding
128
+ slot; static validation emits `V_BIND_VALUE_NOT_IN_ENUM` with
129
+ the full candidate list as a one-step fix for the AI author.
130
+ - No expression-language dependency, no template injection
131
+ surface, no ReDoS, no sandbox escape.
132
+ - Meaning of each value is documented in one place (the v0.1
133
+ grammar reference); authors do not have to read evaluator
134
+ source to know what `ctx.staff: [editor]` returns when there
135
+ is no staff session.
136
+ - AI author hit rate goes way up: a closed enum of 3 values
137
+ (`x-mantle-bind`) is unambiguous; the model outputs the right
138
+ value or none at all.
139
+
140
+ ### Costs
141
+
142
+ - Real use cases that don't fit get pushed to "use a custom
143
+ Procedure handler that does the binding inside TS." For an
144
+ expression like "stamp the user's team_id," the author writes
145
+ TS (cheap; the team_id lookup is in handler scope anyway).
146
+ For an expression like "stamp the value of an upstream API
147
+ call," the author writes TS (correct outcome — that work
148
+ belongs in handler logic, not in declarative metadata).
149
+ - Adding a new enum entry is heavyweight (grammar revise) even
150
+ when the use case is obvious. The friction is the point;
151
+ resist optimizing it away.
152
+ - The `ctx.user` vs `ctx.staff` split forces authors to
153
+ understand the user-vs-staff session distinction up front.
154
+ Acceptable cost; the distinction is fundamental to the
155
+ CMS's auth model.
156
+
157
+ ### Risks
158
+
159
+ - **Pressure to add entries case-by-case.** First use case
160
+ shows up, "it's just one more value, what's the harm?" The
161
+ harm is that the enum stops being load-bearing — every
162
+ subsequent addition is precedent for the next. Mitigation:
163
+ document the grammar-revise process (ADR-0001 § Future
164
+ grammar discipline) and apply it.
165
+ - **Underpowered for genuine multi-tenant deployments.**
166
+ `ctx.tenant` was considered + dropped in v0.1 because the
167
+ current product is single-tenant per deployment. If
168
+ multi-tenant SaaS is pursued later, `ctx.tenant` reintroduces
169
+ via grammar-revise.
170
+ - **`now` semantics on D1 vs PG diverge.** D1 has no
171
+ column-level `DEFAULT now()`; SDK stamps. PG (v0.2+ via
172
+ Hyperdrive — see `mantle-cloudflare` README) has native defaults. Authors writing
173
+ `x-mantle-bind: now` on a future PG-targeted deployment may
174
+ expect column-level enforcement; v0.1.0 ships SDK-stamped
175
+ only.
176
+
177
+ ## Alternatives considered
178
+
179
+ **(a) Open expression language with sandboxing**.
180
+ Rejected: dependency footprint, security surface, AI author hit
181
+ rate, static-validation tractability all bad. Hasura / Strapi /
182
+ PostgREST precedent is not encouraging.
183
+
184
+ **(b) Open expression language with a custom DSL written by
185
+ us**.
186
+ Rejected: same problems as (a), plus we own the DSL. The
187
+ maintenance load on a custom evaluator is permanent.
188
+
189
+ **(c) Open enum with documentation but no validator
190
+ enforcement** (i.e. unknown values silently work if the
191
+ runtime happens to handle them).
192
+ Rejected: every undocumented usage drifts into precedent.
193
+ Validator-enforced closure is the only durable form.
194
+
195
+ **(d) `x-mantle-bind: ctx.user.id`, `ctx.user.email`,
196
+ `ctx.user.locale`** (open path expressions on a structured
197
+ ctx object).
198
+ Rejected: turns `ctx.user` into an object with fields that have
199
+ their own evolution. Each field path becomes a separate spec
200
+ question (when is `ctx.user.email` null? what about
201
+ `ctx.user.preferences.timezone`?). Cleanest decision is the
202
+ opaque identity ID + a separate handler-side lookup if you need
203
+ attributes.
204
+
205
+ ## How to apply
206
+
207
+ - New manifest using a value not in the enum: parse error,
208
+ exact diagnostic shape with `candidates` populated. AI authors
209
+ fix in one turn.
210
+ - New use case wanting an entry not in the enum: open a
211
+ grammar-revise discussion per ADR-0001 § Future grammar
212
+ discipline. Document the use case in the spec PR; do not
213
+ fast-track.
214
+ - Lookups (e.g. "stamp the team_id"): handler-side TS, not
215
+ binding metadata. The Procedure handler has the lookup
216
+ context anyway.
217
+ - Reviewing PRs: any change to the validator's enum check is
218
+ itself a grammar-revise; flag.
219
+
220
+ ## Implementation status
221
+
222
+ Closed-enum types live in `packages/mantle-spec/` and the
223
+ parser rejects unknown values with the structured diagnostic
224
+ shape. Verify in code review that new manifest grammar
225
+ additions do not quietly widen these enums; widening is a
226
+ grammar-revise, not a code-cleanup.