@bespokeagentics/microdots-host 0.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.
package/src/rules.ts ADDED
@@ -0,0 +1,448 @@
1
+ import { Array, Option } from 'effect'
2
+
3
+ import type {
4
+ HostTopology,
5
+ PlacementCondition,
6
+ PlacementSpan,
7
+ RouteRule,
8
+ TopologyPlacement,
9
+ WireEnv,
10
+ } from './wire.ts'
11
+
12
+ /**
13
+ * Rules, THE matcher, and the reading-B resolution — Phase 5 work item 3 of
14
+ * `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`.
15
+ *
16
+ * The `RouteRule` record itself is schema and lives with the other topology
17
+ * records in `./wire.ts`; this module is the behaviour: `ruleMatches` (the one
18
+ * matcher, ruling 1 — the runtime and the Pages screen's match counts both
19
+ * call it, nothing else may reimplement it) and `resolvePlacements` (the
20
+ * reading-B override resolution of
21
+ * `wiki/framework/composition/question-does-the-override-contest-consider-condition.md`).
22
+ *
23
+ * Everything here derives fresh from the topology — nothing below is ever
24
+ * stored, for the same reason `deriveWireState` stores nothing: a stored
25
+ * resolution is stale the moment a rule or a placement changes without it
26
+ * being touched.
27
+ */
28
+
29
+ /* ============================================================
30
+ The matcher.
31
+ ============================================================ */
32
+
33
+ /** `'/docs/rpc-contracts'` → `['docs', 'rpc-contracts']`; `'/'` → `[]`. */
34
+ const segmentsOf = (path: string): ReadonlyArray<string> =>
35
+ path.split('/').filter(segment => segment !== '')
36
+
37
+ /**
38
+ * Pattern semantics, pinned: only the `/*` SUFFIX wildcard exists. Stripping
39
+ * it leaves a prefix, and the path matches when it continues past that prefix
40
+ * through a `/` — so `/blog/*` matches `/blog/effect-first-ports` and
41
+ * `/blog/a/b` but NOT `/blog` itself, and `/*` (empty prefix) matches every
42
+ * normalized path. A pattern without the suffix is an exact match.
43
+ */
44
+ const patternMatches = (pattern: string, path: string): boolean => {
45
+ if (pattern.endsWith('/*')) {
46
+ const prefix = pattern.slice(0, -2)
47
+ return path.startsWith(`${prefix}/`)
48
+ }
49
+ return pattern === path
50
+ }
51
+
52
+ /**
53
+ * Dynamic semantics, pinned: segment-wise, equal length. A `:param` segment
54
+ * matches exactly ONE segment — `/docs/:slug` matches `/docs/anything` but
55
+ * not `/docs` and not `/docs/a/b`; a template wanting two segments says so
56
+ * (`/docs/:a/:b`). Literal segments match exactly.
57
+ */
58
+ const templateMatches = (template: string, path: string): boolean => {
59
+ const templateSegments = segmentsOf(template)
60
+ const pathSegments = segmentsOf(path)
61
+ if (templateSegments.length !== pathSegments.length) {
62
+ return false
63
+ }
64
+ return Array.every(
65
+ Array.zip(templateSegments, pathSegments),
66
+ ([templateSegment, pathSegment]) =>
67
+ templateSegment.startsWith(':') || templateSegment === pathSegment,
68
+ )
69
+ }
70
+
71
+ /**
72
+ * THE matcher (ruling 1). Serves all three rule kinds; used by the runtime
73
+ * resolution below AND by the Pages screen's per-rule match counts, so the
74
+ * screen cannot claim a count the runtime disagrees with.
75
+ */
76
+ export const ruleMatches = (rule: RouteRule, path: string): boolean => {
77
+ switch (rule.kind) {
78
+ case 'pattern':
79
+ return patternMatches(rule.pattern, path)
80
+ case 'group':
81
+ return Array.contains(rule.paths, path)
82
+ case 'dynamic':
83
+ return templateMatches(rule.template, path)
84
+ }
85
+ }
86
+
87
+ /* ============================================================
88
+ Conditions — the overlap algebra the resolution and the checks share.
89
+ ============================================================ */
90
+
91
+ /**
92
+ * A condition with every axis explicit — the derived counterpart of the
93
+ * record's `PlacementCondition`, where an absent axis means `'any'`. Derived
94
+ * at resolution time rather than stored, so a record only ever says what
95
+ * narrows.
96
+ */
97
+ export type ResolvedCondition = {
98
+ readonly who: string
99
+ readonly device: string
100
+ readonly locale: string
101
+ }
102
+
103
+ export const resolveCondition = (
104
+ condition: PlacementCondition | undefined,
105
+ ): ResolvedCondition => ({
106
+ who: condition?.who ?? 'any',
107
+ device: condition?.device ?? 'any',
108
+ locale: condition?.locale ?? 'any',
109
+ })
110
+
111
+ /** One axis overlaps when either side is `'any'` or they are equal — the
112
+ * fixture's `axisOk`, verbatim. */
113
+ const axisOverlaps = (a: string, b: string): boolean =>
114
+ a === 'any' || b === 'any' || a === b
115
+
116
+ /** Conditions overlap when ALL three axes do — the fixture's `overlaps`. */
117
+ export const conditionsOverlap = (
118
+ a: ResolvedCondition,
119
+ b: ResolvedCondition,
120
+ ): boolean =>
121
+ axisOverlaps(a.who, b.who) &&
122
+ axisOverlaps(a.device, b.device) &&
123
+ axisOverlaps(a.locale, b.locale)
124
+
125
+ /** The narrower of two overlapping axes. Only meaningful when the axes
126
+ * overlap — `'any'` yields the other side, equals yield themselves. */
127
+ const axisIntersection = (a: string, b: string): string => (a === 'any' ? b : a)
128
+
129
+ /** The region two OVERLAPPING conditions share — what an override badge
130
+ * names ("overridden for fr"), per reading B's requirement that the loser's
131
+ * badge says which visitors it lost. */
132
+ export const conditionIntersection = (
133
+ a: ResolvedCondition,
134
+ b: ResolvedCondition,
135
+ ): ResolvedCondition => ({
136
+ who: axisIntersection(a.who, b.who),
137
+ device: axisIntersection(a.device, b.device),
138
+ locale: axisIntersection(a.locale, b.locale),
139
+ })
140
+
141
+ /** Whether `winner` covers ALL of `loser` — `'any'` or equal on every axis.
142
+ * Covered entirely means the loser renders for nobody. */
143
+ const conditionCovers = (
144
+ winner: ResolvedCondition,
145
+ loser: ResolvedCondition,
146
+ ): boolean =>
147
+ (winner.who === 'any' || winner.who === loser.who) &&
148
+ (winner.device === 'any' || winner.device === loser.device) &&
149
+ (winner.locale === 'any' || winner.locale === loser.locale)
150
+
151
+ /** The non-`'any'` axes joined for a badge or a sentence — the fixture's
152
+ * `condLabel`, generalised to free axis values. `'everyone'` when nothing
153
+ * narrows. */
154
+ export const conditionLabel = (condition: ResolvedCondition): string => {
155
+ const parts = Array.filter(
156
+ [condition.who, condition.device, condition.locale],
157
+ axis => axis !== 'any',
158
+ )
159
+ return parts.length === 0 ? 'everyone' : parts.join(' · ')
160
+ }
161
+
162
+ /* ============================================================
163
+ The reading-B resolution.
164
+ ============================================================ */
165
+
166
+ /**
167
+ * Where a resolved placement was declared. `rule` carries the label alongside
168
+ * the id so a badge or a check sentence can name the rule the way a human
169
+ * does ("from Product pages") without a join back into the topology — never
170
+ * show a storage key where a human expects a name.
171
+ */
172
+ export type PlacementSource =
173
+ | { readonly _tag: 'route' }
174
+ | { readonly _tag: 'rule'; readonly ruleId: string; readonly label: string }
175
+
176
+ /**
177
+ * One override this placement lost: who beat it, and WHERE. `where` is the
178
+ * overlap of the two conditions — under reading B the winner only takes that
179
+ * region, so the badge names it ("overridden for fr") rather than claiming
180
+ * the whole placement. `entire` is true when the overlap covers this
181
+ * placement's whole condition — it renders for nobody.
182
+ */
183
+ export type PlacementOverride = {
184
+ readonly by: PlacementSource
185
+ readonly winnerId: string
186
+ readonly where: ResolvedCondition
187
+ readonly entire: boolean
188
+ }
189
+
190
+ /**
191
+ * - `active` — renders wherever its condition holds.
192
+ * - `partially-overridden` — a later layer took part of its region; it still
193
+ * renders for everybody outside the overlap, so
194
+ * the checks still check it.
195
+ * - `overridden` — some later placement covers its whole region;
196
+ * it renders for nobody and raises no issues.
197
+ */
198
+ export type ResolvedState = 'active' | 'partially-overridden' | 'overridden'
199
+
200
+ /**
201
+ * One placement as the RESOLUTION sees it: record defaults made explicit
202
+ * (condition axes to `'any'`, `envs` to all three, `values` to `{}`),
203
+ * provenance attached, override state decided. The losers of an override stay
204
+ * in this list — the screen renders them struck-through, not deleted — which
205
+ * is why `state` exists instead of the losers being filtered out.
206
+ *
207
+ * `id` is UNIQUE within one resolution and stable across re-resolutions of the
208
+ * same topology — the Pages screen selects by it across polls and env
209
+ * switches, `overriddenBy.winnerId` matches on it, and every check issue is
210
+ * routed to a placement by it, so a shared id is simultaneously two cards
211
+ * highlighted for one selection, one inspector merging two placements' issues,
212
+ * and a check-4 warning silently suppressed (`overridePair` matches
213
+ * `winnerId === other.id`).
214
+ *
215
+ * It is the declared id when the record has one AND that id is declared
216
+ * exactly once in this resolution; otherwise it is (or is qualified by) a
217
+ * synthesized fallback — `/price|0|route:price-ticker@main-slot#0`: route
218
+ * path, layer, source key, tag, slot, index within its layer. See
219
+ * `resolvedIdOf` for the collision rule.
220
+ */
221
+ export type ResolvedPlacement = {
222
+ readonly id: string
223
+ readonly tag: string
224
+ readonly slotId: string
225
+ readonly source: PlacementSource
226
+ readonly condition: ResolvedCondition
227
+ readonly envs: ReadonlyArray<WireEnv>
228
+ readonly values: Readonly<Record<string, string>>
229
+ readonly span: Option.Option<PlacementSpan>
230
+ readonly order: Option.Option<number>
231
+ readonly state: ResolvedState
232
+ readonly overriddenBy: ReadonlyArray<PlacementOverride>
233
+ }
234
+
235
+ const ALL_ENVS: ReadonlyArray<WireEnv> = ['dev', 'preview', 'prod']
236
+
237
+ /** A placement mid-resolution: flattened out of its layer, defaults applied,
238
+ * id not yet settled, contest not yet run. */
239
+ type LayeredPlacement = {
240
+ readonly layer: number
241
+ /** The record's own `id`, or `undefined` for a Phase-4 record without one. */
242
+ readonly declaredId: string | undefined
243
+ /** The synthesized key — unique within a resolution BY CONSTRUCTION, see
244
+ * `fallbackId`. */
245
+ readonly fallbackId: string
246
+ readonly source: PlacementSource
247
+ readonly condition: ResolvedCondition
248
+ readonly placement: TopologyPlacement
249
+ }
250
+
251
+ /** …with its id settled against the whole layered set. */
252
+ type IdentifiedPlacement = LayeredPlacement & { readonly id: string }
253
+
254
+ const sourceKey = (source: PlacementSource): string =>
255
+ source._tag === 'route' ? 'route' : source.ruleId
256
+
257
+ /**
258
+ * The synthesized key, unique within one resolution by construction.
259
+ *
260
+ * `layer` is what makes that a proof rather than a hope: layers are numbered
261
+ * 0…n over the MATCHING rules in declared order with the route's own layer
262
+ * last, so `(layer, index)` addresses exactly one placement — which is why the
263
+ * layer number is here even though `sourceKey` reads better. Without it a rule
264
+ * whose id is literally `"route"`, or two rules sharing an id, synthesize the
265
+ * route layer's keys verbatim.
266
+ *
267
+ * `path` is what makes it unique ACROSS routes, which the demo host's derived
268
+ * overview needs: that route resolves as the union of every component route's
269
+ * resolution, and a rule placement matching all six of them would otherwise
270
+ * carry one id six times.
271
+ *
272
+ * `index` is within the UNFILTERED layer, so a placement's id does not move
273
+ * when the env filter drops a sibling — the Pages screen keeps its selection
274
+ * across an env switch.
275
+ */
276
+ const fallbackId = (
277
+ path: string,
278
+ layer: number,
279
+ source: PlacementSource,
280
+ placement: TopologyPlacement,
281
+ index: number,
282
+ ): string =>
283
+ `${path}|${layer}|${sourceKey(source)}:${placement.tag}@${placement.slotId}#${index}`
284
+
285
+ const layerPlacements = (
286
+ path: string,
287
+ layer: number,
288
+ source: PlacementSource,
289
+ placements: ReadonlyArray<TopologyPlacement>,
290
+ ): ReadonlyArray<LayeredPlacement> =>
291
+ placements.map((placement, index) => ({
292
+ layer,
293
+ declaredId: placement.id,
294
+ fallbackId: fallbackId(path, layer, source, placement, index),
295
+ source,
296
+ condition: resolveCondition(placement.condition),
297
+ placement,
298
+ }))
299
+
300
+ /** How many times each DECLARED id appears across the whole layered set —
301
+ * computed before the env filter so the collision verdict, and therefore the
302
+ * id, does not change when the environment does. */
303
+ const declaredIdCounts = (
304
+ items: ReadonlyArray<LayeredPlacement>,
305
+ ): ReadonlyMap<string, number> => {
306
+ const counts = new Map<string, number>()
307
+ items.forEach(item => {
308
+ if (item.declaredId !== undefined) {
309
+ counts.set(item.declaredId, (counts.get(item.declaredId) ?? 0) + 1)
310
+ }
311
+ })
312
+ return counts
313
+ }
314
+
315
+ /**
316
+ * The collision rule, chosen deliberately (2026-08-18 review finding 2):
317
+ * **duplicate declared ids are DISAMBIGUATED, symmetrically, not rejected and
318
+ * not first-wins.**
319
+ *
320
+ * - Rejecting an authoring typo would turn it into a blank page in both
321
+ * shells' `activate` — this repo's signature failure mode — for a mistake
322
+ * that has a perfectly good rendering.
323
+ * - A check reporting the duplicate would still hand every consumer the
324
+ * colliding ids; the contest's `winnerId` matching and the checks' own
325
+ * issue routing would stay wrong, and the shells never run the checks.
326
+ * - First-wins would let one of the pair keep the declared id, which reads as
327
+ * authoritative and hides that a second placement is masquerading as it.
328
+ *
329
+ * So BOTH members of a collision are qualified with their synthesized key.
330
+ * The result is deterministic in the topology alone: re-resolving the same
331
+ * topology yields the same ids, so the screen's selection survives the poll.
332
+ */
333
+ const resolvedIdOf = (
334
+ item: LayeredPlacement,
335
+ counts: ReadonlyMap<string, number>,
336
+ ): string => {
337
+ if (item.declaredId === undefined) {
338
+ return item.fallbackId
339
+ }
340
+ return (counts.get(item.declaredId) ?? 0) > 1
341
+ ? `${item.declaredId}~${item.fallbackId}`
342
+ : item.declaredId
343
+ }
344
+
345
+ /**
346
+ * The reading-B resolution — the function
347
+ * `wiki/framework/composition/question-does-the-override-contest-consider-condition.md`
348
+ * describes, pinned:
349
+ *
350
+ * 1. Layers broadest-first: every matching rule in DECLARED order, then the
351
+ * route's own placements last. A topology with no `rules` key resolves to
352
+ * exactly its route mounts — the identity, byte-identical to Phase 4.
353
+ * 2. A placement is filtered out when its `envs` (default all three) exclude
354
+ * `env` — a placement that does not exist in this environment cannot
355
+ * render here and cannot override anything here either.
356
+ * 3. The contest: a placement in a strictly LATER layer with the same
357
+ * `(tag, slotId)` whose condition overlaps an earlier one overrides it —
358
+ * but only WHERE they overlap. The loser survives where not overlapped
359
+ * (`partially-overridden`), and is `overridden` only when some winner
360
+ * covers its whole condition. Two placements in the SAME layer never
361
+ * override each other — they are peers at one specificity, and colliding
362
+ * peers are the checks' business (checks 4/7), not the contest's.
363
+ * 4. Losers are KEPT in the output with provenance and `overriddenBy` — the
364
+ * spec renders them struck-through with a badge naming the condition, not
365
+ * deleted.
366
+ */
367
+ export const resolvePlacements = (
368
+ topology: HostTopology,
369
+ path: string,
370
+ env: WireEnv,
371
+ ): ReadonlyArray<ResolvedPlacement> => {
372
+ const matchingRules = Array.filter(topology.rules ?? [], rule =>
373
+ ruleMatches(rule, path),
374
+ )
375
+ const routeMounts = Option.match(
376
+ Array.findFirst(topology.routes, route => route.path === path),
377
+ {
378
+ onNone: (): ReadonlyArray<TopologyPlacement> => [],
379
+ onSome: route => route.mounts,
380
+ },
381
+ )
382
+
383
+ const ruleLayers = matchingRules.map((rule, index) =>
384
+ layerPlacements(
385
+ path,
386
+ index,
387
+ { _tag: 'rule', ruleId: rule.id, label: rule.label },
388
+ rule.placements,
389
+ ),
390
+ )
391
+ const routeLayer = layerPlacements(
392
+ path,
393
+ matchingRules.length,
394
+ { _tag: 'route' },
395
+ routeMounts,
396
+ )
397
+
398
+ // Ids settle over the WHOLE layered set, before the env filter — see
399
+ // `declaredIdCounts`. Every downstream reference (`winnerId`, the checks'
400
+ // `placementId`, the screen's selection) then names exactly one placement.
401
+ const all = [...Array.flatten(ruleLayers), ...routeLayer]
402
+ const counts = declaredIdCounts(all)
403
+ const layered: ReadonlyArray<IdentifiedPlacement> = Array.filter(
404
+ all.map(item => ({ ...item, id: resolvedIdOf(item, counts) })),
405
+ item => Array.contains(item.placement.envs ?? ALL_ENVS, env),
406
+ )
407
+
408
+ return layered.map(item => {
409
+ // `getSomes` over `map`, not `filterMap` — v4's `filterMap` wants a
410
+ // `Result` and silently drops `Option`s (the AGENTS.md trap).
411
+ const overriddenBy = Array.getSomes(
412
+ Array.map(layered, (other): Option.Option<PlacementOverride> =>
413
+ other.layer > item.layer &&
414
+ other.placement.tag === item.placement.tag &&
415
+ other.placement.slotId === item.placement.slotId &&
416
+ conditionsOverlap(other.condition, item.condition)
417
+ ? Option.some({
418
+ by: other.source,
419
+ winnerId: other.id,
420
+ where: conditionIntersection(other.condition, item.condition),
421
+ entire: conditionCovers(other.condition, item.condition),
422
+ })
423
+ : Option.none(),
424
+ ),
425
+ )
426
+ const state: ResolvedState = Array.some(
427
+ overriddenBy,
428
+ override => override.entire,
429
+ )
430
+ ? 'overridden'
431
+ : overriddenBy.length > 0
432
+ ? 'partially-overridden'
433
+ : 'active'
434
+ return {
435
+ id: item.id,
436
+ tag: item.placement.tag,
437
+ slotId: item.placement.slotId,
438
+ source: item.source,
439
+ condition: item.condition,
440
+ envs: item.placement.envs ?? ALL_ENVS,
441
+ values: item.placement.values ?? {},
442
+ span: Option.fromNullishOr(item.placement.span),
443
+ order: Option.fromNullishOr(item.placement.order),
444
+ state,
445
+ overriddenBy,
446
+ }
447
+ })
448
+ }
@@ -0,0 +1,46 @@
1
+ import { Option, Schema as S } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { HostSlotManifest } from './slots.ts'
5
+
6
+ const decodeManifest = S.decodeUnknownOption(HostSlotManifest)
7
+
8
+ /**
9
+ * A real-shaped manifest, exercising every slot kind: the demo host's
10
+ * per-section `band`s, the platform's three `rail`s (fixed-width sidebar per
11
+ * `apps/platform/index.html`), a `bar`, and a `grid` — the grid kind is
12
+ * fixture-only per the design page ("no invented grid slots" on real hosts).
13
+ */
14
+ const RAW_MANIFEST = {
15
+ theme: { name: '@bespokeagentics/microdots-theme', version: '1.4.0' },
16
+ slots: [
17
+ { id: 'top-bar', kind: 'bar', row: 0 },
18
+ { id: 'hero-band', kind: 'band', row: 1 },
19
+ { id: 'app-list-slot', kind: 'rail', row: 2, width: '280px' },
20
+ { id: 'main-grid', kind: 'grid', row: 2, capacity: 4 },
21
+ ],
22
+ }
23
+
24
+ // The failure mode: the slot manifest is what placement check 2 joins
25
+ // `slotId` against — a manifest the schema silently mangles (a dropped
26
+ // `width`, a defaulted `capacity`) or loosely admits (an invented kind) makes
27
+ // check 2 lie in both directions: real slots reported missing, or misspelled
28
+ // kinds laid out as something they are not.
29
+ describe('HostSlotManifest', () => {
30
+ test('decodes a real-shaped manifest byte-identically', () => {
31
+ const parsed: unknown = JSON.parse(JSON.stringify(RAW_MANIFEST))
32
+ // `toEqual(Option.some(parsed))` pins that decoding neither drops the
33
+ // optional `width`/`capacity` keys nor invents defaults for them.
34
+ expect(decodeManifest(parsed)).toEqual(Option.some(parsed))
35
+ })
36
+
37
+ test('rejects a slot whose kind is not bar, band, rail or grid', () => {
38
+ const parsed: unknown = JSON.parse(
39
+ JSON.stringify({
40
+ theme: RAW_MANIFEST.theme,
41
+ slots: [{ id: 'top-ribbon', kind: 'ribbon', row: 0 }],
42
+ }),
43
+ )
44
+ expect(Option.isNone(decodeManifest(parsed))).toBe(true)
45
+ })
46
+ })
package/src/slots.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { Schema as S } from 'effect'
2
+
3
+ /**
4
+ * The host SLOT MANIFEST — a host declaring, as data, where MicroDots may go.
5
+ *
6
+ * Phase 5 makes placement data the way Phase 4 made wires data, and the slot
7
+ * manifest is the half the HOST owns: the theme's declared slots, versioned.
8
+ * It lives inside `host-topology.json` as `HostTopology.slotManifest` rather
9
+ * than in a second per-host file — the topology is already "a whole host, as
10
+ * data", and a second file would be a second thing to drift. See
11
+ * `wiki/plans/shipped/microdots-platform-phase-5-pages-design.md`
12
+ * work item 1.
13
+ *
14
+ * The `theme.version` is load-bearing: placement check 2's "valid under an
15
+ * earlier theme version" sentence cannot exist without a version to have been
16
+ * earlier than.
17
+ */
18
+
19
+ /**
20
+ * What shape a slot is, which decides how placements lay out inside it:
21
+ *
22
+ * - `bar` / `band` — a full-width horizontal strip; placements stack, position
23
+ * is `order`.
24
+ * - `rail` — a fixed-width vertical column; placements stack full-width.
25
+ * - `grid` — twelve columns; placements carry a `span` (12/8/6/4/3 of 12).
26
+ */
27
+ export const SlotKind = S.Literals(['bar', 'band', 'rail', 'grid'])
28
+ export type SlotKind = typeof SlotKind.Type
29
+
30
+ /**
31
+ * One declared slot. `row` is the vertical band the slot occupies — slots
32
+ * sharing a row sit side by side (the fixture's sidebar / main / aside).
33
+ * `width` is a CSS length for a fixed `rail` (`'280px'`); `capacity` is how
34
+ * many placements the slot is meant to hold. Both `optionalKey` (not
35
+ * `optional`) so the decoded Type is exact-optional under
36
+ * `exactOptionalPropertyTypes`, matching the rest of the topology schemas.
37
+ */
38
+ export const SlotSpec = S.Struct({
39
+ id: S.String,
40
+ kind: SlotKind,
41
+ row: S.Number,
42
+ width: S.optionalKey(S.String),
43
+ capacity: S.optionalKey(S.Number),
44
+ })
45
+ export type SlotSpec = typeof SlotSpec.Type
46
+
47
+ /**
48
+ * The whole manifest: which theme (at which version) declared which slots.
49
+ * Placement check 2 joins `TopologyPlacement.slotId` against `slots[].id`; a
50
+ * topology WITHOUT a slot manifest predates this record, and the checks
51
+ * degrade honestly — slot checks are reported unverifiable, never silently
52
+ * green (see `./placementChecks.ts`).
53
+ */
54
+ export const HostSlotManifest = S.Struct({
55
+ theme: S.Struct({ name: S.String, version: S.String }),
56
+ slots: S.Array(SlotSpec),
57
+ })
58
+ export type HostSlotManifest = typeof HostSlotManifest.Type