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,526 @@
1
+ # GraphDDB CQRS Query / Command Contract Specification
2
+
3
+ ## Overview
4
+
5
+ The contract layer defines storage-independent public interfaces over GraphDDB
6
+ models, organized as one layering:
7
+
8
+ ```text
9
+ Contract ← the public, storage-independent interface
10
+
11
+ Runtime ← the execution engine (batch / retry / transaction / cursor / composition)
12
+
13
+ Storage ← the implementation (DynamoDB today)
14
+ ```
15
+
16
+ A contract is the public interface of a CQRS (logical) service. The **Query**
17
+ side is the read API (a read model resolved from storage); the **Command** side
18
+ is the write API (resolving to write operations / transactions). A contract is
19
+ the single source of truth (SSoT) for runtime generation, multi-language
20
+ bindings, OpenAPI generation, execution-plan generation, and static analysis.
21
+ The serialized SSoT is the `contracts` map in the operations document
22
+ (layered on top of the existing `queries` / `commands` / `transactions`).
23
+
24
+ ### Canonical concepts
25
+
26
+ | Concept | Definition |
27
+ | ----------------- | ----------------------------------- |
28
+ | **Model** | Data structure (private to a context) |
29
+ | **QueryModel** | Public read contract — keyed (Key) |
30
+ | **QueryMethod** | A read use case (a projection) |
31
+ | **CommandModel** | Public write contract — keyed (Key) |
32
+ | **CommandMethod** | A write use case |
33
+ | **Runtime** | Execution engine |
34
+ | **Storage** | Implementation |
35
+
36
+ A QueryModel / CommandModel is identified by its **Key** (the access pattern)
37
+ and holds **one or more Methods**, each a distinct use case. The Key is the
38
+ access pattern; the Method is the use case (e.g. `get` and `summary` over the
39
+ same key, differing only in projection).
40
+
41
+ Scope is **Query** and **Command** contracts only. Workflow / Event
42
+ orchestration is out of scope (a Runtime concern).
43
+
44
+ ## Architecture
45
+
46
+ - **Model** defines the data structure. It is the private implementation of a
47
+ bounded context and does **not** define public access patterns.
48
+ - **Query / Command Contracts** define the public API.
49
+ - **Runtime** performs execution — batching, retries, transactions, per-key
50
+ cursors, and cross-contract composition — not business logic and not storage
51
+ shape.
52
+ - **Storage** is the implementation behind the Runtime.
53
+
54
+ Language targets (TypeScript / Python / OpenAPI / …) are **renderers** of the
55
+ same contract. In 0.2.0 a method is declared as a **descriptor** in a descriptor
56
+ map (no closure body); the two formal method shapes are the generation source:
57
+
58
+ ```ts
59
+ // read route descriptor — exactly one of query | list
60
+ type QueryMethod = { query | list: Model; key; select; options? };
61
+ // write route descriptor — exactly one of create | update | remove
62
+ type CommandMethod = { create | update | remove: Model; key; input?; condition?; result?; mode? };
63
+ ```
64
+
65
+ `GraphQL contrast`: `query` routes are **parallel independent reads** (no
66
+ cross-route consistency); a command's `mode: 'transaction'` is **atomic
67
+ all-or-nothing**, while `mode: 'parallel'` is **non-atomic per-field partial
68
+ success**.
69
+
70
+ ## Query Contract
71
+
72
+ A Query Contract (`QueryModel`) defines a public read interface. It is keyed —
73
+ its Key *is* the access pattern — and behaves as a **virtual Model**: the
74
+ records it returns have an identity (its Key) other contracts can join against
75
+ and batch-resolve. A QueryModel holds one or more Methods, each a read use case
76
+ over that same Key, differing in projection (and params).
77
+
78
+ ### Query Key
79
+
80
+ In 0.2.0 a method's Key is declared **inline in its descriptor** via the `key`
81
+ map (each field a `param.*()`); there is no separate key-type generic or key-
82
+ field list argument. The access pattern — primary key or GSI — is resolved from
83
+ the `Model` plus the `key` fields named in the descriptor:
84
+
85
+ ```ts
86
+ const UserByEmail = publicQueryModel({
87
+ get: { query: User, key: { email: param.string() }, select: { userId: true, email: true } },
88
+ });
89
+ ```
90
+
91
+ ### Query Definition
92
+
93
+ A method is a single declarative read **descriptor** — `{ query | list: Model,
94
+ key, select, options? }`, with exactly one of `query` / `list`. `select` and
95
+ `options` are separate keys (no `select` nested inside `options`).
96
+
97
+ ```ts
98
+ export const ArticleById = publicQueryModel({
99
+ get: { query: Article, key: { articleId: param.string() },
100
+ select: { articleId: true, title: true, body: true },
101
+ options: { consistentRead: false } },
102
+ });
103
+ ```
104
+
105
+ A single descriptor declares the per-key shape; the array capability
106
+ (`get(keys[])`) is derived by the contract.
107
+
108
+ ### Method names are use cases, not operations
109
+
110
+ A method is named for its use case (`get`, `summary`, `recent`, `disable`),
111
+ never for the storage op it runs. Whether it resolves internally to a `query`
112
+ (point lookup) or a `list` (partition read) is the Runtime's concern and is
113
+ invisible to callers and generated bindings. The internal op determines the
114
+ derived `resolution` / `inputArity` / `cardinality` facts, which are
115
+ bookkeeping, not part of the public method name.
116
+
117
+ ### Derived facts: resolution and input arity
118
+
119
+ Per method, two facts are **derived** from the internal op (never
120
+ hand-written):
121
+
122
+ - `resolution`: `'point'` | `'range'`.
123
+ - `inputArity`: `'single'` | `'either'` (`'array'` is reserved, not produced by
124
+ the current resolvers).
125
+
126
+ The resolvers:
127
+
128
+ - a `list` (partition `Query`) → `resolution: 'range'`, `inputArity: 'single'`;
129
+ - a `query` resolving to the **full base-table primary key** (a unique row) →
130
+ `resolution: 'point'`, `inputArity: 'either'` (a key array coalesces to one
131
+ `BatchGetItem`);
132
+ - a `query` resolving to a **full, unique GSI** → `resolution: 'point'`,
133
+ `inputArity: 'single'`: it is a per-key GSI `Query` (a `BatchGetItem` cannot
134
+ read a GSI), so a key array would be an N+1 fan-out;
135
+ - a `query` resolving to **anything else** (a partial PK prefix, a non-unique
136
+ GSI, or a partial GSI prefix) is a multi-row partition `Query` and is treated
137
+ as `resolution: 'range'`, `inputArity: 'single'` (it is not a point — a unique
138
+ row is the only point).
139
+
140
+ ### Derived arity (no author tagging)
141
+
142
+ A method's literal arity is **derived** from its descriptor (`query` vs `list`,
143
+ and whether the `key` resolves a unique row) and **build-verified**. There is no
144
+ author-side arity wrapper — the descriptor's `query` / `list` discriminator and
145
+ the resolved key carry the same information:
146
+
147
+ ```ts
148
+ // base-table PK point — a key array coalesces to one BatchGetItem
149
+ get: { query: Article, key: { articleId: param.string() }, select: { articleId: true, title: true } },
150
+
151
+ // range list — a partition Query, single key only
152
+ list: { list: Member, key: { groupId: param.string() }, select: { userId: true } },
153
+ ```
154
+
155
+ A `query` resolving to a full base-table primary key is `inputArity: 'either'`
156
+ (a key array coalesces to one `BatchGetItem`); a `query` resolving a unique GSI
157
+ is `'single'` (a per-key GSI Query, not coalescible); a `list` is always
158
+ `'single'`.
159
+
160
+ ### Cardinality matrix
161
+
162
+ A method's result shape is determined by its internal `resolution` and the
163
+ input cardinality (single key vs. array), both derived. The four combinations:
164
+
165
+ | Input | Resolution | Per key | Result shape |
166
+ | -------------- | ---------- | ---------- | ------------------------------------------- |
167
+ | `key` (single) | `point` | 0..1 item | `Item \| null` |
168
+ | `key` (single) | `range` | 0..M items | `Connection<Item>` (`{ items, cursor }`) |
169
+ | `keys[]` (N) | `point` | 0..1 each | `Map<keyId, Item \| null>` |
170
+ | `keys[]` (N) | `range` | — | rejected (range is `inputArity: 'single'`) |
171
+
172
+ A batched `point` result is **keyed, not flat**: a `Map` keyed by the canonical
173
+ key identity (a field-sorted JSON string, byte-identical across TS and Python),
174
+ where every input key is present with an explicit `null` for a miss (a
175
+ `BatchGetItem` neither preserves order nor returns absent keys).
176
+
177
+ ### Pagination — per-key cursors
178
+
179
+ A `range` connection's `cursor` is a **per-key envelope**: the inner page cursor
180
+ (the base64url DynamoDB `LastEvaluatedKey`) wrapped together with the canonical
181
+ identity of the owning key, then base64url-encoded as a single opaque string.
182
+ On resume, a supplied `after` is decoded and **verified to belong to the key
183
+ being read** — a cursor minted for one key, fed back for another, is rejected.
184
+ The single-key range form is wrapped too, so cursor handling never branches on
185
+ input arity.
186
+
187
+ ## Command Contract
188
+
189
+ A Command Contract (`CommandModel`) is the write counterpart of a QueryModel,
190
+ fully symmetric: keyed (its Key is the target identity) and holding one or more
191
+ write-use-case Methods. A command method is a single declarative write
192
+ **descriptor** — `{ create | update | remove: Model, key, input?, condition?,
193
+ result?, mode? }`, with exactly one of `create` / `update` / `remove`.
194
+
195
+ ```ts
196
+ export const UserCommands = publicCommandModel({
197
+ disable: { update: User, key: { userId: param.string() },
198
+ input: { status: 'disabled', disableReason: param.string() },
199
+ result: { select: { userId: true, status: true } } },
200
+ enable: { update: User, key: { userId: param.string() }, input: { status: 'active' } },
201
+ changeRole: { update: User, key: { userId: param.string() }, input: { role: param.string() } },
202
+ });
203
+ ```
204
+
205
+ ### Result and condition
206
+
207
+ - `result: { select, options? }` — the post-write read-back projection. Omit it
208
+ → the method returns `void`.
209
+ - `condition` — a `WriteCondition` write gate: the write applies only if the
210
+ persisted item matches. It accepts the **full declarative operator subset** of a
211
+ read filter (`eq`/`ne`/`gt`/`ge`/`lt`/`le`/`between`/`in`/`beginsWith`/
212
+ `contains`/`notContains`/`size`/`attributeType` + `and`/`or`/`not`), the legacy
213
+ existence primitives (`notExists` / `attributeExists` / `attributeNotExists`),
214
+ **or** a raw `cond\`…\`` fragment whose value slots may be `param.*` bindings:
215
+
216
+ ```ts
217
+ export const UserCommands = publicCommandModel({
218
+ // declarative operators in a write condition
219
+ promote: { update: User, key: { userId: param.string() },
220
+ input: { role: 'admin' },
221
+ condition: { and: [{ status: { ne: 'banned' } },
222
+ { version: { ge: param.number() } }] } },
223
+ // raw `cond` escape hatch, value slot bound to a param
224
+ bump: { update: User, key: { userId: param.string() },
225
+ input: { version: param.number() },
226
+ condition: cond`${User.col.version} = ${param.number()}` },
227
+ });
228
+ ```
229
+
230
+ **Constraint:** a `cond` used as the **whole** condition is fully supported. A
231
+ `cond` **nested inside** a declarative `and` / `or` / `not` group is **rejected
232
+ at build time** on this serializable surface (the serialized `expr` tree carries
233
+ no raw fragment) — move it to the top level or express the group declaratively.
234
+ - `key` may be a single object **or** an array of objects (bulk).
235
+
236
+ ### Mode (atomicity) — the `mode` descriptor key
237
+
238
+ A command method declares its atomicity per method via `mode`, replacing the old
239
+ `batch` option:
240
+
241
+ ```ts
242
+ // default — one atomic TransactWriteItems (condition-capable, ≤25)
243
+ disable: { update: User, key: { userId: param.string() },
244
+ input: { status: 'disabled' }, mode: 'transaction' },
245
+
246
+ // non-atomic — BatchWriteItem (chunked + UnprocessedItems retry), per-op partial success
247
+ addMany: { create: GroupMembership, key: { groupId: param.string(), userId: param.string() },
248
+ input: { role: param.string() }, mode: 'parallel' },
249
+ ```
250
+
251
+ - `mode: 'transaction'` (**DEFAULT**) — one `TransactWriteItems`: writes applied
252
+ **atomically**, all-or-nothing rollback, throws on failure, and
253
+ condition-capable (`WriteCondition`: the full declarative operator subset, the
254
+ existence primitives, or a `cond` fragment). More than 25 items
255
+ is a hard error (never split).
256
+ - `mode: 'parallel'` — **non-atomic**: independent ops run in parallel; each
257
+ alias surfaces **per-op partial success** in the result object as `{ ok }` |
258
+ `{ error }`. Ordering of dependent ops is derived from `$.alias.field`
259
+ references.
260
+
261
+ `GraphQL contrast`: `mode: 'transaction'` is atomic all-or-nothing; `mode:
262
+ 'parallel'` is non-atomic per-field partial success.
263
+
264
+ #### The 25-item limit is a hard error, never a silent split
265
+
266
+ `TransactWriteItems` is capped at **25 items** and is atomic. A
267
+ `mode: 'transaction'` method called with more than 25 keys is **rejected** with a
268
+ clear error rather than truncated or split — splitting would break the
269
+ all-or-nothing guarantee. A caller needing per-key independence at higher volume
270
+ must declare `mode: 'parallel'`, which is chunked automatically.
271
+
272
+ ## External Query / Cross-Contract Composition
273
+
274
+ A query method may compose **another query contract's** method as a composed
275
+ read dependency, declared with the `query($ => ({...}))` builder; a cross-
276
+ fragment reference uses `$.alias.field`:
277
+
278
+ ```ts
279
+ export const AccountAccess = publicQueryModel({
280
+ get: query($ => ({
281
+ account: { query: Account, key: { accountId: param.string() },
282
+ select: { accountId: true } },
283
+ billing: { query: BillingPlan, key: { accountId: $.account.billingAccountId },
284
+ select: { plan: true } },
285
+ })),
286
+ });
287
+ ```
288
+
289
+ A `$.alias.field` reference is recorded as a declarative composition edge: the
290
+ fragment whose key reads `$.alias.field` is resolved after, and batched against,
291
+ the producing fragment. The binding is declarative — a `$`-rooted dotted field
292
+ path only (a bare field, index, wildcard, or empty path is rejected, and the
293
+ segments `__proto__` / `constructor` / `prototype` are forbidden), so no
294
+ arbitrary JS reaches a key binding.
295
+
296
+ This is **build-time-resolved, in-process** contract chaining — relation
297
+ chaining where the link is a contract reference instead of a model relation.
298
+ There is no protocol, transport, or service discovery; the "boundary" crossed is
299
+ logical. The runtime collects every bound key produced by the parent step and
300
+ resolves the referenced contract **once, batched** (DataLoader-style), across
301
+ all parent records — N parents collapse to one `BatchGetItem`. The composed
302
+ child **must** be `point`; a `range` child would be an N+1 fan-out and is
303
+ rejected at build time.
304
+
305
+ The serializer recovers the referenced contract's name by identity and emits a
306
+ `compose` node. An independent set of sibling compositions forms one
307
+ concurrency-eligible stage executed after the parent read; the staging,
308
+ concurrency bound (16), and per-request chunk size (100, the `BatchGetItem`
309
+ limit) are serialized as a composition plan the runtimes **honor**, not
310
+ re-derive.
311
+
312
+ ## N+1 Safety (static restriction)
313
+
314
+ Whether a resolution is N+1-safe depends on two facts: the **resolution kind**
315
+ (`point` coalesces; `range` is one request per partition key) and the **input
316
+ cardinality** (a `range` method is one query for one key but N for N keys).
317
+ Output cardinality alone is insufficient, so both `resolution` and `inputArity`
318
+ are decided once on the producer side and **serialized as decided facts** — every
319
+ runtime honors them rather than re-deriving the policy.
320
+
321
+ ### The rule
322
+
323
+ A `range` resolution is allowed only with single-key input. There is
324
+ intentionally **no bounded-fan-out opt-in**; a caller needing "just these few"
325
+ loops in application code, keeping the N visible at the call site. `point`
326
+ resolutions are always allowed (they coalesce). The following are build-time
327
+ errors, raised on the contract build path:
328
+
329
+ - **array into a `range` method** — a `range` method whose `inputArity` is not
330
+ `'single'`;
331
+ - **array into a unique-GSI `point` method** — a `point` method that resolves via
332
+ a unique GSI (a per-key `Query`) but whose `inputArity` is not `'single'`;
333
+ - **list under a list** — a `range` child nested under a parent step whose
334
+ per-key output cardinality is `'many'`;
335
+ - **`range` composed child under a many-yielding parent** — the cross-contract
336
+ form of the above.
337
+
338
+ Enforcement is layered: the generated binding types encode `inputArity` (a
339
+ `'single'` method's array argument is `never`, so an array is a `tsc` error),
340
+ the build-time SSoT checker rejects the four forms above, and the runtime is a
341
+ final backstop that rejects an array fed into any `'single'` method.
342
+
343
+ ## Context boundary enforcement
344
+
345
+ A Model is the private implementation of its bounded context; a Contract is its
346
+ published interface. Within its own context a contract resolves its own Models
347
+ directly. Across a context boundary it may depend on another context **only
348
+ through that context's published Contract** (an External Query / Command);
349
+ reaching directly into a foreign Model is a **build-time error**.
350
+
351
+ Ownership is declared in a `contexts` map (context name → `{ models, contracts }`).
352
+ A method op against a Model owned by a context different from the contract's own
353
+ owning context is the violation. The check is purely additive and fires only
354
+ when **both** the contract and the touched Model have declared, different
355
+ owners: a contract in no declared context is unconstrained, and an undeclared
356
+ Model has no owner to protect. The check runs on the build path (where the
357
+ recorded op `entity` refs and the `contexts` map are both available), not in the
358
+ single-model lint framework.
359
+
360
+ ## Build-time hardening of method bodies
361
+
362
+ A method body is evaluated at definition time with throwing sentinels in place
363
+ of `keys` / `params`, recorded through a model recorder, and subjected to the
364
+ same hardening `defineTransaction` applies:
365
+
366
+ 1. **Throwing sentinel Proxy** — any property / method access other than the
367
+ internal brand reads or primitive coercion throws at the access site.
368
+ 2. **Per-access coercion ledger** — a faithful reference is stored as the Proxy
369
+ object and never coerced, so any coercion of a `keys` / `params` field proves
370
+ the body consumed it into a transform / branch / interpolation, and the build
371
+ is rejected.
372
+ 3. **Differential evaluation** — the body runs twice with disjoint per-field
373
+ markers; every recorded leaf must be a faithful reference or a byte-identical
374
+ literal across both passes.
375
+ 4. **Consumed-field check** — a `params` field the body accessed but that never
376
+ reached the recorded operation (a silent value branch) is rejected.
377
+
378
+ A body must resolve to **exactly one** model operation; zero or more than one is
379
+ rejected (multiple writes belong in a transaction). A query model rejects a body
380
+ that resolves to a write op, and a command model rejects a body that resolves to
381
+ a read op. So any non-declarative body — a transform, a value branch, a
382
+ coercion, or arbitrary JS over `keys` / `params` — is a build error, never
383
+ silently emitted as a wrong spec.
384
+
385
+ ## SSoT Schema
386
+
387
+ The contract layer is serialized as a `contracts` map layered on top of the
388
+ existing operation specs. Each contract method references an existing operation
389
+ spec (in `queries` / `commands` / `transactions`) by name and adds the decided
390
+ facts. The existing operation-spec shapes are unchanged, and both `contracts`
391
+ and `contexts` are **absent** when the input declares none (a contract-free
392
+ input produces a byte-identical pre-contract document).
393
+
394
+ ```jsonc
395
+ {
396
+ "version": "1.0",
397
+ "contracts": {
398
+ // point query: single OR array (array → BatchGetItem)
399
+ "ArticleById": {
400
+ "kind": "query",
401
+ "key": { "fields": ["articleId"] },
402
+ "methods": {
403
+ "get": {
404
+ "resolution": "point",
405
+ "inputArity": "either",
406
+ "cardinality": "one",
407
+ "operation": "ArticleById__get"
408
+ }
409
+ }
410
+ },
411
+
412
+ // range list: single key only (partition Query, not batchable)
413
+ "ArticleByCategory": {
414
+ "kind": "query",
415
+ "key": { "fields": ["categoryId"] },
416
+ "methods": {
417
+ "list": {
418
+ "resolution": "range",
419
+ "inputArity": "single",
420
+ "cardinality": "many",
421
+ "operation": "ArticleByCategory__list"
422
+ }
423
+ }
424
+ },
425
+
426
+ // composition / External Query: child must be `point`
427
+ "AccountAccess": {
428
+ "kind": "query",
429
+ "key": { "fields": ["accountId"] },
430
+ "methods": {
431
+ "get": {
432
+ "resolution": "point",
433
+ "inputArity": "either",
434
+ "cardinality": "one",
435
+ "operation": "AccountAccess__get",
436
+ "compose": [
437
+ {
438
+ "as": "billing",
439
+ "contract": "BillingPlanQueries",
440
+ "method": "get",
441
+ "context": "billing",
442
+ "bind": { "accountId": "$.billingAccountId" },
443
+ "resolution": "point",
444
+ "cardinality": "one"
445
+ }
446
+ ],
447
+ "compositionPlan": { "stages": [[0]], "concurrency": 16, "batchChunkSize": 100 }
448
+ }
449
+ }
450
+ },
451
+
452
+ // command: single → one write; array → transaction
453
+ "UserCommands": {
454
+ "kind": "command",
455
+ "key": { "fields": ["userId"] },
456
+ "methods": {
457
+ "disable": {
458
+ "inputArity": "either",
459
+ "result": "entity",
460
+ "single": { "mode": "op", "operation": "UserCommands__disable" },
461
+ "batch": { "mode": "transaction", "transaction": "UserCommands__disable__batch" }
462
+ }
463
+ }
464
+ }
465
+ },
466
+
467
+ "contexts": {
468
+ "billing": { "models": ["BillingPlan"], "contracts": ["BillingPlanQueries"] }
469
+ },
470
+
471
+ // existing specs, unchanged shape, referenced by `operation` / `transaction`
472
+ "queries": { "ArticleById__get": { /* … */ } },
473
+ "commands": { "UserCommands__disable": { /* … */ } },
474
+ "transactions": { "UserCommands__disable__batch": { /* forEach over keys */ } }
475
+ }
476
+ ```
477
+
478
+ ### Serialized types
479
+
480
+ - `ContractSpec` — `{ kind, key: { fields }, methods }`.
481
+ - `QueryContractMethodSpec` — `{ resolution, inputArity, cardinality, operation,
482
+ compose?, compositionPlan? }`.
483
+ - `ComposeSpec` — `{ as, contract, method, context?, bind, resolution,
484
+ cardinality }`. A composed child's `resolution` must be `'point'`.
485
+ - `CommandContractMethodSpec` — `{ inputArity, result, single, batch? }`, where a
486
+ resolution target is `{ mode: 'op', operation }`, `{ mode: 'transaction',
487
+ transaction }`, or `{ mode: 'parallel', operation }`.
488
+ - `ContextSpec` — `{ models, contracts }` (sorted name lists).
489
+
490
+ ## Public API surface
491
+
492
+ Definition DSL (`src/define/contract.ts`):
493
+
494
+ ```ts
495
+ publicQueryModel({ alias: { query | list: Model, key, select, options? } })
496
+ publicCommandModel({ alias: { create | update | remove: Model, key, input, condition?, result?, mode? } })
497
+ query($ => ({ alias: descriptor, ... })) // composite read; cross-fragment refs $.alias.field
498
+ mutation($ => ({ alias: descriptor, ... })) // composite write; cross-fragment refs $.alias.field
499
+ param.string() / param.number() / ... // descriptor key/input parameters
500
+ ```
501
+
502
+ Runtime (`src/runtime/*`):
503
+
504
+ ```ts
505
+ executeQueryMethod(contract, methodName, key | keys[], params?) // contract-runtime
506
+ executeCommandMethod(contract, methodName, key | keys[], params?) // command-runtime
507
+ encodePerKeyCursor / decodePerKeyCursor / serializeContractKey // per-key-cursor
508
+ ```
509
+
510
+ Build / static analysis (`src/spec/*`):
511
+
512
+ ```ts
513
+ buildContracts(contracts) // → { contracts, queries, commands, transactions }
514
+ buildContexts(contexts) // → context-ownership specs
515
+ assertContractN1Safe(name, spec, isGsiPoint?)
516
+ assertContractBoundaries(...) // context-boundary lint
517
+ ```
518
+
519
+ ## Query vs Command
520
+
521
+ | Aspect | Query | Command |
522
+ | ------------ | ---------------------- | -------------------------------- |
523
+ | Direction | Read | Write |
524
+ | Side effects | None | State mutation |
525
+ | Composition | May compose other contracts | Composes internal write ops |
526
+ | Boundary | Public read contract | Public write contract |