graphddb 0.4.2 → 0.5.0

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,189 @@
1
+ # GraphDDB Middleware / Hooks
2
+
3
+ GraphDDB lets you register **host-side middleware** that runs at fixed points
4
+ around reads and writes. Use it for cross-cutting concerns — logging, metrics,
5
+ tracing, tenant / authorization scoping, soft-delete, result post-processing —
6
+ without embedding any of it in your query or model definitions.
7
+
8
+ ## Principles
9
+
10
+ - **Host-only, never serialized.** Middleware lives on the host runtime
11
+ (`ClientManager`) and never touches the planner, the spec generator, or
12
+ `operations.json`. The declarative "what to read/write" stays serializable and
13
+ portable across the TypeScript and Python runtimes; "what to do around it" is
14
+ host-specific.
15
+ - **Unrestricted, your responsibility.** A hook may observe, mutate the
16
+ in-flight input / operation / result, cancel (by throwing), or recover from an
17
+ error (by returning a value). The library validates nothing and imposes no
18
+ semantics. If a hook breaks an invariant (e.g. removes a derived
19
+ `ConditionCheck` in a write persist hook), that is the host's responsibility.
20
+ Observation is the common use, not a restriction.
21
+ - **Separate from CDC / capture.** The internal change-data-capture mechanism is
22
+ a different surface. Middleware is not a substitute for CDC, nor vice versa;
23
+ they coexist.
24
+
25
+ ## Registration
26
+
27
+ ```ts
28
+ import { DDBModel } from 'graphddb';
29
+
30
+ DDBModel.use(middleware); // register (call as many times as you like)
31
+ DDBModel.clearMiddleware(); // remove all (e.g. in test teardown)
32
+ ```
33
+
34
+ Each registered object may set any subset of read and/or write hooks. Order
35
+ matters: `before` hooks run in registration order (FIFO); `after` / `onError`
36
+ hooks run in reverse (LIFO), so the first-registered middleware is the outermost
37
+ wrapper — onion nesting without a `next()` callback. Every hook may be
38
+ synchronous or return a `Promise`; the runtime always awaits.
39
+
40
+ ### Per-call context
41
+
42
+ Pass `{ context }` on any read or write to make request-scoped values (tenant,
43
+ actor, trace id) available to hooks as `ctx.context`. It is threaded unchanged to
44
+ every relation fan-out operation. A call with no `context` sees `{}`.
45
+
46
+ ```ts
47
+ await User.query(key, select, { context: { tenantId, actor } });
48
+ await User.put(item, { context: { actor } });
49
+ await DDBModel.batchGet(requests, { context: { tenantId } });
50
+ ```
51
+
52
+ `context` is an ordinary host-language value and is **never** serialized.
53
+
54
+ ## The interface
55
+
56
+ ```ts
57
+ interface Middleware {
58
+ name?: string;
59
+ read?: {
60
+ before?(ctx: ReadRequestCtx): void | Promise<void>; // R1
61
+ afterFetch?<T>(ctx: ReadRequestCtx, result: T): T | Promise<T>; // R4
62
+ op?: {
63
+ before?(ctx: ReadOpCtx): void | Promise<void>; // R2
64
+ afterFetch?(ctx: ReadOpCtx, items: Item[]): Item[] | Promise<Item[]>; // R3
65
+ onError?(ctx: ReadOpCtx, err: unknown): void | Item[] | Promise<void | Item[]>; // R5
66
+ };
67
+ onError?(ctx: ReadRequestCtx, err: unknown): unknown; // R5
68
+ };
69
+ write?: {
70
+ before?(ctx: WriteCtx): void | Promise<void>; // W1
71
+ after?(ctx: WriteCtx, change: Change): void | Promise<void>; // W2
72
+ persist?: {
73
+ before?(ctx: PersistCtx): void | Promise<void>; // W3
74
+ after?(ctx: PersistCtx, results: unknown): void | Promise<void>; // W4
75
+ onError?(ctx: PersistCtx, err: unknown): unknown; // W5
76
+ };
77
+ onError?(ctx: WriteCtx, err: unknown): unknown; // W5
78
+ };
79
+ }
80
+ ```
81
+
82
+ All context objects share `{ model, context, state }` (`state` is per-operation
83
+ scratch; for cross-operation accumulation under concurrent fan-out, keep the
84
+ accumulator on a host object referenced via `context`).
85
+
86
+ ## Read hooks
87
+
88
+ A read flows: request entry → one or more physical operations (the root read
89
+ **plus every relation fan-out fetch**) → hydration + relation merge → result.
90
+
91
+ | ID | Hook | Fires | Receives / may do |
92
+ |----|------|-------|-------------------|
93
+ | **R1** | `read.before(ctx)` | once, at request entry (before key-resolution / planning) | mutate `ctx.params` (`key` / `select` / `options`, or `requests` for `batchGet`); `throw` to cancel |
94
+ | **R2** | `read.op.before(ctx)` | per physical op, before send — incl. every relation fan-out fetch | mutate `ctx.operation` (the `DynamoDBOperation`, e.g. inject a `FilterExpression`); `throw` to cancel; inspect `ctx.relationPath` |
95
+ | **R3** | `read.op.afterFetch(ctx, items)` | per physical op, with its raw items | return the (possibly filtered / replaced) `Item[]` |
96
+ | **R4** | `read.afterFetch(ctx, result)` | once, on the final hydrated, relation-merged result | return the (possibly transformed) result |
97
+ | **R5** | `read.op.onError` / `read.onError` | on an op-level / request-level error | observe, rethrow, or **recover** by returning a value |
98
+
99
+ Context types: `ReadRequestCtx` (R1/R4/R5) carries `kind: 'query' | 'list' |
100
+ 'batchGet'` and the mutable `params`. `ReadOpCtx` (R2/R3/R5) carries `kind:
101
+ 'GetItem' | 'Query' | 'BatchGetItem'`, the mutable `operation`, and
102
+ `relationPath` (`[]` for the root read, `['orders']` for a direct relation,
103
+ `['orders', 'product']` for a nested one).
104
+
105
+ ### `DDBModel.batchGet`
106
+
107
+ The multi-key read primitive fires the same hooks: R1/R4/R5 with kind
108
+ `'batchGet'` (the mutable batch input is `ctx.params.requests`), and R2/R3/R5 per
109
+ underlying `BatchGetItem` op (one per ≤100-key chunk). Note DynamoDB
110
+ `BatchGetItem` has no server-side `FilterExpression`, so an R2 hook scopes a batch
111
+ read by narrowing the key set or setting a `ProjectionExpression`.
112
+
113
+ ## Write hooks
114
+
115
+ A write flows: logical write → effect derivation (edges, counter `ADD`s, GSI key
116
+ re-derivation, referential `ConditionCheck`s) → the composed physical batch →
117
+ commit. Hooks attach at two granularities — the **logical** op (before
118
+ derivation) and the **physical** persist (the real composed batch).
119
+
120
+ | ID | Hook | Fires | Receives / may do |
121
+ |----|------|-------|-------------------|
122
+ | **W1** | `write.before(ctx)` | per logical write, before derivation | mutate `ctx.input` and `ctx.kind` (incl. `delete` → `update` soft-delete-by-rewrite — derivation then runs on the rewritten op); `throw` to cancel (in a transaction, aborts **all** ops) |
123
+ | **W2** | `write.after(ctx, change)` | per logical write, after commit | observe `change.{ oldImage, newImage }` (single-op writes carry a real old image; transactions return none) |
124
+ | **W3** | `write.persist.before(ctx)` | once per physical persist (the whole atomic batch in a transaction), after derivation | mutate `ctx.items` (the composed `Put` / `Update` / `Delete` / `ConditionCheck` set — user write(s) **plus** every derived effect); `throw` to abort the batch — mutations are honored on the actual send |
125
+ | **W4** | `write.persist.after(ctx, results)` | once per physical persist, after the executor returns | observe `results` |
126
+ | **W5** | `write.persist.onError` / `write.onError` | on a persist-level / logical-level error | observe, rethrow, or **recover** by returning a value |
127
+
128
+ Context types: `WriteCtx` (W1/W2/W5) carries the mutable `kind` and `input`, plus
129
+ an optional `transaction.id` shared across the ops of one atomic batch.
130
+ `PersistCtx` (W3/W4/W5) carries the mutable `items` and the `origins` of the
131
+ batch.
132
+
133
+ Atomicity: in `DDBModel.transaction` / `mutate({ mode: 'transaction' })`, persist
134
+ hooks fire **once** for the whole atomic batch and any `before` `throw` aborts the
135
+ entire transaction; in `parallel` mode each op is independent.
136
+
137
+ ## Cancellation & recovery
138
+
139
+ - **Cancel** — `throw` from any `before` hook aborts that read/write (a transaction
140
+ `before` throw aborts the whole batch).
141
+ - **Recover** — return a value from an `onError` hook to suppress the error and
142
+ substitute a result (the first hook in LIFO order that returns a non-`undefined`
143
+ value wins; if none does, the original error rethrows). Read `read.onError`
144
+ returns the read's result; `read.op.onError` returns the op's `Item[]`; write
145
+ `onError` / `persist.onError` return the resolved value.
146
+
147
+ ## Examples
148
+
149
+ ```ts
150
+ // Tenant scope on every read, including relation fan-out (R2)
151
+ DDBModel.use({ read: { op: { before(ctx) {
152
+ addFilter(ctx.operation, 'tenantId', ctx.context.tenantId);
153
+ } } } });
154
+
155
+ // Inject audit fields on every write (W1) — flows through derivation
156
+ DDBModel.use({ write: { before(ctx) {
157
+ if (ctx.kind !== 'delete') ctx.input.item = { ...ctx.input.item, updatedBy: ctx.context.actor };
158
+ } } });
159
+
160
+ // Soft delete by rewrite (W1) — stored as an update
161
+ DDBModel.use({ write: { before(ctx) {
162
+ if (ctx.kind === 'delete') { ctx.kind = 'update'; ctx.input.changes = { deletedAt: Date.now() }; }
163
+ } } });
164
+
165
+ // Redact non-admin reads (R4) and audit the real atomic batch (W3)
166
+ DDBModel.use({ read: { afterFetch: (ctx, r) => ctx.context.actor?.isAdmin ? r : redact(r) } });
167
+ DDBModel.use({ write: { persist: { before(ctx) { auditLog(ctx.items, ctx.context); } } } });
168
+
169
+ // Latency metric using per-request scratch (R1 → R4) and metrics on error (R5)
170
+ DDBModel.use({ read: {
171
+ before(ctx) { ctx.state.t0 = performance.now(); },
172
+ afterFetch(ctx, r) { metric(`read.${ctx.kind}`, performance.now() - (ctx.state.t0 as number)); return r; },
173
+ onError(ctx) { count(`read.${ctx.kind}.error`); /* return nothing → rethrow */ },
174
+ } });
175
+ ```
176
+
177
+ ## Python parity
178
+
179
+ The Python runtime exposes the same hook points (R1–R5, W1–W5), interface shape,
180
+ ordering, atomicity, `context` injection, and `onError` recovery, registered
181
+ through the Python runtime's equivalent of `DDBModel.use`. Because hooks are
182
+ host-only and never serialized, the two runtimes implement them independently
183
+ with no shared serialized surface; TS↔Python conformance is unaffected.
184
+
185
+ ## See also
186
+
187
+ - [`cqrs-contract.md`](./cqrs-contract.md) — the public Query / Command contracts.
188
+ - [`python-bridge.md`](./python-bridge.md) — the TypeScript→Python runtime parity.
189
+ - [`cdc-emulator.md`](./cdc-emulator.md) — the (separate) change-data-capture mechanism.
@@ -0,0 +1,356 @@
1
+ # Deriving Command Execution Plans from Mutations + Model Semantics
2
+
3
+ This document is the normative specification of GraphDDB's command-derivation
4
+ layer **as implemented**. The query side compiles a GraphQL-like selection +
5
+ model metadata into a complete read execution plan; the write side mirrors it:
6
+ a **mutation** (an internal write-plan composition DSL) + **model write
7
+ semantics** compiles into a **command execution plan**, so the author does not
8
+ hand-write the write procedure.
9
+
10
+ ## Premise
11
+
12
+ The query side already does this: a GraphQL-like selection + model metadata
13
+ compiles to a complete **read execution plan** (point/range, relations,
14
+ composition, parallel `executionPlan`). The write side now does the same — it
15
+ compiles a **mutation** + model semantics into a **command execution plan** —
16
+ instead of the author hand-writing the write procedure.
17
+
18
+ This holds only to the extent the model carries write semantics. A mutation
19
+ alone is just the *input shape*; it cannot decide what happens in storage. The
20
+ decision lives in the model:
21
+
22
+ ```
23
+ GraphQL mutation = external input shape (syntax)
24
+ graphddb model = update target / relations / constraints / effects / access (semantics)
25
+ mutation + model → command execution plan
26
+ ```
27
+
28
+ Where the model is silent, the plan cannot be derived and must fall back to an
29
+ explicit, declarative action — never arbitrary procedure.
30
+
31
+ ---
32
+
33
+ ## Background — the read/write symmetry
34
+
35
+ ### The read side is fully derived (the template)
36
+
37
+ `select` tree + model metadata → read plan: `point`/`range` resolution,
38
+ relation traversal (`hasMany`→Query, `belongsTo`/`hasOne`→BatchGet), composition
39
+ (External Query), and the `executionPlan` parallel groups — all derived by the
40
+ planner and honored by both runtimes. This is the template the command side
41
+ mirrors.
42
+
43
+ ### The model layer carries READ semantics plus an additive WRITE vocabulary
44
+
45
+ Present in the read/core model layer (`src/decorators/`, `src/metadata/types.ts`):
46
+
47
+ - Entity: `@model` (table), `@key` (segmented PK/SK), `@gsi` (`unique` flag).
48
+ - Fields: `@string` / `@number` / `@boolean` / `@datetime` / `@binary` / sets /
49
+ `@list` / `@map` / `@embedded`, and `@literal(...)` (finite value set). Field
50
+ options: `format`, `readonly`, `serialize`/`deserialize`.
51
+ - Relations: `@hasMany` / `@belongsTo` / `@hasOne` with `keyBinding`, limit/order
52
+ — these are read-traversal declarations.
53
+
54
+ On top of that, the write-semantics vocabulary is declared additively (it does
55
+ **not** change the read-side declarations):
56
+
57
+ - **`Model.writes` / `entityWrites`** (`src/define/entity-writes.ts`) — the
58
+ entity's reusable *save contract*: lifecycle effects, referential integrity,
59
+ uniqueness, derived updates, edge effects, outbox events, idempotency.
60
+ - **`edgeWrites` / `deriveEdgeWriteItems`** (`src/relation/`) — the write side
61
+ of an adjacency edge: from a declared edge, derive the lifecycle-distinguished
62
+ adjacency `Put`/`Delete` transaction items.
63
+ - **`mutation` / `definePlan`** (`src/define/mutation.ts`) — the internal
64
+ write-plan composition DSL.
65
+
66
+ ### Command derivation, not hand-authored ops
67
+
68
+ Earlier, the contract command layer (`publicCommandModel`, issue #64) had the
69
+ author *write* the operation: a method body resolved to a single `put` /
70
+ `update` / `delete` (`CommandSpec`) or a hand-built `defineTransaction`
71
+ (`TransactWriteItems`). The command compiler now derives the plan from declared
72
+ write semantics: an **intent → derive ops** step exists, turning the procedural
73
+ DSL into a *derivable contract*.
74
+
75
+ ---
76
+
77
+ ## Mapping onto DynamoDB transaction primitives
78
+
79
+ The derivation maps cleanly onto the DynamoDB transaction primitives the runtime
80
+ already emits. Each write semantic has a known DynamoDB realization that fits the
81
+ existing `CommandSpec` / `TransactionSpec` / `executionPlan` SSoT:
82
+
83
+ | Derived effect | DynamoDB realization | Reuses |
84
+ | --- | --- | --- |
85
+ | put the entity item | `PutItem` (`attribute_not_exists(PK)` for create) | `CommandSpec` (#64) |
86
+ | create adjacency edge(s) | extra `Put` item(s) in one `TransactWriteItems` | `TransactionSpec` (#64) |
87
+ | `requires X exists` | `ConditionCheck` item (`attribute_exists`) in the tx | tx + extended condition subset |
88
+ | uniqueness (`unique field per parent`) | a `UNIQUE#…` guard `Put` w/ `attribute_not_exists` | tx + uniqueness declaration |
89
+ | counter / derived update | `UpdateItem` `ADD`/`SET` in the tx | tx + derived-update declaration |
90
+ | domain event | outbox `Put` item in the tx, drained to Streams/`cdc` | tx + event declaration + `src/cdc/` |
91
+ | idempotency | client-token guard item (`attribute_not_exists`) | tx + idempotency declaration |
92
+
93
+ A multi-effect create (item + edge + counter + uniqueness + event) is one
94
+ **atomic `TransactWriteItems`** — exactly what `defineTransaction` serializes,
95
+ with the new pieces being *declarations* the compiler turns into items. The
96
+ ≤25-item limit and the "no per-item condition in `BatchWriteItem`" rules (#64)
97
+ apply unchanged.
98
+
99
+ Two capabilities the derivation depends on, both now implemented:
100
+
101
+ 1. **Extended condition subset.** Writes support the full declarative operator
102
+ subset of a read filter (`gt`/`ge`/`lt`/`le`/`ne`/`between`/`in`/`beginsWith`/
103
+ `contains`/`notContains`/`size`/`attributeType` + `and`/`or`/`not`), the
104
+ existence primitives (`attribute_exists` / `attribute_not_exists` / `notExists`),
105
+ equality, and the raw `cond` escape hatch (#114) — plus a **`ConditionCheck`
106
+ transaction item type** (a read-only assertion on another item). This is what
107
+ referential integrity needs.
108
+ 2. **Edge write side.** `@hasMany`/`belongsTo` describe how to *read* an
109
+ adjacency; `edgeWrites` / `deriveEdgeWriteItems` derive the inverse — the
110
+ adjacency `Put`/`Delete` items — from a declared edge.
111
+
112
+ ---
113
+
114
+ ## Specification
115
+
116
+ ### 1. Positioning — mutation is an INTERNAL write-plan composition DSL
117
+
118
+ **The mutation is NOT a public API.** GraphQL is not exposed externally. A
119
+ `mutation` is an **internal DSL for declaring a set of updates that must execute
120
+ atomically** — a *write-plan composition language* used as the *implementation*
121
+ of a Command. The only externally-exposed surface is the fixed **Command IF**
122
+ (`publicCommandModel`, #64): params in, result out.
123
+
124
+ ```
125
+ Public Command IF (external, fixed) = what other services depend on. ← the only public surface
126
+ ↑ implemented by
127
+ Mutation (internal DSL) = declares an atomic write plan by COMPOSING write fragments
128
+ ├─ fragment: create(Post) ┐
129
+ └─ fragment: create(AuditLog) ┘ → compiler MERGES into ONE TransactWriteItems
130
+ Model semantics (entityWrites) = the semantics of each write fragment
131
+ Compiler = mutation AST + model → one CommandPlan / TransactionSpec
132
+ Runtime = atomic execution (TransactWriteItems)
133
+ ```
134
+
135
+ Key consequences:
136
+
137
+ - A mutation's **top-level fields are write *fragments*, not executed function
138
+ calls.** `mutation CreatePost { createPost(...) createAuditLog(...) }` does NOT
139
+ run `createPost` then `createAuditLog` sequentially — both fragments are
140
+ expanded and **composed at compile time into one atomic plan**.
141
+ - Atomicity is achieved by **declaring multiple fragments in one mutation**, NOT
142
+ by a handler doing `await A(); await B()`. Sequential awaits in a handler are
143
+ the anti-pattern this replaces.
144
+ - Each fragment's substance is derived from **model semantics** (§2). The
145
+ compiler composes fragments and validates them together (§3).
146
+ - The CQRS split: **Query → Read Plan**; **Mutation → Command Plan** — the
147
+ mutation is internal; the Command IF is the boundary.
148
+ - `GraphQL contrast`: composing fragments in one mutation under the default
149
+ `mode: 'transaction'` is **atomic all-or-nothing**; `mode: 'parallel'` is
150
+ **non-atomic**, surfacing **per-field partial success** (`{ ok }` | `{ error }`
151
+ per alias); on the read side `query` routes are **parallel independent reads**
152
+ with no cross-route consistency.
153
+
154
+ ### 2. Model write-semantics = reusable *save contract* (`entityWrites`)
155
+
156
+ `Model.writes` declares the **write-side invariants/effects required for this
157
+ entity to be consistent on DynamoDB** — a *reusable save contract*, NOT a
158
+ mutation and NOT business logic. It stays **declarative** (no arbitrary
159
+ procedure). Every leaf binds to an explicit path root (`$.input.*` = mutation
160
+ input, `$.entity.*` = the written entity), so the source of each value is
161
+ unambiguous.
162
+
163
+ ```ts
164
+ static readonly writes = entityWrites<PostModel>((w) => ({
165
+ create: w.lifecycle({
166
+ requires: [ w.exists(() => UserModel, { userId: '$.input.userId' }) ], // → ConditionCheck (#84)
167
+ unique: [ w.unique({ name: 'postTitlePerUser',
168
+ scope: ['$.input.userId'], fields: ['$.input.title'] }) ], // → guard (#86)
169
+ edges: [ w.putEdge(() => UserModel, 'posts') ], // → adjacency Put (#82/#85)
170
+ derive: [ w.increment(() => UserModel, { userId: '$.input.userId' }, 'postCount', 1) ], // (#85)
171
+ emits: [ w.event('PostCreated', { postId: '$.entity.postId', userId: '$.input.userId' }) ], // (#87)
172
+ idempotency: w.idempotentBy('$.input.requestId'), // → guard (#87)
173
+ }),
174
+ remove: w.lifecycle({
175
+ edges: [ w.deleteEdge(() => UserModel, 'posts') ],
176
+ derive: [ w.increment(() => UserModel, { userId: '$.entity.userId' }, 'postCount', -1) ],
177
+ }),
178
+ }));
179
+ ```
180
+
181
+ Design properties:
182
+
183
+ - **`writes` is the entity's reusable *default* save-semantics, not the mutation.**
184
+ The mutation explicitly *adopts* it (`use:`), separating "reusable save rule"
185
+ from "public operation IF" (§3).
186
+ - **`unique` is explicit `{ name, scope, fields }`** — the guard-item key shape
187
+ matters on DynamoDB; a bare `per:` is too weak for composite scope /
188
+ normalization / case-insensitive uniqueness.
189
+ - **Edge effects are lifecycle-distinguished** — `putEdge` (onCreate),
190
+ `deleteEdge` (onDelete), and **`onUpdateKeyChange`** (when a foreign key like
191
+ `post.userId` changes: delete the old edge + create the new one), because
192
+ delete/update need the **old** value.
193
+
194
+ ### Responsibility boundary (what may live in `Model.writes`)
195
+
196
+ `Model.writes` holds ONLY the persistence/relational/integrity contract:
197
+
198
+ > referential integrity · uniqueness · edge create/delete · derived counter
199
+ > updates · basic create/delete lifecycle · outbox event · idempotency guard
200
+
201
+ It does **NOT** hold business flow, approval/state transitions, external API
202
+ calls, notifications, payment, complex authorization, UI concerns, or use-case
203
+ branching. Those are not "how this entity stays consistent on DynamoDB."
204
+
205
+ **The mutation internal is persistence/records ONLY.** Business logic lives
206
+ **outside** the mutation — in the application/service that calls the Command IF,
207
+ or in a cross-cutting middleware layer (a separate concern; see #50). There is
208
+ no business-logic "hook" inside the model or the mutation: needing one is a
209
+ smell that logic leaked into the wrong layer. The split:
210
+
211
+ ```
212
+ Caller / app layer = business logic, authorization, external I/O, orchestration
213
+ ↓ calls
214
+ Command IF (public) = fixed external surface; implemented by a mutation
215
+ Mutation (internal) = composes write fragments into one atomic plan — RECORDS ONLY
216
+ Model.writes = common save semantics (DynamoDB consistency contract)
217
+ Command Compiler = fragment composition + cross-fragment validation → TransactWriteItems
218
+ Middleware (#50) = cross-cutting insertion points, designed separately/later
219
+ ```
220
+
221
+ Note: a *declarative* guard on the current persisted value — e.g. "only writable
222
+ while `status == 'draft'`" — is a **condition on the write** (the `{ field: value }`
223
+ / `attribute_exists` condition subset, #81/#84), which is records-level and stays
224
+ inside. Genuine business logic (multi-step flows, external effects) does not.
225
+
226
+ ### 3. The command compiler (compose fragments → one atomic CommandPlan)
227
+
228
+ A **mutation is internal**: it COMPOSES one or more **write fragments** in a
229
+ descriptor alias-map — each alias an **intent** (`create` / `update` / `remove`)
230
+ + target `Model` + `key` + `input` (with optional `condition` / `result` /
231
+ `use`). The intent selects the lifecycle from the target's `writes`; `use:` is
232
+ **optional** (defaults to the target model's own `writes`) and is given only to
233
+ override with a custom write contract — and it takes the whole `writes` set,
234
+ never `writes.create` (that would double-specify the intent). A cross-fragment
235
+ data dependency is a `$.alias.field` reference. The compiler merges ALL
236
+ fragments into ONE atomic plan. The **public surface is the Command IF**
237
+ (`publicCommandModel`, #64), whose method is *implemented by* the mutation.
238
+
239
+ ```ts
240
+ // ── internal write-plan composition (NOT public) ──
241
+ const CreatePost = mutation($ => ({
242
+ // The INTENT (`create`) already selects the lifecycle. `use:` is OPTIONAL and
243
+ // defaults to the target's own `writes`; the compiler picks writes.create for
244
+ // create / writes.update for update / writes.remove for remove.
245
+ post: { create: PostModel, key: { postId: param.string() }, // fragment 1 → PostModel.writes.create
246
+ input: { userId: param.string(), title: param.string(), body: param.string() } },
247
+ audit: { create: AuditLogModel, key: { id: param.string() }, // fragment 2 — composed atomically
248
+ input: { action: 'post.created', actorId: $.post.userId, postId: $.post.postId } },
249
+ }));
250
+
251
+ // Override only when a non-default save contract is wanted:
252
+ // post: { create: PostModel, use: CustomPostWrites, key: {...}, input: { … } }
253
+ // `use:` takes the WHOLE writes set (not writes.create) — the intent picks the
254
+ // lifecycle, so writing `use: PostModel.writes.create` would double-specify it.
255
+
256
+ // ── public Command IF (external, fixed — the only public surface) ──
257
+ export const PostCommands = publicCommandModel({
258
+ create: mutation($ => ({
259
+ post: { create: PostModel, key: { postId: param.string() },
260
+ input: { requestId: param.string().optional(), userId: param.string(),
261
+ title: param.string(), body: param.string() },
262
+ result: { select: { postId: true, title: true } } }, // return = read projection
263
+ })),
264
+ });
265
+ ```
266
+
267
+ Compilation (`compileFragment` for a single fragment;
268
+ `compileMutationPlan` for the N-fragment atomic merge, #90):
269
+
270
+ ```
271
+ Mutation = { alias₁: fragment₁, alias₂: fragment₂, … } (each: intent + target Model + key + use: saveContract + input)
272
+ → for each fragment, derive its items from the adopted save contract:
273
+ [ ConditionCheck(requires), Put(entity, attribute_not_exists), Put(unique guards),
274
+ Put(edge) / Delete(old edge)+Put(new edge) on key change,
275
+ Update(derived), Put(outbox event), Put(idempotency guard) ]
276
+ → CROSS-FRAGMENT composition + validation:
277
+ • merge all fragments' items into ONE TransactWriteItems
278
+ • reject same-item conflicts (two writes to the same key in one tx — DynamoDB rejects)
279
+ • resolve cross-fragment data deps (e.g. fragment₂ reads fragment₁'s `$.alias.field`)
280
+ • enforce ≤25 items (hard error, never split — atomicity)
281
+ • dedupe/validate idempotency + outbox items
282
+ → emit one CommandSpec (single, single-fragment, no-tx-needed) OR TransactionSpec (atomic)
283
+ → attach executionPlan (#70); resolve return selection as a read projection
284
+ ```
285
+
286
+ At runtime, every write path — single command, multi-fragment mutation, and the
287
+ declarative `defineTransaction` — routes through the **single shared
288
+ `commitTransaction` orchestration** (`src/runtime/transaction-commit.ts`, #97):
289
+ op-aware collapse of same-physical-key collisions, the ≤25 enforcement, the
290
+ atomic commit, and the CDC write-capture loop all live in that one place, so no
291
+ caller carries its own collapse / limit / commit / capture logic.
292
+
293
+ Naming: `mutation($ => ({...}))` = the INTERNAL composition DSL (not public);
294
+ `publicCommandModel({ alias: mutation(...) })` = the public Command IF;
295
+ `CommandPlan` = internal execution plan; `entityWrites` = model save-semantics.
296
+ Fragment intents are the descriptor keys `create` / `update` / `remove`
297
+ (`remove`, not `del`).
298
+
299
+ This is the **same SSoT** as #64 (`CommandSpec`/`TransactionSpec`) and #70
300
+ (`executionPlan`) — the compiler *populates* it from the adopted save contract
301
+ instead of the author hand-writing it. N+1 safety, ≤25-item atomicity,
302
+ condition-subset rules, and TS↔Python conformance all carry over unchanged.
303
+
304
+ ### 4. What is NOT derivable lives OUTSIDE the mutation (no in-model hook)
305
+
306
+ Some logic is genuinely not derivable from declarative model semantics:
307
+
308
+ - **Business state transitions** (multi-step flows beyond a declarative write
309
+ condition).
310
+ - **External calls** (payment, email, notification).
311
+ - **Compensating transactions** (partial-failure recovery across systems).
312
+ - **Complex authorization** (org hierarchy, delegation, time-boxed grants).
313
+
314
+ **This logic does NOT get a hook inside the model or the mutation.** Embedding a
315
+ "policy/action hook" in the contract would blur the layer boundary the CQRS
316
+ split exists to keep clean. The mutation internal is **persistence/records
317
+ only**. Non-derivable business logic belongs **outside**:
318
+
319
+ - in the **application/service** that calls the Command IF (it does authz,
320
+ orchestration, external I/O *around* the command), and/or
321
+ - in a **cross-cutting middleware layer** — a separate concern, deferred and
322
+ tracked under **#50**, NOT part of this derivation layer.
323
+
324
+ Needing a business-logic hook *inside* a mutation is a smell that logic leaked
325
+ into the persistence layer. (A declarative guard on the current persisted value
326
+ is a *write condition*, not business logic — it stays inside, per §2.)
327
+
328
+ ---
329
+
330
+ ## Design summary
331
+
332
+ - **The external IF is GraphQL-like `query` | `mutation`**, forking internally
333
+ to read-plan vs command-plan compilers (CQRS). The mutation is the syntax; the
334
+ model is the semantics.
335
+ - **The command is a derivable contract, not a procedure.** The model
336
+ write-semantics vocabulary (lifecycle / edge-effects / constraints / derived
337
+ updates / events / idempotency) lets the compiler derive the plan. (Authz and
338
+ other business logic are NOT model write-semantics — they live outside the
339
+ mutation; see §4.)
340
+ - **The compile target is the SSoT** built for the contract layer
341
+ (`CommandSpec` / `TransactionSpec` / `executionPlan`) — no new execution
342
+ substrate.
343
+ - **The two enabling primitives are in place**: the extended write condition
344
+ subset (`attribute_exists` + a `ConditionCheck` transaction item type), and
345
+ the edge write side (adjacency Put/Delete derived from a declared edge).
346
+ - **Everything stays declarative + build-verified + conformance-locked**,
347
+ exactly as the query/command contract layer is.
348
+
349
+ `mutation + model semantics → command plan` turns the command interface from a
350
+ hand-written procedural DSL into a **derivable contract**, symmetric with the
351
+ query side. The work is not in execution (the `TransactWriteItems` patterns and
352
+ the SSoT already exist) — it is in the **model's declarative write-semantics
353
+ vocabulary**, plus the two primitives (`ConditionCheck` / `attribute_exists`,
354
+ and edge write-effects). The irreducible non-derivable logic (state machines,
355
+ external I/O, compensation, complex authz) stays outside the mutation, never
356
+ arbitrary procedure inside it.