Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/specifier-value-domain.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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` |
Expand Down
16 changes: 14 additions & 2 deletions content/docs/references/system/settings-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 | |
Expand Down Expand Up @@ -267,3 +268,14 @@ This schema accepts one of the following structures:

---

## SpecifierValueDomain

### Allowed Values

* `iana_time_zone`
* `iso_4217_currency`
* `iso_3166_alpha2`


---

2 changes: 2 additions & 0 deletions packages/spec/api-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,8 @@
"SpecifierScope (type)",
"SpecifierScopeSchema (const)",
"SpecifierType (type)",
"SpecifierValueDomain (type)",
"SpecifierValueDomainSchema (const)",
"StackServerConfig (type)",
"StackServerConfigParsed (type)",
"StackServerConfigSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/json-schema.manifest/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
"system/SpecifierOption",
"system/SpecifierScope",
"system/SpecifierType",
"system/SpecifierValueDomain",
"system/StackServerConfig",
"system/StackServerSecurity",
"system/StorageAcl",
Expand Down
165 changes: 165 additions & 0 deletions packages/spec/src/system/settings-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { describe, it, expect } from 'vitest';
import {
SpecifierType,
SpecifierValueDomainSchema,
SpecifierSchema,
SettingsManifestSchema,
ResolvedSettingValueSchema,
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading