@alistigo/document-format 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ errors: string[];
4
+ }
5
+ /**
6
+ * Validate that an unknown JSON value conforms to the Alistigo document schema.
7
+ *
8
+ * Lazily resolves `ajv` and `ajv-formats` from the consumer's node_modules so
9
+ * we don't pull them in for non-validating consumers.
10
+ */
11
+ export declare function validateDocument(input: unknown): Promise<ValidationResult>;
12
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAehF"}
@@ -0,0 +1,20 @@
1
+ import documentSchema from "./schemas/document.json" with { type: "json" };
2
+ /**
3
+ * Validate that an unknown JSON value conforms to the Alistigo document schema.
4
+ *
5
+ * Lazily resolves `ajv` and `ajv-formats` from the consumer's node_modules so
6
+ * we don't pull them in for non-validating consumers.
7
+ */
8
+ export async function validateDocument(input) {
9
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional peer
10
+ const Ajv = (await import("ajv/dist/2020.js")).default;
11
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional peer
12
+ const addFormats = (await import("ajv-formats")).default;
13
+ const ajv = new Ajv({ allErrors: true, strict: false });
14
+ addFormats(ajv);
15
+ const validate = ajv.compile(documentSchema);
16
+ const valid = validate(input);
17
+ const errors = (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? ""}`.trim());
18
+ return { valid, errors };
19
+ }
20
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,yBAAyB,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAO3E;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,KAAc;IACnD,8EAA8E;IAC9E,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAC,CAAC,OAAc,CAAC;IAC9D,8EAA8E;IAC9E,MAAM,UAAU,GAAG,CAAC,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,OAAc,CAAC;IAEhE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;IACxD,UAAU,CAAC,GAAG,CAAC,CAAC;IAEhB,MAAM,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;IAC7C,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAY,CAAC;IACzC,MAAM,MAAM,GAAG,CAAC,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAA6C,EAAE,EAAE,CAC3F,GAAG,CAAC,CAAC,YAAY,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CACrD,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AAC3B,CAAC"}
@@ -0,0 +1,17 @@
1
+ # Document Format Changelog
2
+
3
+ This is the changelog for the **format itself** — separate from the project changelog. Bump on every change to the JSON Schema or the spec semantics.
4
+
5
+ ## [Unreleased] — 1.0.0
6
+
7
+ ### Initial public version
8
+
9
+ The format ships with three sections:
10
+
11
+ - **`meta`** — identity, dates, and the event-log integrity descriptor (`full` / `truncated` / `absent`).
12
+ - **`eventLog`** — append-only history. Optional. Mid-log edits forbidden; only prefix truncation allowed.
13
+ - **`projection`** — the current `ItemList`, derivable from the event log when one is present.
14
+
15
+ Event types: `ListCreated`, `ElementAdded`, `ElementDeleted`, `ListRenamed`. The base list uses `@type: Thing` for items; `@type: Action` is added by the `checkbox-element` plugin starting M2.
16
+
17
+ Spec lives at [`docs/spec.md`](spec.md). Validation is documented in [`docs/validation.md`](validation.md).
package/docs/spec.md ADDED
@@ -0,0 +1,383 @@
1
+ # Alistigo Document Format — Specification
2
+
3
+ The list document is the **transport format** for an Alistigo list. It is what gets stored on disk, exported, sent over the wire, embedded in URLs, and produced by an LLM.
4
+
5
+ Compared to earlier drafts, the document is now **self-contained**: a single JSON-LD object that bundles three sections — `meta`, `eventLog` (optional), and `projection` — into one shippable artifact. The runtime no longer juggles two parallel artifacts; it ships one.
6
+
7
+ > **At runtime, the source of truth is the event log** (when one is present). The projection is a derivable snapshot. See [`projects/alistigo-ai/architecture.md` §5](../../../projects/alistigo-ai/architecture.md#5-event-sourcing--cqrs) for the full event-sourcing contract and the bootstrap-from-Document path.
8
+
9
+ ---
10
+
11
+ ## 1. Anatomy
12
+
13
+ ```
14
+ Document
15
+ ├── @context JSON-LD: schema.org + alistigo namespace
16
+ ├── @type "alistigo:Document"
17
+ ├── meta identity + integrity descriptors (§3)
18
+ │ ├── listId UUIDv7 — stable identity of the List
19
+ │ ├── formatVersion SemVer of THIS format
20
+ │ ├── listType? optional discriminator (default supplied at runtime)
21
+ │ ├── name? optional human-readable name
22
+ │ ├── plugins? plugins this document opts into
23
+ │ ├── dateCreated
24
+ │ ├── dateModified
25
+ │ └── eventLog { presence: "full" | "truncated" | "absent", … }
26
+ ├── eventLog? the event log (§4) — present unless meta says "absent"
27
+ └── projection current rendered state of the List (§5)
28
+ ```
29
+
30
+ The three sections answer three different questions:
31
+
32
+ | Section | Answers | When you need it |
33
+ |---------|---------|-----------------|
34
+ | **`meta`** | "What is this document, when, by whom, and is its history complete?" | Always — every document carries one. |
35
+ | **`eventLog`** | "What happened, in what order, and who did it?" | When you need history (undo, audit, sync, replay). Can be omitted to shrink the document. |
36
+ | **`projection`** | "What does the list look like right now?" | Always — even an empty one. This is what gets rendered. |
37
+
38
+ ---
39
+
40
+ ## 2. Why this shape
41
+
42
+ | Goal | How the format meets it |
43
+ |------|--------------------------|
44
+ | Self-contained | One JSON object. No "see also" file. An LLM can hand it to the iframe with one URL fragment. |
45
+ | Semantic interop | Built on **schema.org** — `ItemList` for the projection, `Action` (when plugins add it) for completable items. Other tools recognize most of it. |
46
+ | Human-readable | JSON, indented. An LLM can hand-write one. A user can read it. |
47
+ | Evolutive | Versioned schema (§6). Plugins are namespaced (`alistigo:`). New fields are additive; breakers go through MAJOR with a documented migration. |
48
+ | Shrinkable | The eventLog can be truncated or wiped to fit budget constraints (§4.1). The projection alone is a valid (history-less) document. |
49
+ | Round-trippable when wanted | Full export = meta + eventLog + projection. Re-importing reproduces every state, including history. |
50
+
51
+ Why this format and not alternatives:
52
+
53
+ - **schema.org `ItemList` + `ListItem` + `Action`** for the projection — see the [archived rationale in the original spec](#10-references) (we keep using it; nothing changed there).
54
+ - **iCalendar VTODO (RFC 5545) / jCal (RFC 7265)** — referenced for plugin field naming when we add `priority`, `due`, `percentComplete`, but not adopted as the base format.
55
+
56
+ ---
57
+
58
+ ## 3. The `meta` section
59
+
60
+ `meta` carries the document's identity and integrity descriptors. It is required.
61
+
62
+ ### Required fields
63
+
64
+ | Field | Type | Notes |
65
+ |-------|------|-------|
66
+ | `listId` | UUIDv7 URN | Stable identity of the List. Distinct from any per-document id. |
67
+ | `formatVersion` | SemVer | Of THIS document format. Drives migration. |
68
+ | `dateCreated` | RFC 3339 | When the List was created. |
69
+ | `dateModified` | RFC 3339 | When the projection was last refreshed. |
70
+ | `eventLog` | object | Integrity descriptor for the eventLog field — see §3.1. |
71
+
72
+ ### Optional fields
73
+
74
+ | Field | Type | Notes |
75
+ |-------|------|-------|
76
+ | `listType` | string | Discriminator for plugin behavior. Runtime supplies `"list"` for the M1 base list. |
77
+ | `name` | string | Human-readable name. Not user-facing in M1. |
78
+ | `plugins` | string[] | Plugins this document opts into. |
79
+
80
+ ### 3.1 `meta.eventLog` — integrity descriptor
81
+
82
+ The `meta.eventLog` object describes the *state* of the eventLog field. It is required even when the eventLog is absent — that's how we know it was deliberately removed and not just lost.
83
+
84
+ | Field | When required | Means |
85
+ |-------|---------------|-------|
86
+ | `presence` | always | One of `full` \| `truncated` \| `absent` |
87
+ | `firstSeq` | when `presence: truncated` | The `seq` of the oldest surviving event. Events with `seq < firstSeq` have been wiped. |
88
+ | `truncatedAt` | when `presence: truncated` | RFC 3339 timestamp of the truncation. |
89
+ | `truncatedReason` | optional | Free-form note (e.g. "reduce export size"). |
90
+ | `wipedAt` | when `presence: absent` | RFC 3339 timestamp of when the eventLog was discarded. |
91
+ | `wipedReason` | optional | Free-form note. |
92
+
93
+ The three states:
94
+
95
+ #### `presence: "full"`
96
+
97
+ The event log is complete: it starts at `seq = 0` and is contiguous through to the most recent event. Replaying every event reproduces the projection exactly.
98
+
99
+ ```jsonc
100
+ "meta": {
101
+ "listId": "urn:uuid:0190f5cc-7e2e-7a9a-9c61-0c5a8c0f0d11",
102
+ "formatVersion": "1.0.0",
103
+ "dateCreated": "2026-04-28T10:00:00Z",
104
+ "dateModified": "2026-04-30T12:00:00Z",
105
+ "eventLog": { "presence": "full" }
106
+ }
107
+ ```
108
+
109
+ #### `presence: "truncated"`
110
+
111
+ Old events have been wiped to reduce size. Surviving events form a **contiguous prefix-truncated** sequence — i.e. they start at `firstSeq` and run contiguously to the most recent. The projection field represents the state **at truncation time**, before the surviving events; replaying the surviving events on top of the projection should reproduce the current state.
112
+
113
+ > **Rule:** mid-log deletes and edits are forbidden. Only prefix truncation is allowed. This preserves replay determinism for everything that survives.
114
+
115
+ ```jsonc
116
+ "meta": {
117
+ "eventLog": {
118
+ "presence": "truncated",
119
+ "firstSeq": 42,
120
+ "truncatedAt": "2026-04-30T11:00:00Z",
121
+ "truncatedReason": "reduce export size for chat UI"
122
+ }
123
+ }
124
+ ```
125
+
126
+ #### `presence: "absent"`
127
+
128
+ The eventLog field is omitted entirely. There is no replayable history. The projection is the only state available.
129
+
130
+ ```jsonc
131
+ "meta": {
132
+ "eventLog": {
133
+ "presence": "absent",
134
+ "wipedAt": "2026-04-30T11:00:00Z",
135
+ "wipedReason": "privacy reset on user request"
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### 3.2 What you lose, by state
141
+
142
+ | State | Replay history | Audit trail | Undo | Sync after this point |
143
+ |-------|:--:|:--:|:--:|:--:|
144
+ | `full` | ✅ all of it | ✅ everything | ✅ to any prior state | ✅ trivially (events sync) |
145
+ | `truncated` | partial — only post-`firstSeq` | partial | only into the surviving range | yes, but pre-`firstSeq` is lost |
146
+ | `absent` | ❌ | ❌ | ❌ | the projection becomes the new baseline; sync starts from now |
147
+
148
+ Pick the smallest level that meets your needs. The default for new documents is `full`.
149
+
150
+ ---
151
+
152
+ ## 4. The `eventLog` section
153
+
154
+ When `meta.eventLog.presence` is `full` or `truncated`, this field is an **array of events**, each conforming to the Event schema (§4.2). Order is by `seq` (ascending, contiguous).
155
+
156
+ ### 4.1 Truncation rules
157
+
158
+ - **Allowed:** prefix truncation — drop a contiguous range starting at `seq=0`, then update `meta.eventLog` to `{ presence: "truncated", firstSeq, truncatedAt }`.
159
+ - **Allowed:** full wipe — drop all events, set `meta.eventLog` to `{ presence: "absent", wipedAt }`. Any subsequent mutations restart the log from `seq = 0` *with a new `listId`* (a new logical history).
160
+ - **Forbidden:** removing an event from the middle of the log. The remaining events would still claim contiguous seq numbers but the projection would no longer match — replay determinism is broken. Validators MUST reject any non-contiguous log.
161
+ - **Forbidden:** editing the payload of any event in place. Events are immutable.
162
+
163
+ ### 4.2 Event format
164
+
165
+ Common envelope:
166
+
167
+ | Field | Type | Notes |
168
+ |-------|------|-------|
169
+ | `@id` | UUIDv7 URN | Globally unique event id. |
170
+ | `@type` | const `"alistigo:Event"` | |
171
+ | `eventType` | enum | `ListCreated` \| `ElementAdded` \| `ElementDeleted` \| `ListRenamed` |
172
+ | `eventVersion` | SemVer | Versioning per event type — see §7. |
173
+ | `listId` | UUIDv7 URN | Must match `meta.listId`. |
174
+ | `seq` | int ≥ 0 | Per-list monotonic, contiguous. |
175
+ | `occurredAt` | RFC 3339 | When the event was emitted. |
176
+ | `agent` | enum | `user` \| `ai` \| `host` \| `system` |
177
+ | `payload` | object | Event-type-specific (below). |
178
+
179
+ #### M1 event catalog
180
+
181
+ | `eventType` | Payload required | Notes |
182
+ |-------------|------------------|-------|
183
+ | `ListCreated` | `formatVersion` | The genesis event. `name`, `listType`, `plugins` are optional. |
184
+ | `ElementAdded` | `elementId`, `text`, `position` | Append at `position`. |
185
+ | `ElementDeleted` | `elementId` | Remove by identity. |
186
+ | `ListRenamed` | `name` | Updates `meta.name`; does not affect the projection. |
187
+
188
+ ### 4.3 Event invariants
189
+
190
+ A valid event log:
191
+
192
+ 1. Begins with `ListCreated` at `seq = 0` *unless* `presence: "truncated"`, in which case it begins at `seq = firstSeq` with whatever event was active at that point.
193
+ 2. `seq` is strictly increasing, contiguous, no gaps.
194
+ 3. All events share the same `listId` (matching `meta.listId`).
195
+ 4. `@id` is unique across all events.
196
+ 5. **Causality**: `ElementDeleted` for an `elementId` that was never `ElementAdded` (or has already been deleted) is a projector-level error — validators flag it but JSON Schema cannot enforce it structurally.
197
+
198
+ ---
199
+
200
+ ## 5. The `projection` section
201
+
202
+ The current rendered state of the list. Always present.
203
+
204
+ ```jsonc
205
+ "projection": {
206
+ "@type": "ItemList",
207
+ "itemListOrder": "https://schema.org/ItemListUnordered",
208
+ "numberOfItems": 3,
209
+ "itemListElement": [
210
+ { "@type": "ListItem", "position": 1, "item": {
211
+ "@type": "Thing",
212
+ "@id": "urn:uuid:0190f5cc-…-1d6a8c0f0d12",
213
+ "name": "Buy bread"
214
+ }},
215
+ { "@type": "ListItem", "position": 2, "item": {
216
+ "@type": "Thing",
217
+ "@id": "urn:uuid:0190f5cc-…-2e7a8c0f0d13",
218
+ "name": "Pay electricity bill"
219
+ }},
220
+ { "@type": "ListItem", "position": 3, "item": {
221
+ "@type": "Thing",
222
+ "@id": "urn:uuid:0190f5cc-…-3f8a8c0f0d14",
223
+ "name": "Buy bread"
224
+ }}
225
+ ]
226
+ }
227
+ ```
228
+
229
+ The base list uses `@type: Thing` for items (text + id only). The `@type: Action` form arrives once the `checkbox-element` plugin (M2) is loaded — at which point the projection upgrades affected items from `Thing` to `Action` with an `actionStatus`.
230
+
231
+ Element 1 and 3 share the same `name` ("Buy bread") with distinct `@id`s — duplicates are explicitly allowed in the base list.
232
+
233
+ ### 5.1 Projection ↔ event log invariants
234
+
235
+ When `meta.eventLog.presence` is `full`:
236
+
237
+ > `replayEvents(eventLog) === projection` (multiset equality on items, plus exact field equality elsewhere).
238
+
239
+ When `presence` is `truncated`:
240
+
241
+ > `replayEvents(eventLog, projection_at_firstSeq) === projection_now`. The runtime keeps the projection-at-truncation as a separate baseline if it needs full reproducibility; for transport, only the current projection is shipped (and that's lossy by design).
242
+
243
+ When `presence` is `absent`:
244
+
245
+ > No replay possible. The projection is taken as ground truth.
246
+
247
+ See [validation.md](validation.md) for executable pseudo-code.
248
+
249
+ ---
250
+
251
+ ## 6. Versioning & evolution
252
+
253
+ The format follows **SemVer**:
254
+
255
+ | Bump | Triggers |
256
+ |------|----------|
257
+ | **PATCH** (`1.0.x`) | Documentation-only fixes, examples, non-normative cleanups. |
258
+ | **MINOR** (`1.x.0`) | New optional fields, new enum values that consumers can ignore, new `alistigo:*` extensions, new plugin slots. **A v1.0.0 reader must read v1.x.0 documents successfully** (forward-compatible reads, ignoring fields it doesn't understand). |
259
+ | **MAJOR** (`x.0.0`) | Anything that breaks readers: removed fields, renamed fields, changed semantics, tightened validation. **Requires a migration function** in `src/migrations/`. |
260
+
261
+ Every document carries `meta.formatVersion`. The library:
262
+ 1. Parses the JSON.
263
+ 2. Detects `meta.formatVersion`.
264
+ 3. If older than the library's known version, runs migrations forward.
265
+ 4. Validates the migrated document against the current schema.
266
+
267
+ A document whose `formatVersion` is **newer** than the library is **read-only** — the library shows it but disables mutations.
268
+
269
+ ---
270
+
271
+ ## 7. Plugin extension pattern
272
+
273
+ A plugin extends the format in two ways:
274
+
275
+ **(a) Adds namespaced fields to items** (e.g. `alistigo:priority`, `alistigo:dueDate`).
276
+
277
+ **(b) Contributes a JSON Schema fragment** that describes those fields:
278
+
279
+ ```json
280
+ {
281
+ "$id": "https://alistigo.io/schema/v1/plugins/priority.json",
282
+ "type": "object",
283
+ "properties": {
284
+ "alistigo:priority": {
285
+ "type": "integer",
286
+ "minimum": 1,
287
+ "maximum": 9,
288
+ "description": "1 = highest; mirrors VTODO PRIORITY (RFC 5545)."
289
+ }
290
+ }
291
+ }
292
+ ```
293
+
294
+ At validation time the runtime composes the base schema with all plugin schemas listed in `meta.plugins`. A document that uses `alistigo:priority` without declaring `"priority"` in `meta.plugins` fails validation — that's intentional, it keeps documents self-describing.
295
+
296
+ ---
297
+
298
+ ## 8. Event evolution policy
299
+
300
+ Events are **immutable on disk**. We never edit a stored event. So we have a discipline for evolving event payloads.
301
+
302
+ Two patterns; pick one per change and document it in [`projects/alistigo-ai/notes.md`](../../../projects/alistigo-ai/notes.md). Mixing within the same event type is forbidden.
303
+
304
+ **(a) Upcasting** — bump `eventVersion`, write an *upcaster* that transforms `vX → vY` on read. The on-disk event is left alone; the projector sees the upcast version.
305
+
306
+ ```
307
+ Stored: { eventType: "ElementAdded", eventVersion: "1.0.0", payload: { text, position } }
308
+ Read: upcast → { eventType: "ElementAdded", eventVersion: "1.1.0", payload: { text, position, addedBy: "unknown" } }
309
+ ```
310
+
311
+ Use upcasting for:
312
+ - Adding optional fields with defaults
313
+ - Renaming fields (with mapping)
314
+ - Tightening enums when prior values can be mapped
315
+
316
+ **(b) Versioned event types** — introduce a *new* event type for the new shape; keep the old one alive. The projector handles both.
317
+
318
+ Use versioned types for:
319
+ - Splitting one event into multiple
320
+ - Changing semantics
321
+ - Anything where upcasting would be lossy
322
+
323
+ **The rule that applies regardless:** an unknown `eventType` or `eventVersion` is a hard error and disables mutations until the library is updated.
324
+
325
+ ---
326
+
327
+ ## 9. Producer guidance
328
+
329
+ ### For LLMs
330
+
331
+ When asked to produce an Alistigo document:
332
+
333
+ 1. Always emit a **complete, valid** document — `@context`, `@type`, `meta`, `projection`. The eventLog is optional; omit it (with `meta.eventLog.presence: "absent"`) for the smallest size.
334
+ 2. Use **UUIDv7** for `meta.listId`, every `event.@id`, every `event.payload.elementId`, and every `item.@id`. Never reuse IDs across documents.
335
+ 3. Set `meta.dateCreated` and `meta.dateModified` to the current time in ISO 8601 / RFC 3339 form.
336
+ 4. If you produce an eventLog, ensure the seq numbers are contiguous and the events are causally consistent (every `ElementDeleted.elementId` was previously added).
337
+ 5. Pretty-print with 2-space indent.
338
+
339
+ A minimal "history-less" document an LLM might produce:
340
+
341
+ ```json
342
+ {
343
+ "@context": ["https://schema.org", { "alistigo": "https://alistigo.io/ns/v1#" }],
344
+ "@type": "alistigo:Document",
345
+ "meta": {
346
+ "listId": "urn:uuid:0190f5cc-7e2e-7a9a-9c61-0c5a8c0f0d11",
347
+ "formatVersion": "1.0.0",
348
+ "dateCreated": "2026-04-30T12:00:00Z",
349
+ "dateModified": "2026-04-30T12:00:00Z",
350
+ "eventLog": { "presence": "absent", "wipedAt": "2026-04-30T12:00:00Z", "wipedReason": "freshly generated by AI" }
351
+ },
352
+ "projection": {
353
+ "@type": "ItemList",
354
+ "itemListElement": [
355
+ { "@type": "ListItem", "position": 1, "item": {
356
+ "@type": "Thing",
357
+ "@id": "urn:uuid:0190f5cc-…-1d6a8c0f0d12",
358
+ "name": "Buy bread"
359
+ }}
360
+ ]
361
+ }
362
+ }
363
+ ```
364
+
365
+ ### For humans / consumers
366
+
367
+ - Treat unknown `alistigo:*` fields as opaque — preserve them on round-trip.
368
+ - Treat unknown enum values defensively (open enums can grow in MINOR bumps).
369
+ - **Validate before mutating**; never serialize a document the validator rejects. See [validation.md](validation.md).
370
+
371
+ ---
372
+
373
+ ## 10. References
374
+
375
+ - [schema.org `ItemList`](https://schema.org/ItemList)
376
+ - [schema.org `ListItem`](https://schema.org/ListItem)
377
+ - [schema.org `Action`](https://schema.org/Action)
378
+ - [JSON-LD 1.1](https://www.w3.org/TR/json-ld11/)
379
+ - [JSON Schema Draft 2020-12](https://json-schema.org/draft/2020-12/schema)
380
+ - [RFC 5545 — iCalendar (VTODO)](https://www.rfc-editor.org/rfc/rfc5545)
381
+ - [RFC 7265 — jCal](https://www.rfc-editor.org/rfc/rfc7265)
382
+ - [RFC 9562 — UUIDv7](https://www.rfc-editor.org/rfc/rfc9562)
383
+ - [RFC 6902 — JSON Patch](https://www.rfc-editor.org/rfc/rfc6902)
@@ -0,0 +1,207 @@
1
+ # Validating an Alistigo Document
2
+
3
+ There are **three layers** of validation, applied in order. A document is "valid" only when it passes all three.
4
+
5
+ | Layer | What it checks | Tool | When to run |
6
+ |-------|---------------|------|--------------|
7
+ | **1. Schema** | Structure, types, required fields, enums, patterns. | JSON Schema (Ajv) against `documentSchema`. | Always — before anything else. |
8
+ | **2. Plugin schemas** | `alistigo:*` fields conform to the schemas of the plugins declared in `meta.plugins`. | JSON Schema fragments composed at runtime. | When `meta.plugins` is non-empty. |
9
+ | **3. Replay equivalence** | When `meta.eventLog.presence` is `full`, `replayEvents(eventLog)` reproduces `projection`. | The library's `replayEvents()` + a multiset compare. | Whenever the eventLog is present and you want strong correctness. |
10
+
11
+ Each layer catches a different class of bug. Skipping layer 3 is fine for transport ("does this look valid enough to render?") but never for storage ("can I trust this is what the user intended?").
12
+
13
+ ---
14
+
15
+ ## Layer 1 — Schema
16
+
17
+ Use the bundled helper:
18
+
19
+ ```ts
20
+ import { validateDocument } from "@alistigo/document-format";
21
+
22
+ const result = await validateDocument(unknownInput);
23
+ if (!result.valid) {
24
+ console.error("Document failed schema validation:");
25
+ for (const err of result.errors) console.error(" -", err);
26
+ return;
27
+ }
28
+ // safe to treat as AlistigoDocument from here on
29
+ ```
30
+
31
+ Or, if you want to control Ajv directly:
32
+
33
+ ```ts
34
+ import Ajv from "ajv/dist/2020";
35
+ import addFormats from "ajv-formats";
36
+ import { documentSchema } from "@alistigo/document-format";
37
+
38
+ const ajv = new Ajv({ allErrors: true, strict: false });
39
+ addFormats(ajv);
40
+ const validate = ajv.compile(documentSchema);
41
+
42
+ if (!validate(unknownInput)) {
43
+ // validate.errors is the structured list — render however you want
44
+ }
45
+ ```
46
+
47
+ The schema is comprehensive: it checks `@context`, the three sections, every required field per section, the eventLog presence/integrity descriptor, every event's payload shape per `eventType`, and the `unevaluatedProperties: false` strictness on every object.
48
+
49
+ What schema validation does **not** catch:
50
+ - Cross-section consistency (e.g. `meta.listId` must equal every `event.listId` and any `item.@id` must come from an `ElementAdded` event).
51
+ - Plugin-specific fields (those need plugin schemas, layer 2).
52
+ - Replay correctness (layer 3).
53
+
54
+ ---
55
+
56
+ ## Layer 2 — Plugin schemas
57
+
58
+ When `meta.plugins` is non-empty, every plugin contributes a schema fragment that describes the `alistigo:*` fields it owns. At validation time the runtime composes the base schema with every declared plugin's schema:
59
+
60
+ ```ts
61
+ // pseudo-code — the actual API will be in @alistigo/plugin-api (M2)
62
+ import { documentSchema } from "@alistigo/document-format";
63
+ import { prioritySchema } from "@alistigo/plugin-priority";
64
+ import { dueDateSchema } from "@alistigo/plugin-due-date";
65
+
66
+ const ajv = new Ajv({ allErrors: true, strict: false });
67
+ addFormats(ajv);
68
+ ajv.addSchema(prioritySchema, prioritySchema.$id);
69
+ ajv.addSchema(dueDateSchema, dueDateSchema.$id);
70
+
71
+ const validate = ajv.compile(documentSchema);
72
+ // → also validates alistigo:priority and alistigo:dueDate fields
73
+ ```
74
+
75
+ A document that uses `alistigo:priority` but does NOT declare `"priority"` in `meta.plugins` fails layer 2 — that's intentional. Plugins must be declared.
76
+
77
+ This layer is M2+ — in M1 (no plugins yet) it's a no-op.
78
+
79
+ ---
80
+
81
+ ## Layer 3 — Replay equivalence
82
+
83
+ When the eventLog is present, the projection must equal the result of replaying the events. This is the **strongest correctness check** and the only one that catches "the projection got out of sync with the log" bugs.
84
+
85
+ Pseudo-code for the full check:
86
+
87
+ ```ts
88
+ import {
89
+ validateDocument,
90
+ replayEvents,
91
+ type AlistigoDocument,
92
+ } from "@alistigo/document-format";
93
+
94
+ async function fullyValidate(input: unknown): Promise<{ ok: true } | { ok: false; reason: string }> {
95
+ // ── Layer 1
96
+ const schemaResult = await validateDocument(input);
97
+ if (!schemaResult.valid) {
98
+ return { ok: false, reason: `schema: ${schemaResult.errors.join("; ")}` };
99
+ }
100
+ const doc = input as AlistigoDocument;
101
+
102
+ // ── Cross-section integrity (cheap structural checks beyond the schema)
103
+ for (const event of doc.eventLog ?? []) {
104
+ if (event.listId !== doc.meta.listId) {
105
+ return { ok: false, reason: `event ${event["@id"]} has listId mismatching meta.listId` };
106
+ }
107
+ }
108
+
109
+ // ── Layer 2 (plugin schemas) — omitted in M1; see above
110
+
111
+ // ── Layer 3 (replay equivalence)
112
+ const presence = doc.meta.eventLog.presence;
113
+ if (presence === "full") {
114
+ const replayed = replayEvents(doc.eventLog ?? []);
115
+ if (!projectionsEqual(replayed, doc.projection)) {
116
+ return { ok: false, reason: "replay of event log does not match projection" };
117
+ }
118
+ } else if (presence === "truncated") {
119
+ // We do not have the projection-at-firstSeq baseline in the document, so we
120
+ // can only check that the surviving events are well-formed and that the
121
+ // projection has at least the items added since firstSeq. A stricter check
122
+ // requires the runtime to keep the truncation baseline as a snapshot.
123
+ if (!firstSeqMatchesLogStart(doc)) {
124
+ return { ok: false, reason: "meta.eventLog.firstSeq does not match the first event's seq" };
125
+ }
126
+ }
127
+ // presence === "absent" — nothing to replay; trust the projection.
128
+
129
+ return { ok: true };
130
+ }
131
+
132
+ function projectionsEqual(a: AlistigoProjection, b: AlistigoProjection): boolean {
133
+ // Multiset equality: same number of items, same multiset of (@id, name).
134
+ // (Position is renumbered by the projector; we don't compare positions.)
135
+ if (a.itemListElement.length !== b.itemListElement.length) return false;
136
+ const keyOf = (li: AlistigoListItem) => `${li.item["@id"]}::${li.item.name}`;
137
+ const ma = a.itemListElement.map(keyOf).sort();
138
+ const mb = b.itemListElement.map(keyOf).sort();
139
+ return ma.every((k, i) => k === mb[i]);
140
+ }
141
+
142
+ function firstSeqMatchesLogStart(doc: AlistigoDocument): boolean {
143
+ if (doc.meta.eventLog.presence !== "truncated") return true;
144
+ const events = doc.eventLog ?? [];
145
+ if (events.length === 0) return false;
146
+ return events[0].seq === doc.meta.eventLog.firstSeq;
147
+ }
148
+ ```
149
+
150
+ The `replayEvents()` helper (in [`src/validate.ts`](../src/validate.ts)) handles each event type:
151
+
152
+ - `ListCreated` → genesis, no projection mutation
153
+ - `ElementAdded` → append `{ @type: "Thing", @id, name: text }` and renumber
154
+ - `ElementDeleted` → remove by `elementId`, renumber positions to be contiguous 1..N
155
+ - `ListRenamed` → noop on the projection (the rename lives in `meta.name`)
156
+
157
+ Unknown `eventType` throws. Unknown `elementId` in `ElementDeleted` throws.
158
+
159
+ ---
160
+
161
+ ## Validating events on their own
162
+
163
+ Sometimes you just want to validate one event (e.g. before appending it to the local log). The schema's `$defs.Event` is a complete event schema in itself:
164
+
165
+ ```ts
166
+ import Ajv from "ajv/dist/2020";
167
+ import addFormats from "ajv-formats";
168
+ import { documentSchema } from "@alistigo/document-format";
169
+
170
+ const ajv = new Ajv({ allErrors: true, strict: false });
171
+ addFormats(ajv);
172
+ const validateEvent = ajv.compile({ ...documentSchema.$defs.Event, $id: "alistigo-event" });
173
+
174
+ if (!validateEvent(someEvent)) {
175
+ console.error(validateEvent.errors);
176
+ }
177
+ ```
178
+
179
+ Same pattern works for validating a `Projection` or a `Meta` block in isolation — point Ajv at `documentSchema.$defs.Projection` / `documentSchema.$defs.Meta`.
180
+
181
+ ---
182
+
183
+ ## Common failure modes (and which layer catches them)
184
+
185
+ | Bug | Caught by | Symptom |
186
+ |-----|-----------|---------|
187
+ | Missing required field (`@id`, `formatVersion`, …) | Layer 1 | `… must have required property '…'` |
188
+ | Wrong type (`seq` is a string) | Layer 1 | `… must be integer` |
189
+ | Unknown `eventType` | Layer 1 | enum mismatch |
190
+ | Plugin field without declaring the plugin in `meta.plugins` | Layer 2 | `unevaluated property` after composing plugin schemas |
191
+ | Event log seq not starting at 0 (when `presence: full`) | Layer 3 | `replayEvents` throws contiguity error |
192
+ | Mid-log event removed | Layer 3 | replay produces a different projection from the one shipped |
193
+ | Projection out of sync with log (e.g. an item added but no event for it) | Layer 3 | `projectionsEqual` returns false |
194
+ | `event.listId` ≠ `meta.listId` | cross-section integrity | "listId mismatch" |
195
+ | `firstSeq` declared but first surviving event has a different seq | cross-section integrity | "firstSeq does not match log start" |
196
+
197
+ ---
198
+
199
+ ## Testing strategy
200
+
201
+ For each `.feature` scenario in `@alistigo/features`, the runner should:
202
+
203
+ 1. Drive the Application layer (commands → events).
204
+ 2. Read back the resulting Document.
205
+ 3. Run `fullyValidate()` on it.
206
+
207
+ That makes "the document at the end of every scenario validates against all three layers" part of the milestone Definition of Done.