@protoyard/blueprint 0.0.0-stage → 1.1.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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1149 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.js +268 -0
  5. package/dist/app-model.d.ts +76 -0
  6. package/dist/app-model.js +287 -0
  7. package/dist/archetypes/index.d.ts +1 -0
  8. package/dist/archetypes/index.js +1 -0
  9. package/dist/archetypes/schema.d.ts +126 -0
  10. package/dist/archetypes/schema.js +77 -0
  11. package/dist/audit.d.ts +52 -0
  12. package/dist/audit.js +149 -0
  13. package/dist/authoring/deck-lint.d.ts +32 -0
  14. package/dist/authoring/deck-lint.js +567 -0
  15. package/dist/authoring/index.d.ts +1 -0
  16. package/dist/authoring/index.js +1 -0
  17. package/dist/authoring/select-deck-layouts.d.ts +50 -0
  18. package/dist/authoring/select-deck-layouts.js +213 -0
  19. package/dist/authoring-apis.d.ts +13 -0
  20. package/dist/authoring-apis.js +13 -0
  21. package/dist/authoring-validation.d.ts +65 -0
  22. package/dist/authoring-validation.js +267 -0
  23. package/dist/avatar.d.ts +25 -0
  24. package/dist/avatar.js +30 -0
  25. package/dist/brand.d.ts +70 -0
  26. package/dist/brand.js +294 -0
  27. package/dist/brief/archetypes.d.ts +28 -0
  28. package/dist/brief/archetypes.js +798 -0
  29. package/dist/brief/content-synthesize.d.ts +31 -0
  30. package/dist/brief/content-synthesize.js +210 -0
  31. package/dist/brief/deck-outlines.d.ts +96 -0
  32. package/dist/brief/deck-outlines.js +561 -0
  33. package/dist/brief/draft-bodies.d.ts +29 -0
  34. package/dist/brief/draft-bodies.js +224 -0
  35. package/dist/brief/fields.d.ts +19 -0
  36. package/dist/brief/fields.js +229 -0
  37. package/dist/brief/index.d.ts +8 -0
  38. package/dist/brief/index.js +8 -0
  39. package/dist/brief/kind-detection.d.ts +69 -0
  40. package/dist/brief/kind-detection.js +353 -0
  41. package/dist/brief/limits.d.ts +117 -0
  42. package/dist/brief/limits.js +217 -0
  43. package/dist/brief/page-doc-outlines.d.ts +80 -0
  44. package/dist/brief/page-doc-outlines.js +485 -0
  45. package/dist/brief/synthesize.d.ts +59 -0
  46. package/dist/brief/synthesize.js +511 -0
  47. package/dist/brief/text.d.ts +13 -0
  48. package/dist/brief/text.js +59 -0
  49. package/dist/canvas-screen.d.ts +31 -0
  50. package/dist/canvas-screen.js +33 -0
  51. package/dist/canvas-seed.d.ts +58 -0
  52. package/dist/canvas-seed.js +82 -0
  53. package/dist/computed/evaluate.d.ts +11 -0
  54. package/dist/computed/evaluate.js +101 -0
  55. package/dist/computed/index.d.ts +4 -0
  56. package/dist/computed/index.js +3 -0
  57. package/dist/computed/public.d.ts +14 -0
  58. package/dist/computed/public.js +19 -0
  59. package/dist/computed/schema.d.ts +10 -0
  60. package/dist/computed/schema.js +140 -0
  61. package/dist/computed/types.d.ts +70 -0
  62. package/dist/computed/types.js +1 -0
  63. package/dist/computed/validate.d.ts +6 -0
  64. package/dist/computed/validate.js +146 -0
  65. package/dist/csv-guard.d.ts +15 -0
  66. package/dist/csv-guard.js +29 -0
  67. package/dist/csv.d.ts +85 -0
  68. package/dist/csv.js +288 -0
  69. package/dist/files.d.ts +35 -0
  70. package/dist/files.js +246 -0
  71. package/dist/form-schema.d.ts +124 -0
  72. package/dist/form-schema.js +300 -0
  73. package/dist/generator-version.d.ts +11 -0
  74. package/dist/generator-version.js +51 -0
  75. package/dist/generator.d.ts +34 -0
  76. package/dist/generator.js +445 -0
  77. package/dist/identity.d.ts +34 -0
  78. package/dist/identity.js +91 -0
  79. package/dist/import/csv-shape.d.ts +40 -0
  80. package/dist/import/csv-shape.js +265 -0
  81. package/dist/import/json-shape.d.ts +55 -0
  82. package/dist/import/json-shape.js +491 -0
  83. package/dist/import/limits.d.ts +13 -0
  84. package/dist/import/limits.js +14 -0
  85. package/dist/import/sqlite-schema.d.ts +36 -0
  86. package/dist/import/sqlite-schema.js +729 -0
  87. package/dist/index.d.ts +60 -0
  88. package/dist/index.js +58 -0
  89. package/dist/kinds/anchors.d.ts +18 -0
  90. package/dist/kinds/anchors.js +46 -0
  91. package/dist/kinds/bodies.d.ts +6 -0
  92. package/dist/kinds/bodies.js +32 -0
  93. package/dist/kinds/brand-runtime.d.ts +19 -0
  94. package/dist/kinds/brand-runtime.js +35 -0
  95. package/dist/kinds/deck.d.ts +8 -0
  96. package/dist/kinds/deck.js +34 -0
  97. package/dist/kinds/doc.d.ts +19 -0
  98. package/dist/kinds/doc.js +41 -0
  99. package/dist/kinds/embed/index.d.ts +39 -0
  100. package/dist/kinds/embed/index.js +91 -0
  101. package/dist/kinds/envelope.d.ts +159 -0
  102. package/dist/kinds/envelope.js +78 -0
  103. package/dist/kinds/index.d.ts +11 -0
  104. package/dist/kinds/index.js +11 -0
  105. package/dist/kinds/kind.d.ts +36 -0
  106. package/dist/kinds/kind.js +55 -0
  107. package/dist/kinds/limits.d.ts +64 -0
  108. package/dist/kinds/limits.js +190 -0
  109. package/dist/kinds/page-doc-runtime.d.ts +37 -0
  110. package/dist/kinds/page-doc-runtime.js +55 -0
  111. package/dist/kinds/page.d.ts +16 -0
  112. package/dist/kinds/page.js +67 -0
  113. package/dist/kinds/parse.d.ts +18 -0
  114. package/dist/kinds/parse.js +27 -0
  115. package/dist/kinds/runtime.d.ts +128 -0
  116. package/dist/kinds/runtime.js +206 -0
  117. package/dist/kinds/services.d.ts +49 -0
  118. package/dist/kinds/services.js +249 -0
  119. package/dist/kinds/testing.d.ts +9 -0
  120. package/dist/kinds/testing.js +16 -0
  121. package/dist/migrations.d.ts +97 -0
  122. package/dist/migrations.js +351 -0
  123. package/dist/motion.d.ts +50 -0
  124. package/dist/motion.js +52 -0
  125. package/dist/notifications.d.ts +19 -0
  126. package/dist/notifications.js +177 -0
  127. package/dist/patch.d.ts +122 -0
  128. package/dist/patch.js +322 -0
  129. package/dist/policy.d.ts +23 -0
  130. package/dist/policy.js +105 -0
  131. package/dist/runtime-definition.d.ts +39 -0
  132. package/dist/runtime-definition.js +74 -0
  133. package/dist/scenarios.d.ts +9 -0
  134. package/dist/scenarios.js +49 -0
  135. package/dist/schema/blueprint.schema.json +10191 -0
  136. package/dist/schema-json.d.ts +6 -0
  137. package/dist/schema-json.js +36 -0
  138. package/dist/schema.d.ts +6348 -0
  139. package/dist/schema.js +1269 -0
  140. package/dist/search.d.ts +16 -0
  141. package/dist/search.js +96 -0
  142. package/dist/services.d.ts +89 -0
  143. package/dist/services.js +898 -0
  144. package/dist/share.d.ts +78 -0
  145. package/dist/share.js +234 -0
  146. package/dist/sheet-screen.d.ts +49 -0
  147. package/dist/sheet-screen.js +91 -0
  148. package/dist/storage.d.ts +43 -0
  149. package/dist/storage.js +179 -0
  150. package/dist/stream.d.ts +26 -0
  151. package/dist/stream.js +173 -0
  152. package/dist/untrusted.d.ts +63 -0
  153. package/dist/untrusted.js +502 -0
  154. package/dist/validation-tasks.d.ts +178 -0
  155. package/dist/validation-tasks.js +199 -0
  156. package/dist/validation.d.ts +35 -0
  157. package/dist/validation.js +312 -0
  158. package/dist/variants.d.ts +112 -0
  159. package/dist/variants.js +283 -0
  160. package/dist/workflows/index.d.ts +3 -0
  161. package/dist/workflows/index.js +3 -0
  162. package/dist/workflows/schema.d.ts +50 -0
  163. package/dist/workflows/schema.js +34 -0
  164. package/dist/workflows/transition.d.ts +15 -0
  165. package/dist/workflows/transition.js +25 -0
  166. package/dist/workflows/validate.d.ts +17 -0
  167. package/dist/workflows/validate.js +140 -0
  168. package/package.json +120 -3
package/README.md CHANGED
@@ -1,3 +1,1150 @@
1
- # Temporary Holding Version
1
+ # @protoyard/blueprint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Data-only blueprints for Protoyard ([ADR-017](../../docs/DECISIONS.md)). A `blueprint.json`
4
+ describes a product (entities, fields, relations, roles, screens) without any executable
5
+ code. This package validates it and derives what a working prototype needs from it.
6
+
7
+ Framework-agnostic: it uses the shared core, scenarios, themes, blocks, pages and deck schemas,
8
+ plus zod, and never imports Svelte (ADR-002). The Svelte CRUD screens live in
9
+ [`@protoyard/crud`](../crud/README.md). How a blueprint becomes a prototype (`protoyard generate`,
10
+ regeneration, drift checks) is in [docs/GENERATOR.md](../../docs/GENERATOR.md).
11
+
12
+ ## Modules
13
+
14
+ | Module | Derives | Entry |
15
+ | --- | --- | --- |
16
+ | schema | the zod schema, JSON Schema and cross-reference checks | `.` |
17
+ | computed | a bounded data-only expression grammar, type/reference validation and read-time evaluation | `./computed` (opt-in) |
18
+ | workflows | data-only states, field guards, role/capability references and deterministic transition checks | `./workflows` (opt-in) |
19
+ | archetypes | strict, portable kit archetype declarations and supported entry points | `.` |
20
+ | storage | Dexie store strings and integrity rules | `.` |
21
+ | migrations | appended database versions; `deriveDatabaseVersions` rebuilds them from `history` | `.` |
22
+ | validation | create and update zod input schemas per entity | `.` |
23
+ | policy | role capabilities (`<entity>:<action>`) and `:own` record rules | `.` |
24
+ | notifications | validated create/update/assignment rules, atomic writes and actor-scoped read state | `.` |
25
+ | audit | per-mutation `auditEntries` rows (diff + sensitive redaction) and history reads | `.` |
26
+ | files | size/type/quota checked Blob uploads, metadata listing, on-demand reads and removal | `.` |
27
+ | brand | a WCAG AA theme from `brand` (`resolveBlueprintBrand`) | `.` |
28
+ | services | entity services: `session.assert` → zod → `environment.run` → `db.transaction` | `.` |
29
+ | aggregate | count/sum/avg/min/max with groups and UTC date buckets for dashboards and analytics | `.` |
30
+ | app-model | routes, navigation rows with icon ids, navigation groups and layout (`deriveAppModel`) | `.` |
31
+ | runtime-definition | a complete `definePrototype`-valid definition plus the app model (`definePrototypeFromBlueprint`) | `.` (the generator stays lazy) |
32
+ | kinds | content-kind runtime: `defineKindRuntime` (resolves the brand, then calls `defineKindPrototype`, which generated kind prototypes call with the brand `generate` resolved, so they never load the palette generator) (deck/page/doc definition: block-state and form-response stores, `default` scenario, presenter/audience/viewer personas, no generator) and `createKindServices` (persistent `BlockStateHost`, form responses) | `./kinds` (never in the app graph); `./kinds/parse` (synchronous `parseDeckBlueprint`, `parsePageBlueprint`, `parseDocBlueprint`, for generated kind prototypes: no top-level await, see [PUBLISHING](../../docs/PUBLISHING.md)) |
33
+ | csv | RFC 4180 parse/serialize + row validation | `./csv` (lazy) |
34
+ | csv-shape | bounded CSV header/sample inference into a reviewable entity draft | `./import/csv-shape` (lazy authoring) |
35
+ | json-shape | bounded JSON object/array shape inference into reviewable entity drafts | `./import/json-shape` (lazy authoring) |
36
+ | sqlite-schema | bounded SQLite `CREATE TABLE` DDL inference into reviewable entity drafts | `./import/sqlite-schema` (lazy authoring) |
37
+ | generator | deterministic worlds (`ctx.rng`, `ctx.faker`, `ctx.ids`, `ctx.time`) | `./generator` (lazy) |
38
+ | search | ranked, policy-filtered record search across `searchable` fields (the CRUD search palette) | `./search` |
39
+ | identity | content sha256 and `bp-…` storage namespace (`blueprintIdentity`); every shipped blueprint's identity is frozen in `tests/fixtures/identity-golden.json` | `.` |
40
+ | deck authoring lint | structural diagnostics for slide ids, layout props, outline order, fragments and internal links | `.` |
41
+
42
+ ## The schema
43
+
44
+ ```ts
45
+ import {
46
+ parseBlueprint,
47
+ safeParseBlueprint,
48
+ validateBlueprintForAuthoring,
49
+ entityTable,
50
+ } from '@protoyard/blueprint';
51
+
52
+ const blueprint = parseBlueprint(JSON.parse(text)); // throws a ZodError with paths
53
+ entityTable(blueprint.entities[0]); // `support-tickets` → `supportTickets` unless `table` is set
54
+ ```
55
+
56
+ Agent authoring tools can use `validateBlueprintForAuthoring(input)` for stable JSON paths and
57
+ repair hints. Its `valid` flag comes from the complete blueprint parser, including cross-reference
58
+ checks; passing the JSON Schema shape alone does not make a blueprint valid:
59
+
60
+ ```ts
61
+ const result = await validateBlueprintForAuthoring(untrustedJson);
62
+ if (!result.valid) {
63
+ for (const diagnostic of result.diagnostics) {
64
+ console.log(diagnostic.jsonPath, diagnostic.message, diagnostic.hint);
65
+ }
66
+ }
67
+ ```
68
+
69
+ Each diagnostic includes a machine-readable `code`, path segments in `path`, an unambiguous
70
+ `jsonPath`, the original validation `message` and a concise next step in `hint`. Block and asset
71
+ failures map to stable codes such as `unknown-block-type`, `invalid-chart-spec`,
72
+ `missing-alt-text`, `unsupported-url-scheme`, `invalid-step-or-fragment-target` and
73
+ `duplicate-block-id`. Use `diagnosticsFromBlockIssues(issues, pathPrefix)` to lift
74
+ `@protoyard/blocks` parse issues into the same shape under a deck/page/doc path. Validation never
75
+ fetches remote assets and never renders untrusted markdown as HTML. `parseBlueprint`
76
+ and `safeParseBlueprint` keep their existing behavior. The helper is in the package entry module
77
+ and is tree-shakeable for consumers that do not import it.
78
+
79
+ The helper validates every kind through the shared kind union (`safeParseAnyBlueprint`) and returns
80
+ the `kind` it used. Any-kind parsing and authoring validation are asynchronous because deck, page and
81
+ doc body schemas load dynamically by kind. An input with no `kind` is an app (the compatibility rule),
82
+ so existing app blueprints and their diagnostics are unchanged; an app never imports those body
83
+ schemas. A deck, page or doc runs through its strict envelope and body schema: its diagnostics point
84
+ into the body, under `$["<kind>"]`, and keys that belong only to apps get a hint naming the keys that
85
+ kind accepts. Problems with `kind` itself are reported on `$["kind"]`: `invalid-kind` (not a string)
86
+ and `unknown-kind` (needs a newer Protoyard) return `kind: null`, and `kind-unavailable` names a known
87
+ kind whose engine this build does not ship yet.
88
+
89
+ ### Page and doc bodies
90
+
91
+ This build ships the `deck`, `page` and `doc` bodies. Each body schema is loaded only when its
92
+ content kind is parsed.
93
+
94
+ **`kind: "page"` is a single-scroll page.** It is `{ kind: "page", page: { id, title, sections, nav?, forms? } }`.
95
+ The body is `@protoyard/pages`' `PageDocumentSchema` plus `kinds/page.ts`, which checks:
96
+
97
+ - section ids and anchors are unique, and nav links and `forms[].formId` resolve (from the pages schema);
98
+ - block ids, headings included, are unique across **all** sections, because one page is one DOM and
99
+ one form-response store;
100
+ - a block id is never also a section id or anchor;
101
+ - `forms[].sectionId`, when set, is the section that holds that form, containers included;
102
+ - every `#fragment` link in a block (`cta` actions, `cards` items) points at a section anchor or a
103
+ block id on the page.
104
+
105
+ **`kind: "doc"` is a continuous block flow.** It is `{ kind: "doc", doc: { id, title, blocks, toc?, print? } }`.
106
+ The body is `DocDocumentSchema` (block ids, and so heading ids, are unique) plus `kinds/doc.ts`, which
107
+ checks:
108
+
109
+ - a `toc` lists at least one heading from level 2 to `toc.depth` (3 by default). A heading without
110
+ `level` counts as level 2.
111
+ - every `#fragment` link points at a block id in the doc.
112
+
113
+ Fixtures: `tests/fixtures/kinds/landing.page.json` and `tests/fixtures/kinds/quarterly-report.doc.json`
114
+ (`pnpm vitest run packages/blueprint/tests/kinds/page-doc.test.ts`).
115
+
116
+ Deck bodies validate unique slide ids, outline references, known layout ids, catalog props and
117
+ bounded step markers. The layout catalog validates props after temporarily removing animation
118
+ metadata; the parsed props keep `{ step, fragment }` and `{ step, reveal }` so the motion engine can
119
+ resolve them later.
120
+
121
+ A blueprint has `formatVersion: 1`, a `name`, one or more `entities`, one or more `roles` and
122
+ optional `screens`, `brand` and `archetype`. Every object is strict, so unknown keys fail.
123
+
124
+ ## Deck structural lint
125
+
126
+ `lintDeckStructure(input)` checks an untrusted **deck body** (the object under
127
+ `blueprint.deck`, or a bare deck document) without rendering it or mutating the
128
+ input. It returns `{ valid, diagnostics }`. Each diagnostic has a stable
129
+ `code`, `severity: 'error'`, path segments, `jsonPath` and `message`.
130
+
131
+ What it checks (against `@protoyard/decks/schema` and the layout catalog):
132
+
133
+ | Code | Meaning |
134
+ | --- | --- |
135
+ | `missing-slide-id` / `duplicate-slide-id` | Every slide needs a unique slug id |
136
+ | `unknown-layout` | `layout` is not in `@protoyard/decks/layouts` |
137
+ | `invalid-layout-props` | Catalog slide `props` fail that layout's zod schema |
138
+ | `invalid-fragment-order` | Fragment / block `step` values are out of range or out of order |
139
+ | `duplicate-outline-slide` / `invalid-slide-order` | Outline `slideIds` must exist once and follow deck order |
140
+ | `invalid-deck-structure` | Other schema failures |
141
+
142
+ Deep links of the form `#/slide-id` or `#/slide-id/step` must resolve to a
143
+ **visible** (non-hidden) slide. Visual and content quality (notes length,
144
+ layout repetition, WCAG) live in `@protoyard/decks` quality rules, not here.
145
+
146
+ `lintDeckContent(deck)` adds image alt requirements, chart descriptions, CTA
147
+ labels, word density and speaker-note guidance. `lintDeckAssets(deck)` separately
148
+ checks media URL syntax and protocol allowlists on raw deck data, including
149
+ media nested in embedded-app data; it never fetches. All return
150
+ `{ valid, diagnostics }` with stable codes, severity, path segments and
151
+ JSONPath. Layout capacity violations are errors. Three repeated visible layouts
152
+ in a row produce a warning when the catalog offers alternatives. Missing
153
+ accessible content is an error; short chart descriptions, dense copy and missing
154
+ or brief optional presenter notes are warnings. Density is an estimate: rendered
155
+ text must still meet the 24 px body and 18 px label minimums on the 1280×720
156
+ canvas. Final contrast, text size and fit need rendered visual QA.
157
+
158
+ ```ts
159
+ import { lintDeckContent, lintDeckStructure } from '@protoyard/blueprint';
160
+
161
+ const structure = lintDeckStructure(deckJson);
162
+ const content = lintDeckContent(deckJson);
163
+ for (const issue of [...structure.diagnostics, ...content.diagnostics]) {
164
+ console.log(issue.code, issue.jsonPath, issue.message);
165
+ }
166
+ ```
167
+
168
+ CLI packaging of `deck lint` is owned by the CLI / KINDS lane; agents can call
169
+ `lintDeckStructure` and `lintDeckContent` in-process today.
170
+
171
+ ## Agent authoring sequence
172
+
173
+ **Primary path for agents is validated JSON**, not free-form prose at runtime.
174
+ Prose is only the offline seed that produces a draft blueprint file.
175
+
176
+ ### Content kinds (deck, page, doc)
177
+
178
+ 1. **Detect kind** with `detectBriefKind(brief)` or pass `{ kind: 'deck' | 'page' | 'doc' | 'app' }`
179
+ into `blueprintFromBrief` (see [Kind detection](#kind-detection)).
180
+ 2. **Synthesize a draft** with `blueprintFromBrief(brief)` (or
181
+ `synthesizeDeckFromBrief` / page / doc helpers). The result is
182
+ `ContentBriefResult`: `{ kind, outline, json, rationale, kindDetection }`.
183
+ **Generated copy is draft scaffolding only** — placeholders marked "Draft:",
184
+ metrics as "—", no invented facts, quotes or real people (see
185
+ [Deck outline templates](#deck-outline-templates)).
186
+ 3. **Write the JSON** to disk (`pnpm protoyard blueprint new <file> --from-brief …`
187
+ or your own writer). Prefer the full blueprint envelope:
188
+ `{ formatVersion, name, kind, deck|page|doc, … }`.
189
+ 4. **Validate** with `validateBlueprintForAuthoring(json)` and/or
190
+ `pnpm protoyard blueprint validate <file>`. Diagnostics carry stable `code`,
191
+ `path`, `jsonPath`, `message` and `hint`. Revalidate after every edit.
192
+ 5. **Deck-only lint:** `lintDeckStructure(json.deck)` checks layout structure
193
+ and capacity; `lintDeckAssets(json.deck)` checks source URL syntax without
194
+ network access.
195
+ 6. **Generate / play** with `pnpm protoyard generate` or the player when the
196
+ draft is valid. See [the generator guide](../../docs/GENERATOR.md) and
197
+ [BRIEF_TO_PROTOTYPE](../../docs/BRIEF_TO_PROTOTYPE.md).
198
+
199
+ ### Apps
200
+
201
+ For an offline end-to-end app path, start from a [quick blueprint](../../blueprints/quick/README.md)
202
+ or synthesize one with `pnpm protoyard blueprint new <file> --from-brief <brief-file>` (use `-`
203
+ for stdin). Read the rationale, edit the JSON, and run `pnpm protoyard blueprint validate <file>`.
204
+ For machine-actionable repairs, pass the parsed input to `validateBlueprintForAuthoring`; each
205
+ diagnostic provides `jsonPath`, `message` and a suggested `hint`. Revalidate after every repair.
206
+ The JSON Schema is useful for editor completion, but only the package parser checks
207
+ cross-references such as relation targets, screen fields and archetype entity slots.
208
+
209
+ If data shape is the starting point, use the lazy import modules documented below to infer entities
210
+ from CSV headers and samples, JSON objects, or SQLite `CREATE TABLE` statements. These are schema
211
+ imports: drafts and notes do not retain sample values or database rows. Review ambiguous kinds and
212
+ relation candidates, combine the entities with roles, scenario defaults, routes and any selected
213
+ `archetype`, then validate the assembled blueprint. CSV, JSON and SQLite imports currently expose
214
+ package APIs rather than a `protoyard` CLI import command.
215
+
216
+ Select `assistant`, `team-chat`, `saas-onboarding` or `analytics` in the optional portable
217
+ `archetype` block when a matching kit adapter should be available at runtime. The player and
218
+ generator consume this data and load declared kit entry points lazily. Standard entity
219
+ `screenDefaults` and top-level screens cover common routes; specialized workflows still need
220
+ owned Svelte screens and services. The separate `./workflows` module validates state-machine data,
221
+ but formatVersion 1 and generated services do not yet persist or execute those workflows. See
222
+ [the generator guide](../../docs/GENERATOR.md) for the complete author review, generation and
223
+ regeneration loop.
224
+
225
+ - **Brand (optional):** `seed` (`#rrggbb`) and/or built-in `preset` (`violet`, `ocean`, …),
226
+ optional `secondary`, `styleProfile` (`soft` | `crisp` | `flat` | `rounded` | `editorial` |
227
+ `mono`), `font` (catalog id), `radius`, `density`, `id` and `name`.
228
+ `pnpm protoyard generate` turns brand into a WCAG AA theme (`resolveBlueprintBrand`), writes
229
+ hash-headed `src/generated/brand-theme.ts`, registers it on the prototype, and sets
230
+ `defaultTheme`. `libraryTheme` (a theme library id, exclusive with every other theme field)
231
+ skips the module: `defaultTheme` becomes the library id, checked by `brandLibraryThemeId`.
232
+ Without `brand`, the template default theme is kept.
233
+ - **Catalog (optional):** the same `catalog` block as a prototype manifest, plus optional
234
+ `tags`, `icon` and `category`. Files under `blueprints/<slug>.json` that set **both**
235
+ `brand` and `catalog` are indexed by `protoyard index` as `kind: blueprint` (see
236
+ [blueprints/README.md](../../blueprints/README.md)).
237
+ - **Kit archetype (optional):** portable runtime data for `assistant`, `team-chat`,
238
+ `saas-onboarding` or `analytics`. It maps stable entity slots to entities in this blueprint,
239
+ declares a kit screen and route, records scenario defaults, and lists only allow-listed public
240
+ kit entry points. These strings are validated metadata; parsing never imports or executes them.
241
+ `BlueprintSchema` checks entity references and route collisions. Omitting the block keeps
242
+ ordinary CRUD blueprints unchanged. For example:
243
+
244
+ ```json
245
+ {
246
+ "archetype": {
247
+ "id": "assistant",
248
+ "kits": ["@protoyard/ai", "@protoyard/ai-ui"],
249
+ "entities": { "conversations": "conversation", "messages": "message" },
250
+ "screens": [{ "kind": "assistant-workspace", "path": "/assistant" }],
251
+ "scenarioDefaults": { "density": "normal", "activity": "normal" }
252
+ }
253
+ }
254
+ ```
255
+
256
+ The integration contract is checked against each kit's public package export map in
257
+ `tests/archetypes/kit-contract.test.ts`: AI archetypes target the AI engine and workspace,
258
+ SaaS onboarding uses `saasRoutes`' lazy loader, and analytics selects only the grid and
259
+ date-range UI subpaths it declares. The test also checks their callback and simulation
260
+ contracts. The blueprint package keeps kit names as validated data and does not depend on
261
+ or import the kits. The player's route registry is responsible for loading the selected
262
+ screen lazily. `@protoyard/ai-ui` currently exports an assistant workspace but no dedicated
263
+ team-chat screen, so the player still needs an adapter or a future UI entry for that screen.
264
+
265
+ - **Entities:** kebab-case `name` (singular) and `plural`, optional `table`, `fields`,
266
+ `titleField`, `searchable`, `ownerField` (a `persona` field, enables `:own` actions),
267
+ `indexes`, `defaultSort`, optional `screenDefaults`, and `features` (`softDelete`, `audit`,
268
+ `notifications`, `files`, `csv`, all off by default). `screenDefaults.views` can enable
269
+ `list`, `detail` and `form` for that entity; `list.columns` selects visible fields and
270
+ `detail.tabs` selects overview, related and feature-backed sections. When the block is absent,
271
+ the legacy list and detail routes remain. Entity defaults materialize as ordinary screens;
272
+ an explicit top-level screen of the same entity and type takes precedence and is not replaced.
273
+ - **Field kinds:** `text`, `longText`, `number`, `integer`, `money` (integer cents plus an
274
+ ISO 4217 `currency`, default `USD`), `boolean`, `date`, `datetime`, `enum` (`values`),
275
+ `email`, `url`, `phone`, `tags`, `relation` (`to`, `cardinality` `one|many`, `onDelete`
276
+ `restrict|cascade|nullify`), `persona` (a `py_personas` row), `file` (`accept`,
277
+ `maxSizeBytes`, `multiple`), `json`, `geo` (`region`; stored as `{ lat, lng }` with 5
278
+ decimals, accepted as an object or `"lat, lng"` text, never indexed, searched or sorted)
279
+ and `avatar` (a face from `@protoyard/avatars` stored as its id, `av-001` … `av-175`;
280
+ only library ids validate; never indexed, searched or sorted). `id`, `createdAt`, `updatedAt`, `deletedAt` and
281
+ `deletedBy` are managed by the platform and cannot be declared.
282
+ - **Roles:** `permissions` maps an entity name (or `*`) to actions: `read`, `create`,
283
+ `update`, `update:own`, `delete`, `delete:own`, `import`, `export` or `*`.
284
+ - **Screens:** `list` (`columns`), `detail` (optional `tabs`: `overview`, `related:<entity>`,
285
+ `activity`, `files`, `audit`), `form`, `board` (`groupBy` an enum, `cardFields`, `sumField`),
286
+ `calendar` (`dateField`, `endField`, `colorBy`, `view`), `timeline` (`startField`,
287
+ `endField`, `groupBy`, `zoom`), `map` (`geoField`, `colorBy`), `analytics` (`dateField`,
288
+ `measures`, `dimensions`), `sheet` (records as a spreadsheet: `columns` of text, number,
289
+ money, boolean, date, enum and contact fields, plus read-only datetime, relation, persona and
290
+ tags; `totals` sums number, integer or money columns; at most 26 columns; schema in
291
+ `src/sheet-screen.ts`), `canvas` (one infinite canvas per record, stored in the `json`
292
+ `field` it names; schema in `src/canvas-screen.ts`), `dashboard` (`widgets`: `kpi`, `chart` line/area/bar/donut,
293
+ `list`, `board-summary`, each with `aggregate` count/sum/avg/min/max, optional `field`,
294
+ `groupBy`, date `bucket` and `filter`; the formatVersion 1 `metrics` still parse) and
295
+ `settings` (`sections`). Every screen takes `path`, `title` and `nav` (`label`, a Lucide
296
+ `icon` from `SCREEN_NAV_ICONS`, `order`, `hidden`). `screenPath(screen, entities)` derives
297
+ default routes (`/<plural>`, `/<plural>/:id`, `/<plural>/new`, `/<plural>/<kind>`,
298
+ `/dashboard`, `/settings`); `dashboardWidgets(screen)` returns legacy metrics as `kpi`
299
+ widgets followed by `widgets`. All keys are additive: `formatVersion` stays `1`.
300
+ - **Notifications:** optional top-level rules use `on: 'create' | 'update' | 'assign'`, an
301
+ entity, `notify: 'owner' | 'assignee' | 'role:<name>'`, and a plain-text title with `{field}`
302
+ substitutions. Assignment rules name a persona field. Writes share the entity mutation
303
+ transaction, exclude the actor, and store an empty body for future UI copy.
304
+
305
+ After the shape is valid, cross-references are checked with path-specific messages, for
306
+ example `entities.1.fields.3.to: relation "support-ticket.customer" targets unknown entity
307
+ "client"`: relation targets, title and owner fields, enum values and defaults, the reserved
308
+ `py_` table prefix, duplicate names, `:own` actions without an owner field, and screens: every
309
+ referenced field exists and has the right kind (enum `groupBy`, date fields, numeric
310
+ aggregates, `geo` for maps), filter values and ops, chart requirements (donut needs `groupBy`,
311
+ line and area need a date bucket), related tabs need a relation back, feature-gated tabs and
312
+ settings sections, archetype entity slots, and duplicate or reserved (`/members`) routes.
313
+ Entity screen defaults receive the same field, feature, related-entity and route-collision checks
314
+ as top-level screens; errors point back to `entities.<n>.screenDefaults`.
315
+
316
+ Relations keep their stored direction explicit: the entity declaring a relation owns its
317
+ `<field>Id` or `<field>Ids` foreign-key property. `required` means a one relation must contain an
318
+ id and a many relation must contain at least one id; when omitted, a one relation is optional
319
+ and a many relation may be empty. Related-detail tabs name the target entity; the generated UI finds
320
+ the inverse relation field(s) on that entity and uses their declared labels (or field names).
321
+ This avoids a second, unsynchronized foreign-key declaration. Relation fields retain the v1
322
+ shape and defaults, so existing blueprints and derived stores need no migration.
323
+
324
+ ## JSON Schema
325
+
326
+ `schema/blueprint.schema.json` (draft 2020-12) gives editors autocompletion: point a
327
+ blueprint's `$schema` at it. It describes the shape only; the cross-reference checks above
328
+ run in `parseBlueprint`. The package exports it as `@protoyard/blueprint/schema.json` (the
329
+ packed package ships it as `dist/schema/blueprint.schema.json`). `protoyard blueprint new`
330
+ finds it through that export, from this repository or an installed package, and writes a
331
+ relative `$schema`. A test fails when the file is out of date. Regenerate it with:
332
+
333
+ ```bash
334
+ pnpm exec tsx -e "import { blueprintJsonSchema } from './packages/blueprint/src/index.ts'; import { writeFileSync } from 'node:fs'; void blueprintJsonSchema().then(schema => writeFileSync('packages/blueprint/schema/blueprint.schema.json', JSON.stringify(schema, null, 2) + '\n'));" && npx biome format --write packages/blueprint/schema
335
+ ```
336
+
337
+ **Size:** measured with esbuild (minified, zod and `@protoyard/core` external) and gzip -9:
338
+ about 6.1 KB for parsing (`parseBlueprint`, `entityTable`) with the screen kinds (3.9 KB
339
+ before them). `blueprintJsonSchema` tree-shakes away when unused.
340
+
341
+ ## Storage derivation
342
+
343
+ ```ts
344
+ import { deriveStores, deriveIntegrity, parseBlueprint } from '@protoyard/blueprint';
345
+
346
+ const blueprint = parseBlueprint(JSON.parse(text));
347
+ const stores = deriveStores(blueprint);
348
+ // { customers: 'id, *tags, name, createdAt, updatedAt', supportTickets: 'id, customerId, …', … }
349
+ const integrity = deriveIntegrity(blueprint);
350
+ // { relations: { supportTickets: { customerId: 'customers', assigneeId: 'py_personas' } },
351
+ // timestamps: { supportTickets: ['createdAt', 'updatedAt', 'dueOn', 'deletedAt'] } }
352
+ ```
353
+
354
+ `deriveStores` is deterministic (same blueprint → byte-identical JSON) and matches what
355
+ `PrototypeDatabase` accepts as Dexie store strings:
356
+
357
+ - every table starts with `id`
358
+ - relation `one` → indexed `<field>Id`; `many` → multi-entry `*<field>Ids`
359
+ - persona → indexed `<field>Id` (integrity target `py_personas`)
360
+ - tags → multi-entry `*<field>`; file fields → `<field>Id` / `*<field>Ids`
361
+ - `indexes`, `titleField` and `defaultSort` add plain indexes when needed
362
+ - `createdAt` / `updatedAt` always; `deletedAt` when `features.softDelete`
363
+ - when any entity enables `audit` / `notifications` / `files`, shared domain tables
364
+ `auditEntries`, `notifications` and `files` are appended (not `py_*`)
365
+
366
+ `deriveIntegrity` feeds `validateWorld` from `@protoyard/scenarios`: dangling foreign keys
367
+ and out-of-range timestamps fail the scenario load.
368
+
369
+ ## Migrations
370
+
371
+ ```ts
372
+ import {
373
+ deriveStores,
374
+ diffBlueprints,
375
+ appendMigration,
376
+ toDatabaseVersion,
377
+ } from '@protoyard/blueprint';
378
+
379
+ const v1 = { version: 1, stores: deriveStores(prev) };
380
+ const migration = diffBlueprints(prev, next, {
381
+ fromVersion: 1,
382
+ // allowDrop: true, // required to drop a table or field
383
+ // renamedFrom: { fields: { 'notes.headline': 'title' }, tables: { clients: 'customers' } },
384
+ });
385
+ // null when nothing changed; otherwise version 2 + stores + serializable ops
386
+ const versions = appendMigration([v1], migration);
387
+ ```
388
+
389
+ `diffBlueprints` classifies add entity, add field (backfill default), add index, rename via
390
+ `renamedFrom`, and drop (`allowDrop` → Dexie store `null`). Upgrade steps are **data**
391
+ (`backfill`, `renameField`, `copyTable`) so blueprint history stays JSON; `toDatabaseVersion`
392
+ turns them into a Dexie `upgrade` callback. Drops without `allowDrop` throw `MigrationError`
393
+ — migrations never delete user data silently.
394
+
395
+ `protoyard generate` records every storage change in the blueprint's `history` (earlier
396
+ blueprints with their schema version and `migrateNext.allowDrop`). `deriveDatabaseVersions`
397
+ rebuilds the full version list from that data alone, and `latestSchemaVersion` returns its
398
+ tip, which is what generated `src/generated/schema.ts` and the `validate` drift check use:
399
+
400
+ ```ts
401
+ import { deriveDatabaseVersions, latestSchemaVersion } from '@protoyard/blueprint';
402
+
403
+ const versions = deriveDatabaseVersions(blueprint); // v1 from the oldest entry, then each diff
404
+ latestSchemaVersion(blueprint); // must equal protoyard.json storage.schemaVersion
405
+ ```
406
+
407
+ ## Policy derivation
408
+
409
+ ```ts
410
+ import { CAPABILITIES, derivePolicy, parseBlueprint } from '@protoyard/blueprint';
411
+ import { createPermissionPolicy } from '@protoyard/core';
412
+
413
+ const blueprint = parseBlueprint(input);
414
+ const policy = createPermissionPolicy(derivePolicy(blueprint));
415
+ const caps = CAPABILITIES(blueprint);
416
+ // UI and services use caps['support-ticket'].read / .deleteOwn / .updateOwn.
417
+ ```
418
+
419
+ Role actions become `<entity>:<action>` capabilities. `*` grants are expanded or preserved for the runtime policy, and explicit `-capability` entries remain denials. `update:own` and `delete:own` use the persona id column derived from the entity's `ownerField`. Owner/admin roles receive the platform `members:*` capability for team-management services. These permissions are simulated UI behavior, not security.
420
+
421
+ ## Input validation
422
+
423
+ ```ts
424
+ import { parseInput } from '@protoyard/core';
425
+ import { deriveInputSchemas } from '@protoyard/blueprint';
426
+
427
+ const { create, update } = deriveInputSchemas(entity); // built on first use, cached per entity
428
+ const row = parseInput(create, formValues); // throws ValidationError { issues: { subject: 'Subject is required' } }
429
+ const patch = parseInput(update, { status: 'solved' }); // only keys present are validated and returned
430
+ ```
431
+
432
+ - **Keys** are row properties: relation, persona and file fields use `customerId`,
433
+ `relatedIds`, `assigneeId`, `attachmentsIds` (see `inputProperty`); other fields use
434
+ their name. Unknown keys are dropped.
435
+ - **Coercion** of form strings: numbers and integers, money (`'12.34'`, `'$1,234.50'` →
436
+ integer cents; plain numbers are already cents and must be integers), booleans (`on`,
437
+ `true`, `1`, `yes`), dates (`YYYY-MM-DD`, real calendar dates), datetimes (ISO with an
438
+ offset, normalized to UTC), comma-separated tags (trimmed, de-duplicated) and JSON text.
439
+ - **Checks:** text `minLength`/`maxLength`, number/integer/money `min`/`max`, enum values,
440
+ email, http(s) URLs, phone numbers, tag count and length, library ids for avatars, and
441
+ non-empty ids for relations, personas and files.
442
+ - **Blanks:** on create, a blank or absent optional field becomes its `default`, `false`
443
+ for booleans, `[]` for lists, otherwise `null`. A blank required field fails with
444
+ "<Label> is required" unless it has a default. On update, blanks clear optional fields
445
+ (`null` or `[]`) and fail for required ones.
446
+ - **Messages** use the field `label`, or the name in sentence case (`dueOn` → "Due on").
447
+
448
+ ## CSV codec
449
+
450
+ Lazy entry point — load only when the user opens import/export:
451
+
452
+ ```ts
453
+ import {
454
+ parseCsv,
455
+ serializeCsv,
456
+ serializeEntityCsv,
457
+ validateCsv,
458
+ CSV_MAX_BYTES,
459
+ CSV_MAX_ROWS,
460
+ } from '@protoyard/blueprint/csv';
461
+
462
+ const matrix = parseCsv(text); // RFC 4180: quotes, "", CRLF, BOM
463
+ const csv = serializeEntityCsv(entity, rows); // headers = labels; formulas neutralized
464
+ const { valid, errors } = validateCsv(entity, text);
465
+ // errors: [{ row: 2, field: 'amount', message: '…' }] (1-based data rows)
466
+ ```
467
+
468
+ - Headers match field **name**, input **property** (`customerId`) or **label** (case-insensitive).
469
+ - A `geo` field is two columns, `<name>Lat` and `<name>Lng` (also matched as `<label> lat` /
470
+ `<label> lng`); import needs both halves or neither.
471
+ - An `avatar` field exports its id (`av-007`) and imports only library ids.
472
+ - Money and dates go through `deriveInputSchemas` create coercion (`12.50` → 1250 cents).
473
+ - Export prefixes with `'` cells starting with `= + - @` (also after leading spaces or full-width), a tab or a carriage return, unless the whole cell is a plain number such as `-5` (spreadsheet formula guard, `guardCsvFormula`). Import strips only a prefix the export would have added, so values round-trip.
474
+ - Hard limits: `CSV_MAX_BYTES` (2 MiB) and `CSV_MAX_ROWS` (10,000 data rows); override per call.
475
+
476
+ ### CSV shape authoring
477
+
478
+ Shape inference is a separate lazy authoring entry, so player consumers that only
479
+ need the row codec do not load it:
480
+
481
+ ```ts
482
+ import { inferCsvEntity } from '@protoyard/blueprint/import/csv-shape';
483
+
484
+ const { entity, notes } = inferCsvEntity(csvText, { entityName: 'invoice' });
485
+ ```
486
+
487
+ It normalizes headers into deterministic field names, infers conservative kinds from
488
+ at most 100 non-empty rows, and returns a validated entity draft with review notes.
489
+ The result never includes imported cell values, enum values, sample-derived limits or
490
+ row data. The existing 2 MiB / 10,000-row CSV limits still apply; shape inference also
491
+ caps the header at 100 columns, header and sampled cells at 16 KiB each, and sample
492
+ inspection at 100 non-empty rows. Caller limits can lower these caps only. Ambiguous columns
493
+ remain `text` and are called out in `notes` for author review. Formula-like sample
494
+ cells are unguarded only while classifying them and are never returned.
495
+
496
+ ### JSON shape authoring
497
+
498
+ JSON shape inference is also a separate lazy authoring entry. It accepts one
499
+ representative object or an array of objects:
500
+
501
+ ```ts
502
+ import { inferJsonShape } from '@protoyard/blueprint/import/json-shape';
503
+
504
+ const { entities, notes, relationCandidates } = inferJsonShape(jsonText, {
505
+ entityName: 'support-ticket',
506
+ });
507
+ ```
508
+
509
+ The result is a validated, value-free entity draft. Scalar fields infer
510
+ conservative text, number, integer, boolean, email, URL, date or datetime
511
+ kinds. Missing and null samples make a field optional; mixed shapes and
512
+ unrecognized nested values stay `json` with a review note. A single sample
513
+ never marks a field required; multiple samples mark it required only when every
514
+ sample has a non-null value. Nested objects are
515
+ kept as JSON. Arrays of objects produce related entity drafts and candidate
516
+ one-to-many fields, which should be reviewed before generation. Arrays of
517
+ strings can become `tags`; enum values and sample limits are never copied.
518
+
519
+ Input is capped at 2 MiB, 100 sampled objects per entity, 8 levels of nesting,
520
+ 2,000 object/array nodes, 20,000 total value nodes, 100 fields per object and
521
+ 16 KiB per string value or key. Prototype-related keys and keys with control or
522
+ bidirectional formatting characters are rejected. Higher caller limits are
523
+ clamped to these hard caps. Output entity and field
524
+ ordering is stable regardless of JSON key insertion order, and notes/errors do
525
+ not echo source values.
526
+
527
+ ### SQLite schema authoring
528
+
529
+ SQLite schema import accepts SQL text and never opens a database, runs a
530
+ statement or reads table rows. Import it only while authoring a draft:
531
+
532
+ ```ts
533
+ import { importSqliteSchema } from '@protoyard/blueprint/import/sqlite-schema';
534
+
535
+ const { entities, primaryKeys, notes } = importSqliteSchema(`
536
+ CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
537
+ CREATE TABLE orders (
538
+ id INTEGER PRIMARY KEY,
539
+ customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE
540
+ );
541
+ `);
542
+ ```
543
+
544
+ The supported grammar is one or more `CREATE [TEMP|TEMPORARY] TABLE [IF NOT
545
+ EXISTS]` declarations with plain or quoted identifiers, comma-separated
546
+ columns, optional declared types and column or table `PRIMARY KEY`, `NOT NULL`,
547
+ `NULL`, `UNIQUE`, `CHECK`, `DEFAULT`, `COLLATE`, `REFERENCES` and `FOREIGN KEY`
548
+ constraints. Foreign keys must reference one local column and one declared
549
+ table. `ON DELETE CASCADE`, `SET NULL`, `RESTRICT` and `NO ACTION` map to the
550
+ blueprint's delete behavior. Table and target names match case-insensitively.
551
+
552
+ `INTEGER PRIMARY KEY` maps to Protoyard's generated `id`; other primary-key
553
+ fields become required and are listed in `primaryKeys`, since blueprint fields
554
+ do not enforce uniqueness. The returned `notes` call out that limitation.
555
+ `INT` maps to `integer`; `REAL`, `FLOAT`, `DOUBLE`, `NUMERIC` and `DECIMAL` map
556
+ to `number`; character, text and CLOB types map to `text`; `BOOLEAN`, `DATE`,
557
+ `DATETIME` and `TIMESTAMP` map to their corresponding blueprint kinds. BLOB
558
+ and absent types use conservative reviewable representations. Defaults, checks,
559
+ unique constraints and collations do not become behavior; default literals are
560
+ discarded. Composite foreign keys, unsupported types and non-CREATE statements
561
+ are rejected with a path-aware `SqliteSchemaError`. Input is limited to 256 KiB,
562
+ 50 tables, 100 columns per table, 40,000 tokens and 8 levels of parenthesis
563
+ nesting. Caller limits can lower these caps only. SQL literal values and
564
+ database rows never appear in the output or parser diagnostics.
565
+
566
+ ## World generator
567
+
568
+ Lazy entry so Faker stays out of the initial chunk:
569
+
570
+ ```ts
571
+ import { createBlueprintGenerator } from '@protoyard/blueprint/generator';
572
+ import { createGenerateContext } from '@protoyard/scenarios';
573
+
574
+ const { generate, generatorVersion } = createBlueprintGenerator(blueprint);
575
+ const world = generate(createGenerateContext(scenario));
576
+ // team + entity rows × ctx.scale(), relations in topological order, optional
577
+ // auditEntries / notifications history when features are on
578
+ ```
579
+
580
+ - Field values use `ctx.faker` / `ctx.rng` / `ctx.time` / `ctx.ids` only (no `Math.random` / `Date.now`).
581
+ - Optional `field.fake` must be an allow-listed path such as `company.name` or `person.fullName`.
582
+ - Emails always use `example.com` / `example.org`.
583
+ - `generatorVersion` mixes a blueprint content hash with the generator SDK and scenarios SDK versions.
584
+ `blueprintGeneratorVersion` lives in a small module (`src/generator-version.ts`, re-exported here)
585
+ so the runtime definition reads it without loading the generator.
586
+ - `geo` fields get points from `geoPoints()` inside their `region` (default `metro-grid`), one
587
+ point set per entity field, so the same seed gives the same points (generator SDK 2).
588
+ - Team personas come from `generateTeam`, so each one carries a seeded library `avatar`
589
+ (`av-NNN`, no repeats until all 175 are used; generator SDK 4).
590
+ - `avatar` fields get one seeded face per row from `createAvatarPool` on a fork keyed by
591
+ entity and field, dealing the team's faces last: no repeats within the field until all
592
+ 175 are used, more rows keep the earlier faces, and other values do not shift
593
+ (generator SDK 5).
594
+
595
+ ## Audit log
596
+
597
+ When an entity sets `features.audit`, every successful mutation writes one row to the shared
598
+ `auditEntries` table **inside the same Dexie transaction** as the row change:
599
+
600
+ ```ts
601
+ import { createEntityServices, createAuditService } from '@protoyard/blueprint';
602
+
603
+ const services = createEntityServices(runtime, blueprint);
604
+ const audit = createAuditService(runtime, blueprint);
605
+ await services.ticket.update(id, { status: 'solved' });
606
+ const history = await audit.listForRecord('ticket', id);
607
+ // [{ action: 'update', actorId, at, changes: { status: { before: 'open', after: 'solved' } } }, …]
608
+ ```
609
+
610
+ - Diffs list **only changed** input properties; `sensitive` fields store `[redacted]`.
611
+ - Failed mutations (validation, permission denied, offline) write **no** audit row.
612
+ - Soft delete / restore / purge / bulk ops emit `delete` / `restore` / `purge` / `update` actions.
613
+ - `listForRecord` and `listRecent` require `<entity>:read` on an audited entity.
614
+
615
+ ## Entity services
616
+
617
+ ```ts
618
+ import { createEntityServices } from '@protoyard/blueprint';
619
+
620
+ const services = createEntityServices(runtime, blueprint); // one service per entity name
621
+ const ticket = await services.ticket.create({ subject: 'Printer jam', customerId });
622
+ await services.ticket.update(ticket.id, { status: 'solved' });
623
+ const undoToken = await services.customer.remove(customerId); // token only when softDelete is enabled
624
+ if (undoToken) await services.customer.restore(undoToken); // restore within the simulated undo window
625
+ await services.customer.purge(customerId); // permanently remove trashed rows
626
+ ```
627
+
628
+ Each entity gets `list`, `trash`, `get`, `create`, `update`, `remove`, `restore`, `purge`,
629
+ `shuffleAvatar`, `bulkUpdate` and `bulkRemove`. `shuffleAvatar(id, field?)` saves the next face
630
+ for an avatar field (default: the first) with the same checks as `update` (operation
631
+ `<entity>.shuffleAvatar`); the face comes from `nextAvatar(current, '<entity>:<id>:<field>')`,
632
+ a deterministic walk with no clock or random source that never repeats the current face and
633
+ visits all 175 before any repeat. Entities without an avatar field reject it with a
634
+ `ValidationError`. `list` hides soft-deleted rows; `trash` reads the indexed
635
+ `deletedAt` values for review. When `features.softDelete` is enabled, `remove` marks the record
636
+ and its cascade set with `deletedAt` / `deletedBy` and returns an undo token that works for 30
637
+ seconds of simulated time, including after a reload. Restoring also reverses nullified relation
638
+ fields. `purge` requires the full `<entity>:delete` capability and permanently removes the trashed
639
+ record and its cascade dependents. Entities without soft delete keep hard-delete `remove`; the
640
+ explicit `bulkRemove` operation is also permanent and returns the number of removed rows.
641
+
642
+ Every mutation runs:
643
+
644
+ 1. `session.assert('<entity>:<action>')`. For update and delete, a role with only
645
+ `update:own` / `delete:own` passes when the record's `ownerField` is the actor. A
646
+ denial names the full capability (`ticket:update`). This is simulated policy, not
647
+ security.
648
+ 2. zod through `deriveInputSchemas` and `parseInput` (field-keyed `ValidationError`).
649
+ 3. `environment.run('<entity>.<create|update|remove|bulkUpdate|bulkRemove>')`, so
650
+ DevTools latency, offline mode and scoped failures (`failureScope: ['ticket.']`) apply.
651
+ 4. One `db.transaction('rw', …)` that checks referenced rows exist (relations,
652
+ `py_personas`, files; a missing one fails as `customerId: 'Customer not found'`),
653
+ writes the row with `createdAt`/`updatedAt` from the simulated clock, and applies every
654
+ `onDelete` rule: `cascade` removes dependents recursively (softly for an undoable remove),
655
+ `nullify` clears single references or removes the id from many-relations, and `restrict`
656
+ refuses the whole delete with `EntityInUseError`. A failure at any step writes nothing.
657
+
658
+ New rows get `createRuntimeId` ids (`support_ticket_u3f9…`) and the actor as owner when
659
+ the entity has an `ownerField` and none was given. Updating or deleting a missing id
660
+ throws `EntityNotFoundError`. Bulk writes go through `bulkPut` in chunks of `BULK_CHUNK`
661
+ (1,000) rows inside the same transaction. Reads (`list`, `get`) need `<entity>:read`, hide
662
+ rows with `deletedAt`, sort by `defaultSort` (else `createdAt`), and read IndexedDB
663
+ directly without the simulated network, like `liveQuery` reads.
664
+
665
+ When notification rules are present, matching create and update mutations append notification
666
+ rows in that same transaction. Assignment rules fire when the named persona field changes to a
667
+ new persona. Rule titles are plain text and interpolate entity fields; the actor never receives
668
+ their own notification. `createNotificationService(runtime)` scopes list, unread count and read
669
+ state changes to whichever persona is active, so switching persona changes the visible inbox.
670
+
671
+ Entities with `features.files` can use `createFileServices(runtime, blueprint)`. Uploads validate
672
+ the file field's `accept` and `maxSizeBytes`, check the 50 MiB per-prototype default quota and the
673
+ runtime storage estimate, then attach the file id and persist binary bytes in the same simulated
674
+ service transaction. `list()` returns metadata without a `blob`; `get()` reconstructs a Blob on
675
+ demand. JSON snapshots do not preserve file bytes, so users should not rely on snapshots to move
676
+ attachments between prototypes. A visible note in the snapshot UI still requires the runtime
677
+ snapshot surface to opt in.
678
+
679
+ CSV import/export live on the same service object (capabilities `<entity>:export` /
680
+ `<entity>:import`):
681
+
682
+ ```ts
683
+ const csv = await services.ticket.exportCsv(); // visible rows, formula-safe
684
+ const preview = await services.ticket.importCsv(text); // mode defaults to preview
685
+ const committed = await services.ticket.importCsv(text, {
686
+ mode: 'commit',
687
+ skipInvalid: true, // otherwise any invalid row aborts and writes nothing
688
+ });
689
+ // Relation cells accept a target id or its title. Commit is one transaction
690
+ // (bulk chunks of 1,000). Audit rows are written when features.audit is on.
691
+ ```
692
+
693
+ ## Brief to blueprint
694
+
695
+ Kind synthesis fixtures live in `tests/fixtures/briefs/kinds.json` and are exercised by
696
+ `tests/brief-kinds.test.ts` (all four kinds, cue conflicts, explicit overrides, empty and
697
+ maximum-length input, content-only requests). The historical app set in
698
+ `tests/fixtures/briefs/briefs.json` stays the regression baseline
699
+ (`pnpm vitest run packages/blueprint/tests/brief-kinds.test.ts packages/blueprint/tests/brief.test.ts`).
700
+
701
+ Generated kind JSON Schema (from the live Zod unions via `z.toJSONSchema`) is checked against
702
+ catalog fixtures in `tests/kind-json-schema.test.ts`. That suite also locks synthesizer drafts:
703
+ every bench brief (`scripts/bench-brief-kinds.ts`) must detect the expected kind and pass
704
+ `safeParseAnyBlueprint` (page/doc bodies include `id` + `title`; deck body is `style` + `slides`
705
+ only). Cross-reference and lint rules still require the package validator — JSON Schema alone is
706
+ not enough.
707
+
708
+ `blueprintFromBrief(brief, { archetype?, name?, kind? })` turns a short product brief into a
709
+ valid blueprint (or content draft), offline and deterministically (same brief, same JSON; no
710
+ network or model). Kind dispatch runs first (ADR-036):
711
+
712
+ 1. An explicit `kind` (`app` | `deck` | `page` | `doc`) overrides every cue; unknown kinds throw a
713
+ stable `RangeError`.
714
+ 2. Strong deck / page / doc cues route to kind-specific synthesizers **before** app archetype
715
+ scoring:
716
+ - **Deck** — eight outline templates (pitch, QBR, launch, status, training, lesson, report,
717
+ talk) via `synthesizeDeckOutline`, then catalog layout selection via `selectDeckLayouts`
718
+ (see below). Bodies are draft placeholders, not research.
719
+ - **Page** — landing / pricing / waitlist / generic section outlines
720
+ (`buildPageOutline`).
721
+ - **Doc** — report / handbook / one-pager / proposal / generic outlines
722
+ (`buildDocOutline`). Bounded block vocabulary, stable section ids, explicit interactive
723
+ omissions in the rationale.
724
+ 3. App briefs keep the historical result fields and byte-compatible JSON.
725
+
726
+ ### Brief and outline bounds (`src/brief/limits.ts`)
727
+
728
+ | Cap | Value | Notes |
729
+ | --- | --- | --- |
730
+ | `MAX_BRIEF_LENGTH` / `MAX_CONTENT_BRIEF_LENGTH` | 5,000 characters | Longer briefs are truncated; synthesizers record a stable notice in `rationale`. |
731
+ | `OUTLINE_LIMITS.maxSlides` | 64 | Deck outlines only. |
732
+ | `OUTLINE_LIMITS.maxSections` | 64 | Page and doc outlines. |
733
+ | `OUTLINE_LIMITS.maxBlocksPerUnit` | 12 | Per slide/section (matches block column capacity). |
734
+ | `OUTLINE_LIMITS.maxTitleLength` | 80 | Envelope and unit titles. |
735
+ | `OUTLINE_LIMITS.maxBodyLength` | 600 | Short body copy (block shortMarkdown). |
736
+ | `OUTLINE_LIMITS.maxTextLength` | 4,000 | Long text (block markdown). |
737
+
738
+ Use `boundBriefInput` / `prepareBriefForSynthesis` before custom synthesis, `checkOutlineBounds` for hard failures, and `applyOutlineBounds` to clip deterministically with notices. Text ceilings agree with `@protoyard/blocks` content limits so drafts stay within untrusted validators.
739
+
740
+ ```ts
741
+ import { blueprintFromBrief, isAppBriefResult } from '@protoyard/blueprint';
742
+
743
+ const result = blueprintFromBrief(
744
+ 'A dog grooming salon that books appointments, tracks groomers and invoices with amount and due date.'
745
+ );
746
+ if (isAppBriefResult(result)) {
747
+ // archetype 'booking'; entities appointment, client, groomer, invoice
748
+ const { blueprint, json, archetype, layout, rationale, kindDetection } = result;
749
+ }
750
+ ```
751
+
752
+ - **Archetype:** keyword scoring over `ARCHETYPES` (help desk, CRM, booking, logistics,
753
+ assistant, team chat, SaaS onboarding, analytics, projects, LMS, clinic, finance, wiki, IoT,
754
+ social and marketplace). The assistant, team-chat, SaaS-onboarding and analytics seeds recognize
755
+ high-signal cues, add their portable config to `blueprint.archetype`, and expose every matched
756
+ cue in the rationale. When equally strong kit cues conflict, synthesis falls back to the generic
757
+ `tracker`; `blueprintFromBrief(..., { archetype })` explicitly overrides scoring. Other
758
+ archetypes retain their earlier-order tie break. A lone stray keyword is not
759
+ enough unless it names an archetype entity; otherwise the brief's main noun becomes the tracker
760
+ entity.
761
+ - **Entities:** nouns after verbs such as *track*, *manage*, *log* or *schedule* ("track
762
+ groomers and invoices") become entities linked to the main one; attribute words
763
+ ("locations", "prices") do not.
764
+ - **Fields:** "… with amount, due date and status" infers kinds from cue words
765
+ (`fieldFromPhrase`): dates, datetimes, money, numbers, enums, geo, personas, email, phone,
766
+ URL, tags, long text and booleans. Brief-wide cues (a location, a price, a deadline, an
767
+ owner) add a field to the main entity when it lacks that kind.
768
+ - **Screens** follow the data: list for every entity, board for a status enum, calendar for
769
+ upcoming dates, timeline for start/end pairs, map for geo, a dashboard at `/`, analytics for
770
+ amounts, a tabbed detail and settings. `protoyard generate` emits lazy route wrappers for
771
+ declared screens; wrappers read the matching config from the runtime blueprint.
772
+ - **Faces:** people-like entities (`PEOPLE_ENTITIES`: customer, contact, lead, client,
773
+ patient, student, member, driver, agent, volunteer, follower and similar) get an optional
774
+ `avatar` field labeled Photo (named `photo` when `avatar` is taken).
775
+ - Always the roles `owner`, `admin`, `member`, `viewer`; a brand seed picked by name hash from
776
+ the catalog's AA-checked seeds; `layout` is `mobile-tabs` for phone briefs, else `sidebar`.
777
+ This is a suggested layout in the `blueprintFromBrief()` result and can be included as the
778
+ blueprint's top-level `{ "id": "mobile-tabs" }` layout configuration.
779
+
780
+ It is a heuristic starting point: read the `rationale`, then refine `blueprint.json`.
781
+
782
+ ### Kind detection
783
+
784
+ `detectBriefKind(brief, { kind? })` in `src/brief/kind-detection.ts` decides whether a brief asks
785
+ for an `app`, a `deck`, a `page` or a `doc` (ADR-036). It runs before archetype scoring. It is
786
+ deterministic and offline, and it matches whole words, longest phrase first. It returns
787
+ `{ kind, confidence, ambiguous, reason, cues, suggestion }`. `reason` is one sentence a CLI or UI
788
+ can show as is.
789
+
790
+ - **Strong cues** switch the kind: `slides`, `slide deck`, `pitch deck`, `keynote`,
791
+ `presentation` (deck); `landing page`, `marketing site`, `pricing page`, `microsite` (page);
792
+ `a report`, `quarterly report`, `one-pager`, `whitepaper`, `handbook`, `design doc` (doc). The
793
+ full lists are `KIND_CUES`.
794
+ - **Weak cues** (`talk`, `website`, a bare `report`, which is often a verb) only fill
795
+ `suggestion`. The kind stays `app`.
796
+ - **App fallback.** The kind stays `app`, with `confidence: 'fallback'` and a reason, in four
797
+ cases: there is no cue; a cue names a tool that makes the content (`slide deck builder`,
798
+ `report generator`); an app noun from `APP_CUES` (`app`, `dashboard`, `platform`, `wiki`…)
799
+ comes before the first strong cue; or two content kinds both have strong cues. The last two
800
+ cases set `ambiguous: true`.
801
+ - An explicit `kind` overrides every cue (`confidence: 'explicit'`). An unknown explicit kind
802
+ throws a `RangeError`.
803
+
804
+ `detectBriefKind` and the content synthesizers (`synthesizeDeckFromBrief`,
805
+ `synthesizePageFromBrief`, `synthesizeDocFromBrief`) are exported from `@protoyard/blueprint`.
806
+ Page/doc section templates live in `buildPageOutline` / `buildDocOutline`
807
+ (`packages/blueprint/src/brief/page-doc-outlines.ts`): ordered sections with draft blocks
808
+ (heading, text, list, cta, …) and rationale lines for omitted interactive features (forms,
809
+ payments, auth). No app entities or roles.
810
+ `blueprintFromBrief` returns `AppBriefResult | ContentBriefResult`; narrow with
811
+ `isAppBriefResult` / `isContentBriefResult`. Content drafts carry `kind`, `outline`, `json` and
812
+ `rationale` (no app `entities` / `roles`); engine body schemas validate them once wired.
813
+
814
+ ### Deck outline templates
815
+
816
+ `src/brief/deck-outlines.ts` has eight deterministic deck outlines: pitch, QBR, launch, status,
817
+ training, lesson, report and talk (`DECK_OUTLINE_IDS`).
818
+
819
+ `synthesizeDeckOutline(brief, { outline?, slides?, title? })` returns:
820
+
821
+ - `deck`: a deck body (`{ title, aspect, slides }`) of free slides
822
+ (`{ id, title, layout: "free", blocks, notes }`) built from `@protoyard/blocks` blocks;
823
+ - `plan`: one `{ id, role, pacing, purpose, core }` per slide, read by layout selection;
824
+ - `rationale`: why this outline and size were chosen.
825
+
826
+ - **Choosing a template.** `chooseDeckOutline` matches whole-word cues, and multi-word cues
827
+ count more ("status update" beats "update", "pitch deck" beats "deck"). With no cue it uses
828
+ the general talk outline and says so. An explicit `outline` wins.
829
+ - **Size.** `requestedSlideCount` reads "12 slides", "a ten-slide pitch" or "a 15-minute talk"
830
+ (one slide a minute); `slides` overrides it.
831
+ - Each template has core slides, which are always present, and optional slides, which fill
832
+ a larger count in template order (`deckOutlineRange`). Templates range from 6 to 12
833
+ slides (pitch 7–12, QBR 7–11, launch 6–9, status 6–8, training 8–11, lesson 6–9, report
834
+ 6–10, talk 6–11), under a cap of 16.
835
+ - A count outside the range is clamped, and the rationale says so.
836
+ - Slide ids (`slide-<key>`) are stable at every size.
837
+ - **Pacing.** Every slide is tagged punch (one line, about 8 s), idea (one concept, about 25 s),
838
+ diagram (a structure to walk through) or story (a sequence). Speaker notes repeat the purpose
839
+ and the pacing guidance.
840
+ - **Honest scaffolding.** Every slide body is a placeholder marked "Draft:" that says what to
841
+ write. Metrics show "—", charts are a note asking for real data, people are "Person 1…", and
842
+ nothing links anywhere. No facts, numbers, quotes or names are invented.
843
+ - Lessons and training always get a placeholder quiz. A warm-up poll (lessons) or an
844
+ audience poll (talks) is optional and appears when the slide count leaves room.
845
+ - The deck title is the brief's topic (after about/on/for), with control and bidi
846
+ characters stripped, at most 80 characters.
847
+ - **Deterministic.** The same brief and options always give the same slide ids, order, blocks
848
+ and rationale. The module never reads the clock or a random source.
849
+
850
+ `synthesizeDeckFromBrief` calls `synthesizeDeckOutline`, then `selectDeckLayouts` (see below),
851
+ and exports the outline helpers from `@protoyard/blueprint` / `./brief`.
852
+
853
+ ### Deck layout selection
854
+
855
+ `src/authoring/select-deck-layouts.ts` maps each slide's `role`, `pacing` and optional
856
+ `blockTypes` to a catalog layout id from `@protoyard/decks/layouts`. It is pure (no clock or
857
+ random source):
858
+
859
+ ```ts
860
+ import { selectDeckLayouts, synthesizeDeckOutline } from '@protoyard/blueprint';
861
+
862
+ const outline = synthesizeDeckOutline('A pitch deck about Example Harbor.');
863
+ const layouts = selectDeckLayouts(
864
+ outline.plan.map((slide, i) => ({
865
+ id: slide.id,
866
+ role: slide.role,
867
+ pacing: slide.pacing,
868
+ blockTypes: outline.deck.slides[i]?.blocks.map((b) => b.type),
869
+ })),
870
+ { aspect: outline.deck.aspect }
871
+ );
872
+ // layouts[i].layout — e.g. title-hero, process-steps, closing-cta
873
+ // layouts[i].rationale — stable explanation for agents
874
+ ```
875
+
876
+ Rules: explicit `layout` overrides win; block types (chart, steps, …) rank above a generic
877
+ role; pacing must fit the catalog entry when possible; three identical layouts in a row are
878
+ avoided when another compatible candidate exists. `propsForSelectedLayout` seeds catalog
879
+ `props` from the layout sample and slide title when a free outline slide is rewritten.
880
+
881
+ ### Authoring diagnostics (JSON path + hint)
882
+
883
+ `validateBlueprintForAuthoring(input)` is the primary machine interface for agents editing
884
+ blueprint JSON. It never fetches remote assets and never renders markdown as HTML.
885
+
886
+ | Surface | API | Notes |
887
+ | --- | --- | --- |
888
+ | Full blueprint (any kind) | `validateBlueprintForAuthoring` | `code`, `path`, `jsonPath`, `message`, `hint` |
889
+ | Block list under a path | `diagnosticsFromBlockIssues(issues, pathPrefix)` | Lifts `@protoyard/blocks` parse issues |
890
+ | Deck body structure | `lintDeckStructure` | Slide ids, layouts, capacities, fragments, outline links |
891
+ | Deck asset references | `lintDeckAssets` | URL syntax and protocol allowlist; never fetches |
892
+
893
+ Stable block/asset codes include `unknown-block-type`, `invalid-chart-spec`,
894
+ `missing-alt-text`, `unsupported-url-scheme`, `invalid-step-or-fragment-target` and
895
+ `duplicate-block-id`. App codes still cover `unknown-entity`, `unknown-field`,
896
+ `invalid-reference`, and so on. After each repair, re-run validation until `valid: true`.
897
+
898
+ ```ts
899
+ import {
900
+ validateBlueprintForAuthoring,
901
+ lintDeckStructure,
902
+ diagnosticsFromBlockIssues,
903
+ } from '@protoyard/blueprint';
904
+ import { safeParseBlocks } from '@protoyard/blocks';
905
+
906
+ const authoring = validateBlueprintForAuthoring(blueprintJson);
907
+ if (!authoring.valid) {
908
+ for (const d of authoring.diagnostics) {
909
+ console.log(d.jsonPath, d.code, d.hint);
910
+ }
911
+ }
912
+
913
+ if (blueprintJson.kind === 'deck' && blueprintJson.deck) {
914
+ const lint = lintDeckStructure(blueprintJson.deck);
915
+ // …
916
+ }
917
+
918
+ const blocks = safeParseBlocks(someBlocks);
919
+ if (!blocks.success) {
920
+ const lifted = diagnosticsFromBlockIssues(blocks.issues, ['deck', 'slides', 0, 'blocks']);
921
+ }
922
+ ```
923
+
924
+ ## Authoring exports
925
+
926
+ Authoring APIs ship from the package root (`@protoyard/blueprint`); there is no separate
927
+ subpath. They are plain named exports of a `sideEffects: false` package, so a prototype that
928
+ only parses or generates never bundles them:
929
+
930
+ | Need | Exports |
931
+ | --- | --- |
932
+ | Brief to blueprint, by kind | `blueprintFromBrief`, `synthesizeAppFromBrief`, `synthesizeContentFromBrief`, `synthesizeDeckFromBrief`, `synthesizePageFromBrief`, `synthesizeDocFromBrief`, `isAppBriefResult`, `isContentBriefResult` |
933
+ | Kind detection | `detectBriefKind`, `KIND_CUES`, `APP_CUES`; blueprint kinds: `detectBlueprintKind`, `BLUEPRINT_KINDS`, `CONTENT_KINDS` |
934
+ | Outlines and bounds | `synthesizeDeckOutline`, `chooseDeckOutline`, `DECK_OUTLINES`, `buildPageOutline`, `buildDocOutline`, `selectPageOutline`, `selectDocOutline`, `boundBriefInput`, `prepareBriefForSynthesis`, `checkOutlineBounds`, `applyOutlineBounds`, `OUTLINE_LIMITS` |
935
+ | Deck layouts and lint | `selectDeckLayouts`, `propsForSelectedLayout`, `lintDeckStructure`, `lintDeckContent`, `lintDeckAssets`; catalog types `DeckLayoutEntry`, `DeckLayoutId`, `LayoutPacing` (type-only) |
936
+ | Validation | `validateBlueprintForAuthoring`, `parseAnyBlueprint`, `safeParseAnyBlueprint` |
937
+ | Entity forms on `@texuli/forms` | `entityFormModel` (a one-page Texuli `FormSchema` as plain data, with value conversion both ways), `HOSTED_FORM_KINDS` (relation, datetime, geo, avatar, file: painted by Protoyard controls), `UNASSIGNED_OPTION` |
938
+ | Streamed blueprints | `paintStreamedBlueprint`, `truncatedJsonCandidates`, `MAX_STREAM_CANDIDATES`: the valid part of a truncated deck, page or doc, checked like a complete one (`quick serve` drafts) |
939
+ | Lazy, for browsers | `loadAuthoringApis()` |
940
+
941
+ `loadAuthoringApis()` resolves to brief synthesis, kind detection, outlines, deck lint, layout
942
+ selection, authoring validation and the deck layout catalog (`deckLayouts`, `getDeckLayout`,
943
+ `describeDeckLayouts`). It loads them as one chunk, so a browser tool can parse blueprints first
944
+ and fetch authoring only when the author opens it. The functions are the same ones the root
945
+ exports. Server tools (CLI, MCP) can simply import the named exports.
946
+
947
+ Measured on 2026-10-22 with a real Vite build of the linked package (minified, gzip level 9; asserted in
948
+ `tests/authoring-exports.test.ts`):
949
+
950
+ | Entry imports | Initial JS |
951
+ | --- | --- |
952
+ | `parseBlueprint` only (a generated app) | 39.3 KB |
953
+ | `parseAnyBlueprint` (the kind union, as the player uses it) | 41.6 KB |
954
+ | The kind union plus `loadAuthoringApis` | 42.3 KB (+0.6 KB); authoring loads lazily |
955
+ | Static authoring imports (`blueprintFromBrief`, `detectBriefKind`, `lintDeckStructure`, `selectDeckLayouts`, `validateBlueprintForAuthoring`) | 83.0 KB (+41.3 KB) |
956
+
957
+ The test also checks that the app-only and kind-union bundles contain no authoring module and
958
+ no deck layout catalog.
959
+
960
+ ## Workflow state models
961
+
962
+ `@protoyard/blueprint/workflows` validates a workflow separately from the formatVersion 1
963
+ blueprint schema. This keeps the first state model opt-in while the serialized blueprint versioning
964
+ and migration path are decided. A workflow binds to an existing enum field; its state ids must be
965
+ that enum's values. Transitions name explicit endpoints and can add plain equality guards and a
966
+ role/capability authorization reference:
967
+
968
+ ```ts
969
+ import {
970
+ checkWorkflowTransition,
971
+ validateWorkflowDefinition,
972
+ } from '@protoyard/blueprint/workflows';
973
+
974
+ const validation = validateWorkflowDefinition(workflowData, parsedBlueprint);
975
+ if (validation.valid) {
976
+ const decision = checkWorkflowTransition(validation.workflow, 'open', 'resolved', {
977
+ role: 'agent',
978
+ capabilities: ['ticket:update'],
979
+ record: { priority: 1 },
980
+ });
981
+ }
982
+ ```
983
+
984
+ Validation checks the initial state, enum values, transition endpoints, guard fields and types,
985
+ role references, and that each referenced role has the declared capability. The transition check
986
+ is a deterministic, pure decision helper; it does not authorize a database write. Services must
987
+ still enforce their normal session policy. Workflows are never JavaScript and are not yet embedded
988
+ in blueprint formatVersion 1 or generated services.
989
+
990
+ Measured workflow subpath: 4.2 KB minified (1.76 KB gzip) with zod external, using esbuild on
991
+ `src/workflows/index.ts`. Consumers that do not import `@protoyard/blueprint/workflows` do not load
992
+ the transition engine.
993
+
994
+ ## Computed expressions
995
+
996
+ Computed values use a small JSON AST and are evaluated at read time. They are never stored in a
997
+ row, generated as JavaScript or included in storage migrations. Import the opt-in module only
998
+ where the consumer needs computed values. The `./computed` package subpath is opt-in:
999
+
1000
+ ```ts
1001
+ import {
1002
+ evaluateComputedExpression,
1003
+ validateComputedExpression,
1004
+ } from '@protoyard/blueprint/computed';
1005
+
1006
+ const expression = {
1007
+ op: 'dateDiff',
1008
+ start: { op: 'field', field: 'startedAt' },
1009
+ end: { op: 'field', field: 'dueAt' },
1010
+ unit: 'days',
1011
+ };
1012
+ const check = validateComputedExpression(expression, { sourceEntity, entities });
1013
+ const result = evaluateComputedExpression(expression, {
1014
+ sourceEntity, entities, row, relations,
1015
+ });
1016
+ ```
1017
+
1018
+ The grammar allows scalar literals, scalar field references, numeric `add` / `subtract` /
1019
+ `multiply` / `divide`, string `concat`, date `dateDiff` and relation `aggregate` (`count`,
1020
+ `sum`, `avg`, `min`, `max`). Arithmetic accepts number, integer and money fields; `concat`
1021
+ accepts text-like fields; date differences accept date/datetime fields; aggregates validate the
1022
+ relation target and the aggregate field kind. `validateComputedExpression` reports the inferred
1023
+ result type and whether it may be null. Missing field values, empty non-count aggregates,
1024
+ invalid runtime values and division by zero return `null`; count over an absent relation is zero.
1025
+ Dates are input as ISO strings or epoch milliseconds, and differences use the requested unit.
1026
+
1027
+ `safeParseComputedExpression` and `parseComputedExpression` enforce strict object shapes, a
1028
+ maximum depth of 32 and at most 256 object/array nodes before recursive parsing. The evaluator
1029
+ walks only these fixed operators; strings remain literal data and cannot invoke code. This is a
1030
+ standalone authoring/runtime primitive: the blueprint storage schema and generated prototypes do
1031
+ not yet declare computed fields.
1032
+
1033
+ ## Aggregates
1034
+
1035
+ `aggregate(rows, spec, entity?)` computes one aggregate in a single pass;
1036
+ `createAggregateQuery(services, { entity, ...spec })` returns a `liveQuery` querier that reads
1037
+ through the entity service, so `session.assert('<entity>:read')` applies, and resolves to
1038
+ `null` when the persona cannot read (like the generated Home counts).
1039
+
1040
+ ```ts
1041
+ import { createAggregateQuery } from '@protoyard/blueprint';
1042
+
1043
+ const revenue = liveQuery(
1044
+ createAggregateQuery(services, {
1045
+ entity: 'invoice',
1046
+ aggregate: 'sum',
1047
+ field: 'amount',
1048
+ groupBy: 'status',
1049
+ bucket: { field: 'createdAt', unit: 'week' },
1050
+ filter: { field: 'status', op: 'in', value: ['paid', 'sent'] },
1051
+ }),
1052
+ { initial: null }
1053
+ );
1054
+ // revenue.current?.series → [{ key: '2026-05-04T00:00:00.000Z|paid', bucket, group, count, value }, …]
1055
+ ```
1056
+
1057
+ - `aggregate`: `count` (default), `sum`, `avg`, `min`, `max`. Money sums stay integer cents;
1058
+ money and integer averages round to integers; `min`/`max` over dates return epoch ms
1059
+ (`valueKind` says `money`, `date` or `number`). `avg`/`min`/`max` over no values are `null`.
1060
+ - `groupBy`: relations and personas group by id, tags and many-relations count once per
1061
+ item, booleans as `'true'`/`'false'`. Enum groups keep the declared order and every value
1062
+ appears, even at zero; rows without a value go to `NO_GROUP`, last.
1063
+ - `bucket`: UTC days, weeks starting Monday, months starting on the 1st, over a date or
1064
+ datetime field or `createdAt`/`updatedAt`. Buckets are contiguous between the data's first
1065
+ and last bucket, or over `range: { from, to }`; more than `MAX_BUCKETS` (5,000) throws.
1066
+ - `filter`: `eq`, `in`, `gte`, `lte`; dates compare as instants, array fields match any item.
1067
+ - Soft-deleted rows are always skipped. Keys (`<bucket|*>|<group|*>`) are stable.
1068
+
1069
+ Measured: 10,000 rows grouped by status and bucketed by day in 10.7 ms in Vitest while the
1070
+ machine was under heavy load (`pnpm vitest run packages/blueprint/tests/aggregate.test.ts`).
1071
+
1072
+ ## App model
1073
+
1074
+ `deriveAppModel(blueprint)` returns the app a blueprint describes as plain data
1075
+ ([ADR-029](../../docs/DECISIONS.md)), with no Svelte or Lucide imports:
1076
+
1077
+ - `routes`, in router order. Each has a `name`, `path`, `title` and `description`,
1078
+ plus a `view` that says what it renders:
1079
+ - `home`: the overview with a count per entity;
1080
+ - `members`: workspace people and their simulated roles;
1081
+ - `entity`: the entity page (list, create, edit), with the `screenIndex` and
1082
+ `screenType` of the `list`, `form` or tab-less `detail` screen that claims it;
1083
+ - `screen`: a screen with its own view (board, calendar, timeline, map,
1084
+ analytics, dashboard, settings, tabbed detail), with its `screenIndex`,
1085
+ `screenType` and a unique `component` id such as `Dashboard1Screen`.
1086
+ - `nav`: sidebar rows. Home comes first (`exact: true`), then entities without a
1087
+ list screen, then visible screens by `nav.order` and blueprint order, then
1088
+ Members. Icons are `SCREEN_NAV_ICONS` ids.
1089
+ - `navGroups`: Overview, Records, Views and Workspace, with empty groups dropped.
1090
+ - `layout`: `{ id, mobile? }`, `sidebar` by default.
1091
+ - `icons`: every icon id the navigation may reference.
1092
+
1093
+ A later route on the same path replaces an earlier one in place, so a screen can
1094
+ claim an entity's default list or detail route. `screenNavIcon`, `screenHasOwnView`
1095
+ and `entityLabel` expose the per-screen rules. Generated prototypes and the player
1096
+ both render this model through `BlueprintApp` (`@protoyard/crud/app`), so the
1097
+ generator emits no route or layout files. `tests/app-model.test.ts` pins the model of
1098
+ every catalog blueprint in `tests/__snapshots__/app-model.catalog.json`. The snapshot
1099
+ was taken when the model was proven byte-identical to the generator's former
1100
+ `routes.ts` and `layout.ts`, so a change is always a reviewed diff.
1101
+
1102
+ `blueprintGeneratorVersion(blueprint)` (synchronous, no Faker) is the world
1103
+ generator's version. Generated prototypes declare it as `generatorVersion`, and so
1104
+ does the runtime definition, so a stored world reseeds when the blueprint's
1105
+ generative surface or the generator SDK changes.
1106
+
1107
+ ## Runtime definition
1108
+
1109
+ `definePrototypeFromBlueprint(blueprint, { namespace, slug?, version? })` builds in
1110
+ memory what `protoyard generate` writes as files ([ADR-029](../../docs/DECISIONS.md)),
1111
+ so the player can run a blueprint with no build. It returns
1112
+ `{ definition, appModel }`; the definition passes `definePrototype`:
1113
+
1114
+ - `manifest`: `id` `local.<slug>`, the slug (default: the namespace), the blueprint's
1115
+ name, description, category, tags and icon, `defaultScenario: "active"`,
1116
+ `defaultTheme` from `brandDefaultThemeId` (`violet` without a brand),
1117
+ `preview.layout.id` from the app model, `storage.schemaVersion` from
1118
+ `latestSchemaVersion`, and the scenario ids;
1119
+ - `database.versions` from `deriveDatabaseVersions`, `policy`, `integrity` and the
1120
+ default `blueprintScenarios`;
1121
+ - `themes`: the theme a seed or preset brand builds (a library brand ships by id);
1122
+ - `generate` loads `./generator` on first use, and `generatorVersion` is
1123
+ `blueprintGeneratorVersion`.
1124
+
1125
+ `brandDefaultThemeId` is the same function codegen uses for `protoyard.json`.
1126
+ Measured with Vite 8 (minified, gzip -9) on an entry that imports
1127
+ `definePrototypeFromBlueprint` from the package entry: about 20.0 KB entry plus a
1128
+ 40.5 KB shared chunk at start, and the generator as a separate 5.5 KB lazy chunk.
1129
+ `tests/runtime-definition.test.ts` boots the definition on the real runtime
1130
+ (fake IndexedDB): it seeds every default scenario, keeps created rows across a
1131
+ reload, and seeds the same world twice for the same seed.
1132
+
1133
+ ## Tests
1134
+
1135
+ Services run on the real runtime with the help desk fixture in
1136
+ `tests/fixtures/blueprint-prototype.ts`, a prototype whose stores, policy and integrity are
1137
+ all derived from its blueprint.
1138
+
1139
+ ```bash
1140
+ pnpm vitest run packages/blueprint
1141
+ pnpm vitest run packages/blueprint/tests/authoring-docs.test.ts
1142
+ pnpm vitest run packages/blueprint/tests/brief-kinds.test.ts
1143
+ pnpm vitest run packages/blueprint/tests/deck-lint.test.ts
1144
+ pnpm vitest run packages/blueprint/tests/select-deck-layouts.test.ts
1145
+ pnpm --filter @protoyard/blueprint typecheck
1146
+ ```
1147
+
1148
+ ## Package output
1149
+
1150
+ The manifest keeps TypeScript source exports for workspace development. `pnpm build:packages --filter engine` creates the ESM and declaration files under `dist/` that the packed manifest exposes.