Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/functions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,10 @@
// DECLARES it (`{ handler, effect: 'writes' }`) so a run reports
// `unmeasuredEffect` rather than claiming it wrote nothing.

import {
DUTY_TIMEZONE_GUARD_HANDLER,
dulyValidateDutyTimezone,
} from '../hooks/duty.hook.js';
import { DISPATCH_HANDLER_NAME, dulyDispatch } from '../jobs/dispatch.job.js';

export const dulyFunctions = {
Expand All@@ -20,4 +24,13 @@ export const dulyFunctions = {
// has no flow graph around it to do the writing — see the note on data reach
// in `src/jobs/dispatch.job.ts`.
[DISPATCH_HANDLER_NAME]: { handler: dulyDispatch, effect: 'writes' as const },

// `duly_duty_timezone_guard`'s handler. A hook resolves a STRING handler
// against this map (`resolveHandler` -> `opts.functions[name]`), which is the
// whole reason the guard can use `Intl`: the alternative spelling — an inline
// handler — is lowered by `objectstack build` into a QuickJS `body`, where
// `Intl` does not exist. Registered in the bare form, which IS the
// declaration `effect: 'pure'`: it inspects the payload and either returns or
// throws, and writes nothing.
[DUTY_TIMEZONE_GUARD_HANDLER]: dulyValidateDutyTimezone,
};
39 changes: 39 additions & 0 deletions src/functions/period.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,45 @@ function formatterFor(timezone: string): Intl.DateTimeFormat {
return formatter;
}

/**
* Does this host resolve `timezone` as a real IANA zone?
*
* The ONE membership oracle for a duty's timezone, shared deliberately with
* {@link formatterFor} above rather than reimplemented next to the write path.
* A guard that admitted a different set than the engine would be wrong in one
* of two directions: refuse a zone that dispatches perfectly well, or pass one
* that still throws on dispatch night — which is the whole defect it exists to
* close. Sharing the constructor also shares its OPTIONS (`hourCycle: 'h23'`,
* `era: 'short'`), so an ICU build that rejected one of those would fail the
* guard too, instead of admitting a zone this module cannot actually format.
*
* ── Why the `Intl.DateTimeFormat` probe and NOT `Intl.supportedValuesOf` ──
* `supportedValuesOf('timeZone')` is the tempting spelling and it is the wrong
* one. Measured on this repo's Node 22 baseline it returns 418 CLDR
* *canonical* names and omits `UTC` — `duly_duty.timezone`'s own declared
* `defaultValue` — along with `GMT`, `Asia/Kolkata`, `Europe/Kyiv`,
* `Asia/Ho_Chi_Minh` and `US/Eastern`. Every one of those resolves here and
* gets correct boundaries out of this module, so a guard built on that list
* would refuse every duty created with the field default. This is also the
* definition the platform publishes for its own `iana_time_zone` value domain
* (`@objectstack/spec`, `system/settings-manifest.zod.ts`): *membership is the
* `Intl.DateTimeFormat` probe*, explicitly not the enumerated list.
*
* Case and aliases are the host's business, not this module's: `GMT`,
* `US/Eastern` and `america/new_york` all resolve, and boundaries are computed
* in whatever zone ICU maps them to. Nothing here rewrites the caller's
* spelling — canonicalising a stored value would be changing data, not
* validating it.
*/
export function isResolvableTimeZone(timezone: string): boolean {
try {
formatterFor(timezone);
return true;
} catch {
return false;
}
}

/** The wall-clock reading a zone shows at an instant. */
function wallTimeAt(instant: Date, timezone: string): WallTime {
const parts = formatterFor(timezone).formatToParts(instant);
Expand Down
147 changes: 147 additions & 0 deletions src/hooks/duty.hook.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { Hook, HookContext } from '@objectstack/spec/data';

import { isResolvableTimeZone } from '../functions/period.js';

/**
* `duly_duty.timezone` must be a zone the host can actually resolve.
*
* Period boundaries and due dates are computed in the duty's own zone
* (`src/functions/period.ts`). The engine deliberately REFUSES a zone it
* cannot resolve rather than falling back to UTC, because a duty quietly
* resolving "the 5th of the month" in the wrong zone is a wrong due date
* nobody can see is wrong. That is the right call for the consumer — but
* without this guard the author never hears about a typo until the nightly
* dispatcher touches the record, where the throw is attributed to the job
* rather than to the duty, and every other duty in the same run is collateral.
*
* ── Where the check belongs: WRITE time. Not author time, not dispatch ────
* Three moments were available and only one of them is where the typo is:
*
* - AUTHOR TIME (`pnpm validate`) sees METADATA. Duties are records, created
* by people in a form at run time, and the linter never sees one. The only
* zone literals in this repo's metadata are the field's own `defaultValue`
* and nothing else — so 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 would be caught by this guard
* on the very first duty anyone creates, loudly and immediately.)
* - 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.
* - DISPATCH TIME is where it fails today. Too late and mis-attributed.
*
* ── Why a hook rather than a `validation` on the object ───────────────────
* A validation rule was the obvious home and it cannot express this. Rules are
* CEL, and the whole stdlib 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` — no zone oracle, and no way for an
* application to register one. The only reachable spelling would be
* `matches(record.timezone, '<regex>')`, which either checks shape only (and
* `Europe/Munich` is perfectly well shaped) or freezes a tzdata snapshot into
* metadata that disagrees with the host's. `@objectstack/spec` publishes the
* right vocabulary for this — `valueDomain: 'iana_time_zone'` — but only on a
* settings specifier; an object field has no equivalent. Filed upstream as
* **objectstack-ai/objectstack#14168**; a lifecycle hook is what
* `validation.zod.ts` itself prescribes in the meantime ("Custom handler → a
* `beforeInsert` / `beforeUpdate` lifecycle hook, the typed, supported
* extension point for arbitrary validation code").
*
* ── ⛔ Why `handler` is a STRING and must stay one ────────────────────────
* This is the load-bearing line in the file. `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 on
* quickjs-emscripten 0.32.0, the variant the runtime wires through
* `QuickJSScriptRunner`: `typeof Intl` is `undefined`, while `Date` and `JSON`
* are present). No `HookBodyCapability` grants it either. `resolveHandler`
* prefers `body` over `handler` whenever both exist, so writing this as an
* inline function would ship a hook that throws `ReferenceError: Intl is not
* defined` on every duty write — and with `onError: 'abort'` that refuses
* every write to `duly_duty`. All four gates stay GREEN while that is true,
* because tests run the raw function in Node, where `Intl` exists.
*
* 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[handler]` — the `defineStack({ functions })` map, the same
* path the dispatch job's handler already takes. `hook.zod.ts` marks `handler`
* deprecated in favour of `body`; following that here would break the check,
* which is half of what #14168 asks the platform to reconcile.
*
* DO NOT "modernise" this into an inline handler or a `body`.
* `test/duty-timezone.test.ts` pins the string form for that reason.
*
* ── Why no declarative `condition` ────────────────────────────────────────
* A CEL `condition` of `!isBlank(record.timezone)` is the natural way to skip the
* handler when there is nothing to check, and it silently reopens half the
* defect: `isBlank('')` is TRUE, so an explicit empty string would be waved
* through — and `''` is one of the values that fails at dispatch, since
* `dispatch.plan.ts`'s `duty.timezone ?? DEFAULT_TIMEZONE` catches null and
* undefined but not `''`. Presence is therefore decided in the handler, where
* "key absent" and "key present and empty" are still distinguishable.
*/
export const DUTY_TIMEZONE_GUARD_HANDLER = 'dulyValidateDutyTimezone';

/**
* A payload that carries no `timezone` key is NOT this guard's business.
*
* On insert the field default supplies `'UTC'`; on update, an untouched zone
* belongs to the write that set it, and refusing an unrelated edit because of
* a pre-existing bad value would punish the wrong person. Where the value
* comes from in the first place is a different, still-open question — a duty
* has no source to resolve a zone from, which is duly#26 and deliberately not
* settled here. This guard judges values, never their absence.
*/
export const dulyValidateDutyTimezone = (ctx: HookContext): void => {
// `input.<field>` IS the record field on a declarative code handler; the
// `input.data.<field>` envelope spelling belongs to the raw
// `engine.registerHook` form and is not what this receives.
const input = ctx.input as Record<string, unknown>;

if (!('timezone' in input)) return;

const value = input.timezone;
if (value === null || value === undefined) return;

if (typeof value === 'string' && isResolvableTimeZone(value)) return;

// Quote the value: the failures worth naming here are invisible otherwise —
// `"Asia/Shanghai "` with a trailing space and `""` both read as blank in an
// unquoted message.
const message =
`Timezone ${JSON.stringify(value)} is not a time zone this system can resolve. `
+ 'Use an IANA name such as Europe/Berlin, Asia/Shanghai or UTC. '
+ 'Period boundaries and due dates are computed in this zone, so an '
+ 'unresolvable one would fail later, inside the nightly dispatch, '
+ 'instead of here.';

// The ADR-0112 refusal envelope, shaped to match the engine's own
// `ValidationError` (name / `code` / `fields`) so a caller cannot tell this
// refusal apart from the object's declared validation rules. It is built by
// hand rather than imported because `ValidationError` is internal to
// `@objectstack/objectql`, which this app does not depend on directly.
// `status` is deliberately not set: the platform's own ValidationError
// carries none, and inventing one here would override the boundary's mapping.
const error = new Error(message) as Error & { code: string; fields: unknown[] };
error.name = 'ValidationError';
error.code = 'VALIDATION_FAILED';
error.fields = [{ field: 'timezone', code: 'INVALID_VALUE', message }];
throw error;
};

export const DutyTimezoneGuard: Hook = {
name: 'duly_duty_timezone_guard',
label: 'Duty timezone is a real IANA zone',
object: 'duly_duty',
events: ['beforeInsert', 'beforeUpdate'],
description:
'Refuses a duly_duty write whose timezone is not a zone this host can resolve, using the '
+ 'same Intl probe the period engine uses — so a typo is caught on the record that has it, '
+ 'instead of throwing inside the nightly dispatch job days later.',
// A guard that fails open is not a guard. If the probe itself throws, the
// write must be refused rather than committed unchecked.
onError: 'abort',
// ⛔ A STRING, not the function. See the note above — an inline handler is
// lowered into the Intl-less QuickJS sandbox and would refuse every write.
handler: DUTY_TIMEZONE_GUARD_HANDLER,
};
5 changes: 3 additions & 2 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,8 +13,9 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

import { DutyTimezoneGuard } from './duty.hook.js';
import { TaskLifecycleHook } from './task.hook.js';

export { TaskLifecycleHook };
export { DutyTimezoneGuard, TaskLifecycleHook };

export const dulyHooks = [TaskLifecycleHook];
export const dulyHooks = [DutyTimezoneGuard, TaskLifecycleHook];
19 changes: 16 additions & 3 deletions test/dispatch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -521,7 +521,7 @@ afterEach(async () => {
});

let seq = 0;
const seedDuty = async (over: AnyRow = {}): Promise<string> => {
const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise<string> => {
const created = await data.insert('duly_duty', {
name: `Duty ${++seq}`,
form: 'recurring',
Expand All@@ -534,7 +534,7 @@ const seedDuty = async (over: AnyRow = {}): Promise<string> => {
lead_days: 0,
timezone: 'UTC',
...over,
});
}, options);
const row = (Array.isArray(created) ? created[0] : created) as AnyRow;
seeded.push(String(row.id));
return String(row.id);
Expand DownExpand Up@@ -735,7 +735,20 @@ describe('the job handler', () => {
it('reports degraded — not failed — when a duty could not be dispatched', async () => {
// `degraded` is "ran to completion, work did not happen". It never retries,
// which is right: retrying a typo'd timezone at 01:05 will not fix it.
await seedDuty({ timezone: 'Mars/Olympus' });
//
// ── Why this row needs `skipAutomations` to exist at all (#24) ────────
// `duly_duty_timezone_guard` now refuses an unresolvable zone on the way
// in, so an ordinary write can no longer produce this row — which is the
// point of that guard, and this assertion would otherwise have to be
// deleted along with the defect it describes. It must NOT be deleted: the
// rows it models still exist. `skipAutomations` is the platform's own
// "import with run automations unchecked" opt-out (`triggerHooks` skips
// metadata-bound hooks on it), and it is exactly how such a duty is born
// in the wild — an import that bypassed the guard, a row that predates it,
// or a zone the host's tzdata stopped recognising after the duty was
// saved. Dispatch must still degrade rather than fail on those, and must
// still not retry them.
await seedDuty({ timezone: 'Mars/Olympus' }, { context: { skipAutomations: true } });
bindDispatchEngine(data);
const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME });
expect(outcome.outcome).toBe('degraded');
Expand Down
Loading
Loading