unoverse 0.1.182 → 0.1.184

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.
@@ -2,14 +2,19 @@
2
2
  # DEPLOY IMAGES — the images half of the platform deploy.
3
3
  #
4
4
  # Pull the latest DOCR images and restart. That's the whole deployment:
5
- # - platform code (incl. plugin-base, baked in the unoverse image) = the images
6
- # - marketplace nodes CONVERGE at boot (keep-latest: the shared installed_plugins
7
- # row is "the newest version anyone chose"; boot reinstalls anything missing
8
- # or older — see runtime/plugins/startup.ts)
5
+ # - platform code = the images
6
+ # - node manifests are declarative YAML, loaded at boot
9
7
  # - component nodes synthesize from design/ definitions at boot (no build, ever)
8
+ # - marketplace CONTENT is database rows and does not ride a deploy at all
10
9
  #
11
- # The CLI's `unoverse deploy` runs this first, then deploy-packages.yml (the
12
- # local-source half). Use directly only when images alone changed.
10
+ # CORRECTED 2026-08-30. This used to say marketplace nodes "CONVERGE at boot (keep-latest:
11
+ # the shared installed_plugins row... boot reinstalls anything missing or older — see
12
+ # runtime/plugins/startup.ts)". That module is deleted, along with the whole npm install
13
+ # lane; nothing is fetched from a registry (MARKETPLACE.md §10 q3). It also named
14
+ # plugin-base, retired to _legacy, and chained to deploy-packages.yml, which does not
15
+ # exist. Three things this playbook claimed on every deploy, none of them true.
16
+ #
17
+ # The CLI's `unoverse deploy` runs this. Use directly only when images alone changed.
13
18
  #
14
19
  # Usage (via CLI — recommended):
15
20
  # unoverse deploy
@@ -71,10 +76,17 @@
71
76
  debug:
72
77
  msg: "{{ pull_result.stdout_lines[-8:] | default(['done']) }}"
73
78
 
74
- - name: "[3/4] Restart on the new images (marketplace converges at boot)"
79
+ # A DEPLOY TAKES THE NEWEST NODE RUNTIME. The entrypoint installs
80
+ # @unoverse-platform/base within the range the image declares, but it only goes to the
81
+ # registry when the volume is cold or the range moved — otherwise a restart would be a
82
+ # network call, and a restart must be instant and offline. A DEPLOY is the moment the
83
+ # operator asked for "make this current" (MARKETPLACE.md §5a), so it says so explicitly.
84
+ # Scoped to this one `up`, never written into .env: a later `docker restart` must not
85
+ # inherit it.
86
+ - name: "[3/4] Restart on the new images (and take the newest base in range)"
75
87
  shell: |
76
88
  cd {{ gravity_dir }}
77
- docker compose up -d 2>&1 | tail -6
89
+ UNOVERSE_BASE_REFRESH=1 docker compose up -d 2>&1 | tail -6
78
90
  register: up_result
79
91
 
80
92
  # `up -d` RECREATES ON CONFIG CHANGE, NOT ON IMAGE CHANGE — not reliably, and not on
@@ -120,7 +132,8 @@
120
132
  ============================================
121
133
  Host: {{ inventory_hostname }} ({{ ansible_host }})
122
134
  {{ status_result.stdout }}
123
- Marketplace nodes converge to the recorded
124
- versions at boot; component nodes synthesize
125
- from design/ definitions.
135
+ Node manifests load at boot; component nodes
136
+ synthesize from design/ definitions.
137
+ Marketplace content is rows and is unchanged
138
+ by a deploy.
126
139
  ============================================
@@ -80,14 +80,18 @@
80
80
  chdir: /opt/gravity
81
81
  when: restore_umap
82
82
 
83
+ # THROUGH THE DOCKER NETWORK, not the host. umap publishes no port (docker-compose.yml,
84
+ # 2026-08-30: it is internal to Docker, full stop), so `http://localhost:5001` reaches
85
+ # nothing from here and this wait would spin for 60s and fail a good restore.
83
86
  - name: Wait for UMAP to be healthy
84
- uri:
85
- url: "http://localhost:5001/health"
86
- status_code: 200
87
+ shell: |
88
+ cd /opt/gravity
89
+ docker compose exec -T umap python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5001/health', timeout=5).status == 200 else 1)"
87
90
  register: health_check
88
- until: health_check.status == 200
91
+ until: health_check.rc == 0
89
92
  retries: 30
90
93
  delay: 2
94
+ changed_when: false
91
95
  when: restore_umap
92
96
 
93
97
  - name: Cleanup restore files
@@ -36,12 +36,14 @@
36
36
  host: localhost
37
37
  port: "{{ item.port }}"
38
38
  timeout: 5
39
+ # UMAP is deliberately absent: it publishes no port to the host (internal to Docker
40
+ # only). Its reachability is proved by the Docker-DNS check below, which is the only
41
+ # thing that was ever actually load-bearing for it.
39
42
  loop:
40
43
  - { name: "Canvas", port: 3001 }
41
44
  - { name: "Engine", port: 4101 }
42
45
  - { name: "Unoverse", port: 4105 }
43
46
  - { name: "Memory", port: 4104 }
44
- - { name: "UMAP", port: 5001 }
45
47
  register: port_checks
46
48
  ignore_errors: yes
47
49
 
@@ -83,7 +85,6 @@
83
85
  - { name: "Engine", port: 4101 }
84
86
  - { name: "Unoverse", port: 4105 }
85
87
  - { name: "Memory", port: 4104 }
86
- - { name: "UMAP", port: 5001 }
87
88
  register: health_checks
88
89
  ignore_errors: yes
89
90
 
@@ -162,10 +163,12 @@
162
163
  register: plugin_check
163
164
  ignore_errors: yes
164
165
 
165
- # NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount that was retired
166
- # when nodes moved to npm at runtime plus the marketplace. Nothing declares
166
+ # NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount retired when nodes
167
+ # became declarative YAML and marketplace content became rows. Nothing declares
167
168
  # PACKAGES_PATH any more and docker-compose.yml has no such volume, so the check could
168
169
  # only ever report 0 and tell the operator to run a command that would not fix it.
170
+ # (The original note said nodes "moved to npm at runtime"; that lane never survived and
171
+ # was removed outright on 2026-08-30. MARKETPLACE.md §10 q3.)
169
172
 
170
173
  # SINCE THE SERVICES RESTARTED, NOT SINCE FIVE MINUTES AGO.
171
174
  #
@@ -130,11 +130,35 @@ services:
130
130
  # Studio is NOT a platform image (removed 2026-07-28): it ships from npm and
131
131
  # connects to a universe over the API with a publish key.
132
132
 
133
- # UMAP Service - Python ML service
133
+ # UMAP Service - Python ML service.
134
+ #
135
+ # THE ENGINE REACHES IT AS http://umap:5001 OVER THE DOCKER NETWORK, which needs no
136
+ # published port at all. It used to read "5001:5001", which binds 0.0.0.0 and put an ML
137
+ # service on the internet, and until 2026-08-30 that service also failed OPEN:
138
+ # its `require_auth` returned the handler unguarded whenever OIDC was unconfigured, and no
139
+ # AUTH_ISSUER was ever passed here. So `/train` answered anyone who could reach the port.
140
+ #
141
+ # It was the only service in this file that was both externally bound and ungated: 4101 and
142
+ # 4106 are loopback-pinned with comments saying never to widen them, and 4105 is published
143
+ # but JWT-gated. This one had neither half.
144
+ #
145
+ # NOT PUBLISHED AT ALL. Owner ruling 2026-08-30: this is internal to Docker, full stop.
146
+ # There is no `ports:` block below and that is the whole point — a service reachable only
147
+ # over the compose network cannot be bound to the wrong interface by a later edit, because
148
+ # there is no line to get wrong. 4101 and 4106 publish to loopback because a developer's
149
+ # tooling on the host genuinely needs them; nothing on the host needs this.
150
+ #
151
+ # Auth stays configured anyway. The network boundary and the gate are not alternatives,
152
+ # and this service is the proof: it had a `ports:` line and a fail-open decorator, so
153
+ # losing either one lost everything.
134
154
  umap:
135
155
  image: registry.digitalocean.com/gravity-repo/umap:latest
136
- ports:
137
- - "5001:5001"
156
+ environment:
157
+ # app.py refuses to boot if auth is on and these are missing (assert_auth_startup),
158
+ # which is the point: an unconfigured deployment fails loudly instead of open.
159
+ - AUTH_ENABLED=${AUTH_ENABLED:-true}
160
+ - AUTH_ISSUER=${AUTH_ISSUER}
161
+ - AUTH_AUDIENCE=${AUTH_AUDIENCE}
138
162
  volumes:
139
163
  - umap_models:/app/models
140
164
  logging: *default-logging
@@ -251,8 +275,20 @@ services:
251
275
  # copy carrying a built dist. Without this the runtime hits MODULE_NOT_FOUND and
252
276
  # the nodes show "Package not installed".
253
277
  - NODE_PATH=/app/host_node_modules:/app/plugins/node_modules
278
+ # PASSTHROUGH, not a setting. `unoverse deploy` runs `UNOVERSE_BASE_REFRESH=1 docker
279
+ # compose up -d` to mean "take the newest node runtime in range"; without this line
280
+ # that variable would reach the compose CLI and stop there, never the container.
281
+ # Defaults to 0, so an ordinary restart stays offline and instant.
282
+ - UNOVERSE_BASE_REFRESH=${UNOVERSE_BASE_REFRESH:-0}
254
283
  volumes:
255
284
  - unoverse_plugins:/app/plugins
285
+ # THE NODE RUNTIME, which this image does not carry (MARKETPLACE.md §5a). The
286
+ # entrypoint installs @unoverse-platform/base here at startup, within the range
287
+ # apps/unoverse/package.json declares, and symlinks it into
288
+ # /app/node_modules/@unoverse-platform/base. A volume rather than a layer so a plain
289
+ # restart is offline and instant: the fetch happens on a cold volume, or when the
290
+ # declared range changes, or when a deploy sets UNOVERSE_BASE_REFRESH=1.
291
+ - unoverse_base:/app/base
256
292
  # ── Platform-dev mounts (monorepo only; sync-starter strips these three
257
293
  # lines from the starter's copy — a customer universe serves what the DB
258
294
  # says). Local edits are picked up on restart; design/prompts are data, no build.
@@ -371,3 +407,4 @@ services:
371
407
  volumes:
372
408
  umap_models:
373
409
  unoverse_plugins: # Marketplace-installed node packages (persisted across restarts)
410
+ unoverse_base: # @unoverse-platform/base, installed at startup (MARKETPLACE.md §5a)
@@ -77,6 +77,32 @@ cmd_check() {
77
77
  done
78
78
  echo ""
79
79
 
80
+ # 1b. THE NODE RUNTIME THIS UNIVERSE IS ACTUALLY RUNNING.
81
+ #
82
+ # base is installed at startup, not baked into the image (MARKETPLACE.md §5a), so a
83
+ # universe's platform version and its runtime version are two different numbers that move
84
+ # independently. Nobody updates what they cannot see: this is the line that turns "which
85
+ # base am I on?" into something answerable, and shows the gap when the registry has newer.
86
+ # Read out of the RUNNING container rather than off this machine's disk, because the
87
+ # container is the only thing whose answer counts.
88
+ local base_installed base_range base_latest
89
+ base_installed=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
90
+ node -p "require('/app/node_modules/@unoverse-platform/base/package.json').version" 2>/dev/null | tr -d '\r')
91
+ if [ -n "$base_installed" ]; then
92
+ base_range=$(docker compose -f "$ROOT/docker-compose.yml" exec -T unoverse \
93
+ node -p "require('/app/apps/unoverse/package.json').dependencies['@unoverse-platform/base']" 2>/dev/null | tr -d '\r')
94
+ base_latest=$(curl -s --max-time 5 "https://registry.npmjs.org/-/package/@unoverse-platform%2Fbase/dist-tags" 2>/dev/null \
95
+ | node -pe 'try{JSON.parse(require("fs").readFileSync(0,"utf8")).latest ?? ""}catch(e){""}' 2>/dev/null)
96
+ if [ -n "$base_latest" ] && [ "$base_latest" != "$base_installed" ]; then
97
+ # NOT a failure. A universe is entitled to sit on an older runtime; it is not entitled
98
+ # to be unable to find out. `deploy` and `update` are what take the newer one.
99
+ warn "node runtime ${BOLD}$base_installed${NC} ${DIM}(range $base_range · npm has $base_latest — 'unoverse deploy' takes it)${NC}"
100
+ else
101
+ ok "node runtime ${BOLD}$base_installed${NC} ${DIM}(range ${base_range:-unknown})${NC}"
102
+ fi
103
+ echo ""
104
+ fi
105
+
80
106
  # 2. Health endpoints
81
107
  for endpoint in 4105:unoverse 4101:engine 5001:umap 4104:memory; do
82
108
  local port="${endpoint%%:*}" name="${endpoint##*:}"
@@ -40,7 +40,8 @@ cmd_db_verify() {
40
40
  ],
41
41
  workflow_executions: [
42
42
  "execution_id", "workflow_id", "status", "start_time", "end_time",
43
- "duration", "error", "result", "created_at", "was_from_cache", "source"
43
+ "duration", "error", "result", "created_at", "was_from_cache", "source",
44
+ "user_id"
44
45
  ],
45
46
  node_traces: [
46
47
  "trace_id", "execution_id", "node_id", "node_type", "start_time",
@@ -52,7 +53,7 @@ cmd_db_verify() {
52
53
  ],
53
54
  token_usage: [
54
55
  "id", "workflow_id", "execution_id", "node_id", "node_type",
55
- "model", "usage", "timestamp", "created_at"
56
+ "model", "usage", "timestamp", "created_at", "user_id"
56
57
  ],
57
58
  raw_messages: [
58
59
  "id", "user_id", "workflow_id", "conversation_id", "chat_id",
@@ -892,9 +892,13 @@ EOF
892
892
  case "$subcommand" in
893
893
  ""|deploy)
894
894
  # THE deploy: the server takes the latest platform images (pull + restart).
895
- # Content does NOT ride deploys — it arrives via git (`unoverse update`
896
- # pulls the starter clone incl. the carve-out), the marketplace (DB-driven,
897
- # self-healing at boot) and, when the gate lands, Studio publish.
895
+ # Content does NOT ride deploys — it arrives from the marketplace as database
896
+ # ROWS (`POST /marketplace/install`) or from `unoverse deploy studio`.
897
+ #
898
+ # "self-healing at boot" was the npm install lane (`plugins/startup.ts`),
899
+ # deleted 2026-08-30 — nothing is fetched from a registry (MARKETPLACE.md §10 q3).
900
+ # The one fetch that is coming is the node runtime, `@unoverse-platform/base`,
901
+ # installed here within a range the image declares (§5a, NOT YET BUILT).
898
902
  info "Deploying platform images..."
899
903
  echo ""
900
904
  ansible-playbook \
@@ -155,6 +155,12 @@ case "${1:-}" in
155
155
  exit 1
156
156
  fi
157
157
  if [ -n "$(docker compose -f "$ROOT/docker-compose.yml" ps -q 2>/dev/null)" ]; then
158
+ # THE NODE RUNTIME UPDATES LIKE THE IMAGES DO. base is installed at startup, not
159
+ # baked (MARKETPLACE.md §5a), and the entrypoint only goes to the registry on a cold
160
+ # volume or a changed range — otherwise every restart would be a network call. An
161
+ # UPDATE is exactly the moment the operator asked for the newest of everything, so
162
+ # it says so. Scoped to this command; nothing is written to .env.
163
+ UNOVERSE_BASE_REFRESH=1 \
158
164
  docker compose -f "$ROOT/docker-compose.yml" --env-file "$ROOT/.env" up -d --remove-orphans >/dev/null 2>&1 \
159
165
  && ok "Running services moved to the new images"
160
166
  # MIGRATIONS RUN ON UPDATE, same as deploy and start. An update is exactly when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.182",
3
+ "version": "0.1.184",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -93,9 +93,9 @@ export function collectProject(designRoot, project) {
93
93
  // `course-card` or `hr-education-coach`, so the org must be in the name for the
94
94
  // rows to coexist (the same trick a style row already uses: its name IS the org).
95
95
  // A prompt block is NEVER qualified this way — see the blocks/ walk below for why.
96
- // Templates keep their bare ids: those are org-qualified by convention
97
- // (`<org>-chat-layout`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
98
- const QUALIFIED_KINDS = new Set(["component", "skill", "design-template"]);
96
+ // Apps keep their bare ids: those are org-qualified by convention
97
+ // (`<org>-chat`). docs/unoverse/UNOVERSE_COMPONENT_ORGS.md.
98
+ const QUALIFIED_KINDS = new Set(["component", "skill", "template"]);
99
99
  const add = (kind, name, definition) => items.push({
100
100
  kind,
101
101
  name: QUALIFIED_KINDS.has(kind) ? `${project}/${name}` : name,
@@ -104,9 +104,9 @@ export function collectProject(designRoot, project) {
104
104
  org: project,
105
105
  base_version,
106
106
  });
107
- // `templates` publishes as kind `template`, NOT `recipe`. A recipe is a workflow graph
107
+ // `apps` publishes as kind `app`, NOT `recipe`. A recipe is a workflow graph
108
108
  // that is copied onto a canvas and never installed; an app is installed and tracked, so
109
- // taking a newer version is the point rather than a hazard (migration 017).
109
+ // taking a newer version is the point rather than a hazard (migrations 017 + 029).
110
110
  //
111
111
  // A DEFINITION IS EITHER A FOLDER OR A SINGLE FILE, and both are normal. A component
112
112
  // that grew layouts and states is a folder; an atom is one file, and templates are a
@@ -115,11 +115,11 @@ export function collectProject(designRoot, project) {
115
115
  //
116
116
  // Both become the same shape, a map of relative path to contents, so nothing downstream
117
117
  // has to care which it was. A single file is simply a map with one entry.
118
- // `templates/` (the Template Model kind, 2026-08-28) stores as `design-template`:
119
- // the natural word `template` is FROZEN as the apps' stored kind (migration-free
120
- // legacy), and overloading it would make hydrate guess. Forgetting this dir here
121
- // shipped design deploys that silently dropped every authored template.
122
- for (const [dir, kind] of [["components", "component"], ["apps", "template"], ["templates", "design-template"], ["atoms", "atom"]]) {
118
+ // APP MEANS APP, TEMPLATE MEANS TEMPLATE (owner ruling 2026-08-28, migration
119
+ // 029): apps publish as kind `app` and the Template Model kind owns the bare
120
+ // word. Forgetting a design dir here ships deploys that silently drop every
121
+ // artifact of that kind.
122
+ for (const [dir, kind] of [["components", "component"], ["apps", "app"], ["templates", "template"], ["atoms", "atom"]]) {
123
123
  const home = join(root, dir);
124
124
  if (!existsSync(home))
125
125
  continue;
@@ -14,11 +14,39 @@
14
14
  */
15
15
  import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
16
16
  import { join, dirname, basename, relative, sep } from "node:path";
17
- import { RAW_VALUE, PARTIAL_DIRS } from "./vocabulary.mjs";
17
+ import { RAW_VALUE, PARTIAL_DIRS, PROP_KEYS, INTERFACE_FIELDS, AUTHORED_CONTROLS } from "./vocabulary.mjs";
18
18
  import { isDefFile, defName, defPath, readDef, parseDef } from "./defs.mjs";
19
19
 
20
+ /**
21
+ * THE PROPS MAP IS THE CONTRACT (UNOVERSE_AUTHORING §3b, ruled 2026-08-30). Every field's
22
+ * description, limits, mock and hydration live here now, so this is where they are held
23
+ * to a shape — and where a prop wearing an INTERFACE FIELD's name is told what happens
24
+ * when a delivery carries that field.
25
+ */
26
+ function checkProps(props, file, report, path) {
27
+ if (!props || typeof props !== "object" || Array.isArray(props)) return;
28
+ for (const [name, spec] of Object.entries(props)) {
29
+ if (name.startsWith("_comment")) continue; // a JSON comment, not a prop
30
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) continue;
31
+ const where = path ? `${path}${name}` : name;
32
+ for (const k of Object.keys(spec))
33
+ if (!PROP_KEYS.includes(k))
34
+ report("error", file, `prop "${where}" has no "${k}". A prop reads ${PROP_KEYS.map((x) => `"${x}"`).join(", ")} — everything it needs to be understood and written (UNOVERSE_AUTHORING §3b)`);
35
+ // THE DELIVERY WINS. A row fills a component by name with no mapping layer
36
+ // (docs.unoverse.ai/design/interface-data), so `title` on a content-attached card is
37
+ // the row's title. Using the name is right and expected; writing a brief for a field
38
+ // the delivery supplies is the part that quietly never applies.
39
+ if (!path && INTERFACE_FIELDS.has(name)) {
40
+ const controls = AUTHORED_CONTROLS.filter((k) => spec[k] !== undefined);
41
+ if (controls.length)
42
+ report("warn", file, `prop "${name}" is an INTERFACE FIELD, so a delivery fills it by name — and a row's value is not measured against the ${controls.map((c) => `"${c}"`).join(", ")} written here. Keep the name (binding is by name, never mapped); the limit applies only to a value an Agent writes (docs.unoverse.ai/design/interface-data)`);
43
+ }
44
+ if (spec.items && typeof spec.items === "object") checkProps(spec.items, file, report, `${where}.items.`);
45
+ }
46
+ }
47
+
20
48
  export function makeLintFile(ctx) {
21
- const { report, walkNode, isFixture, isHook, isManifest, isAppPath, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
49
+ const { report, walkNode, isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePart, defRoot, componentNamesForFile, DS, orgDirs, RX, readText } = ctx;
22
50
 
23
51
  function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
24
52
  if (!Array.isArray(order)) return;
@@ -42,6 +70,86 @@ function checkStateOrder(order, rootFolder, file, includeLayouts = false) {
42
70
  report("warn", file, `states/${name}.json is not in stateOrder. It falls to the end of the picker; add it to lock the order (docs.unoverse.ai/design/studio)`);
43
71
  }
44
72
 
73
+
74
+ // THE APP TREE CHECK (extracted 2026-08-29: the tree's home is the ENVELOPE —
75
+ // one folder grammar — while a manifest-held tree remains the legacy read, so
76
+ // both call sites validate identically).
77
+ function checkAppTree(json, root, file) {
78
+ if (json.states !== undefined) {
79
+ if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
80
+ report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
81
+ } else {
82
+ const names = Object.keys(json.states);
83
+ const base = json.layout ?? Object.keys(json.states)[0] ?? "main";
84
+ if (!names.includes(base))
85
+ report("error", file, `the template tree must include the BASE state "${base}" (named for the default layout) — the ladder derives as the top level minus it (STATE_MODEL §5)`);
86
+ const subs = [];
87
+ for (const [n, s] of Object.entries(json.states)) {
88
+ if (s !== null && (typeof s !== "object" || Array.isArray(s)))
89
+ report("error", file, `tree state "${n}" must be an object declaring its layout path (STATE_MODEL §5)`);
90
+ // EXPLICIT PATHS (owner ruling 2026-08-29): every state DECLARES the
91
+ // layout it draws as a root-relative path (layout: layouts/<file>);
92
+ // nothing is assumed from the state's name and no folder is magic.
93
+ const declared = s && typeof s === "object" && typeof s.layout === "string" ? s.layout : undefined;
94
+ if (!declared)
95
+ report("error", file, `tree state "${n}" declares no layout. Every state names its drawing as a path — layout: layouts/${n} (2026-08-29 ruling; the same-name default is retired) (STATE_MODEL §5)`);
96
+ else if (!defPath(root, declared))
97
+ report("error", file, `tree state "${n}" declares layout "${declared}" but no such file exists (STATE_MODEL §5)`);
98
+ const nested = s && typeof s === "object" ? s.states : undefined;
99
+ if (nested && typeof nested === "object" && !Array.isArray(nested))
100
+ for (const [sub, sv] of Object.entries(nested)) {
101
+ subs.push(sub);
102
+ const subDeclared = sv && typeof sv === "object" && typeof sv.layout === "string" ? sv.layout : undefined;
103
+ if (!subDeclared)
104
+ report("error", file, `substate "${sub}" (under "${n}") declares no layout. Every state names its drawing as a path — layout: layouts/${n}-${sub} (the owner-prefix naming convention survives as readability) (2026-08-29 ruling) (LAYERS §3)`);
105
+ else if (!defPath(root, subDeclared))
106
+ report("error", file, `substate "${sub}" (under "${n}") declares layout "${subDeclared}" but no such file exists (LAYERS §3)`);
107
+ }
108
+ }
109
+ for (const sub of subs)
110
+ if (names.includes(sub))
111
+ report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
112
+ // THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
113
+ // either a SCREEN (a bare name declaring a top-level state) or a substate
114
+ // drawing (<owner>-<sub>, both declared). Anything else is an error — a
115
+ // shared part belongs in components/.
116
+ {
117
+ const ldir = join(root, "layouts");
118
+ if (existsSync(ldir)) {
119
+ const declaredSubs = new Map();
120
+ for (const [n2, s2] of Object.entries(json.states)) {
121
+ const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
122
+ if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
123
+ for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
124
+ }
125
+ // A file NAMED by any state's/substate's declared path is accounted
126
+ // for wherever it sits (2026-08-29: declarations, not names, link).
127
+ const declaredPaths = new Set();
128
+ for (const s3 of Object.values(json.states)) {
129
+ if (s3 && typeof s3 === "object" && typeof s3.layout === "string") declaredPaths.add(s3.layout);
130
+ const nested3 = s3 && typeof s3 === "object" ? s3.states : undefined;
131
+ if (nested3 && typeof nested3 === "object" && !Array.isArray(nested3))
132
+ for (const sv3 of Object.values(nested3))
133
+ if (sv3 && typeof sv3 === "object" && typeof sv3.layout === "string") declaredPaths.add(sv3.layout);
134
+ }
135
+ for (const f of readdirSync(ldir).filter(isDefFile)) {
136
+ const nm = defName(f);
137
+ if (declaredPaths.has(`layouts/${nm}`)) continue;
138
+ if (names.includes(nm) || declaredSubs.has(nm)) continue;
139
+ const owner = names.find((n2) => nm.startsWith(`${n2}-`));
140
+ if (owner)
141
+ report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
142
+ else
143
+ report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
144
+ }
145
+ }
146
+ }
147
+ if (json.stateOrder !== undefined)
148
+ report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
149
+ }
150
+ } else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
151
+ }
152
+
45
153
  // ── lint one file ──
46
154
  function lintFile(file) {
47
155
  // `readText` is disk unless the caller supplied an overlay for this path (index.mjs).
@@ -87,6 +195,7 @@ function lintFile(file) {
87
195
 
88
196
  if (isFixture(file)) return; // legacy fixture, unused: don't choke
89
197
  if (isHook(file)) return; // a lifecycle hook: calls + a projection, not a UI tree
198
+ if (isPropsExtract(file)) return; // the envelope's props map, composed there — not a UI tree
90
199
 
91
200
  if (isManifest(file)) {
92
201
  const root = dirname(file);
@@ -106,9 +215,19 @@ function lintFile(file) {
106
215
  const firstState = json.states && typeof json.states === "object" && !Array.isArray(json.states)
107
216
  ? Object.keys(json.states)[0]
108
217
  : undefined;
109
- const layoutName = json.layout ?? firstState ?? "main";
110
- if (!defPath(join(root, "layouts"), layoutName))
111
- report("error", file, `manifest.layout "${layoutName}" layouts/${layoutName} does not exist (and no <name> envelope) (docs.unoverse.ai/design/apps)`);
218
+ // The base state's DECLARED path wins (2026-08-29 ruling: layout is a
219
+ // root-relative path, nothing assumed); the name fallback survives only
220
+ // for the unswept installed tier.
221
+ const firstEntry = firstState ? json.states[firstState] : undefined;
222
+ const declaredBase = firstEntry && typeof firstEntry === "object" && typeof firstEntry.layout === "string" ? firstEntry.layout : undefined;
223
+ if (declaredBase) {
224
+ if (!defPath(root, declaredBase))
225
+ report("error", file, `base state "${firstState}" declares layout "${declaredBase}" but no such file exists (docs.unoverse.ai/design/apps)`);
226
+ } else {
227
+ const layoutName = json.layout ?? firstState ?? "main";
228
+ if (!defPath(join(root, "layouts"), layoutName))
229
+ report("error", file, `manifest.layout "${layoutName}" → layouts/${layoutName} does not exist (and no <name> envelope) (docs.unoverse.ai/design/apps)`);
230
+ }
112
231
  }
113
232
  // THE TEMPLATE TREE (STATE_MODEL v2, checkpoint 2026-08-08): a manifest
114
233
  // `states:` block declares the whole machine — validate the DECLARATION:
@@ -123,59 +242,7 @@ function lintFile(file) {
123
242
  // into layouts/ and update $include paths.
124
243
  if (existsSync(join(root, "states")))
125
244
  report("error", file, `template carries a states/ folder — templates have ONE drawings folder. Move states/* into layouts/ and update every "$include: states/<name>" to "layouts/<name>" (anatomy unified with components, LAYERS §3)`);
126
- if (json.states !== undefined) {
127
- if (!json.states || typeof json.states !== "object" || Array.isArray(json.states)) {
128
- report("error", file, `manifest "states" must be an object — the template tree: { <base>: { states: {…} }, <reaction>: {}, … } (STATE_MODEL §5)`);
129
- } else {
130
- const names = Object.keys(json.states);
131
- const base = json.layout ?? Object.keys(json.states)[0] ?? "main";
132
- if (!names.includes(base))
133
- report("error", file, `the template tree must include the BASE state "${base}" (named for the default layout) — the ladder derives as the top level minus it (STATE_MODEL §5)`);
134
- const subs = [];
135
- for (const [n, s] of Object.entries(json.states)) {
136
- if (s !== null && (typeof s !== "object" || Array.isArray(s)))
137
- report("error", file, `tree state "${n}" must be an object ({} is a complete state) (STATE_MODEL §5)`);
138
- if (n !== base && !defPath(join(root, "layouts"), n))
139
- report("error", file, `tree state "${n}" has no layouts/${n}. Every state's drawing — a full arrangement or a stacked overlay — lives in layouts/, the template's ONE drawings folder (anatomy unified with components, LAYERS §3) (STATE_MODEL §5)`);
140
- const nested = s && typeof s === "object" ? s.states : undefined;
141
- if (nested && typeof nested === "object" && !Array.isArray(nested))
142
- for (const sub of Object.keys(nested)) {
143
- subs.push(sub);
144
- if (!defPath(join(root, "layouts"), `${n}-${sub}`))
145
- report("error", file, `substate "${sub}" (under "${n}") has no layouts/${n}-${sub} file — a substate's drawing is OWNER-PREFIXED in layouts/ (the step-* convention: bare names are the app's screens, <owner>-<sub> their sub-drawings) (LAYERS §3)`);
146
- }
147
- }
148
- for (const sub of subs)
149
- if (names.includes(sub))
150
- report("error", file, `"${sub}" is declared both as a top-level state and a substate — nesting IS containment; a name lives at exactly one level (STATE_MODEL §5)`);
151
- // THE FOLDER CANNOT LIE (owner ruling 2026-08-22): every layouts/ file is
152
- // either a SCREEN (a bare name declaring a top-level state) or a substate
153
- // drawing (<owner>-<sub>, both declared). Anything else is an error — a
154
- // shared part belongs in components/.
155
- {
156
- const ldir = join(root, "layouts");
157
- if (existsSync(ldir)) {
158
- const declaredSubs = new Map();
159
- for (const [n2, s2] of Object.entries(json.states)) {
160
- const nested2 = s2 && typeof s2 === "object" ? s2.states : undefined;
161
- if (nested2 && typeof nested2 === "object" && !Array.isArray(nested2))
162
- for (const sub2 of Object.keys(nested2)) declaredSubs.set(`${n2}-${sub2}`, n2);
163
- }
164
- for (const f of readdirSync(ldir).filter(isDefFile)) {
165
- const nm = defName(f);
166
- if (names.includes(nm) || declaredSubs.has(nm)) continue;
167
- const owner = names.find((n2) => nm.startsWith(`${n2}-`));
168
- if (owner)
169
- report("error", file, `layouts/${nm} is prefixed for state "${owner}" but "${nm.slice(owner.length + 1)}" is not declared as its substate — declare it in the tree, or move the file to components/ if it is a shared part (LAYERS §3)`);
170
- else
171
- report("error", file, `layouts/${nm} is neither a declared state nor an <owner>-<sub> substate drawing — bare names are the app's screens; declare it, prefix it with its owner, or move it to components/ (LAYERS §3)`);
172
- }
173
- }
174
- }
175
- if (json.stateOrder !== undefined)
176
- report("warn", file, `"stateOrder" is superseded by the "states" tree (the ladder derives from the top level minus the base) — delete it (STATE_MODEL §5)`);
177
- }
178
- } else checkStateOrder(json.stateOrder, root, file, /* includeLayouts */ true);
245
+ checkAppTree(json, root, file);
179
246
  // ONE STATE AT A TIME (docs.unoverse.ai/design/state): the active state is derived from the
180
247
  // latest surfaced VIEW, so no two surfaces in one template may claim the same
181
248
  // view — the active surface would be ambiguous.
@@ -185,7 +252,7 @@ function lintFile(file) {
185
252
  if (Array.isArray(n)) return n.forEach((c) => collectClaims(c, from));
186
253
  if (!n || typeof n !== "object") return;
187
254
  const w = n.type === "ComponentSlot" ? n.select?.where : null;
188
- if ((w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
255
+ if ((w?.field === "state" || w?.field === "view" || w?.field === "defaultState") && typeof w.eq === "string") {
189
256
  if (claims.has(w.eq))
190
257
  report("error", file, `two reaction surfaces claim the view "${w.eq}" (${claims.get(w.eq)} and ${from}). A template is in ONE state at a time; each view has exactly one surface (docs.unoverse.ai/design/state)`);
191
258
  else claims.set(w.eq, from);
@@ -227,9 +294,16 @@ function lintFile(file) {
227
294
  // The resolver already looks both ways (definitions.ts, template tree); without
228
295
  // the same lookup here a correctly-migrated template lints as though every one
229
296
  // of its substates had vanished.
230
- const declared = json.states && typeof json.states === "object" && !Array.isArray(json.states) ? json.states : {};
231
- for (const [parent, node] of Object.entries(declared))
297
+ // The tree's home is the ENVELOPE (one folder grammar, 2026-08-29); a
298
+ // manifest-held tree is the legacy read.
299
+ const envPath = defPath(root, basename(root));
300
+ const envStates = envPath ? readDef(envPath)?.states : undefined;
301
+ const treeStates = (json.states && typeof json.states === "object" && !Array.isArray(json.states) ? json.states : undefined)
302
+ ?? (envStates && typeof envStates === "object" && !Array.isArray(envStates) ? envStates : {});
303
+ for (const [parent, node] of Object.entries(treeStates)) {
304
+ states.add(parent);
232
305
  for (const sub of Object.keys(node?.states ?? {})) if (states.has(`${parent}-${sub}`)) states.add(sub);
306
+ }
233
307
  const comps = componentNamesForFile(file);
234
308
  for (const [state, list] of Object.entries(json.preview)) {
235
309
  if (!states.has(state))
@@ -283,39 +357,104 @@ function lintFile(file) {
283
357
  const root = defRoot(file);
284
358
 
285
359
  if (isEnvelope) {
286
- // COMPONENT envelope (templates have no envelope — their manifest is it).
287
360
  // The declaration word is `type:`; `kind:` is the legacy spelling, still read.
361
+ // ONE FOLDER GRAMMAR (owner ruling 2026-08-29): every kind has an envelope
362
+ // saying what it IS. component/atom = root; template = parts+shapes; app =
363
+ // the states tree (its manifest stays the MCP face).
288
364
  const declared = json.type ?? json.kind;
289
- for (const req of ["name", "root"])
365
+ // Template grammar §9b (2026-08-29): states like every kind; parts/shapes = legacy.
366
+ // ONE REQUIREMENT SHAPE (2026-08-29): a kind declares its tree (`states:`) OR
367
+ // its drawing (`root` — flat components/atoms). A tree component's root is
368
+ // SYNTHESIZED by the compiler, never required.
369
+ const envelopeRequired =
370
+ declared === "app" || declared === "template" ? ["name", "states"]
371
+ : declared === "component" ? ["name", ...(json.states !== undefined || json.state?.view !== undefined ? [] : ["root"])]
372
+ : ["name", "root"];
373
+ if (declared === "template" && json.states === undefined && (json.parts !== undefined || json.shapes !== undefined)) envelopeRequired.pop();
374
+ for (const req of envelopeRequired)
290
375
  if (json[req] === undefined) report("error", file, `envelope missing "${req}" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
291
376
  if (declared === undefined) report("error", file, `envelope missing "type" (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
292
377
  if (json.kind !== undefined && json.type === undefined)
293
378
  report("warn", file, `"kind" is the legacy spelling of the declaration — write "type: ${json.kind}"`);
294
- if (declared && !["component", "template", "atom"].includes(declared))
379
+ if (declared && !["component", "template", "atom", "app"].includes(declared))
295
380
  report("error", file, `unknown type "${declared}"`);
296
381
  if (declared === "component" && !json.category)
297
382
  report("warn", file, `component has no "category". Used to group it in the palette (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
383
+ // APP envelope: the states tree lives HERE (one folder grammar) — validate it
384
+ // exactly as the legacy manifest-held tree was.
385
+ if (declared === "app") checkAppTree(json, root, file);
298
386
  if (json.root) walkNode(json.root, file, root);
299
387
 
300
388
  // ── the contained-microapp discipline (mirrors microapp-structure.test.ts) ──
301
389
  if (declared === "component") {
302
- const hasLayouts = existsSync(join(root, "layouts"));
390
+ // A TEMPLATE'S OWN PART IS A FLAT COMPONENT (TEMPLATE_MODEL §9b rule 7): one file,
391
+ // no manifest, no state tree — the face IS the filename. Its `root` here is the
392
+ // TEMPLATE's folder, which does carry `layouts/`, so without this the whole faced-
393
+ // component discipline fires on every part and demands a Switch it must not have.
394
+ const hasLayouts = !isTemplatePart(file) && existsSync(join(root, "layouts"));
303
395
  const statesDir = join(root, "states");
304
396
  const stateFiles = existsSync(statesDir)
305
397
  ? readdirSync(statesDir).filter(isDefFile).map(defName).sort()
306
398
  : [];
307
399
  const hasStateBlock = json.state && typeof json.state === "object";
308
400
 
309
- // the discovery manifest is the single home for description/whenToUseno dup
310
- if (defPath(root, "manifest"))
311
- for (const k of ["description", "whenToUse"])
312
- if (json[k] !== undefined)
313
- report("error", file, `envelope duplicates manifest meta "${k}". The discovery manifest is the single home (docs.unoverse.ai/design/components)`);
401
+ // THE DISCOVERY MANIFEST IS THE SINGLE HOME for the meta but the envelope's own
402
+ // `description` is now COMPOSITION CONTEXT (UNOVERSE_AUTHORING §3b, 2026-08-30):
403
+ // what an Agent filling the WHOLE of this must know, which used to ride an unbound
404
+ // `brief` on the layout root. Two files, two audiences. Only a COPY of the
405
+ // manifest's text is the duplication this rule exists to stop.
406
+ {
407
+ const mPath = defPath(root, "manifest");
408
+ let m = null;
409
+ if (mPath) { try { m = readDef(mPath); } catch { m = null; } }
410
+ if (m) {
411
+ if (json.whenToUse !== undefined)
412
+ report("error", file, `envelope duplicates manifest meta "whenToUse". The discovery manifest is the single home (docs.unoverse.ai/design/components)`);
413
+ if (typeof json.description === "string" && json.description.trim() === String(m.description ?? "").trim())
414
+ report("error", file, `the envelope's "description" is a copy of the manifest's. The manifest is the single home for the DISCOVERY line; the envelope's own description is composition context — what an Agent filling the whole of this must know (UNOVERSE_AUTHORING §3b)`);
415
+ }
416
+ }
314
417
 
315
418
  // deprecated bridge: a top-level `defaultState` triggers the component node APP_DATA emit
316
419
  if (json.defaultState !== undefined)
317
420
  report("warn", file, `top-level "defaultState" is the deprecated bridge (component-node APP_DATA emit). The master state lives in the \`state\` block; templates react via ComponentSlot.select.where (STATE_MODEL §5b)`);
318
421
 
422
+ // THE PROP IS THE BRIEF (UNOVERSE_AUTHORING §3b, 2026-08-30): a prop declares
423
+ // everything about itself, so the props map is the whole contract and is held to a
424
+ // closed shape. The props extract composes first (`props: { $include: props }` —
425
+ // the loader does the same before any reader sees the map).
426
+ let judgedProps = json.props ?? {};
427
+ if (typeof judgedProps.$include === "string") {
428
+ const incPath = defPath(root, judgedProps.$include);
429
+ if (!incPath) {
430
+ report("error", file, `props $include "${judgedProps.$include}" resolves to no file in the definition folder (docs.unoverse.ai/design/components)`);
431
+ judgedProps = {};
432
+ } else {
433
+ try { judgedProps = readDef(incPath) ?? {}; } catch { judgedProps = {}; }
434
+ }
435
+ }
436
+ checkProps(judgedProps, file, report, "");
437
+
438
+ // EVERY PROP CARRIES A DESCRIPTION (UNOVERSE_AUTHORING §3b, RULED 2026-08-30).
439
+ //
440
+ // It reads BOTH ways, which is why it is compulsory rather than encouraged. A
441
+ // description is not only what a copywriter fills against: it is what lets an agent
442
+ // UNDERSTAND the screen it is looking at. `read` reports where the person is; the
443
+ // descriptions say what is actually in front of them. A screen of unnamed fields is
444
+ // one no agent can reason about, in either direction.
445
+ //
446
+ // AND IT SAYS NOTHING ABOUT PERMISSION. That is the layout's word — `static` /
447
+ // `copywriter` / `director` (TEMPLATE_MODEL §9b) — never a prop's. The rule this
448
+ // replaces was "a described prop is a writable prop", which made silence the safety
449
+ // mechanism: a field was protected because nobody had explained it, and explaining it
450
+ // was what exposed it. Describing a field is now free of consequence, so there is no
451
+ // longer any reason to leave one blank.
452
+ const undescribed = Object.entries(judgedProps)
453
+ .filter(([, v]) => !(v && typeof v === "object" && typeof v.description === "string" && v.description.trim()))
454
+ .map(([k]) => k);
455
+ if (undescribed.length)
456
+ report("error", file, `props [${undescribed.join(", ")}] carry no "description". Every prop says what it IS, so an agent can read the screen as well as fill it. A description grants nothing: permission is the layout's word (static / copywriter / director), never a prop's (UNOVERSE_AUTHORING §3b)`);
457
+
319
458
  // only components that ADOPTED the structure are held to the full discipline
320
459
  if (hasLayouts || stateFiles.length || hasStateBlock) {
321
460
  // Every prop declares what fills it. Was "must be input:true", from when props
@@ -323,12 +462,15 @@ function lintFile(file) {
323
462
  // preview axis, literals passed via `Ref with`) — input:true there is false AND
324
463
  // load-bearing, since configSchema derives from it. Silence is the error:
325
464
  // unflagged props default to INPUTS.
326
- const undeclared = Object.entries(json.props ?? {})
465
+ // The props extract composes first (`props: { $include: props }` — the loader
466
+ // does the same before any reader sees the map).
467
+ const undeclared = Object.entries(judgedProps)
327
468
  .filter(([, v]) => !(v && typeof v === "object" && typeof v.input === "boolean"))
328
469
  .map(([k]) => k);
329
470
  if (undeclared.length)
330
471
  report("error", file, `microapp props [${undeclared.join(", ")}] do not declare "input". Every prop says what fills it: input:true = a workflow streams it (it joins the node's configSchema); input:false = nothing does (a preview axis, or a literal the host passes via Ref with). Unflagged props default to INPUTS (docs.unoverse.ai/design/components)`);
331
472
 
473
+
332
474
  // STATE MODEL v2 (UNOVERSE_STATE_MODEL §5): an authored `state.view` TREE is
333
475
  // the component's state machine — the ONE object the scalar rule admits.
334
476
  // Well-formed = { initial?: string, states: { <name>: { layout?/layouts?/on?/
@@ -344,38 +486,53 @@ function lintFile(file) {
344
486
  (s.layout === undefined || typeof s.layout === "string"),
345
487
  );
346
488
  };
347
- const viewTree = hasStateBlock && isViewTree(json.state.view) ? json.state.view : null;
489
+ // THE RULED FORM (2026-08-29): the tree lives at TOP-LEVEL `states:` like
490
+ // every kind; `state.view` is the legacy wrapper, still read.
491
+ const topTree = json.states && typeof json.states === "object" && !Array.isArray(json.states) && isViewTree({ states: json.states }) ? { states: json.states } : null;
492
+ const viewTree = topTree ?? (hasStateBlock && isViewTree(json.state.view) ? json.state.view : null);
348
493
 
349
494
  // the state block holds SCALAR internal view-state ONLY — an array/object (a
350
495
  // finder's result rows) or a URL is content/data slop: hardcode it in the layout,
351
496
  // or move workflow-fed data to props (input:true) (AUTHORING §3). The one
352
497
  // exception: a well-formed v2 `state.view` tree (STATE_MODEL §5 rule 1).
353
- if (hasStateBlock)
354
- for (const [k, v] of Object.entries(json.state)) {
355
- if (k === "view" && viewTree) continue;
498
+ const scalarHomes = [];
499
+ if (hasStateBlock) scalarHomes.push(["state", json.state]);
500
+ if (json.values && typeof json.values === "object" && !Array.isArray(json.values)) scalarHomes.push(["values", json.values]);
501
+ for (const [home, blk] of scalarHomes)
502
+ for (const [k, v] of Object.entries(blk)) {
503
+ if (home === "state" && k === "view" && viewTree && !topTree) continue; // legacy wrapper only
356
504
  if (Array.isArray(v) || (v && typeof v === "object"))
357
- report("error", file, `state.${k} is an ${Array.isArray(v) ? "array" : "object"}. The state block is SCALAR view-state only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs.unoverse.ai/design/components)`);
505
+ report("error", file, `${home}.${k} is an ${Array.isArray(v) ? "array" : "object"}. The values block is SCALAR starting values only (the one object allowed is a well-formed v2 \`state.view\` tree); workflow-fed data → props (input:true), static content → hardcode in the layout (docs.unoverse.ai/design/components)`);
358
506
  else if (typeof v === "string" && /^https?:\/\//.test(v))
359
- report("error", file, `state.${k} is a URL. Content, not view-state; hardcode it in the layout (or props input:true if workflow-fed) (docs.unoverse.ai/design/components)`);
507
+ report("error", file, `${home}.${k} is a URL. Content, not a starting value; hardcode it in the layout (or props input:true if workflow-fed) (docs.unoverse.ai/design/components)`);
360
508
  }
361
509
 
362
510
  if (hasLayouts) {
363
511
  const raw = JSON.stringify(json.root ?? {}).replace(/\s/g, "");
364
- if (viewTree) {
365
- // v2: the tree owns the states; the root switches the PUBLIC axis (`view`).
366
- if (!raw.includes('"on":"view"') || !/"\$include":"layouts\//.test(raw))
367
- report("error", file, `a v2 component (state.view tree) must root-Switch on "view" → $include layouts/<layout> (each state owns its layout; same-name by convention) (STATE_MODEL §5)`);
512
+ if (viewTree && json.root == null) {
513
+ // THE TREE IS THE MACHINE (2026-08-29 late): no authored root the
514
+ // compiler synthesizes the Switch from the tree. Nothing to check.
515
+ } else if (viewTree) {
516
+ // An AUTHORED root is a real shell (beyond the plain Switch) — it must
517
+ // still switch the public axis. A root that merely restates the tree is
518
+ // redundant: delete it and let the compiler synthesize.
519
+ if ((!raw.includes('"on":"state"') && !raw.includes('"on":"view"')) || !/"\$include":"layouts\//.test(raw))
520
+ report("error", file, `a tree component's authored root must Switch on "state" (the ruled public-axis word; "view" is the legacy spelling) → $include layouts/<layout> (STATE_MODEL §5)`);
521
+ const rootObj = json.root ?? {};
522
+ const kids = Array.isArray(rootObj.children) ? rootObj.children : [];
523
+ if (rootObj.type === "Box" && kids.length === 1 && kids[0]?.type === "Switch")
524
+ report("warn", file, `the root is only the tree's Switch restated — delete it; the compiler synthesizes the root from \`states:\` (2026-08-29 ruling)`);
368
525
  } else if (!raw.includes('"on":"defaultState"') || !/"\$include":"layouts\//.test(raw))
369
526
  report("error", file, `a faced component's root must Switch on defaultState → $include layouts/<state> (legacy; or declare a v2 state.view tree and Switch on "view") (docs.unoverse.ai/design/components)`);
370
527
 
371
528
  // ── face set ⇄ layouts/ cross-check (OPEN name set — inline/focused/<any>) ──
372
529
  // The FACES are the root Switch's cases; Studio's face toggle and the render
373
530
  // path both derive from them, so cases and layout files must agree exactly.
374
- const axis = viewTree ? "view" : "defaultState";
531
+ const axes = viewTree ? ["state", "view"] : ["defaultState"];
375
532
  const findFaceSwitch = (n) => {
376
533
  if (!n || typeof n !== "object") return null;
377
534
  if (Array.isArray(n)) { for (const c of n) { const r = findFaceSwitch(c); if (r) return r; } return null; }
378
- if (n.type === "Switch" && n.on === axis && n.cases && typeof n.cases === "object") return n;
535
+ if (n.type === "Switch" && axes.includes(n.on) && n.cases && typeof n.cases === "object") return n;
379
536
  for (const v of Object.values(n)) { const r = findFaceSwitch(v); if (r) return r; }
380
537
  return null;
381
538
  };
@@ -414,8 +571,13 @@ function lintFile(file) {
414
571
  const layoutName = inc.slice("layouts/".length);
415
572
  usedLayouts.add(layoutName);
416
573
  // v2: the STATE owns its layout — the tree's declaration decides which
417
- // file draws it (same-name by default). Legacy: same-name is the law.
418
- const expected = viewTree?.states?.[name]?.layout ?? name;
574
+ // file draws it. Declarations are root-relative PATHS (2026-08-29
575
+ // ruling); a bare name still reads as layouts/<name> for the unswept
576
+ // installed tier.
577
+ const declaredLayout = viewTree?.states?.[name]?.layout;
578
+ const expected = declaredLayout
579
+ ? (declaredLayout.includes("/") ? declaredLayout : `layouts/${declaredLayout}`).replace(/^layouts\//, "")
580
+ : name;
419
581
  if (name !== "default" && layoutName !== expected)
420
582
  report("error", file, viewTree
421
583
  ? `state "${name}" declares layout "${expected}" but its case includes layouts/${layoutName} — the tree's declaration and the case must agree (STATE_MODEL §5 rule 1)`
@@ -424,12 +586,16 @@ function lintFile(file) {
424
586
  // v2: tree-declared layouts (including nested substates' and variants') are
425
587
  // reachable by declaration — seed them so the orphan check knows them.
426
588
  if (viewTree) {
589
+ // Declared values are PATHS (2026-08-29); the orphan set is keyed by
590
+ // layouts/-relative names, so strip the prefix (bare legacy names
591
+ // pass through unchanged).
592
+ const asName = (l) => l.replace(/^layouts\//, "");
427
593
  const seed = (states) => {
428
594
  for (const s of Object.values(states ?? {})) {
429
595
  if (!s || typeof s !== "object") continue;
430
- if (typeof s.layout === "string") usedLayouts.add(s.layout);
596
+ if (typeof s.layout === "string") usedLayouts.add(asName(s.layout));
431
597
  if (s.layouts && typeof s.layouts === "object")
432
- for (const l of Object.values(s.layouts)) if (typeof l === "string") usedLayouts.add(l);
598
+ for (const l of Object.values(s.layouts)) if (typeof l === "string") usedLayouts.add(asName(l));
433
599
  if (s.states) seed(s.states);
434
600
  }
435
601
  };
@@ -480,7 +646,7 @@ function lintFile(file) {
480
646
  for (const lf of readdirSync(d).filter(isDefFile)) {
481
647
  try {
482
648
  if (/"key":"defaultState"/.test(JSON.stringify(readDef(join(d, lf)))))
483
- report("warn", file, `${sub}/${lf} writes setValue "defaultState" but this component declares a v2 tree — write "view" (the alias is scheduled for deletion) (STATE_MODEL §2)`);
649
+ report("warn", file, `${sub}/${lf} writes setValue "defaultState" but this component declares a tree — write "state" (the ruled word; "view"/"defaultState" are legacy) (STATE_MODEL §2)`);
484
650
  } catch { /* that file lints separately */ }
485
651
  }
486
652
  }
@@ -163,8 +163,33 @@ const isHook = (f) => {
163
163
  const stem = basename(f).replace(/\.(json|ya?ml)$/, "").toLowerCase();
164
164
  return life.some((e) => typeof e === "object" && e && typeof e.handler === "string" && e.handler.toLowerCase() === stem);
165
165
  };
166
+ // THE PROPS EXTRACT (UNOVERSE_MCP_FORMS §Per-field purpose): a folder-root file the
167
+ // envelope pulls in with `props: { $include: <stem> }`. It is a props MAP, not a
168
+ // drawing — the node walk must not judge it, and the props rules read it composed.
169
+ const isPropsExtract = (f) => {
170
+ const dir = dirname(f);
171
+ const env = defPath(dir, basename(dir));
172
+ if (!env || env === f) return false;
173
+ let inc;
174
+ try { inc = readDef(env).props?.$include; } catch { return false; }
175
+ if (typeof inc !== "string") return false;
176
+ return basename(f).replace(/\.(json|ya?ml)$/, "").toLowerCase() === inc.toLowerCase();
177
+ };
166
178
  const isManifest = (f) => /^manifest\.(json|yaml)$/.test(basename(f));
167
- const isAppPath = (f) => f.includes(`${sep}apps${sep}`);
179
+ // WHICH HOME A FILE IS IN — asked of the path BELOW the design root, never the absolute
180
+ // one. Testing the absolute path for an `apps` segment was true of every file in this
181
+ // monorepo, because the design tree itself lives at `apps/unoverse/design`: every
182
+ // component manifest in the repo was linted as an APP manifest (two bogus warnings each,
183
+ // while the real component-manifest rules never ran once). A rule that cannot tell the
184
+ // homes apart is worse than no rule — it trains people to ignore its output.
185
+ const homeSegments = (f) => relative(DESIGN_ROOT, f).split(sep);
186
+ const isAppPath = (f) => homeSegments(f).includes("apps");
187
+ // THE TEMPLATE KIND (UNOVERSE_TEMPLATE_MODEL.md §9b). Its folders answer to different
188
+ // rules than an app's or a component's, so the rules that would misfire have to be able
189
+ // to ask. `isTemplatePart` is the template's OWN components/ folder — flat parts that
190
+ // carry no manifest and declare no state tree, the face being the filename (§9b rule 7).
191
+ const isTemplatePath = (f) => homeSegments(f).includes("templates");
192
+ const isTemplatePart = (f) => isTemplatePath(f) && homeSegments(f).includes("components");
168
193
  // The DEFINITION ROOT folder for a file: a bare partial lives one level under it
169
194
  // (layouts/ states/ components/ blocks/); every `$include` resolves against it.
170
195
  const defRoot = (file) => {
@@ -424,6 +449,22 @@ for (const orgDir of orgDirs) {
424
449
  }
425
450
  orgComponentNames.set(basename(orgDir), set);
426
451
  }
452
+ // TEMPLATE-LOCAL PARTS are addressed `<template>/<part>` (§9b rule 7) — the same
453
+ // qualified shape as `<org>/<name>`, resolved against the template homes. The runtime
454
+ // resolver has always done this (componentDirs' fallback); the lint did not, so a part
455
+ // composing a sibling part read as an unresolvable Ref while it rendered perfectly.
456
+ const templatePartNames = new Map(); // template folder name -> Set(lower part names)
457
+ for (const root of [DS, ...orgDirs]) {
458
+ const home = join(root, "templates");
459
+ if (!existsSync(home)) continue;
460
+ for (const t of readdirSync(home)) {
461
+ const parts = join(home, t, "components");
462
+ if (!existsSync(parts)) continue;
463
+ const set = templatePartNames.get(t.toLowerCase()) ?? new Set();
464
+ for (const e of readdirSync(parts)) if (isDefFile(e)) set.add(defName(e).toLowerCase());
465
+ templatePartNames.set(t.toLowerCase(), set);
466
+ }
467
+ }
427
468
  const orgOfFile = (file) => {
428
469
  const home = orgDirs.find((d) => file.startsWith(d + sep));
429
470
  return home ? basename(home) : null;
@@ -436,9 +477,12 @@ const refResolves = (ref, file) => {
436
477
  const fileOrg = file ? orgOfFile(file) : null;
437
478
  const slash = lower.indexOf("/");
438
479
  if (slash > 0) {
439
- const org = lower.slice(0, slash);
440
- if (org !== fileOrg) return false; // another org's, or a DS file naming any org
441
- return orgComponentNames.get(org)?.has(lower.slice(slash + 1)) ?? false;
480
+ const qualifier = lower.slice(0, slash);
481
+ const name = lower.slice(slash + 1);
482
+ // A template's own part, composing another part of the same template.
483
+ if (templatePartNames.get(qualifier)?.has(name)) return true;
484
+ if (qualifier !== fileOrg) return false; // another org's, or a DS file naming any org
485
+ return orgComponentNames.get(qualifier)?.has(name) ?? false;
442
486
  }
443
487
  if (atomNames.has(lower) || dsComponentNames.has(lower)) return true;
444
488
  return fileOrg ? (orgComponentNames.get(fileOrg)?.has(lower) ?? false) : false;
@@ -564,11 +608,19 @@ for (const [dir, isDir] of keyHomes) {
564
608
  }
565
609
  }
566
610
 
567
- // definition homes: marketplace components/atoms + each org's templates AND
568
- // components (same law everywhere — an org component is a component, just org-private)
569
- const homes = [{ dir: join(DS, "components") }, { dir: join(DS, "atoms") }];
611
+ // definition homes: the marketplace's components/atoms/templates + each org's apps,
612
+ // components and templates (same law everywhere — an org component is a component,
613
+ // just org-private).
614
+ //
615
+ // TEMPLATES ARE A KIND, AND A KIND IS LINTED (2026-08-30). The Template Model's
616
+ // `templates/` folders were never added here, so the entire kind was invisible: an
617
+ // invented token or an off-scale space step inside `<org>/templates/**` reported clean,
618
+ // while the same file under `components/` threw two errors. A home that is not
619
+ // enumerated is a home where every rule is off, silently — and the parts a template
620
+ // carries are ordinary components, so they were never exempt from anything.
621
+ const homes = [{ dir: join(DS, "components") }, { dir: join(DS, "atoms") }, { dir: join(DS, "templates") }];
570
622
  for (const orgDir of orgDirs) {
571
- for (const sub of ["apps", "components"]) {
623
+ for (const sub of ["apps", "components", "templates"]) {
572
624
  const d = join(orgDir, sub);
573
625
  if (existsSync(d) && statSync(d).isDirectory()) homes.push({ dir: d });
574
626
  }
@@ -855,12 +907,24 @@ const componentNamesCache = new Map();
855
907
  // Components an ORG's template may reference: the marketplace tier + that org's OWN
856
908
  // components — never another org's (org-privacy). Cached per org.
857
909
  function componentNamesForFile(file) {
858
- const m = file.split(`${sep}orgs${sep}`);
859
- if (m.length < 2) return null;
860
- const org = m[1].split(sep)[0];
910
+ // THE PROJECT A FILE BELONGS TO, from the path below the design root. This read
911
+ // `${sep}orgs${sep}` and returned null for anything else, so on the flat layout — which
912
+ // is every project now (`design/<org>/…`, "flat wins") — it answered "no list" and
913
+ // every rule that asks it, preview names included, silently stopped checking.
914
+ const segs = relative(DESIGN_ROOT, file).split(sep);
915
+ if (segs.length < 2 || segs[0] === "..") return null;
916
+ const projectSegs = segs[0] === "orgs" ? segs.slice(0, 2) : segs.slice(0, 1);
917
+ const org = projectSegs.join("/");
861
918
  const cached = componentNamesCache.get(org);
862
919
  if (cached !== undefined) return cached;
863
- const dirs = [join(m[0], "components"), join(m[0], "orgs", org, "components")];
920
+ const dirs = [
921
+ join(DS, "components"),
922
+ join(DESIGN_ROOT, ...projectSegs, "components"),
923
+ // TEMPLATES ARE PEERS (§9c): a state that holds one seeds it by name like any
924
+ // component, so the name set a preview is checked against includes them.
925
+ join(DS, "templates"),
926
+ join(DESIGN_ROOT, ...projectSegs, "templates"),
927
+ ];
864
928
  let names = null;
865
929
  for (const dir of dirs) {
866
930
  if (!existsSync(dir)) continue;
@@ -877,7 +941,7 @@ function componentNamesForFile(file) {
877
941
  const ctx = {
878
942
  DESIGN_ROOT, DS, orgDirs, report, spaceSteps, stepList, checkDimension, checkToken, checkCondition,
879
943
  appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists,
880
- isFixture, isHook, isManifest, isAppPath, defRoot, readText,
944
+ isFixture, isHook, isPropsExtract, isManifest, isAppPath, isTemplatePath, isTemplatePart, defRoot, readText,
881
945
  };
882
946
  const walkNode = makeWalkNode(ctx);
883
947
  const lintFile = makeLintFile({ ...ctx, walkNode });
@@ -33,10 +33,44 @@ export const CONDITION_KEYS = new Set(["field", "eq", "ne", "in"]);
33
33
  * Schema halves, so the editor squiggle and the publish gate cannot disagree.
34
34
  */
35
35
  export const UNIVERSAL_PROPS = new Set([
36
- // read for every node before dispatch; `brief` rides the tree for the composer
37
- "type", "style", "visibleWhen", "bind", "action", "analytics", "skeleton", "brief", "$include",
36
+ // read for every node before dispatch. `brief` is GONE (UNOVERSE_AUTHORING §3b, ruled
37
+ // 2026-08-30): a field's contract lives on its prop, and the layout only arranges.
38
+ "type", "style", "visibleWhen", "bind", "action", "analytics", "skeleton", "$include",
38
39
  ]);
39
40
 
41
+ /**
42
+ * A PROP, whole (UNOVERSE_AUTHORING §3b): JSON Schema's own words plus the platform's
43
+ * four. Closed, because the prop is now the only place a field's contract can live —
44
+ * a misspelled `maxlength` there is a limit that silently never applies.
45
+ */
46
+ export const PROP_KEYS = [
47
+ "type", "input", "description", "maxLength", "minItems", "maxItems",
48
+ "hydrate", "optional", "enum", "preview", "default", "items",
49
+ ];
50
+
51
+ /**
52
+ * THE INTERFACE FIELDS (packages/docs/design/interface-data.md): the names a content row
53
+ * carries. A row fills a component BY NAME with no mapping layer, so a prop wearing one
54
+ * of these names is filled by the delivery — whatever the definition declares about it.
55
+ * Layer 1 arrives with every card; the `service`/`need` editorial fields arrive with the
56
+ * row that has them; layer 2 arrives when `getDetail` runs.
57
+ */
58
+ export const INTERFACE_FIELDS = new Set([
59
+ "universal_id", "title", "description", "object_type", "key_need", "source_url", "source_id",
60
+ "tagline", "shortDescription", "introParagraph", "callToAction", "actionPrompt", "primaryImage", "images", "action",
61
+ "bodyCopy", "features", "mainCategory", "section", "needs",
62
+ ]);
63
+
64
+ /**
65
+ * The keys that only make sense if an AUTHOR, not a delivery, decides the value — a cap,
66
+ * a count, a closed set. `description` is deliberately NOT one: every prop describes
67
+ * itself as standard (UNOVERSE_AUTHORING §3b), including the ones a row fills, so
68
+ * flagging a described `title` would fire on every content card in the estate and teach
69
+ * people to ignore the rule. What is worth saying is narrower and still true: a LIMIT you
70
+ * wrote does not apply to a value the delivery brings.
71
+ */
72
+ export const AUTHORED_CONTROLS = ["maxLength", "minItems", "maxItems", "enum"];
73
+
40
74
  export const PRIMITIVE_PROPS = {
41
75
  Box: ["children", "autoScroll", "revealOnStick", "selection", "appWidth"],
42
76
  Stack: ["children", "autoScroll", "revealOnStick", "appWidth"],
@@ -17,8 +17,14 @@ import { join, dirname, basename, relative, sep } from "node:path";
17
17
  import { PRIMITIVES, CONDITION_KEYS, STYLE_KEYS, RAW_VALUE, CHILD_NODE_KEYS, PARTIAL_DIRS, DIMENSION_KEYS, UNIVERSAL_PROPS, PRIMITIVE_PROPS, PRIMITIVE_BINDS } from "./vocabulary.mjs";
18
18
  import { isDefFile, defName, defPath, readDef } from "./defs.mjs";
19
19
 
20
+ // THE THREE WORDS and the director's closed block (UNOVERSE_TEMPLATE_MODEL.md §9b).
21
+ const SECTION_WORDS = ["static", "copywriter", "director"];
22
+ const DIRECTOR_KEYS = ["state", "limit", "rules", "preview"];
23
+
20
24
  export function makeWalkNode(ctx) {
21
- const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, defRoot } = ctx;
25
+ const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, defRoot } = ctx;
26
+ // One director per LAYOUT FILE, tracked for this run only (each file is walked once).
27
+ const directorsSeen = new Map();
22
28
 
23
29
  function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
24
30
  if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
@@ -33,6 +39,81 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
33
39
  return; // the included file is linted on its own
34
40
  }
35
41
 
42
+ // THE THREE WORDS (UNOVERSE_TEMPLATE_MODEL.md §9b): a template layout's parts say HOW
43
+ // they are filled instead of naming a primitive. They compile to ComponentSlots at
44
+ // serve time, so the linter checks the authored word here and hands the rest of the
45
+ // node to the ordinary walk as the slot it becomes.
46
+ const word = SECTION_WORDS.find((w) => node[w] !== undefined);
47
+ if (word && node.type === undefined) {
48
+ if (!isTemplatePath(file))
49
+ report("error", file, `"${word}:" is template grammar — only a template layout's parts say how they are filled. Apps and components author ComponentSlot directly (UNOVERSE_TEMPLATE_MODEL §9b)`);
50
+ if (word === "director") {
51
+ const d = node.director;
52
+ if (!d || typeof d !== "object" || Array.isArray(d))
53
+ report("error", file, `"director:" is a block, not a path — no component is ever named there: it is the one part whose content nobody knows at design time (§9b rule 1)`);
54
+ else {
55
+ for (const k of Object.keys(d))
56
+ if (!DIRECTOR_KEYS.includes(k))
57
+ report("error", file, `director has no "${k}". It reads ${DIRECTOR_KEYS.map((x) => `"${x}"`).join(", ")} (§9b)`);
58
+ if (d.limit !== undefined && typeof d.limit !== "number")
59
+ report("error", file, `director.limit is a NUMBER — a hard cap the machine enforces, never trusted to the model (§9b)`);
60
+ if (d.rules !== undefined && typeof d.rules !== "string")
61
+ report("error", file, `director.rules is written like a brief: one string of English (§9b)`);
62
+ if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
63
+ report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
64
+ else if (Array.isArray(d.preview)) {
65
+ const comps = componentNamesForFile(file);
66
+ for (const c of d.preview)
67
+ if (comps && !comps.has(String(c).toLowerCase()))
68
+ report("error", file, `director.preview names unknown component "${c}". No match in the design system or this org's components (lookup is case-insensitive)`);
69
+ }
70
+ }
71
+ // ONE MIND, MANY BANDS (§9b rule 3, amended 2026-08-30): a layout may hold several
72
+ // directed bands — one verdict, arranged — but each must claim a DISTINCT face, or
73
+ // the surface is ambiguous (the reaction contract's one-view-one-surface rule). So
74
+ // on a multi-band layout every director declares its `state:`; two bands claiming
75
+ // the same face is the error this rule now exists for.
76
+ const seen = directorsSeen.get(file) ?? [];
77
+ const face = typeof d?.state === "string" ? d.state : null;
78
+ if (seen.length && !face)
79
+ report("error", file, `a second "director:" in this layout declares no "state:". Several bands are allowed — one mind, arranged — but each must claim a DISTINCT face, so every one of them names it (§9b rule 3)`);
80
+ else if (face && seen.includes(face))
81
+ report("error", file, `two directed bands claim the face "${face}". A face has exactly one surface, or the active one is ambiguous (§9b rule 3, the reaction contract)`);
82
+ directorsSeen.set(file, [...seen, face]);
83
+ } else if (typeof node[word] !== "string") {
84
+ report("error", file, `"${word}:" links a component by PATH (${word}: components/<part>) — that is what makes it a known part (§9b rule 1)`);
85
+ } else {
86
+ const path = node[word];
87
+ if (!defPath(root, path) && !existsSync(join(root, path)))
88
+ report("error", file, `${word} "${path}" does not resolve under ${relative(process.cwd(), root)}/ (§9b rule 1)`);
89
+ }
90
+ // Everything else on the line rides through to the slot it compiles into. The select
91
+ // is SYNTHESIZED here exactly as the compiler builds it (a linked part claims its own
92
+ // face, limit 1; the director's claim is its `state:` pin) so the rest of the walk
93
+ // sees what will actually be served — and an authored one is the ceremony §9b rule 9
94
+ // exists to abolish.
95
+ if (node.select !== undefined)
96
+ report("error", file, `a template part never authors "select" — the three words compile to the wire primitives at serve time (§9b rule 9)`);
97
+ const { static: _s, copywriter: _c, director: _d, select: _sel, ...rest } = node;
98
+ // An unpinned director claims the OWNING STATE's name (arrivals keep their own state
99
+ // and the template's states react by name — the free grid↔page dance), so the face
100
+ // comes from whichever state in the envelope declares this layout.
101
+ const owningState = () => {
102
+ const env = defPath(root, basename(root));
103
+ const states = env ? readDef(env)?.states : undefined;
104
+ if (!states || typeof states !== "object") return undefined;
105
+ const here = defName(basename(file));
106
+ return Object.keys(states).find((s) => {
107
+ const l = states[s]?.layout;
108
+ return typeof l === "string" && basename(l).toLowerCase() === here.toLowerCase();
109
+ });
110
+ };
111
+ const face = word === "director" ? (node.director?.state ?? owningState()) : String(node[word]).split("/").pop();
112
+ const limit = word === "director" ? node.director?.limit : 1;
113
+ const select = { from: "all", ...(face ? { where: { field: "state", eq: face } } : {}), ...(typeof limit === "number" ? { limit } : {}) };
114
+ return walkNode({ type: "ComponentSlot", select, ...rest }, file, root, widthCap);
115
+ }
116
+
36
117
  const t = node.type;
37
118
  if (typeof t !== "string")
38
119
  report("error", file, `node without "type" (and no $include). Every node names a primitive (docs.unoverse.ai/design/sdui-and-mcp-apps)`);
@@ -136,12 +217,12 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
136
217
  // PUBLIC state — the `view` axis (`defaultState` = legacy alias) — never to a
137
218
  // component's internal state (step/phase/…) which is private to the component.
138
219
  // Selecting on any other field reaches past the boundary the whole model rests on.
139
- else if (node.select.where && node.select.where.field && node.select.where.field !== "view" && node.select.where.field !== "defaultState")
140
- report("warn", file, `reaction surface selects on "${node.select.where.field}". A template reacts to a component's PUBLIC state ("view"; legacy "defaultState"), never its internal state (that is private to the component) (STATE_MODEL §5)`);
220
+ else if (node.select.where && node.select.where.field && node.select.where.field !== "state" && node.select.where.field !== "view" && node.select.where.field !== "defaultState")
221
+ report("warn", file, `reaction surface selects on "${node.select.where.field}". A template reacts to a component's PUBLIC state ("state"; legacy "view"/"defaultState"), never its internal state (that is private to the component) (STATE_MODEL §5)`);
141
222
  // ONE STATE AT A TIME: the template is in exactly one state, so a surface must
142
223
  // claim exactly ONE view by `eq` — `ne`/`in`/bare selects make "which state is
143
224
  // the template in?" ambiguous.
144
- else if (node.select.where && (node.select.where.field === "view" || node.select.where.field === "defaultState") && typeof node.select.where.eq !== "string")
225
+ else if (node.select.where && (node.select.where.field === "state" || node.select.where.field === "view" || node.select.where.field === "defaultState") && typeof node.select.where.eq !== "string")
145
226
  report("error", file, `a reaction surface claims exactly ONE view: select.where needs "eq": "<view>": ne/in/bare make the template's active state ambiguous (docs.unoverse.ai/design/state)`);
146
227
  }
147
228