Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Reject a duly_duty timezone the host cannot resolve, at write time - #83

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation
Sep 1, 2026
Merged

Reject a duly_duty timezone the host cannot resolve, at write time#83
os-warren merged 1 commit into
mainfrom
claude/issue-24-timezone-validation

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#24

duly_duty.timezone was a bare Field.text, so Europe/Munich, CET+1, Asia/Shanghai (trailing space) and '' all saved clean, passed pnpm 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:

  • Author time (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.
  • Write time is where a person types Europe/Munich and presses save. It covers the form, REST/OpenAPI/MCP, the catalog-apply action, and the seed path (skipTriggers suppresses record-change automation, not hooks). This is where the guard is.
  • Dispatch time is where it fails today, and the engine's refusal there stays exactly as it is — a duty quietly resolving "the 5th of the month" in the wrong zone is a wrong due date nobody can see is wrong.

One membership oracle, shared with the engine

isResolvableTimeZone is added to src/functions/period.ts and delegates to that module's own formatterFor. 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.DateTimeFormat probe and deliberately not Intl.supportedValuesOf('timeZone'). Measured on this container, Node v22.22.2:

Intl.supportedValuesOf('timeZone').length -> 418
includes 'UTC' -> false (duly_duty.timezone's own defaultValue)
includes 'GMT' -> false
includes 'Asia/Kolkata' -> false
includes 'US/Eastern' -> false
new Intl.DateTimeFormat('en-US', { timeZone: v }) -> resolves for all of them

A guard built on the enumerated list would refuse every duty created with the field default. This is also the definition @objectstack/spec publishes for its own iana_time_zone value domain: "membership is the Intl.DateTimeFormat probe … NOT Intl.supportedValuesOf".

The guard validates; it does not canonicalise. america/new_york is 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 script validation was the obvious home and cannot do it:

  • CEL has no zone oracle. The whole stdlib registered in @objectstack/formula 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, and an app cannot register one. The only reachable spelling is a matches(record.timezone, …) regex, which either checks shape only (Europe/Munich is 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 settings Specifier. An object field has no equivalent, and FieldType has no timezone member.

Filed upstream as objectstack-ai/objectstack#14168, and a lifecycle hook is what validation.zod.ts itself prescribes meanwhile ("Custom handler → a beforeInsert / beforeUpdate lifecycle 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 build lowers a self-contained inline handler into a metadata body, which runs in the QuickJS sandbox — and that sandbox has no Intl. Measured directly against the runtime's own sandbox (quickjs-emscripten 0.32.0, the variant AppPlugin wires through QuickJSScriptRunner):

typeof Intl -> undefined
typeof Date -> function
typeof JSON -> object

No HookBodyCapability grants it either (api.read | api.write | api.transaction | crypto.uuid | log). Since resolveHandler prefers body over handler whenever both exist, writing this guard as an inline handler would ship a hook that throws ReferenceError: Intl is not defined on every duty write and — with the onError: 'abort' a validation-shaped hook must declare — refuse every write to duly_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 body is emitted, and resolveHandler falls through to opts.functions[name] — the same path the dispatch job handler already takes. Confirmed in the built artifact:

duly_duty_timezone_guard | handler: "dulyValidateDutyTimezone" | hasBody: false
duly_task_lifecycle_stamps | handler: "duly_task_lifecycle_stamps" | hasBody: true

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.ts pins typeof handler === 'string' and body === undefined for 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, because isBlank('') is true and '' is one of the values that fails at dispatch (dispatch.plan.ts's duty.timezone ?? DEFAULT_TIMEZONE catches 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 no timezone key is left alone; a test pins that).

duly_catalog_item carries no timezone field, per the note on the issue, so this has one home.

Verification

All four gates green in one chained run at 38ae875:

✓ Validation passed (356ms) # the one expected hierarchy-security warning, unchanged
> duly@0.1.0 typecheck # tsc --noEmit, clean
Test Files 21 passed (21)
Tests 557 passed (557) # 21 of them new
✓ Build complete (570ms) # Bundling 3 handlers (was 2)

Reverse-verification — both legs mutated, confirmed on disk by an anchored grep, run, then restored by an EXIT/INT/TERM trap (tree verified clean after each):

AblationDirection observed
Remove DutyTimezoneGuard from dulyHooks8 failed / 13 passed — every write-path refusal plus the barrel pin go red; the pure-oracle tests stay green, which is correct: they do not measure the wiring
Swap the oracle to Intl.supportedValuesOf8 failed / 13 passed — UTC refused, along with GMT, 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 refused

The 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 with timezone: '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 which triggerHooks skips metadata-bound hooks. Dispatch must still degrade rather than fail on such rows, and still not retry them.

Out of scope, filed

No changeset: this repo has no changesets mechanism (no .changeset/, no @changesets/* dependency, no script, no mention in AGENTS.md); the four gates are the whole contract.


Generated by Claude Code

`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 #24
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Reviewed — merging. The Intl-in-the-sandbox finding is the most valuable thing in this round.

Gates, re-run by me on 38ae875 in a clean review worktree:validate 0, typecheck 0, test 0 (Test Files 21 passed, Tests 557 passed), build 0 (→ Bundling 3 handlers, was 2).

And I verified the artifact claim independently, because it is the one that would break production silently. In dist/objectstack.json:

duly_duty_timezone_guard handler: "dulyValidateDutyTimezone" hasBody: false
duly_task_lifecycle_stamps handler: "duly_task_lifecycle_stamps" hasBody: true

The only occurrence of the string Intl anywhere in the artifact is inside a description, not a body. And the probe appears six times in dist/objectstack-runtime.*.mjs — so it ships as a bundled Node handler, where Intl exists, exactly as the design requires.

That check matters more than usual here. A hook that uses a host intrinsic is lowerable, buildable and testable — all four gates green — and broken only in production, because the tests run the raw function in Node. With onError: 'abort' the consequence is that every write to duly_duty is refused. Getting this wrong would have been undetectable by anything this repo runs.

The two measurements that changed the answer

  1. Intl.supportedValuesOf omits UTC — the field's own defaultValue — along with GMT, Asia/Kolkata, Europe/Kyiv and US/Eastern. A guard built on the obvious oracle would have refused every ordinary duty create, and the ablation proves it: 8 failed / 13 passed, guard verdict for UTC: expected false to be true. Delegating to the period engine's own formatterFor instead means the set admitted on write is exactly the set the engine can compute boundaries for — which is the only definition of "valid" that is actually true for this product.

  2. The QuickJS sandbox has no Intl at all (typeof Intl → undefined, while Date and JSON are present, measured on quickjs-emscripten 0.32.0). That, not preference, is why handler is a string ref — and the fact that hook.zod.ts deprecates the one spelling that keeps it in Node is the sharp end of it. The upstream card (objectstack#14168) is right to pair the missing valueDomain: 'iana_time_zone' on fields with that lowering hazard; the second half is worth more than the first.

The "DO NOT modernise this into an inline handler" comment plus the test pinning the string form is the correct defence. Someone will try.

On my card's Options section

Both corrections accepted. Option A as I wrote it is not expressible — CEL has no zone oracle and no way for an app to register one, and matches() against a regex either checks shape only (Europe/Munich is perfectly well shaped) or freezes a tzdata snapshot into metadata that drifts from the host. Option B stays available on top; this does not pre-empt it.

Also good: repairing the Mars/Olympus fixture in test/dispatch.test.ts through the platform's own skipAutomations opt-out rather than deleting it. The legacy and import rows it models really do exist, and a test that stops modelling them because a new guard is inconvenient is a test that stopped being about anything. Scope was held — no default added, #26 untouched, and a write carrying no timezone key is left alone with a test pinning that it is not a back-door required: true.

#82 filed for due_offset_days accepting a fractional value is the right call, and right not to fix here — scale: 0 versus a product-voice validation, and whether bounds belong too, is a choice.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:06
@os-warren
os-warren merged commit 3ab948a into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

duly_duty.timezone accepts any string — a typo'd IANA zone validates clean and fails at dispatch

1 participant

@os-warren