@bespokeagentics/microdots-host 0.1.0 → 0.1.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,802 @@
1
+ import { Array, Option, Schema as S } from 'effect'
2
+
3
+ import {
4
+ AvailableMicroDotSlug,
5
+ type CatalogLockV1,
6
+ CatalogLockV1 as CatalogLockV1Schema,
7
+ type MigrationResult,
8
+ ResolvedMicroDotSpecV2 as ResolvedMicroDotSpecV2Schema,
9
+ decodeAuthoringDiagnosticSync,
10
+ migrated,
11
+ migrationNeedsInput,
12
+ } from '@bespokeagentics/microdots-authoring'
13
+ import type { ManifestTag } from '@bespokeagentics/microdots-element'
14
+
15
+ import { HostTopology, deriveWireState, topologyRouteTable } from './wire.ts'
16
+
17
+ /**
18
+ * The composition IR a spec-filler emits and the compiler consumes.
19
+ *
20
+ * `topology` is the already-shipping host record. `generate` names new
21
+ * MicroDots the compiler should scaffold; tags that appear in the topology
22
+ * but not here must already exist in the surface catalog. The model never
23
+ * writes source files — see
24
+ * `wiki/plans/active/microdots-specialist-spec-filler.md`.
25
+ */
26
+ export const GenerateDotSpecV1 = S.Struct({
27
+ slug: AvailableMicroDotSlug,
28
+ tag: S.NonEmptyString.check(S.isPattern(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/)),
29
+ port: S.Int.check(S.isGreaterThan(0)),
30
+ })
31
+ export type GenerateDotSpecV1 = typeof GenerateDotSpecV1.Type
32
+
33
+ /** @deprecated v1 compatibility name; new authoring uses ResolvedMicroDotSpecV2. */
34
+ export const GenerateDotSpec = GenerateDotSpecV1
35
+ /** @deprecated v1 compatibility name; new authoring uses ResolvedMicroDotSpecV2. */
36
+ export type GenerateDotSpec = GenerateDotSpecV1
37
+
38
+ export const CompositionSpecV1 = S.Struct({
39
+ schemaVersion: S.Literal(1),
40
+ brief: S.optionalKey(S.String),
41
+ generate: S.Array(GenerateDotSpecV1),
42
+ topology: HostTopology,
43
+ })
44
+ export type CompositionSpecV1 = typeof CompositionSpecV1.Type
45
+
46
+ /** Legacy read/caller contract; new Workbench traffic uses explicit v2. */
47
+ export const CompositionSpec = CompositionSpecV1
48
+ /** Legacy read/caller contract; new Workbench traffic uses explicit v2. */
49
+ export type CompositionSpec = CompositionSpecV1
50
+
51
+ const CompositionSpecV2Base = S.Struct({
52
+ schemaVersion: S.Literal(2),
53
+ brief: S.optionalKey(S.String),
54
+ generate: S.Array(ResolvedMicroDotSpecV2Schema),
55
+ topology: HostTopology,
56
+ catalogLock: CatalogLockV1Schema,
57
+ })
58
+
59
+ const sameDigestRecord = (
60
+ left: Readonly<Record<string, string>>,
61
+ right: Readonly<Record<string, string>>,
62
+ ): boolean => {
63
+ const leftKeys = Object.keys(left)
64
+ return (
65
+ leftKeys.length === Object.keys(right).length &&
66
+ leftKeys.every(key => left[key] === right[key])
67
+ )
68
+ }
69
+
70
+ export const catalogLocksEqual = (
71
+ left: CatalogLockV1,
72
+ right: CatalogLockV1,
73
+ ): boolean =>
74
+ left.schemaVersion === right.schemaVersion &&
75
+ left.snapshotSha256 === right.snapshotSha256 &&
76
+ left.compilerVersion === right.compilerVersion &&
77
+ sameDigestRecord(left.shapeDigests, right.shapeDigests) &&
78
+ sameDigestRecord(left.capabilityDigests, right.capabilityDigests) &&
79
+ left.backendCatalogDigest === right.backendCatalogDigest &&
80
+ left.targetMatrixDigest === right.targetMatrixDigest &&
81
+ left.allocationBaselineDigest === right.allocationBaselineDigest
82
+
83
+ export const CompositionSpecV2 = CompositionSpecV2Base.check(
84
+ S.makeFilter(
85
+ spec =>
86
+ spec.generate.every(generated =>
87
+ catalogLocksEqual(generated.catalogLock, spec.catalogLock),
88
+ ),
89
+ { expected: 'every generated MicroDot uses the composition catalog lock' },
90
+ ),
91
+ )
92
+ export type CompositionSpecV2 = typeof CompositionSpecV2.Type
93
+
94
+ export const CompositionSpecVersioned = S.Union([
95
+ CompositionSpecV1,
96
+ CompositionSpecV2,
97
+ ])
98
+ export type CompositionSpecVersioned = typeof CompositionSpecVersioned.Type
99
+
100
+ export type CompositionIssue = {
101
+ readonly path: string
102
+ readonly message: string
103
+ }
104
+
105
+ const issue = (path: string, message: string): CompositionIssue => ({
106
+ path,
107
+ message,
108
+ })
109
+
110
+ const tagOf = (surface: ManifestTag): string => surface.tag
111
+
112
+ const knownTags = (
113
+ spec: CompositionSpec,
114
+ surfaces: ReadonlyArray<ManifestTag>,
115
+ ): ReadonlySet<string> => {
116
+ const tags = new Set<string>(surfaces.map(tagOf))
117
+ for (const generated of spec.generate) {
118
+ tags.add(generated.tag)
119
+ }
120
+ return tags
121
+ }
122
+
123
+ const duplicatePaths = (
124
+ values: ReadonlyArray<string>,
125
+ pathOf: (index: number) => string,
126
+ label: string,
127
+ ): ReadonlyArray<CompositionIssue> => {
128
+ const seen = new Map<string, number>()
129
+ const issues: Array<CompositionIssue> = []
130
+ for (const [index, value] of values.entries()) {
131
+ const previous = seen.get(value)
132
+ if (previous === undefined) {
133
+ seen.set(value, index)
134
+ continue
135
+ }
136
+ issues.push(
137
+ issue(
138
+ pathOf(index),
139
+ `${label} "${value}" duplicates generate[${previous.toString()}]`,
140
+ ),
141
+ )
142
+ }
143
+ return issues
144
+ }
145
+
146
+ const placedTags = (spec: CompositionSpec): ReadonlySet<string> => {
147
+ const tags = new Set<string>()
148
+ for (const route of spec.topology.routes) {
149
+ for (const mount of route.mounts) {
150
+ tags.add(mount.tag)
151
+ }
152
+ }
153
+ for (const rule of spec.topology.rules ?? []) {
154
+ for (const placement of rule.placements) {
155
+ tags.add(placement.tag)
156
+ }
157
+ }
158
+ return tags
159
+ }
160
+
161
+ const slotIds = (spec: CompositionSpec): ReadonlySet<string> | undefined => {
162
+ const manifest = spec.topology.slotManifest
163
+ if (manifest === undefined) {
164
+ return undefined
165
+ }
166
+ return new Set(manifest.slots.map(slot => slot.id))
167
+ }
168
+
169
+ const checkMounts = (
170
+ spec: CompositionSpec,
171
+ tags: ReadonlySet<string>,
172
+ ): ReadonlyArray<CompositionIssue> => {
173
+ const slots = slotIds(spec)
174
+ const issues: Array<CompositionIssue> = []
175
+ for (const [routeIndex, route] of spec.topology.routes.entries()) {
176
+ for (const [mountIndex, mount] of route.mounts.entries()) {
177
+ const path = `topology.routes[${routeIndex.toString()}].mounts[${mountIndex.toString()}]`
178
+ if (!tags.has(mount.tag)) {
179
+ issues.push(
180
+ issue(
181
+ `${path}.tag`,
182
+ `tag "${mount.tag}" is neither a catalogued surface nor in generate[]`,
183
+ ),
184
+ )
185
+ }
186
+ if (slots !== undefined && !slots.has(mount.slotId)) {
187
+ issues.push(
188
+ issue(
189
+ `${path}.slotId`,
190
+ `slot "${mount.slotId}" is not in topology.slotManifest`,
191
+ ),
192
+ )
193
+ }
194
+ }
195
+ }
196
+ return issues
197
+ }
198
+
199
+ const checkWires = (
200
+ spec: CompositionSpec,
201
+ surfaces: ReadonlyArray<ManifestTag>,
202
+ tags: ReadonlySet<string>,
203
+ ): ReadonlyArray<CompositionIssue> => {
204
+ const generatedTags = new Set(spec.generate.map(generated => generated.tag))
205
+ const table = topologyRouteTable(spec.topology)
206
+ const issues: Array<CompositionIssue> = []
207
+ for (const [index, wire] of spec.topology.wires.entries()) {
208
+ const path = `topology.wires[${index.toString()}]`
209
+ if (!tags.has(wire.from)) {
210
+ issues.push(
211
+ issue(
212
+ `${path}.from`,
213
+ `tag "${wire.from}" is neither a catalogued surface nor in generate[]`,
214
+ ),
215
+ )
216
+ }
217
+ if (!tags.has(wire.to)) {
218
+ issues.push(
219
+ issue(
220
+ `${path}.to`,
221
+ `tag "${wire.to}" is neither a catalogued surface nor in generate[]`,
222
+ ),
223
+ )
224
+ }
225
+ if (generatedTags.has(wire.from) || generatedTags.has(wire.to)) {
226
+ continue
227
+ }
228
+ if (!tags.has(wire.from) || !tags.has(wire.to)) {
229
+ continue
230
+ }
231
+ const state = deriveWireState(wire, { manifests: surfaces, table })
232
+ if (state === 'draft') {
233
+ issues.push(
234
+ issue(
235
+ path,
236
+ `wire "${wire.id}" derives draft — a tag, event, field, input or type pairing is missing from the surface catalog`,
237
+ ),
238
+ )
239
+ }
240
+ }
241
+ return issues
242
+ }
243
+
244
+ /**
245
+ * Fail-closed composition lint. Schema-decode is the first gate; this is
246
+ * the second: identities, slot membership, and `deriveWireState !== 'draft'`
247
+ * for wires whose both ends are catalogued surfaces. Wires that touch a
248
+ * `generate[]` tag skip the manifest check — the scaffold surface is not
249
+ * the domain surface yet.
250
+ */
251
+ export const validateComposition = (
252
+ spec: CompositionSpec,
253
+ surfaces: ReadonlyArray<ManifestTag>,
254
+ ): ReadonlyArray<CompositionIssue> => {
255
+ const tags = knownTags(spec, surfaces)
256
+ return [
257
+ ...duplicatePaths(
258
+ spec.generate.map(generated => generated.slug),
259
+ index => `generate[${index.toString()}].slug`,
260
+ 'slug',
261
+ ),
262
+ ...duplicatePaths(
263
+ spec.generate.map(generated => generated.tag),
264
+ index => `generate[${index.toString()}].tag`,
265
+ 'tag',
266
+ ),
267
+ ...duplicatePaths(
268
+ spec.generate.map(generated => String(generated.port)),
269
+ index => `generate[${index.toString()}].port`,
270
+ 'port',
271
+ ),
272
+ ...checkMounts(spec, tags),
273
+ ...checkWires(spec, surfaces, tags),
274
+ ...Array.getSomes(
275
+ spec.generate.map((generated, index) =>
276
+ placedTags(spec).has(generated.tag)
277
+ ? Option.none()
278
+ : Option.some(
279
+ issue(
280
+ `generate[${index.toString()}].tag`,
281
+ `generated tag "${generated.tag}" is never placed in the topology`,
282
+ ),
283
+ ),
284
+ ),
285
+ ),
286
+ ]
287
+ }
288
+
289
+ /**
290
+ * The current existing-surface lane writes v2, but it never generates a new
291
+ * MicroDot. Keep that boundary explicit so a resolved application spec cannot
292
+ * accidentally fall through to the legacy scaffold compiler.
293
+ *
294
+ * Once `generate[]` is known empty, the topology validation is exactly the v1
295
+ * compatibility rule: every mounted/wired tag must already exist in the
296
+ * emitted surface catalog. The temporary v1 value is not encoded or persisted;
297
+ * it is the retained read-side validator during the Phase 6 migration.
298
+ */
299
+ export const validateExistingSurfaceComposition = (
300
+ spec: CompositionSpecV2,
301
+ surfaces: ReadonlyArray<ManifestTag>,
302
+ ): ReadonlyArray<CompositionIssue> => {
303
+ if (spec.generate.length > 0) {
304
+ return [
305
+ issue(
306
+ 'generate',
307
+ 'existing-surface composition requires generate[] to be empty; use the application-authoring compiler for new MicroDots',
308
+ ),
309
+ ]
310
+ }
311
+
312
+ const compatibilitySpec: CompositionSpecV1 = {
313
+ schemaVersion: 1,
314
+ generate: [],
315
+ topology: spec.topology,
316
+ ...(spec.brief === undefined ? {} : { brief: spec.brief }),
317
+ }
318
+ return validateComposition(compatibilitySpec, surfaces)
319
+ }
320
+
321
+ export const encodeTopologyJson = (topology: HostTopology): string =>
322
+ `${JSON.stringify(S.encodeSync(HostTopology)(topology), null, 2)}\n`
323
+
324
+ /* ============================================================
325
+ Surface-derived JSON Schema — the smaller prompt.
326
+ Illegal tags/events/attrs/transforms are unrepresentable here; the
327
+ Effect schema is still the decoder. Built by hand because the
328
+ constrained enums are a function of the live ManifestTag catalog,
329
+ not of HostTopology's free strings.
330
+ ============================================================ */
331
+
332
+ export type JsonSchema = {
333
+ readonly $schema?: string
334
+ readonly $comment?: string
335
+ readonly $defs?: Readonly<Record<string, JsonSchema>>
336
+ readonly type?:
337
+ 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
338
+ readonly enum?: ReadonlyArray<string | number>
339
+ readonly const?: string | number | boolean
340
+ readonly properties?: Readonly<Record<string, JsonSchema>>
341
+ readonly required?: ReadonlyArray<string>
342
+ readonly additionalProperties?: boolean | JsonSchema
343
+ readonly items?: JsonSchema
344
+ readonly minItems?: number
345
+ readonly minLength?: number
346
+ readonly minimum?: number
347
+ readonly maximum?: number
348
+ readonly pattern?: string
349
+ readonly if?: JsonSchema
350
+ readonly then?: JsonSchema
351
+ readonly allOf?: ReadonlyArray<JsonSchema>
352
+ readonly oneOf?: ReadonlyArray<JsonSchema>
353
+ readonly anyOf?: ReadonlyArray<JsonSchema>
354
+ }
355
+
356
+ const objectSchema = (
357
+ properties: Readonly<Record<string, JsonSchema>>,
358
+ required: ReadonlyArray<string>,
359
+ ): JsonSchema => ({
360
+ type: 'object',
361
+ additionalProperties: false,
362
+ properties,
363
+ required,
364
+ })
365
+
366
+ const stringEnum = (values: ReadonlyArray<string>): JsonSchema => ({
367
+ type: 'string',
368
+ enum: values,
369
+ })
370
+
371
+ const TAG_PATTERN = '^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$'
372
+
373
+ const tagNameSchema = (
374
+ known: ReadonlyArray<string>,
375
+ allowNewTags: boolean,
376
+ ): JsonSchema => {
377
+ if (known.length === 0) {
378
+ return { type: 'string', pattern: TAG_PATTERN }
379
+ }
380
+ if (!allowNewTags) {
381
+ return stringEnum(known)
382
+ }
383
+ return {
384
+ anyOf: [stringEnum(known), { type: 'string', pattern: TAG_PATTERN }],
385
+ }
386
+ }
387
+
388
+ const wireIfThen = (
389
+ surfaces: ReadonlyArray<ManifestTag>,
390
+ ): ReadonlyArray<JsonSchema> =>
391
+ surfaces.flatMap(surface => {
392
+ const clauses: Array<JsonSchema> = []
393
+ const events = surface.events.map(event => event.name)
394
+ clauses.push({
395
+ if: {
396
+ properties: { from: { const: surface.tag } },
397
+ required: ['from'],
398
+ },
399
+ then: {
400
+ properties: { event: stringEnum(events) },
401
+ },
402
+ })
403
+ const inputs = surface.attributes.map(attribute => attribute.name)
404
+ clauses.push({
405
+ if: {
406
+ properties: { to: { const: surface.tag } },
407
+ required: ['to'],
408
+ },
409
+ then: {
410
+ properties: { input: stringEnum(inputs) },
411
+ },
412
+ })
413
+ return clauses
414
+ })
415
+
416
+ const VALUE_TYPES: ReadonlyArray<string> = [
417
+ 'string',
418
+ 'number',
419
+ 'boolean',
420
+ 'json',
421
+ 'enum',
422
+ ]
423
+
424
+ const ENV_ENUM: JsonSchema = stringEnum(['dev', 'preview', 'prod'])
425
+
426
+ const transformSchema: JsonSchema = {
427
+ oneOf: [
428
+ objectSchema({ _tag: { const: 'direct' } }, ['_tag']),
429
+ objectSchema(
430
+ {
431
+ _tag: { const: 'lookup' },
432
+ rows: {
433
+ type: 'object',
434
+ additionalProperties: { type: 'string' },
435
+ },
436
+ fallback: { type: 'string' },
437
+ },
438
+ ['_tag', 'rows', 'fallback'],
439
+ ),
440
+ objectSchema(
441
+ {
442
+ _tag: { const: 'condition' },
443
+ field: { type: 'string' },
444
+ op: { const: 'is' },
445
+ value: { type: 'string' },
446
+ },
447
+ ['_tag', 'field', 'op', 'value'],
448
+ ),
449
+ ],
450
+ }
451
+
452
+ const wireSchema = (
453
+ surfaces: ReadonlyArray<ManifestTag>,
454
+ allowNewTags: boolean,
455
+ ): JsonSchema => {
456
+ const known = surfaces.map(tagOf)
457
+ const base = objectSchema(
458
+ {
459
+ id: { type: 'string', minLength: 1 },
460
+ from: tagNameSchema(known, allowNewTags),
461
+ event: { type: 'string', minLength: 1 },
462
+ field: { type: 'string', minLength: 1 },
463
+ fieldType: stringEnum(VALUE_TYPES),
464
+ to: tagNameSchema(known, allowNewTags),
465
+ input: { type: 'string', minLength: 1 },
466
+ inputType: stringEnum(VALUE_TYPES),
467
+ transform: transformSchema,
468
+ envs: { type: 'array', items: ENV_ENUM, minItems: 1 },
469
+ plain: { type: 'string' },
470
+ },
471
+ [
472
+ 'id',
473
+ 'from',
474
+ 'event',
475
+ 'field',
476
+ 'fieldType',
477
+ 'to',
478
+ 'input',
479
+ 'inputType',
480
+ 'transform',
481
+ 'envs',
482
+ 'plain',
483
+ ],
484
+ )
485
+ const clauses = wireIfThen(surfaces)
486
+ if (clauses.length === 0) {
487
+ return base
488
+ }
489
+ return { allOf: [base, ...clauses] }
490
+ }
491
+
492
+ const placementSchema = (
493
+ surfaces: ReadonlyArray<ManifestTag>,
494
+ allowNewTags: boolean,
495
+ ): JsonSchema =>
496
+ objectSchema(
497
+ {
498
+ id: { type: 'string' },
499
+ tag: tagNameSchema(surfaces.map(tagOf), allowNewTags),
500
+ slotId: { type: 'string', minLength: 1 },
501
+ values: {
502
+ type: 'object',
503
+ additionalProperties: { type: 'string' },
504
+ },
505
+ condition: objectSchema(
506
+ {
507
+ who: { type: 'string' },
508
+ device: { type: 'string' },
509
+ locale: { type: 'string' },
510
+ },
511
+ [],
512
+ ),
513
+ envs: { type: 'array', items: ENV_ENUM },
514
+ span: { enum: [12, 8, 6, 4, 3] },
515
+ order: { type: 'number' },
516
+ },
517
+ ['tag', 'slotId'],
518
+ )
519
+
520
+ const routeSchema = (
521
+ surfaces: ReadonlyArray<ManifestTag>,
522
+ allowNewTags: boolean,
523
+ ): JsonSchema =>
524
+ objectSchema(
525
+ {
526
+ path: { type: 'string', minLength: 1 },
527
+ label: { type: 'string', minLength: 1 },
528
+ title: { type: 'string', minLength: 1 },
529
+ sectionIds: { type: 'array', items: { type: 'string' } },
530
+ mounts: {
531
+ type: 'array',
532
+ items: placementSchema(surfaces, allowNewTags),
533
+ },
534
+ },
535
+ ['path', 'label', 'title', 'sectionIds', 'mounts'],
536
+ )
537
+
538
+ const slotSpecSchema = objectSchema(
539
+ {
540
+ id: { type: 'string', minLength: 1 },
541
+ kind: stringEnum(['bar', 'band', 'rail', 'grid']),
542
+ row: { type: 'number' },
543
+ width: { type: 'string' },
544
+ capacity: { type: 'number' },
545
+ },
546
+ ['id', 'kind', 'row'],
547
+ )
548
+
549
+ const topologySchema = (
550
+ surfaces: ReadonlyArray<ManifestTag>,
551
+ allowNewTags: boolean,
552
+ ): JsonSchema =>
553
+ objectSchema(
554
+ {
555
+ host: objectSchema(
556
+ {
557
+ id: { type: 'string', minLength: 1 },
558
+ label: { type: 'string', minLength: 1 },
559
+ ownedInputs: {
560
+ type: 'array',
561
+ items: objectSchema(
562
+ { name: { type: 'string' }, type: { type: 'string' } },
563
+ ['name', 'type'],
564
+ ),
565
+ },
566
+ },
567
+ ['id', 'label', 'ownedInputs'],
568
+ ),
569
+ routes: {
570
+ type: 'array',
571
+ minItems: 1,
572
+ items: routeSchema(surfaces, allowNewTags),
573
+ },
574
+ overview: objectSchema(
575
+ {
576
+ path: { type: 'string' },
577
+ label: { type: 'string' },
578
+ title: { type: 'string' },
579
+ leadingSectionIds: { type: 'array', items: { type: 'string' } },
580
+ },
581
+ ['path', 'label', 'title'],
582
+ ),
583
+ slotManifest: objectSchema(
584
+ {
585
+ theme: objectSchema(
586
+ { name: { type: 'string' }, version: { type: 'string' } },
587
+ ['name', 'version'],
588
+ ),
589
+ slots: { type: 'array', items: slotSpecSchema },
590
+ },
591
+ ['theme', 'slots'],
592
+ ),
593
+ rules: {
594
+ type: 'array',
595
+ items: {
596
+ oneOf: [
597
+ objectSchema(
598
+ {
599
+ id: { type: 'string' },
600
+ label: { type: 'string' },
601
+ kind: { const: 'pattern' },
602
+ pattern: { type: 'string' },
603
+ placements: {
604
+ type: 'array',
605
+ items: placementSchema(surfaces, allowNewTags),
606
+ },
607
+ },
608
+ ['id', 'label', 'kind', 'pattern', 'placements'],
609
+ ),
610
+ objectSchema(
611
+ {
612
+ id: { type: 'string' },
613
+ label: { type: 'string' },
614
+ kind: { const: 'group' },
615
+ paths: { type: 'array', items: { type: 'string' } },
616
+ placements: {
617
+ type: 'array',
618
+ items: placementSchema(surfaces, allowNewTags),
619
+ },
620
+ },
621
+ ['id', 'label', 'kind', 'paths', 'placements'],
622
+ ),
623
+ objectSchema(
624
+ {
625
+ id: { type: 'string' },
626
+ label: { type: 'string' },
627
+ kind: { const: 'dynamic' },
628
+ template: { type: 'string' },
629
+ placements: {
630
+ type: 'array',
631
+ items: placementSchema(surfaces, allowNewTags),
632
+ },
633
+ },
634
+ ['id', 'label', 'kind', 'template', 'placements'],
635
+ ),
636
+ ],
637
+ },
638
+ },
639
+ wires: { type: 'array', items: wireSchema(surfaces, allowNewTags) },
640
+ watch: {
641
+ type: 'array',
642
+ items: objectSchema({ event: { type: 'string' } }, ['event']),
643
+ },
644
+ },
645
+ ['host', 'routes', 'wires', 'watch'],
646
+ )
647
+
648
+ /**
649
+ * JSON Schema (draft 2020-12) for constrained decoding of a CompositionSpec.
650
+ * Event and attribute names are closed over the supplied surfaces, so a
651
+ * model cannot emit `from: "readout-view", event: "bid-placed"`.
652
+ */
653
+ export type CompositionJsonSchemaOptions = {
654
+ /** When false, topology tags are a closed enum of the catalog. Default true. */
655
+ readonly allowNewTags?: boolean
656
+ }
657
+
658
+ export const compositionJsonSchema = (
659
+ surfaces: ReadonlyArray<ManifestTag>,
660
+ options: CompositionJsonSchemaOptions = {},
661
+ ): JsonSchema => {
662
+ const allowNewTags = options.allowNewTags ?? true
663
+ return {
664
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
665
+ $comment:
666
+ 'Legal tags, events, attributes and transforms are derived from the ManifestTag catalog. Do not invent names.',
667
+ ...objectSchema(
668
+ {
669
+ schemaVersion: { const: 1 },
670
+ brief: { type: 'string' },
671
+ generate: {
672
+ type: 'array',
673
+ items: objectSchema(
674
+ {
675
+ slug: {
676
+ type: 'string',
677
+ pattern: '^[a-z][a-z0-9-]*$',
678
+ },
679
+ tag: { type: 'string', pattern: TAG_PATTERN },
680
+ port: { type: 'integer', minimum: 1, maximum: 65535 },
681
+ },
682
+ ['slug', 'tag', 'port'],
683
+ ),
684
+ },
685
+ topology: topologySchema(surfaces, allowNewTags),
686
+ },
687
+ ['schemaVersion', 'generate', 'topology'],
688
+ ),
689
+ }
690
+ }
691
+
692
+ /** Test/helper: event names the schema admits when `from` is this tag. */
693
+ export const schemaEventsFor = (
694
+ schema: JsonSchema,
695
+ fromTag: string,
696
+ ): ReadonlyArray<string> => {
697
+ const topology = schema.properties?.['topology']
698
+ const wires = topology?.properties?.['wires']
699
+ const items = wires?.items
700
+ const clauses = items?.allOf ?? (items === undefined ? [] : [items])
701
+ for (const clause of clauses) {
702
+ if (clause.if?.properties?.['from']?.const === fromTag) {
703
+ const enumerated = clause.then?.properties?.['event']?.enum
704
+ if (enumerated === undefined) {
705
+ return []
706
+ }
707
+ return Array.getSomes(
708
+ enumerated.map(value =>
709
+ typeof value === 'string' ? Option.some(value) : Option.none(),
710
+ ),
711
+ )
712
+ }
713
+ }
714
+ return []
715
+ }
716
+
717
+ /** Test/helper: attribute names the schema admits when `to` is this tag. */
718
+ export const schemaInputsFor = (
719
+ schema: JsonSchema,
720
+ toTag: string,
721
+ ): ReadonlyArray<string> => {
722
+ const topology = schema.properties?.['topology']
723
+ const wires = topology?.properties?.['wires']
724
+ const items = wires?.items
725
+ const clauses = items?.allOf ?? (items === undefined ? [] : [items])
726
+ for (const clause of clauses) {
727
+ if (clause.if?.properties?.['to']?.const === toTag) {
728
+ const enumerated = clause.then?.properties?.['input']?.enum
729
+ if (enumerated === undefined) {
730
+ return []
731
+ }
732
+ return Array.getSomes(
733
+ enumerated.map(value =>
734
+ typeof value === 'string' ? Option.some(value) : Option.none(),
735
+ ),
736
+ )
737
+ }
738
+ }
739
+ return []
740
+ }
741
+
742
+ /** Legacy v1 decoder, preserved through the Phase 6 saved-spec migration. */
743
+ export const decodeCompositionSpec = S.decodeUnknownEffect(CompositionSpecV1)
744
+ /** Legacy v1 decoder, preserved through the Phase 6 saved-spec migration. */
745
+ export const decodeCompositionSpecSync = S.decodeUnknownSync(CompositionSpecV1)
746
+
747
+ export const decodeCompositionSpecVersioned = S.decodeUnknownEffect(
748
+ CompositionSpecVersioned,
749
+ )
750
+ export const decodeCompositionSpecVersionedSync = S.decodeUnknownSync(
751
+ CompositionSpecVersioned,
752
+ )
753
+
754
+ export const encodeCompositionSpecV2Sync = S.encodeSync(CompositionSpecV2)
755
+
756
+ /**
757
+ * The only lossless v1 migration available before catalogs exist is a
758
+ * topology-only composition. Legacy generate rows lack domain, shape,
759
+ * capability, target and provenance semantics, so Phase 0 reports the closed
760
+ * input boundary instead of guessing them.
761
+ */
762
+ export const migrateCompositionSpecV1 = (
763
+ spec: CompositionSpecV1,
764
+ catalogLock: CatalogLockV1,
765
+ ): MigrationResult<CompositionSpecV2> => {
766
+ if (spec.generate.length === 0) {
767
+ const value: CompositionSpecV2 = {
768
+ schemaVersion: 2,
769
+ generate: [],
770
+ topology: spec.topology,
771
+ catalogLock,
772
+ ...(spec.brief === undefined ? {} : { brief: spec.brief }),
773
+ }
774
+ return migrated(value)
775
+ }
776
+
777
+ return migrationNeedsInput(
778
+ decodeAuthoringDiagnosticSync({
779
+ schemaVersion: 1,
780
+ code: 'migration-input-required',
781
+ phase: 'migrate',
782
+ path: ['generate'],
783
+ entityIds: spec.generate.map(generated => generated.slug),
784
+ severity: 'error',
785
+ retryable: true,
786
+ references: [
787
+ 'wiki/plans/active/microdots-authoring-catalog-programme.md',
788
+ ],
789
+ destinationMutated: false,
790
+ nextLane: 'application-correction',
791
+ parameters: {
792
+ required: [
793
+ 'fronts',
794
+ 'domain',
795
+ 'capabilities',
796
+ 'target-profile',
797
+ 'provenance',
798
+ ],
799
+ },
800
+ }),
801
+ )
802
+ }