Found while adding "this must be a real IANA zone" to an application field (duly_duty.timezone, objectstack-ai/duly#24). Filing per that app's rule "if the platform genuinely cannot express it, file upstream rather than quietly working around it".
The gap
packages/spec already publishes the vocabulary and, valuably, the definition of membership — SpecifierValueDomainSchema in system/settings-manifest.zod.ts:
'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'
with the note that for iana_time_zone"membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf('timeZone')". That note is correct and it saved me a bug — see the measurement below.
But valueDomain is reachable only from a settings Specifier. An object field has no equivalent. FieldType has no timezone member, and FieldSchema carries maxLength / minLength / min / max but no value-domain slot. So an authored zone field is a bare Field.text and nothing checks it; the value is discovered to be wrong wherever it is finally consumed.
Why an application cannot close it at its own layer either
Both extension points an app can reach were measured on @objectstack/spec / @objectstack/runtime / @objectstack/objectql17.2.0, Node v22.22.2.
1. A script / cross_field validation rule (CEL) cannot. The whole stdlib registered in @objectstack/formula's registerStdLib is:
now today daysFromNow daysAgo isBlank coalesce trim joinNonEmpty daysBetween
addDays addMonths date datetime abs round floor ceil min max upper lower
contains startsWith endsWith matches len isEmpty
There is no zone/currency/country oracle, and no app-level way to register one — buildEnv is internal to the package. The only reachable spelling is matches(record.timezone, '<regex>'), which either checks shape only (Europe/Munich passes) or freezes a tzdata snapshot into metadata — precisely what the iana_time_zone note warns is a measurably different set from what the host accepts.
2. An L2 hook body cannot: the sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, newQuickJSWASMModule, the variant AppPlugin wires through QuickJSScriptRunner):
typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object
HookBodyCapability is api.read | api.write | api.transaction | crypto.uuid | log — nothing grants Intl, so this is not a capability the author forgot to declare.
The part that makes this actively hazardous, not merely missing
objectstack buildsilently lowers a self-contained inline handler into an L2 body — confirmed in a real artifact, where a hook authored as handler: <inline fn> ships as:
{ "handler": "duly_task_lifecycle_stamps",
"body": { "language": "js", "source": "…", "capabilities": [] } }and resolveHandler in bindHooksToEngine prefers body and ignores handler whenever both are present. So the natural way to write this check — an inline beforeInsert handler doing the Intl.DateTimeFormat probe — behaves like this:
pnpm validate, typecheck, test and build are all green (in-process tests run the raw JS function in Node, where Intl exists);- in production the lowered body throws
ReferenceError: Intl is not defined inside the sandbox; - with the
onError: 'abort' that a validation-shaped hook must declare, every write to that object is refused.
Nothing anywhere in that sequence says the handler moved to a runtime that lacks the global it uses.
The only route that keeps the probe in Node is the string handler ref (handler: 'my_fn' + defineStack({ functions })), resolved against the bundle functions map + runtimeModule. That works — but hook.zod.ts marks handler"DEPRECATED, prefer body" and warnLegacyHandler prints "Move the handler source into Hook.body". Following that advice on any host-API-dependent check converts a working guard into a refuse-everything hook. The deprecation direction and the only viable route for this class of check point opposite ways.
What would close it
In rough order of preference:
valueDomain on a field — Field.text({ valueDomain: 'iana_time_zone' }), enforced on the write path with the membership definition already written down in settings-manifest.zod.ts. Declarative, translatable refusal message, visible to Studio / OpenAPI / the form layer, and one oracle for settings and fields alike.- A
format validation member alongside email | url | phone | json drawn from the same closed vocabulary. - Failing both: state in
hook.zod.ts that a body cannot reach host intrinsics and that the string handler ref is the supported route for checks that need them, so the deprecation does not read as advice to break them.
Independently of (1)-(3), the sandbox-lowering hazard seems worth a build-time diagnostic on its own: an inline handler that references a global the sandbox does not provide is lowerable, buildable, testable and broken only in production.
Meanwhile
objectstack-ai/duly is using the string handler ref with the Intl.DateTimeFormat probe in Node, sharing one oracle with the period engine that consumes the value, and pointing at this issue in the code.
Measurement backing the iana_time_zone note, since it is the crux and it is worth having twice — Node v22.22.2:
Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'Europe/Kyiv' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for every one of them
A field constrained against the enumerated list would reject UTC — the platform's own declared default.
Generated by Claude Code
Found while adding "this must be a real IANA zone" to an application field (
duly_duty.timezone, objectstack-ai/duly#24). Filing per that app's rule "if the platform genuinely cannot express it, file upstream rather than quietly working around it".The gap
packages/specalready publishes the vocabulary and, valuably, the definition of membership —SpecifierValueDomainSchemainsystem/settings-manifest.zod.ts:with the note that for
iana_time_zone"membership is theIntl.DateTimeFormatprobe … NOTIntl.supportedValuesOf('timeZone')". That note is correct and it saved me a bug — see the measurement below.But
valueDomainis reachable only from a settingsSpecifier. An object field has no equivalent.FieldTypehas notimezonemember, andFieldSchemacarriesmaxLength/minLength/min/maxbut no value-domain slot. So an authored zone field is a bareField.textand nothing checks it; the value is discovered to be wrong wherever it is finally consumed.Why an application cannot close it at its own layer either
Both extension points an app can reach were measured on
@objectstack/spec/@objectstack/runtime/@objectstack/objectql17.2.0, Node v22.22.2.1. A
script/cross_fieldvalidation rule (CEL) cannot. The whole stdlib registered in@objectstack/formula'sregisterStdLibis:There is no zone/currency/country oracle, and no app-level way to register one —
buildEnvis internal to the package. The only reachable spelling ismatches(record.timezone, '<regex>'), which either checks shape only (Europe/Munichpasses) or freezes a tzdata snapshot into metadata — precisely what theiana_time_zonenote warns is a measurably different set from what the host accepts.2. An L2 hook
bodycannot: the sandbox has noIntl. Measured directly against the runtime's own sandbox (quickjs-emscripten0.32.0,newQuickJSWASMModule, the variantAppPluginwires throughQuickJSScriptRunner):HookBodyCapabilityisapi.read | api.write | api.transaction | crypto.uuid | log— nothing grantsIntl, so this is not a capability the author forgot to declare.The part that makes this actively hazardous, not merely missing
objectstack buildsilently lowers a self-contained inline handler into an L2body— confirmed in a real artifact, where a hook authored ashandler: <inline fn>ships as:{ "handler": "duly_task_lifecycle_stamps", "body": { "language": "js", "source": "…", "capabilities": [] } }and
resolveHandlerinbindHooksToEngineprefersbodyand ignoreshandlerwhenever both are present. So the natural way to write this check — an inlinebeforeInserthandler doing theIntl.DateTimeFormatprobe — behaves like this:pnpm validate,typecheck,testandbuildare all green (in-process tests run the raw JS function in Node, whereIntlexists);ReferenceError: Intl is not definedinside the sandbox;onError: 'abort'that a validation-shaped hook must declare, every write to that object is refused.Nothing anywhere in that sequence says the handler moved to a runtime that lacks the global it uses.
The only route that keeps the probe in Node is the string handler ref (
handler: 'my_fn'+defineStack({ functions })), resolved against the bundle functions map +runtimeModule. That works — buthook.zod.tsmarkshandler"DEPRECATED, preferbody" andwarnLegacyHandlerprints "Move the handler source intoHook.body". Following that advice on any host-API-dependent check converts a working guard into a refuse-everything hook. The deprecation direction and the only viable route for this class of check point opposite ways.What would close it
In rough order of preference:
valueDomainon a field —Field.text({ valueDomain: 'iana_time_zone' }), enforced on the write path with the membership definition already written down insettings-manifest.zod.ts. Declarative, translatable refusal message, visible to Studio / OpenAPI / the form layer, and one oracle for settings and fields alike.formatvalidation member alongsideemail | url | phone | jsondrawn from the same closed vocabulary.hook.zod.tsthat a body cannot reach host intrinsics and that the string handler ref is the supported route for checks that need them, so the deprecation does not read as advice to break them.Independently of (1)-(3), the sandbox-lowering hazard seems worth a build-time diagnostic on its own: an inline handler that references a global the sandbox does not provide is lowerable, buildable, testable and broken only in production.
Meanwhile
objectstack-ai/duly is using the string handler ref with the
Intl.DateTimeFormatprobe in Node, sharing one oracle with the period engine that consumes the value, and pointing at this issue in the code.Measurement backing the
iana_time_zonenote, since it is the crux and it is worth having twice — Node v22.22.2:A field constrained against the enumerated list would reject
UTC— the platform's own declared default.Generated by Claude Code