unoverse 0.1.208 → 0.1.210

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.
@@ -61,7 +61,7 @@ UNOVERSE_MARKETPLACE_URL=
61
61
  CREDENTIAL_ENCRYPTION_KEY=
62
62
 
63
63
  # Auth is ON by default (missing flag = enabled), same as production.
64
- AUTH_ISSUER=https://your-tenant.auth0.com
64
+ AUTH_ISSUER=https://your-environment.authkit.app
65
65
  AUTH_CLIENT_ID=your-client-id
66
66
  AUTH_AUDIENCE=gravity-api
67
67
 
@@ -430,7 +430,7 @@ resource "aws_elasticache_replication_group" "redis" {
430
430
 
431
431
  # ── Cognito: user pool (Essentials) + pre-token Lambda ────────────────────────
432
432
  # The Lambda is LOAD-BEARING: it puts email/roles/permissions on the ACCESS token
433
- # (the platform's token contract, AUTH_TOKEN_FLOW.md). Roles come from Cognito
433
+ # (the platform's token contract, docs/auth-security/IDENTITY.md). Roles come from Cognito
434
434
  # groups. It lives in Terraform precisely so a pool rebuild cannot drop it — the
435
435
  # documented Auth0 footgun, not repeated here.
436
436
 
@@ -561,7 +561,7 @@ resource "aws_cognito_user_pool" "pool" {
561
561
  locals {
562
562
  # TWO LEVELS, BECAUSE THE PLATFORM READS TWO CLAIMS.
563
563
  #
564
- # Auth0 (the DigitalOcean ground) models this properly and is the standing contract:
564
+ # The standing contract, whichever IdP issues the token:
565
565
  #
566
566
  # ROLE admin → permission admin:access
567
567
  # ROLE developer → permissions marketplace:publish, workflow:author, workflow:promote
@@ -2,10 +2,10 @@
2
2
  * Cognito Pre Token Generation (V2_0) — the platform's token contract.
3
3
  *
4
4
  * LOAD-BEARING. The platform reads sub/email/roles (and permissions) off the
5
- * ACCESS token and never calls back to the IdP (docs/AUTH_TOKEN_FLOW.md). Without
5
+ * ACCESS token and never calls back to the IdP (docs/auth-security/IDENTITY.md). Without
6
6
  * this Lambda, email-keyed features silently no-op and no role-gated surface
7
7
  * (workflow:author builder, marketplace:publish publish, requires.role nodes)
8
- * can ever pass. The Cognito equivalent of the Auth0 Post-Login Action.
8
+ * can ever pass. The Cognito equivalent of another IdP's claims configuration.
9
9
  *
10
10
  * Roles = the user's Cognito groups, verbatim. Groups are named in the platform's
11
11
  * noun:verb grammar (workflow:author, marketplace:publish, finance:approve, ...),
@@ -40,7 +40,7 @@ oauth_callback_urls = [
40
40
 
41
41
  # RBAC roles (noun:verb — the grammar node manifests' requires.role matches).
42
42
  # Each becomes a Cognito group; put a user in the group and the role rides their
43
- # token. THE FULL SET, mirroring the Auth0 tenant's Gravity API permissions
43
+ # token. THE FULL SET, mirroring the platform's standing permission set
44
44
  # (2026-07-28). The first two are enforced by code gates and are always created
45
45
  # even if removed from this list; the last two are declared intent (no code gate
46
46
  # yet — enforcement arrives with the surfaces they name).
@@ -109,7 +109,7 @@ output "env_production" {
109
109
  REDIS_TLS=${local.redis_tls}
110
110
  REDIS_NAMESPACE=universe
111
111
 
112
- # OIDC — byo (Auth0 today): the tenant is authoritative for roles/permissions.
112
+ # OIDC — byo (WorkOS today): the tenant is authoritative for roles/permissions.
113
113
  AUTH_ISSUER=${var.auth_issuer}
114
114
  AUTH_CLIENT_ID=${var.auth_client_id}
115
115
  AUTH_AUDIENCE=${var.auth_audience}
@@ -24,9 +24,9 @@ manage_dns = false # true only if the domain's DNS is on DO
24
24
  # Add that URL to the IdP's allowed origins. Default false = admin-only.
25
25
  canvas_public = true
26
26
 
27
- # Auth — byo-oidc (the standing choice: DO universes use the existing Auth0
28
- # tenant; roles/permissions live THERE, Terraform doesn't touch them).
29
- auth_issuer = "https://your-tenant.auth0.com"
27
+ # Auth — byo-oidc (the standing choice: DO universes use the existing WorkOS
28
+ # environment; roles/permissions live THERE, Terraform doesn't touch them).
29
+ auth_issuer = "https://your-environment.authkit.app"
30
30
  auth_client_id = "your-spa-client-id"
31
31
  auth_audience = "gravity-api"
32
32
  # In that client's Allowed Callback URLs, include http://127.0.0.1:4109/callback —
@@ -51,11 +51,11 @@ variable "manage_dns" {
51
51
  default = false
52
52
  }
53
53
 
54
- # ── Auth: DigitalOcean universes run byo-oidc (Auth0 today) — the tenant is
54
+ # ── Auth: DigitalOcean universes run byo-oidc (WorkOS today) — the tenant is
55
55
  # authoritative for roles/permissions; Terraform only passes the pointers through
56
56
  # (INFRASTRUCTURE.md: roles are PROVISIONED only under Cognito on AWS).
57
57
  variable "auth_issuer" {
58
- description = "OIDC issuer, e.g. https://your-tenant.auth0.com"
58
+ description = "OIDC issuer, e.g. https://your-environment.authkit.app"
59
59
  type = string
60
60
  }
61
61
 
@@ -65,7 +65,7 @@ variable "auth_client_id" {
65
65
  }
66
66
 
67
67
  variable "auth_audience" {
68
- description = "OIDC audience (the Auth0 API identifier, e.g. gravity-api)."
68
+ description = "OIDC audience (the API identifier, e.g. gravity-api)."
69
69
  type = string
70
70
  default = "gravity-api"
71
71
  }
@@ -485,7 +485,7 @@ _ensure_ground_config() {
485
485
  if [ -z "$env_auth" ]; then
486
486
  echo ""
487
487
  info "A deployed universe requires a login. Local auth-off does not deploy"
488
- info "${DIM}(Auth0 or any OIDC provider; the issuer looks like https://your-tenant.auth0.com)${NC}"
488
+ info "${DIM}(WorkOS or any OIDC provider; the issuer looks like https://your-environment.authkit.app)${NC}"
489
489
  fi
490
490
  fi
491
491
  # AWS ONLY. Cognito needs a first administrator, and nothing else in the CLI collects an
@@ -187,7 +187,7 @@ manage_dns = false # true only if the domain's DNS is on D
187
187
  canvas_public = true
188
188
 
189
189
  # Auth — byo-oidc: roles/permissions live in YOUR IdP tenant.
190
- auth_issuer = "FILL_ME" # e.g. https://your-tenant.auth0.com
190
+ auth_issuer = "FILL_ME" # e.g. https://your-environment.authkit.app
191
191
  auth_client_id = "FILL_ME"
192
192
  auth_audience = "gravity-api"
193
193
 
@@ -219,7 +219,7 @@ cmd_setup() {
219
219
  [ "$cur_auth" = "true" ] && idp_prompt="[Y/n]"
220
220
  echo ""
221
221
  info "Sign-in: deploying provides one. ${DIM}AWS creates a Cognito user pool; DigitalOcean asks for an issuer then${NC}"
222
- read -r -p " Are you bringing your own (Auth0/OIDC)? $idp_prompt " HAS_IDP
222
+ read -r -p " Are you bringing your own (WorkOS/OIDC)? $idp_prompt " HAS_IDP
223
223
  echo ""
224
224
  if [ -z "$HAS_IDP" ] && [ "$cur_auth" = "true" ]; then HAS_IDP=y; fi
225
225
 
@@ -235,7 +235,7 @@ cmd_setup() {
235
235
  read -p " AUTH_ISSUER [$cur_iss]: " AUTH_ISSUER || { fail "no input (end of stream). Run unoverse create interactively"; exit 1; }
236
236
  AUTH_ISSUER="${AUTH_ISSUER:-$cur_iss}"
237
237
  else
238
- read -p " AUTH_ISSUER (e.g. https://your-tenant.auth0.com): " AUTH_ISSUER || { fail "no input (end of stream). Run unoverse create interactively"; exit 1; }
238
+ read -p " AUTH_ISSUER (e.g. https://your-environment.authkit.app): " AUTH_ISSUER || { fail "no input (end of stream). Run unoverse create interactively"; exit 1; }
239
239
  fi
240
240
  if [ -n "$AUTH_ISSUER" ] && [[ "$AUTH_ISSUER" == https://* ]]; then
241
241
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.208",
3
+ "version": "0.1.210",
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",
@@ -413,6 +413,28 @@ function lintFile(file) {
413
413
  }
414
414
  }
415
415
 
416
+ // AN ORG'S OWN IMAGES (definitions/load/assets.ts): `src: assets/<file>` must name a file in
417
+ // an assets/ folder from the file's own folder up to the org's (a template keeps its own), or
418
+ // the shared one, of an image type, under 256 KB.
419
+ {
420
+ const home = (orgDirs ?? []).find((d) => file.startsWith(d + sep)) ?? null;
421
+ const shared = DS ?? null;
422
+ const walk = (n) => {
423
+ if (Array.isArray(n)) return n.forEach(walk);
424
+ if (!n || typeof n !== "object") return;
425
+ if (n.type === "Image" && typeof n.src === "string" && n.src.startsWith("assets/")) {
426
+ const dirs = [];
427
+ for (let d = dirname(file); home && d.startsWith(home); d = dirname(d)) dirs.push(d);
428
+ const hit = [...dirs, shared].map((d) => d && join(d, n.src)).find((f) => f && existsSync(f));
429
+ if (!hit) report("error", file, `image "${n.src}" is not in this org's assets/ folder (docs.unoverse.ai/design/styles-and-tokens)`);
430
+ else if (!/\.(svg|png|jpe?g|webp|gif)$/i.test(n.src)) report("error", file, `image "${n.src}" is not an image type the platform serves (svg, png, jpg, webp, gif)`);
431
+ else if (statSync(hit).size > 256 * 1024) report("error", file, `image "${n.src}" is over 256 KB; a photo belongs on a CDN, not in the design folder`);
432
+ }
433
+ Object.values(n).forEach(walk);
434
+ };
435
+ walk(json);
436
+ }
437
+
416
438
  // A STATE'S PLACE (UNOVERSE_SCREEN.md §2.3) is `chat` or a Place some app declares. A name no
417
439
  // app has sends the interface to the conversation without a word, so it is refused here.
418
440
  if (placeNames && !isAppPath(file) && json && typeof json === "object" && !Array.isArray(json) && json.states && typeof json.states === "object") {
@@ -552,8 +574,10 @@ function lintFile(file) {
552
574
  report("error", file, `preview."${state}" must be an array of component names, or an object of template-state mock data`);
553
575
  continue;
554
576
  }
577
+ // `name#state` names the state the seed opens in (the address grammar, owner
578
+ // 2026-09-11); the lookup is the name before the hash.
555
579
  for (const c of list)
556
- if (comps && !comps.has(String(c).toLowerCase()))
580
+ if (comps && !comps.has(String(c).split("#")[0].toLowerCase()))
557
581
  report("error", file, `preview."${state}" names unknown component "${c}". No match in design/marketplace/components/ or this org's components/ (org-privacy: another org's components are out of reach; lookup is case-insensitive)`);
558
582
  }
559
583
  }
@@ -676,13 +700,13 @@ function lintFile(file) {
676
700
  // EVERY PROP CARRIES A DESCRIPTION (UNOVERSE_AUTHORING §3b, RULED 2026-08-30).
677
701
  //
678
702
  // It reads BOTH ways, which is why it is compulsory rather than encouraged. A
679
- // description is not only what a copywriter fills against: it is what lets an agent
703
+ // description is not only what an Agent fills against: it is what lets an agent
680
704
  // UNDERSTAND the screen it is looking at. `read` reports where the person is; the
681
705
  // descriptions say what is actually in front of them. A screen of unnamed fields is
682
706
  // one no agent can reason about, in either direction.
683
707
  //
684
- // AND IT SAYS NOTHING ABOUT PERMISSION. That is the layout's word — `static` /
685
- // `copywriter` / `director` (TEMPLATE_MODEL §9b) — never a prop's. The rule this
708
+ // AND IT SAYS NOTHING ABOUT PERMISSION. That is the prop's `input` (UNOVERSE_AUTHORING
709
+ // "input decides"), never its description. The rule this
686
710
  // replaces was "a described prop is a writable prop", which made silence the safety
687
711
  // mechanism: a field was protected because nobody had explained it, and explaining it
688
712
  // was what exposed it. Describing a field is now free of consequence, so there is no
@@ -691,7 +715,7 @@ function lintFile(file) {
691
715
  .filter(([, v]) => !(v && typeof v === "object" && typeof v.description === "string" && v.description.trim()))
692
716
  .map(([k]) => k);
693
717
  if (undescribed.length)
694
- 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)`);
718
+ 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 prop's input: true or false (UNOVERSE_AUTHORING §3b)`);
695
719
 
696
720
  // only components that ADOPTED the structure are held to the full discipline
697
721
  if (hasLayouts || stateFiles.length || hasStateBlock) {
@@ -114,7 +114,7 @@ export const STYLE_KEYS = new Set([
114
114
  "width", "height", "maxWidth", "minWidth", "minHeight", "maxHeight", "flex",
115
115
  "padding", "margin", "gap", "overflow",
116
116
  "position", "inset", "top", "right", "bottom", "left", "zIndex",
117
- "direction", "wrap", "align", "justify", "display", "columns", "span", "stackBelow", "container", "hideBelow", "hideAbove",
117
+ "direction", "wrap", "align", "justify", "display", "columns", "span", "stackBelow", "stage", "container", "hideBelow", "hideAbove",
118
118
  "background", "radial", "border", "borderTop", "borderRight", "borderBottom", "borderLeft",
119
119
  "outline", "shadow", "radius", "radiusTopLeft", "radiusTopRight", "radiusBottomLeft", "radiusBottomRight",
120
120
  "font", "weight", "lineHeight", "color", "textAlign", "fit",
@@ -13,26 +13,19 @@
13
13
  * The context is created per run by index.mjs, so nothing survives between runs.
14
14
  */
15
15
  import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
16
- import { join, dirname, basename, relative, sep } from "node:path";
16
+ import { join, dirname, 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
- import { isDefFile, defName, defPath, readDef } from "./defs.mjs";
18
+ import { isDefFile, defPath } from "./defs.mjs";
19
19
 
20
- // THE FOUR WORDS and the two closed blocks (UNOVERSE_TEMPLATE_MODEL.md §9b + §9e).
21
- const SECTION_WORDS = ["static", "copywriter", "director"];
22
- const DELIVERY_KEYS = ["state", "pick", "rules", "preview"];
23
- // §9e: a queried place says WHAT draws it, WHAT it asks, WHAT KIND may answer, HOW MANY.
24
- const SEARCH_KEYS = ["component", "type", "include", "pick", "items"];
25
- // The `object_type`s a place may admit. Apps and skills are absent by construction: the
26
- // page's own searches are SILENT (no app unlocked, no tool minted, no card lane, no
27
- // director woken), or every place would rain cards onto a page still assembling itself.
28
- // `products` since migration 035 (2026-09-05; `services` retired 2026-09-07, `actions` with it:
29
- // no row is written as one). Each word maps to a declared spatial type (guarded by a test).
30
- const QUERY_TYPES = ["products", "images", "needs"];
20
+ // RETIRED LAYOUT WORDS. A template layout places its own parts with the ordinary element,
21
+ // `type: Ref` + `ref: components/<name>`, and what an Agent writes into a part is each
22
+ // prop's `input`, never the element that places it (UNOVERSE_TEMPLATE_MODEL §9b).
23
+ const RETIRED_WORDS = ["static", "director", "copywriter", "query"];
24
+ /** The one way a layout places a part: a `Ref` into the template's own `components/`. */
25
+ const PART_PREFIX = "components/";
31
26
 
32
27
  export function makeWalkNode(ctx) {
33
- const { report, checkCondition, checkDimension, checkToken, appSizesForFile, componentNamesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, isTemplatePart, defRoot } = ctx;
34
- // One director per LAYOUT FILE, tracked for this run only (each file is walked once).
35
- const directorsSeen = new Map();
28
+ const { report, checkCondition, checkDimension, checkToken, appSizesForFile, refResolves, canonicalRef, declaredProps, atomsDirExists, stepList, spaceSteps, isAppPath, isTemplatePath, isTemplatePart, defRoot } = ctx;
36
29
 
37
30
  function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
38
31
  if (Array.isArray(node)) return node.forEach((n) => walkNode(n, file, root, widthCap));
@@ -54,147 +47,29 @@ function walkNode(node, file, root, widthCap = null, isLayoutRoot = false) {
54
47
  return; // the included file is linted on its own
55
48
  }
56
49
 
57
- // THE THREE WORDS (UNOVERSE_TEMPLATE_MODEL.md §9b): a template layout's parts say HOW
58
- // they are filled instead of naming a primitive. They compile to ComponentSlots at
59
- // serve time, so the linter checks the authored word here and hands the rest of the
60
- // node to the ordinary walk as the slot it becomes.
61
- const word = SECTION_WORDS.find((w) => node[w] !== undefined);
62
- if (word && node.type === undefined) {
63
- if (!isTemplatePath(file))
64
- 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)`);
65
- if (node.query !== undefined) {
66
- report("error", file, `"query:" is retired vocabulary (§9e rule 10b REVERSED 2026-09-02) — spell the place director: with the same component/items/type/include/pick. One word, one judge`);
67
- }
68
- if (word === "director") {
69
- /**
70
- * ONE WORD, TWO MODES (§9e, 2026-09-02): a director place WITH `component` is a
71
- * SEARCH place (the page fetches its own content); WITHOUT one it is a DELIVERY
72
- * band (the conversation fills it). The key sets are mutually exclusive, so a
73
- * place is always unambiguously one or the other — behaviour never forks on a
74
- * stray key. The QUESTION is never authored (rule 10d): it is derived from the
75
- * writing, the page's pool, the component's contract, or the guest's ask.
76
- */
77
- const d = node.director;
78
- if (!d || typeof d !== "object" || Array.isArray(d)) {
79
- report("error", file, `"director:" is a block, not a path (§9b rule 1)`);
80
- } else if (d.limit !== undefined) {
81
- report("error", file, `director.limit is retired — the one cap word is "pick": how many the director keeps (§9e rule 9, renamed 2026-09-02)`);
82
- } else if (d.ask !== undefined || d.query !== undefined) {
83
- report("error", file, `a director place never states its question — it is DERIVED (§9e rule 10d): the writer's headings (items), the page's pool (a gallery), the component's own contract, or the guest's ask`);
84
- } else if (typeof d.component === "string" && d.component) {
85
- // A SEARCH PLACE. The checks here are the ones that fail SILENTLY at runtime: a
86
- // place missing include or pick is skipped, and a kind nobody stores draws
87
- // nothing — both read as "the design is broken" with no log saying why.
88
- //
89
- // THE SEARCH WORDS SIT ON THE THING THAT SEARCHES (owner ruling 2026-09-02): on
90
- // an items place each ITEM searches, so type/include/pick nest INSIDE items:
91
- // beside min/max, and pick reads unambiguously as per-item. Spelled flat, the
92
- // same word meant two scopes depending on whether items: was present.
93
- const itemed = d.items && typeof d.items === "object" && !Array.isArray(d.items);
94
- if (d.items !== undefined && !itemed)
95
- report("error", file, `director.items is a block — items: { min, max, type, include, pick } (§9e rule 10c)`);
96
- if (itemed && (d.type !== undefined || d.include !== undefined || d.pick !== undefined))
97
- report("error", file, `an items place nests its search words INSIDE items: { min, max, type, include, pick } — each ITEM searches, so pick reads per item (§9e rule 10c, 2026-09-02)`);
98
- const TOP_KEYS = itemed ? ["component", "items"] : SEARCH_KEYS;
99
- for (const k of Object.keys(d))
100
- if (!TOP_KEYS.includes(k))
101
- report("error", file, `a search place has no "${k}". It reads ${TOP_KEYS.map((x) => `"${x}"`).join(", ")} (§9e; "state"/"rules" belong to a delivery band — a place with a component is governed by the page's brief)`);
102
- if (!defPath(root, d.component) && !existsSync(join(root, d.component)))
103
- report("error", file, `director.component "${d.component}" does not resolve under ${relative(process.cwd(), root)}/ (§9e)`);
104
- const ITEM_KEYS = ["min", "max", "type", "include", "pick"];
105
- if (itemed) {
106
- for (const k of Object.keys(d.items))
107
- if (!ITEM_KEYS.includes(k))
108
- report("error", file, `director.items has no "${k}". It reads ${ITEM_KEYS.map((x) => `"${x}"`).join(", ")} (§9e rule 10c)`);
109
- const { min, max } = d.items;
110
- if (typeof min !== "number" || typeof max !== "number" || min < 1 || max < min)
111
- report("error", file, `director.items carries { min, max } — how many items the writer may shape, 1 <= min <= max (§9e rule 10c)`);
112
- }
113
- // The search words, read where they live: the place itself, or each item.
114
- const s = itemed ? d.items : d;
115
- const at = itemed ? "director.items" : "director";
116
- // REQUIRED, both (2026-09-01, carried over). The engine supplies NO defaults: a
117
- // default include or pick would be one template's answer written into the platform.
118
- if (s.include === undefined)
119
- report("error", file, `${at}.include is REQUIRED on a search place — what KIND may answer; the engine has no default kind (§9e rule 6)`);
120
- else if (!Array.isArray(s.include) || s.include.some((t) => typeof t !== "string"))
121
- report("error", file, `${at}.include is an array of result types — ${QUERY_TYPES.map((t) => `"${t}"`).join(", ")} (§9e rule 6)`);
122
- else
123
- for (const t of s.include)
124
- if (!QUERY_TYPES.includes(String(t)))
125
- report("error", file, `${at}.include has no "${t}". A place may admit ${QUERY_TYPES.map((x) => `"${x}"`).join(", ")}; apps and skills are never included, because the page's own searches are SILENT (§9e rule 6)`);
126
- if (s.pick === undefined)
127
- report("error", file, `${at}.pick is REQUIRED — how many the director keeps${itemed ? " PER ITEM" : ""}; the engine has no default count (§9e rule 9)`);
128
- else if (typeof s.pick !== "number" || s.pick < 1)
129
- report("error", file, `${at}.pick is a NUMBER — how many the director keeps${itemed ? " per item" : ""}, a cap the machine enforces (§9e rule 9)`);
130
- // `type` is OPTIONAL since the question derives (rule 10d): authored it stands;
131
- // absent, a contract subject searches as intent and the guest's ask as discovery.
132
- if (s.type !== undefined && s.type !== "discovery" && s.type !== "intent")
133
- report("error", file, `${at}.type is "discovery" (what lives around a topic) or "intent" (a named need) — or omitted, and the derivation decides (§9e rule 10d)`);
134
- } else {
135
- // A DELIVERY BAND.
136
- for (const k of Object.keys(d))
137
- if (!DELIVERY_KEYS.includes(k))
138
- report("error", file, `a delivery band has no "${k}". It reads ${DELIVERY_KEYS.map((x) => `"${x}"`).join(", ")} (§9b; a place that names a component is a search place)`);
139
- if (d.pick !== undefined && (typeof d.pick !== "number" || d.pick < 1))
140
- report("error", file, `director.pick is a NUMBER — how many the director keeps, a cap the machine enforces (§9e rule 9)`);
141
- // NOTHING READS `rules`: the director's model was removed (UNOVERSE_SCREEN.md §2.7).
142
- if (d.rules !== undefined)
143
- report("warn", file, `director.rules is ignored: no model reads it any more. A band holds what arrives in search rank, up to pick. Remove it`);
144
- if (d.preview !== undefined && (!Array.isArray(d.preview) || d.preview.some((c) => typeof c !== "string")))
145
- report("error", file, `director.preview is an array of component names — Studio's stand-ins until a real run exists (§9b rule 6)`);
146
- else if (Array.isArray(d.preview)) {
147
- const comps = componentNamesForFile(file);
148
- for (const c of d.preview)
149
- if (comps && !comps.has(String(c).toLowerCase()))
150
- report("error", file, `director.preview names unknown component "${c}". No match in the design system or this org's components (lookup is case-insensitive)`);
151
- }
152
- // A PLACE NEEDS NO NAME (§9d): the compiler addresses delivery bands by
153
- // position; an AUTHORED state: still stands (the flat-email pin), so two bands
154
- // pinned to the SAME face remains the one genuine ambiguity.
155
- const seen = directorsSeen.get(file) ?? [];
156
- const face = typeof d?.state === "string" ? d.state : null;
157
- if (face && seen.includes(face))
158
- 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)`);
159
- directorsSeen.set(file, [...seen, face]);
160
- }
161
- } else if (typeof node[word] !== "string") {
162
- report("error", file, `"${word}:" links a component by PATH (${word}: components/<part>) — that is what makes it a known part (§9b rule 1)`);
163
- } else {
164
- const path = node[word];
165
- if (!defPath(root, path) && !existsSync(join(root, path)))
166
- report("error", file, `${word} "${path}" does not resolve under ${relative(process.cwd(), root)}/ (§9b rule 1)`);
167
- }
168
- // Everything else on the line rides through to the slot it compiles into. The select
169
- // is SYNTHESIZED here exactly as the compiler builds it (a linked part claims its own
170
- // face, limit 1; the director's claim is its `state:` pin) so the rest of the walk
171
- // sees what will actually be served — and an authored one is the ceremony §9b rule 9
172
- // exists to abolish.
50
+ // RETIRED: the section words. A stale layout fails here with the fix in the message.
51
+ const retired = RETIRED_WORDS.find((w) => node[w] !== undefined);
52
+ if (retired) {
53
+ report("error", file, `"${retired}:" is retired. Place the part with type: Ref and ref: components/<name>, and mark each of its props input: true (an Agent fills it) or input: false (drawn from its default) (UNOVERSE_AUTHORING "input decides")`);
54
+ return;
55
+ }
56
+
57
+ // A PLACED PART (UNOVERSE_TEMPLATE_MODEL §9b): in a template's layout, a `Ref` into the
58
+ // template's own `components/` folder. It compiles to a ComponentSlot at serve time,
59
+ // claiming the part's own face (its basename) at limit 1, so the linter checks the part
60
+ // resolves and hands the rest of the node to the ordinary walk as the slot it becomes.
61
+ if (node.type === "Ref" && typeof node.ref === "string" && node.ref.startsWith(PART_PREFIX) && isTemplatePath(file) && !isTemplatePart(file)) {
62
+ const path = node.ref;
63
+ if (!defPath(root, path) && !existsSync(join(root, path)))
64
+ report("error", file, `part "${path}" does not resolve under ${relative(process.cwd(), root)}/. A layout places the template's own parts: components/<name> (§9b)`);
65
+ if (node.props !== undefined || node.with !== undefined)
66
+ report("error", file, `a placed part takes no "props" or "with": its props are its own, filled by an Agent where marked input: true and drawn from their defaults where input: false (§9b)`);
67
+ // An authored select is the ceremony §9b rule 9 abolishes: the claim is synthesized.
173
68
  if (node.select !== undefined)
174
- report("error", file, `a template part never authors "select" — the three words compile to the wire primitives at serve time (§9b rule 9)`);
175
- const { static: _s, copywriter: _c, director: _d, query: _q, select: _sel, ...rest } = node;
176
- // An unpinned director claims the OWNING STATE's name (arrivals keep their own state
177
- // and the template's states react by name — the free grid↔page dance), so the face
178
- // comes from whichever state in the envelope declares this layout.
179
- const owningState = () => {
180
- const env = defPath(root, basename(root));
181
- const states = env ? readDef(env)?.states : undefined;
182
- if (!states || typeof states !== "object") return undefined;
183
- const here = defName(basename(file));
184
- return Object.keys(states).find((s) => {
185
- const l = states[s]?.layout;
186
- return typeof l === "string" && basename(l).toLowerCase() === here.toLowerCase();
187
- });
188
- };
189
- // A QUERIED place links its component under `component:`, and claims that part's own
190
- // face at limit 1 exactly as a linked part does — its `limit:` counts ROWS inside the
191
- // part, not instances of it, so it never reaches the slot (§9e).
192
- const linkedPath = word === "query" ? node.query?.component : node[word];
193
- const face =
194
- word === "director" ? (node.director?.state ?? owningState()) : typeof linkedPath === "string" ? linkedPath.split("/").pop() : undefined;
195
- const limit = word === "director" ? node.director?.limit : 1;
196
- const select = { from: "all", ...(face ? { where: { field: "state", eq: face } } : {}), ...(typeof limit === "number" ? { limit } : {}) };
197
- return walkNode({ type: "ComponentSlot", select, ...rest }, file, root, widthCap);
69
+ report("error", file, `a placed part never authors "select". It claims its own face at serve time (§9b rule 9)`);
70
+ const { type: _t, ref: _r, props: _p, with: _w, select: _sel, ...rest } = node;
71
+ const face = path.split("/").pop();
72
+ return walkNode({ type: "ComponentSlot", select: { from: "all", where: { field: "state", eq: face }, limit: 1 }, ...rest }, file, root, widthCap);
198
73
  }
199
74
 
200
75
  const t = node.type;
@@ -108,6 +108,13 @@
108
108
  "endWhen": {
109
109
  "$ref": "_defs.schema.json#/definitions/expression",
110
110
  "description": "THE MODEL ENDING THE CALL ITSELF, tested against every vendor event with it in scope as `response`. True ends the session exactly as a hang-up does.\n\n`endOn` above is the LANE's list and every entry on it means the PERSON left: a carrier's stop frame, a browser tab closing. Nothing there can say the ASSISTANT is finished, and a live voice model may send no end-of-call event of its own, so a call whose business was done stayed open: it said goodbye, stopped talking, and held the line until the idle timer fired minutes later. The caller hears silence and the session bills for it.\n\nWhat counts is the node's to decide. For a vendor with function calling the natural answer is a tool the model may call, so the expression tests for that name. Absent, nothing changes: only the lane ends a call."
111
+ },
112
+ "speech": {
113
+ "type": "array",
114
+ "items": {
115
+ "type": "string"
116
+ },
117
+ "description": "The vendor events that mean someone actually SPOKE, the person or the assistant. A call with none of them for 60 seconds ends (docs/VOICE_STREAMING_GUIDE.md, \"Every Call Ends\"), so a call nobody hangs up is not billed for ever.\n\nName them when the defaults are wrong for the vendor. Absent, speech is the person's speech event (`userSpeaking`) and the assistant's audio (`out.match`), which is wrong for a vendor that streams silent audio: GPT-Live names its transcript deltas instead."
111
118
  }
112
119
  },
113
120
  "additionalProperties": false
@@ -74,7 +74,7 @@
74
74
  "secret": {
75
75
  "type": "boolean",
76
76
  "default": true,
77
- "description": "REQUIRED. Encrypted at rest, masked in the UI, never returned to a client once saved. Optional was how a Google API key and a Cloudinary API key ended up in the database in plaintext (docs/auth-security/SECURITY.md, 2026-08-30): an omission read as false and nothing warned. Set false ONLY for a genuinely non-sensitive field such as a region or an account id, and say why."
77
+ "description": "REQUIRED. Encrypted at rest, masked in the UI, never returned to a client once saved. Optional was how a Google API key and a Cloudinary API key ended up in the database in plaintext (docs/auth-security/legacy/SECURITY_FINDINGS_LOG.md, 2026-08-30): an omission read as false and nothing warned. Set false ONLY for a genuinely non-sensitive field such as a region or an account id, and say why."
78
78
  },
79
79
  "default": {
80
80
  "type": "string"
@@ -185,8 +185,11 @@
185
185
  "items": {
186
186
  "type": "object",
187
187
  "required": [
188
- "label",
189
- "component"
188
+ "label"
189
+ ],
190
+ "oneOf": [
191
+ { "required": ["component"], "not": { "required": ["service"] } },
192
+ { "required": ["service"], "not": { "required": ["component"] } }
190
193
  ],
191
194
  "additionalProperties": false,
192
195
  "properties": {
@@ -195,6 +198,11 @@
195
198
  "minLength": 1,
196
199
  "description": "The tab, as a person reads it."
197
200
  },
201
+ "service": {
202
+ "type": "string",
203
+ "enum": ["voice"],
204
+ "description": "A native service the tab opens instead of drawing a component. `voice` is a live call: the tab starts this workflow from the trigger upstream of this node, with that trigger's Testing inputs, and carries the person's microphone and speakers on the browser lane for as long as the tab is open. For a voice node on the browser lane."
205
+ },
198
206
  "component": {
199
207
  "type": "string",
200
208
  "minLength": 1,