Skip to content

No declarative way to constrain an object FIELD to a value domain (iana_time_zone), and neither extension point an app can reach can express it #14168

Description

@os-warren

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 membershipSpecifierValueDomainSchema 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/objectql 17.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 build silently 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:

  1. valueDomain on a fieldField.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.
  2. A format validation member alongside email | url | phone | json drawn from the same closed vocabulary.
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions