Uh oh!
There was an error while loading. Please reload this page.
Reject a duly_duty timezone the host cannot resolve, at write time - #83
Conversation
`duly_duty.timezone` was a bare `Field.text`, so `Europe/Munich`, `CET+1`,
`Asia/Shanghai ` and `''` all saved clean and threw days later inside the
nightly dispatch job, attributed to the job rather than to the record.
The check lands at WRITE time — where the person who made the typo is still
looking at the field. Author time (`pnpm validate`) only ever sees metadata,
and duties are records; dispatch time is where it already fails.
The guard and the period engine share ONE membership oracle
(`isResolvableTimeZone`, delegating to `period.ts`'s own `formatterFor`), so
the set admitted at write time is exactly the set the engine can compute
boundaries for. Deliberately the `Intl.DateTimeFormat` probe and NOT
`Intl.supportedValuesOf('timeZone')`: measured on Node 22 the enumerated list
holds 418 canonical names and omits `UTC` — the field's own `defaultValue` —
along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv` and `US/Eastern`. It is also
the definition `@objectstack/spec` publishes for its `iana_time_zone` value
domain.
The check is a lifecycle hook because no declarative surface can express it:
CEL has no zone oracle, and the L2 hook sandbox has no `Intl` at all
(measured: `typeof Intl === 'undefined'` in quickjs-emscripten 0.32.0). Filed
upstream as objectstack-ai/objectstack#14168. Its handler is a STRING ref into
`defineStack({ functions })` for that reason — an inline handler would be
lowered into that Intl-less sandbox and refuse every duty write while all four
gates stayed green. `test/duty-timezone.test.ts` pins the string form.
Scope: this validates the value only. Where a duty's timezone comes from is
duly#26 and stays open — no default added, no dispatch behaviour changed.
Part of #24os-warren
commented
Sep 1, 2026
Reviewed — merging. The |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#24
duly_duty.timezonewas a bareField.text, soEurope/Munich,CET+1,Asia/Shanghai(trailing space) and''all saved clean, passedpnpm validate, and threw days later inside the nightly dispatcher — attributed to the job rather than to the record that carried the typo.Where the check went, and why not the other two moments
The card names a failure at dispatch, which is neither of the moments a check can live at. Of the three available:
pnpm validate) only ever sees metadata. Duties are records, typed into a form at run time, and the linter never sees one — an author-time check would be a check on a population that does not contain the defect. It is not needed as a second line either: a typo'd field default is caught by this guard on the first duty anyone creates.Europe/Munichand presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggerssuppresses record-change automation, not hooks). This is where the guard is.One membership oracle, shared with the engine
isResolvableTimeZoneis added tosrc/functions/period.tsand delegates to that module's ownformatterFor. This is the load-bearing design choice: a guard admitting a different set than the engine would be wrong in one of two directions — refuse a duty that dispatches fine, or pass one that still throws on dispatch night, which is the defect reintroduced behind a check that looks like it works. Sharing the constructor also shares its options (hourCycle: 'h23',era: 'short').It is the
Intl.DateTimeFormatprobe and deliberately notIntl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition
@objectstack/specpublishes for its owniana_time_zonevalue domain: "membership is theIntl.DateTimeFormatprobe … NOTIntl.supportedValuesOf".The guard validates; it does not canonicalise.
america/new_yorkis stored verbatim — rewriting a stored value would be changing data, not checking it.Metadata-first (AGENTS.md rule 9): the platform cannot express this, filed upstream
Checked before writing any code. A
scriptvalidation was the obvious home and cannot do it:@objectstack/formulaisnow 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, and an app cannot register one. The only reachable spelling is amatches(record.timezone, …)regex, which either checks shape only (Europe/Munichis perfectly well shaped) or freezes a tzdata snapshot into metadata that disagrees with the host's.valueDomain: 'iana_time_zone'exists in@objectstack/spec— but only on a settingsSpecifier. An object field has no equivalent, andFieldTypehas notimezonemember.Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what
validation.zod.tsitself prescribes meanwhile ("Custom handler → abeforeInsert/beforeUpdatelifecycle hook, the typed, supported extension point for arbitrary validation code").⛔ Why the handler is a STRING, and the trap it avoids
The most important line in the diff, and the one most likely to be "cleaned up" later.
objectstack buildlowers a self-contained inline handler into a metadatabody, which runs in the QuickJS sandbox — and that sandbox has noIntl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variantAppPluginwires throughQuickJSScriptRunner):No
HookBodyCapabilitygrants it either (api.read | api.write | api.transaction | crypto.uuid | log). SinceresolveHandlerprefersbodyoverhandlerwhenever both exist, writing this guard as an inline handler would ship a hook that throwsReferenceError: Intl is not definedon every duty write and — with theonError: 'abort'a validation-shaped hook must declare — refuse every write toduly_duty, while all four gates stayed green, because tests run the raw function in Node.The string ref keeps the probe in Node: nothing inline for the extractor to lower, so no
bodyis emitted, andresolveHandlerfalls through toopts.functions[name]— the same path the dispatch job handler already takes. Confirmed in the built artifact:The contrast with the neighbouring task hook is the proof that the lowering is real and that the string form is what avoids it.
test/duty-timezone.test.tspinstypeof handler === 'string'andbody === undefinedfor exactly this reason.Also deliberately no declarative
condition:!isBlank(record.timezone)is the natural way to skip the handler and silently reopens half the defect, becauseisBlank('')is true and''is one of the values that fails at dispatch (dispatch.plan.ts'sduty.timezone ?? DEFAULT_TIMEZONEcatches null/undefined, not'').Scope
This validates the value only. Where a duty's timezone comes from is #26 and stays open — no default added, no dispatch timezone handling touched, and the guard is not a back-door
required: true(a write that carries notimezonekey is left alone; a test pins that).duly_catalog_itemcarries notimezonefield, per the note on the issue, so this has one home.Verification
All four gates green in one chained run at
38ae875:Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an
EXIT/INT/TERMtrap (tree verified clean after each):DutyTimezoneGuardfromdulyHooksIntl.supportedValuesOfUTCrefused, along withGMT,US/Eastern,Asia/Kolkata,america/new_york; "leaves a write that does not touch the timezone alone" fails too, i.e. every ordinary duty create would be refusedThe second is the one worth reading: it demonstrates the trap the oracle choice avoids, rather than asserting it.
One fixture repaired, deliberately not deleted
test/dispatch.test.ts's "reports degraded — not failed" seeded a duty withtimezone: 'Mars/Olympus', which this guard now refuses — so the fixture's construction path closed. The assertion must not go with it: the rows it models still exist (an import that bypassed the guard, a row predating it, or a zone the host's tzdata stopped recognising after the duty was saved). It now writes that row through the platform's own automation opt-out,{ context: { skipAutomations: true } }— the "import with run automations unchecked" path, on whichtriggerHooksskips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.Out of scope, filed
due_offset_daysaccepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch #82 —due_offset_daysaccepts a fractional value: same defect shape one field over (noscaledeclared, so the engine's number validator never checks;period.tsthrowswhole number of daysat dispatch). Not fixed here — the right shape (scale: 0vs a product-voice validation, and whether bounds belong too) is a choice, not a mechanical edit.No changeset: this repo has no changesets mechanism (no
.changeset/, no@changesets/*dependency, no script, no mention inAGENTS.md); the four gates are the whole contract.Generated by Claude Code