Repository navigation
duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch #24
Description
Activity
One factual note from #5, where I read this object closely — not a disagreement with the issue, which stands.
Same field, same gap on
duly_catalog_item.duly_catalog_itemdoes not carry atimezonefield. Its full field list onorigin/main(src/objects/catalog-item.object.ts) is:name, description, position_code, form, frequency, due_anchor, due_offset_days, lead_days, grace_days, regulation_ref, activegrep -c timezone src/objects/catalog-item.object.ts→0.So the validation this issue asks for has one home, not two:
duly_duty. That also means the zone is not something a catalog item can carry down into the duties instantiated from it —duly_catalog_applyhas to resolve it per person at apply time, which turns out to have no source to resolve from at all. Filed separately as #26, since it is a different defect from the one here (no producer for the value, vs. no validation of the value).
Generated by Claude Code
Claiming this card.
- Session:
session_01SqkTcrxUFci7nqXdbBSe2p - Branch:
claude/issue-24-timezone-validation - Worktree:
../duly-issue-24offmain(19a0306)
Scope I am taking: reject a
duly_duty.timezonevalue that is not a zone the host'sIntlcan resolve, at author time. Per the note above,duly_catalog_itemcarries notimezonefield, so this has one home. #26 (no source to resolve a duty's timezone from) is out of scope and stays open — I am not changing where the value comes from, not adding a default, and not touching the dispatch engine's timezone handling.
Generated by Claude Code
Generated by Claude Code
- Session:
- added a commit that references this issue
on Sep 1, 2026 { "issue": 24, "status": "done", "branch": "claude/issue-24-timezone-validation", "pr": "https://github.com/objectstack-ai/duly/pull/83", "premise_still_valid": true, "summary": "duly_duty.timezone is now rejected at WRITE time when the host cannot resolve it, via a beforeInsert/beforeUpdate hook whose membership oracle (isResolvableTimeZone, new in period.ts) delegates to the period engine's own formatterFor — so the set admitted on write is exactly the set the engine can compute boundaries for. Deliberately the Intl.DateTimeFormat probe and NOT Intl.supportedValuesOf: measured, the enumerated list omits UTC (the field's own defaultValue), GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern, and an ablation confirms a guard built on it would refuse every ordinary duty create. Two corrections to the issue's Options section, neither invalidating the card: (1) option A as written is NOT expressible — a script validation is CEL, and the CEL stdlib has no zone oracle; nor can an L2 hook body do it, because the QuickJS sandbox has no Intl at all (measured: typeof Intl === 'undefined'). That gap is filed upstream as objectstack-ai/objectstack#14168, and it is why the hook's handler is a STRING ref into defineStack({ functions }) rather than an inline function — an inline handler is lowered by objectstack build into that Intl-less sandbox and would refuse every duty write while all four gates stayed green. The built artifact confirms the guard ships with handler:\"dulyValidateDutyTimezone\" and hasBody:false, next to the task hook's hasBody:true. (2) Option B (a curated select of served zones) is untouched and still available on top — it is the product call the issue says it is, and this fix does not pre-empt it. Scope held: value validation only. #26 stays open — no default added, no dispatch timezone handling changed, and the guard is not a back-door required:true (a write carrying no timezone key is left alone, pinned by a test). One fixture repaired rather than deleted: test/dispatch.test.ts's 'reports degraded — not failed' seeded timezone:'Mars/Olympus', which the guard now refuses; it writes that row through the platform's own skipAutomations opt-out, since the legacy/import rows it models still exist. No changeset — per the coordinator's mid-task correction, this repo has no changesets mechanism (verified: no .changeset/, no @changesets/* dep, no script, no mention in AGENTS.md).", "tests": "All four gates run as one chained command under the shared verify lock at HEAD 38ae875, tree clean: `pnpm validate && pnpm typecheck && pnpm test --maxWorkers=2 && pnpm build`. Verdicts read from each gate's own output line, not from $?: validate `✓ Validation passed (356ms)` with only the one hierarchy-security warning AGENTS.md documents as expected; typecheck banner `> duly@0.1.0 typecheck` with no tsc output; test `Test Files 21 passed (21)` / `Tests 557 passed (557)` (21 of them new in test/duty-timezone.test.ts, all green in a verbose run); build `✓ Build complete (570ms)`, `→ Bundling 3 handlers` (was 2). Wrapper line: `os-verify-lock: VERDICT command-exit 0 · held the lock 28s`. REVERSE VERIFICATION — two ablations, each mutated then restored by a trap on EXIT/INT/TERM, with the working tree verified clean by `git status --porcelain` after each; no build/dist leg applies because the suites import src directly (no exports-resolved dist, no vitest alias). (a) Remove DutyTimezoneGuard from dulyHooks: disk-proof `dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook]` -> 0 occurrences, `dulyHooks = [TaskLifecycleHook]` -> 1; result 8 failed / 13 passed — every write-path refusal plus the barrel pin red, the pure-oracle tests green, which is the correct direction since they do not measure the wiring. (b) Swap isResolvableTimeZone to Intl.supportedValuesOf: injected marker `ABLATION_ENUMERATED_ORACLE.includes` -> 1 occurrence; result 8 failed / 13 passed, with `guard verdict for \"UTC\": expected false to be true` plus GMT, US/Eastern, Asia/Kolkata, america/new_york and the no-timezone-key test — i.e. every ordinary duty create refused. Honest note on leg (b)'s disk-proof: my removed-text grep `formatterFor(timezone);` printed 1 rather than 0 because that call site is not unique in period.ts (dueDateFor has the other); the restored file has 2, so the predicate's call was indeed removed, and the injected-marker half of the check is the unambiguous one. Sandbox measurement backing the design, run directly against quickjs-emscripten 0.32.0: typeof Intl -> undefined, typeof Date -> function, typeof JSON -> object. Artifact check after build: duly_duty_timezone_guard handler=\"dulyValidateDutyTimezone\" hasBody=false; duly_task_lifecycle_stamps hasBody=true.", "open_questions": [], "out_of_scope_findings": [ "filed as #82: due_offset_days accepts a fractional value — same defect shape as this card one field over (no `scale` declared, so the engine's number validator never checks; period.ts throws 'whole number of days' at dispatch). Not fixed here because the right shape (scale: 0 vs a product-voice validation, and whether min/max bounds belong too) is a choice, not a mechanical edit. lead_days and grace_days share the declaration.", "filed as objectstack-ai/objectstack#14168: the platform has no field-level equivalent of the settings `valueDomain: 'iana_time_zone'`, and neither extension point an app can reach can express the check — CEL has no zone oracle, and the L2 hook sandbox has no Intl. Includes the sharper hazard: `objectstack build` silently lowers an inline handler into that sandbox, so a hook using a host intrinsic is lowerable, buildable, testable and broken only in production, while `hook.zod.ts` deprecates the one spelling that keeps it in Node." ] }
Generated by Claude Code
Generated by Claude Code
os-dev-report
(Supersedes the comment immediately above, which carried the same JSON: GitHub's body sanitizer stripped the HTML-comment marker on that one, so it is invisible to a marker scan. This is the readable copy — same report, literal-text marker.)
{ "issue": 24, "status": "done", "branch": "claude/issue-24-timezone-validation", "pr": "https://github.com/objectstack-ai/duly/pull/83", "premise_still_valid": true, "summary": "duly_duty.timezone is now rejected at WRITE time when the host cannot resolve it, via a beforeInsert/beforeUpdate hook whose membership oracle (isResolvableTimeZone, new in period.ts) delegates to the period engine's own formatterFor — so the set admitted on write is exactly the set the engine can compute boundaries for. Deliberately the Intl.DateTimeFormat probe and NOT Intl.supportedValuesOf: measured, the enumerated list omits UTC (the field's own defaultValue), GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern, and an ablation confirms a guard built on it would refuse every ordinary duty create. Two corrections to the issue's Options section, neither invalidating the card: (1) option A as written is NOT expressible — a script validation is CEL, and the CEL stdlib has no zone oracle; nor can an L2 hook body do it, because the QuickJS sandbox has no Intl at all (measured: typeof Intl === 'undefined'). That gap is filed upstream as objectstack-ai/objectstack#14168, and it is why the hook's handler is a STRING ref into defineStack({ functions }) rather than an inline function — an inline handler is lowered by objectstack build into that Intl-less sandbox and would refuse every duty write while all four gates stayed green. The built artifact confirms the guard ships with handler dulyValidateDutyTimezone and hasBody false, next to the task hook's hasBody true. (2) Option B (a curated select of served zones) is untouched and still available on top — it is the product call the issue says it is, and this fix does not pre-empt it. Scope held: value validation only. #26 stays open — no default added, no dispatch timezone handling changed, and the guard is not a back-door required:true (a write carrying no timezone key is left alone, pinned by a test). One fixture repaired rather than deleted: test/dispatch.test.ts's 'reports degraded — not failed' seeded timezone 'Mars/Olympus', which the guard now refuses; it writes that row through the platform's own skipAutomations opt-out, since the legacy/import rows it models still exist. No changeset — per the coordinator's mid-task correction, this repo has no changesets mechanism (verified: no .changeset/, no @changesets/* dep, no script, no mention in AGENTS.md).", "tests": "All four gates run as one chained command under the shared verify lock at HEAD 38ae875, tree clean: pnpm validate && pnpm typecheck && pnpm test --maxWorkers=2 && pnpm build. Verdicts read from each gate's own output line, not from $?: validate '✓ Validation passed (356ms)' with only the one hierarchy-security warning AGENTS.md documents as expected; typecheck banner '> duly@0.1.0 typecheck' with no tsc output; test 'Test Files 21 passed (21)' / 'Tests 557 passed (557)' (21 of them new in test/duty-timezone.test.ts, all green in a verbose run); build '✓ Build complete (570ms)', '→ Bundling 3 handlers' (was 2). Wrapper line: 'os-verify-lock: VERDICT command-exit 0 · held the lock 28s'. REVERSE VERIFICATION — two ablations, each mutated then restored by a trap on EXIT/INT/TERM, with the working tree verified clean by git status --porcelain after each; no build/dist leg applies because the suites import src directly (no exports-resolved dist, no vitest alias). (a) Remove DutyTimezoneGuard from dulyHooks: disk-proof, the removed form 'dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook]' went to 0 occurrences and the injected 'dulyHooks = [TaskLifecycleHook]' to 1; result 8 failed / 13 passed — every write-path refusal plus the barrel pin red, the pure-oracle tests green, which is the correct direction since they do not measure the wiring. (b) Swap isResolvableTimeZone to Intl.supportedValuesOf: injected marker ABLATION_ENUMERATED_ORACLE.includes present once; result 8 failed / 13 passed, with 'guard verdict for UTC: expected false to be true' plus GMT, US/Eastern, Asia/Kolkata, america/new_york and the no-timezone-key test — i.e. every ordinary duty create refused. Honest note on leg (b)'s disk-proof: my removed-text grep 'formatterFor(timezone);' printed 1 rather than 0 because that call site is not unique in period.ts (dueDateFor has the other); the restored file has 2, so the predicate's call was indeed removed, and the injected-marker half of the check is the unambiguous one. Sandbox measurement backing the design, run directly against quickjs-emscripten 0.32.0: typeof Intl -> undefined, typeof Date -> function, typeof JSON -> object. Artifact check after build: duly_duty_timezone_guard handler dulyValidateDutyTimezone hasBody false; duly_task_lifecycle_stamps hasBody true.", "open_questions": [], "out_of_scope_findings": [ "filed as #82: due_offset_days accepts a fractional value — same defect shape as this card one field over (no scale declared, so the engine's number validator never checks; period.ts throws 'whole number of days' at dispatch). Not fixed here because the right shape (scale: 0 vs a product-voice validation, and whether min/max bounds belong too) is a choice, not a mechanical edit. lead_days and grace_days share the declaration.", "filed as objectstack-ai/objectstack#14168: the platform has no field-level equivalent of the settings valueDomain 'iana_time_zone', and neither extension point an app can reach can express the check — CEL has no zone oracle, and the L2 hook sandbox has no Intl. Includes the sharper hazard: objectstack build silently lowers an inline handler into that sandbox, so a hook using a host intrinsic is lowerable, buildable, testable and broken only in production, while hook.zod.ts deprecates the one spelling that keeps it in Node." ] }
Generated by Claude Code
Generated by Claude Code
Found while implementing the period engine (#1). Not fixed there: the fix belongs on
src/objects/duty.object.ts, outside that card's file surface.What happens now
Nothing checks the value is a zone the host knows.
Europe/Munich,CET+1,Asia/Shanghaiwith a trailing space, or an empty string all save, all passpnpm validate, and all look correct in the duty form.The failure surfaces later and somewhere else:
periodKeyFor/periodBounds/dueDateForinsrc/functions/period.tsthrowUnknown IANA timezone: "Europe/Munich"the first time the dispatcher touches that duty. So one bad character on one duty record becomes a run-time throw inside a batch job, attributed to the job rather than to the record — and every other duty in the same run is collateral depending on how #2 handles a partial failure.Why it is worth a validation rather than tolerance in the engine
The engine deliberately refuses instead of falling back to UTC: a duty silently resolving "the 5th of the month" in the wrong zone is a wrong due date that nobody can see is wrong, which is strictly worse than a loud failure. That is the right call for the consumer — but it means the author never hears about the typo until dispatch night. The check wants to live at author time, where the person who made the typo is still looking at the field.
Options
scriptvalidation onduly_duty(andduly_catalog_item, which carries the same field) rejecting a value the host'sIntlcannot resolve, in the shape of the two validations already on the object.selectof the zones the deployment actually serves, if the product would rather not offer all 418.Same field, same gap on
duly_catalog_item. Filing unassigned for triage — the choice between the two options is a product call, not a mechanical fix.