diff --git a/.changeset/specifier-value-domain.md b/.changeset/specifier-value-domain.md new file mode 100644 index 0000000000..aa5665e4a1 --- /dev/null +++ b/.changeset/specifier-value-domain.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): SettingsManifest specifiers can declare a standard `valueDomain` (#5933) + +`SpecifierSchema`'s value constraints were `options` / `pattern` / `min` / `max` / +`minLength` / `maxLength`, and none of them can express "the legal values here are +whatever the published standard says". `pattern` constrains the *shape* of a string, +so `^[A-Za-z]{2}$` admits `ZZ` and `Mars/Olympus` is a shape-valid time zone that does +not exist; `options` is exhaustive (#5131), so completing it would mean checking a +600-entry tzdb table into a manifest and re-checking it every tzdb release. The +`localization` manifest hits this on three keys at once — `timezone`, `currency`, +`default_country` — and `company.country` carries the same two-letter pattern with the +same hole. + +**New optional key: `specifier.valueDomain`**, a closed enum with three members: + +- `iana_time_zone` +- `iso_4217_currency` +- `iso_3166_alpha2` + +Declaring it moves the enforcement boundary: the standard's membership becomes what a +write is judged against, and `options` degrades to a **UI convenience list** — a curated +dropdown of values worth suggesting, no longer an exhaustive statement of what is legal. +A value outside `options` but inside the domain is accepted. + +**Nothing changes when it is absent.** `options` stays exhaustive and the save path keeps +rejecting anything the table does not list, which is the right shape for tables the +platform itself backs (`mail.provider`, `sms.provider`) where "legal" means "this +deployment ships an adapter for it". `pattern` / `minLength` / `maxLength` still apply +alongside a domain and still narrow — shape and membership are independent, and a value +must satisfy both. + +The **enforcement** is not in this release. `packages/spec` declares the domain and +nothing more (Prime Directive #2); the write-path check lands in `service-settings` +(#5712, blocked on this). What ships here so both halves agree is the *definition of +membership* for each domain, pinned by tests rather than left to prose, because for two +of the three the obvious oracle is the wrong one: + +- `iana_time_zone` is the `Intl.DateTimeFormat` probe, not + `Intl.supportedValuesOf('timeZone')` — measured on the Node 22 baseline, that list + holds 418 CLDR canonical names and omits `UTC` (this platform's own declared default) + and `Asia/Kolkata` (a value the shipped localization manifest curates), carrying the + latter only under the legacy spelling `Asia/Calcutta`. +- `iso_4217_currency` **is** `Intl.supportedValuesOf('currency')` — 162 entries, + admitting `CHF` and all nine curated options while rejecting `XYZ`. +- `iso_3166_alpha2` has no standard-library oracle at all: + `Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct name for `ZZ` + ("Unknown Region", the exact value this domain exists to reject) and for `UK` (a CLDR + alias that is not an ISO 3166-1 code), so the enforcing side must carry an explicit + code list. + +`bcp47_locale`, the fourth member the proposal listed, is deliberately **not** in the +vocabulary. Its only candidate key is `localization.locale`, whose options are exactly +the shipped message catalogs — a registry-backed table, so a domain there would loosen +it and admit locales with no catalog. And BCP-47 has no membership registry to enforce +against (`Intl.getCanonicalLocales('xx-YY')` succeeds), so the "domain" would only +re-check syntax — the weakness `pattern` already has and this key exists to fix. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a2faf3eb17..f7f074d784 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1604 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1605 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -31,9 +31,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 37 | 296 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 146 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1604** | 14 protocol modules | +| **Total** | **200** | **1605** | 14 protocol modules | --- @@ -318,7 +318,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 295 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 296 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -353,7 +353,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`search-engine.zod.ts`](/docs/references/system/search-engine) | `AnalyzerConfig`, `FacetConfig`, `SearchConfig`, `SearchIndexConfig`, `SearchProvider` | | [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` | | [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` | -| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType` | +| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` | | [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` | | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | diff --git a/content/docs/references/system/settings-manifest.mdx b/content/docs/references/system/settings-manifest.mdx index ee42c1f1f7..3a8518d95c 100644 --- a/content/docs/references/system/settings-manifest.mdx +++ b/content/docs/references/system/settings-manifest.mdx @@ -31,8 +31,8 @@ Resolution order (handled by `SettingsService.get`): ## TypeScript Usage ```typescript -import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType } from '@objectstack/spec/system'; -import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType } from '@objectstack/spec/system'; +import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType, SpecifierValueDomainSchema } from '@objectstack/spec/system'; +import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType, SpecifierValueDomain } from '@objectstack/spec/system'; // Validate data const result = ResolvedSettingValueSchema.parse(data); @@ -130,6 +130,7 @@ const result = ResolvedSettingValueSchema.parse(data); | **deprecated** | `boolean` | optional | Mark deprecated | | **replacedBy** | `string` | optional | Replacement key (used when deprecated=true) | | **options** | `{ value: string \| number \| boolean; label: string; description?: string; icon?: string }[]` | optional | Options for select/radio/multiselect | +| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain enforced on write (options degrade to a UI suggestion list) | | **min** | `number` | optional | | | **max** | `number` | optional | | | **step** | `number` | optional | | @@ -267,3 +268,14 @@ This schema accepts one of the following structures: --- +## SpecifierValueDomain + +### Allowed Values + +* `iana_time_zone` +* `iso_4217_currency` +* `iso_3166_alpha2` + + +--- + diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9f9a215fdd..3594ed81bc 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -619,6 +619,8 @@ "SpecifierScope (type)", "SpecifierScopeSchema (const)", "SpecifierType (type)", + "SpecifierValueDomain (type)", + "SpecifierValueDomainSchema (const)", "StackServerConfig (type)", "StackServerConfigParsed (type)", "StackServerConfigSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index c9da8ba25d..27ad36886f 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1142,6 +1142,7 @@ "system/Specifier:scope", "system/Specifier:step", "system/Specifier:type", + "system/Specifier:valueDomain", "system/Specifier:visible", "system/Specifier:writePermission", "system/SpecifierOption:description", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 25b6090c5b..e2834118fc 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -245,6 +245,7 @@ "system/SpecifierOption", "system/SpecifierScope", "system/SpecifierType", + "system/SpecifierValueDomain", "system/StackServerConfig", "system/StackServerSecurity", "system/StorageAcl", diff --git a/packages/spec/src/system/settings-manifest.test.ts b/packages/spec/src/system/settings-manifest.test.ts index 6269b9666c..da2713b8cd 100644 --- a/packages/spec/src/system/settings-manifest.test.ts +++ b/packages/spec/src/system/settings-manifest.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { SpecifierType, + SpecifierValueDomainSchema, SpecifierSchema, SettingsManifestSchema, ResolvedSettingValueSchema, @@ -193,6 +194,170 @@ describe('SpecifierSchema — layout-only specifiers', () => { }); }); +describe('SpecifierValueDomainSchema — the closed standard-domain vocabulary (#5933)', () => { + it('accepts exactly the three domains with measured authoring pull', () => { + for (const d of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) { + expect(() => SpecifierValueDomainSchema.parse(d)).not.toThrow(); + } + expect(SpecifierValueDomainSchema.options).toEqual([ + 'iana_time_zone', + 'iso_4217_currency', + 'iso_3166_alpha2', + ]); + }); + + it('rejects `bcp47_locale` — the proposal member that was deliberately dropped', () => { + // #5933's proposal listed a fourth member. Its only candidate key is + // `localization.locale`, a `select` whose options ARE the shipped message + // catalogs — a registry-backed table, so declaring a domain there would + // LOOSEN it (options degrade to a suggestion list) and admit locales that + // have no catalog. And BCP-47 has no membership registry to enforce + // against: `Intl.getCanonicalLocales('xx-YY')` succeeds, so the "domain" + // would only re-check syntax — exactly the weakness `pattern` already has + // and this key exists to fix. It is not in the vocabulary; a manifest that + // spells it is refused by name rather than silently stripped. + expect(() => SpecifierValueDomainSchema.parse('bcp47_locale')).toThrow(); + expect(() => Intl.getCanonicalLocales('xx-YY')).not.toThrow(); + }); + + it('rejects unknown domains', () => { + expect(() => SpecifierValueDomainSchema.parse('iso_9999_unicorn')).toThrow(); + expect(() => SpecifierValueDomainSchema.parse('')).toThrow(); + }); +}); + +describe('Specifier.valueDomain (#5933)', () => { + it('SURVIVES the parse — a dropped key is the defect this closes', () => { + // #5712's dev measured the current shape by smuggling a `format` key in: + // Zod stripped it and `parse()` returned `undefined`, so the manifest had + // no way to say "the boundary is a standard". This assertion is that + // measurement inverted, and it is the one that must never regress. + const parsed = SpecifierSchema.parse({ + type: 'select', + key: 'timezone', + label: 'Default timezone', + valueDomain: 'iana_time_zone', + options: [{ value: 'UTC', label: 'UTC' }], + }); + expect(parsed.valueDomain).toBe('iana_time_zone'); + }); + + it('is optional — an undeclared specifier keeps #5131 exhaustive-options semantics', () => { + const parsed = SpecifierSchema.parse({ + type: 'select', + key: 'provider', + label: 'Provider', + options: [{ value: 'smtp', label: 'SMTP' }], + }); + expect(parsed.valueDomain).toBeUndefined(); + }); + + it('accepts a `text` specifier alongside pattern/length constraints', () => { + // Shape and membership are independent narrowings and a value must satisfy + // both — `^[A-Za-z]{2}$` is what admits `ZZ` today, which is why the domain + // is added rather than the pattern replaced. + const parsed = SpecifierSchema.parse({ + type: 'text', + key: 'default_country', + label: 'Default country', + valueDomain: 'iso_3166_alpha2', + pattern: '^[A-Za-z]{2}$', + minLength: 2, + maxLength: 2, + }); + expect(parsed.valueDomain).toBe('iso_3166_alpha2'); + expect(parsed.pattern).toBe('^[A-Za-z]{2}$'); + }); + + it('still requires `options` on a select — the list degrades, it does not vanish', () => { + expect(() => + SpecifierSchema.parse({ + type: 'select', + key: 'currency', + label: 'Default currency', + valueDomain: 'iso_4217_currency', + }) + ).toThrow(/requires non-empty 'options'/); + }); + + it('rejects a valueDomain on a layout-only specifier', () => { + expect(() => + SpecifierSchema.parse({ + type: 'group', + label: 'Region', + valueDomain: 'iana_time_zone', + }) + ).toThrow(/carries no value, so it must not declare a 'valueDomain'/); + }); + + it('rejects an unknown domain on an otherwise valid specifier', () => { + expect(() => + SpecifierSchema.parse({ + type: 'text', + key: 'default_country', + label: 'Default country', + valueDomain: 'iso_3166_alpha3', + }) + ).toThrow(); + }); +}); + +describe('valueDomain membership definitions — the measurements service-settings must implement', () => { + // These pin the TSDoc on `SpecifierValueDomainSchema`. `packages/spec` does + // not enforce a domain (Prime Directive #2) — but the two halves have to agree + // on WHAT the domain is, and the obvious oracle is the wrong one for two of + // the three. A doc nobody re-measures rots; these go red when it does. + + const probeTimeZone = (tz: string): boolean => { + try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } + }; + + it('iana_time_zone: the Intl.DateTimeFormat probe is the definition', () => { + // Same shape as `isValidTimeZone` in + // packages/core/src/security/resolve-authz-context.ts. + for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT', 'Asia/Shanghai']) { + expect(probeTimeZone(tz)).toBe(true); + } + // And it does what `pattern` cannot: a shape-valid zone that does not exist. + expect(probeTimeZone('Mars/Olympus')).toBe(false); + }); + + it('iana_time_zone: Intl.supportedValuesOf is NOT the definition', () => { + // Measured on the repo's Node 22 baseline: a CLDR canonical-name subset + // that omits values this platform itself ships. If this ever goes green in + // the other direction, ICU has changed — re-measure before relaxing the + // TSDoc, do not just delete the assertion. + const enumerated = Intl.supportedValuesOf('timeZone'); + for (const shipped of ['UTC', 'Asia/Kolkata']) { + expect(probeTimeZone(shipped)).toBe(true); + expect(enumerated).not.toContain(shipped); + } + // It is not merely a subset — it renames: the zones above are present under + // legacy spellings, so even a normalising membership test is not free. + expect(enumerated).toContain('Asia/Calcutta'); + }); + + it('iso_4217_currency: Intl.supportedValuesOf IS the definition', () => { + const currencies = Intl.supportedValuesOf('currency'); + // The nine curated localization options plus CHF, the canonical "valid but + // not curated" value the degraded-options semantics must admit. + for (const c of ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL', 'CHF']) { + expect(currencies).toContain(c); + } + expect(currencies).not.toContain('XYZ'); + }); + + it('iso_3166_alpha2: Intl.DisplayNames is NOT a membership oracle', () => { + // The tempting test is "the display name differs from the input". It admits + // `ZZ` — the exact value #5933 cites as slipping past `^[A-Za-z]{2}$` — and + // `UK`, which is a CLDR alias and not an ISO 3166-1 code at all. The + // enforcing side needs an explicit code list; that list is not spec's. + const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); + expect(regionNames.of('ZZ')).not.toBe('ZZ'); + expect(regionNames.of('UK')).not.toBe('UK'); + }); +}); + describe('SettingsManifestSchema', () => { const minimalManifest: SettingsManifest = { namespace: 'mail', diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index e012e6aa30..2dfc25c422 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -134,6 +134,57 @@ export type SpecifierHandlerParsed = z.infer; export const SpecifierScopeSchema = z.enum(['global', 'tenant', 'user']); export type SpecifierScope = z.input; +/** + * Closed vocabulary of **standard value domains** a specifier's value may be + * drawn from (#5933, the spec half of #5712). + * + * A specifier that declares `valueDomain` says: *the legal values for this key + * are the members of this published standard*, and that membership — not the + * `options` table — is the enforcement boundary. See {@link Specifier} for the + * authoring semantics; enforcement itself lives in `service-settings` + * (Prime Directive #2 — the spec declares, it does not execute). + * + * The vocabulary is closed and deliberately small: a member earns its place by + * a metadata key that actually needs it, not by being a standard that exists. + * Each member below carries the **definition of membership** the enforcing side + * must implement, because "the IANA time zone database" and "what this Node + * happens to enumerate" are measurably different sets, and picking the wrong + * one rejects legal values. + * + * - `iana_time_zone` — an IANA/tzdb zone identifier (`UTC`, `Asia/Kolkata`, + * `Europe/Kyiv`). **Membership is the `Intl.DateTimeFormat` probe** + * (construct with `{ timeZone: value }`, catch `RangeError`) — the definition + * already used by `isValidTimeZone` in + * `packages/core/src/security/resolve-authz-context.ts` and by + * `localization.manifest.test.ts`. + * NOT `Intl.supportedValuesOf('timeZone')`: measured on the repo's Node 22 + * baseline it returns 418 CLDR *canonical* names and omits `UTC` (this + * platform's own declared default), `Asia/Kolkata` (a curated option in the + * shipped localization manifest), `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, + * `US/Eastern` and `GMT`. Testing membership against that list rejects values + * every runtime accepts. + * - `iso_4217_currency` — an ISO 4217 alphabetic currency code (`USD`, `CHF`). + * Here `Intl.supportedValuesOf('currency')` IS usable: measured 162 entries + * on the same baseline, admitting `CHF` and all nine curated localization + * options while rejecting `XYZ`. Known gaps are the recently assigned `VED` + * and the metal/fund codes (`XAU`, `XDR`, …) — widen the definition + * deliberately if a deployment needs one, but never fall back to a regex. + * - `iso_3166_alpha2` — an ISO 3166-1 alpha-2 country code (`US`, `GB`, `CN`). + * There is no standard-library oracle for this one: measured, + * `Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct name for + * `ZZ` ("Unknown Region" — the exact value this domain exists to reject) and + * for `UK` (a CLDR alias that is not an ISO 3166-1 code), so "the name + * differs from the input" is not a membership test. The enforcing side must + * carry an explicit alpha-2 code list; that list does not live here, because + * `packages/spec` holds no data tables (Prime Directive #2). + */ +export const SpecifierValueDomainSchema = z.enum([ + 'iana_time_zone', + 'iso_4217_currency', + 'iso_3166_alpha2', +]); +export type SpecifierValueDomain = z.input; + // --------------------------------------------------------------------------- // Specifier schema (the unit of UI in a manifest) // --------------------------------------------------------------------------- @@ -231,6 +282,41 @@ export const SpecifierSchema = lazySchema(() => z.object({ /** Options for `select` / `radio` / `multiselect`. */ options: z.array(SpecifierOptionSchema).optional().describe('Options for select/radio/multiselect'), + /** + * Declares that this key's legal values are the members of a published + * standard — see {@link SpecifierValueDomainSchema} for the vocabulary and + * for each domain's definition of membership. + * + * **Declaring it moves the enforcement boundary.** When `valueDomain` is + * present, the standard's membership is what a write is judged against, and + * `options` degrades to a **UI convenience list**: a curated dropdown of the + * values worth suggesting, no longer an exhaustive statement of what is + * legal. A value outside `options` but inside the domain is accepted. + * + * When `valueDomain` is ABSENT nothing changes: `options` remains exhaustive + * and the save path rejects anything the table does not list (#5131). That is + * the right shape for tables the platform itself backs — `mail.provider`, + * `sms.provider` — where "legal" means "this deployment ships an adapter for + * it", not "some registrar published it". Do not add `valueDomain` to those. + * + * `pattern` / `minLength` / `maxLength` still apply and still narrow: they + * constrain the *shape* of the string, the domain constrains its + * *membership*. A value must satisfy both. Reach for `valueDomain` precisely + * where `pattern` cannot help — `^[A-Za-z]{2}$` admits `ZZ`, and + * `Mars/Olympus` is a shape-valid time zone that does not exist. + * + * Only meaningful on value-bearing, string-valued specifiers (`text`, + * `select`, `radio`, `multiselect`); the parse refuses it on layout-only + * specifiers, which carry no value to judge. For a `select`, `options` is + * still required — a dropdown needs something to draw. + * + * The check itself runs in `service-settings` on the write path (#5712); + * `packages/spec` only declares the domain. + */ + valueDomain: SpecifierValueDomainSchema + .optional() + .describe('Standard value domain enforced on write (options degrade to a UI suggestion list)'), + /** `number` / `slider`: numeric bounds and step. */ min: z.number().optional(), max: z.number().optional(), @@ -270,6 +356,17 @@ export const SpecifierSchema = lazySchema(() => z.object({ message: `Specifier of type '${spec.type}' must not declare a 'key'.`, }); } + // A value domain judges a VALUE, so a specifier that carries none cannot + // declare one. Caught here rather than left to the renderer because a + // `valueDomain` on a `group` is a silent no-op otherwise — declared and never + // enforced, which is the shape Prime Directive #10 exists to refuse. + if (spec.valueDomain && !SPECIFIERS_REQUIRING_KEY.has(spec.type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['valueDomain'], + message: `Specifier of type '${spec.type}' carries no value, so it must not declare a 'valueDomain'.`, + }); + } // select/radio/multiselect require options. if (['select', 'radio', 'multiselect'].includes(spec.type)) { if (!spec.options || spec.options.length === 0) { diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 8082b72c28..2d46ec5d03 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1194,6 +1194,7 @@ export type Iso624 = Assert, z.infer< t export type Iso625 = Assert, z.infer< typeof M147.SpecifierOptionSchema > >>; export type Iso626 = Assert, z.infer< typeof M147.SpecifierScopeSchema > >>; export type Iso627 = Assert, z.infer< typeof M147.SettingsActionResultSchema > >>; +export type Iso758 = Assert, z.infer< typeof M147.SpecifierValueDomainSchema > >>; // system/supplier-security.zod.ts export type Iso628 = Assert, z.infer< typeof M148.SupplierRiskLevelSchema > >>; @@ -1490,9 +1491,14 @@ describe('ADR-0122 type-alias convention', () => { // 751 -> 754 is #6037's `ValidateDataIssue` / `ValidateDataRequest` / // `ValidateDataResponse` — three new protocol shapes with no defaults or // transforms anywhere in their trees, i.e. the second (RISE) case above. + // + // 754 -> 755 is #5933's `SpecifierValueDomain` — one new closed enum on + // `SettingsManifest`'s SpecifierSchema, the same (RISE) case: a `z.enum` + // has no default or transform, so its two shapes coincide and it gets a pin + // rather than a `SpecifierValueDomainParsed` synonym. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert {