Found while implementing #15872 (valueDomain: 'iana_time_zone' on sys_job.timezone and sys_report_schedule.timezone). Retrofitting the AUTHORING tier was outside that card's scope — it is about two object columns — so it is carded here rather than widened into that PR. Unassigned; for triage.
The reading
Measured on origin/main at dacb73f4f.
packages/spec/src/system/job.zod.ts:
export const CronScheduleSchema = lazySchema(() => z.object({
type: z.literal('cron'),
expression: CronExpressionInputSchema.describe(...),
timezone: z.string().optional().default('UTC').describe('Timezone for cron execution (e.g., "America/New_York")'),
}));
A bare z.string(). After #15872 the platform validates this exact concept in four places — sys_business_unit.timezone, sys_organization.timezone (#14238), and now sys_job.timezone and sys_report_schedule.timezone — all through one predicate, isValueDomainMember('iana_time_zone', value), exported from @objectstack/spec/shared and living in the same package as the schema above. The authoring door for a background job's cron zone does not use it.
Consequence, measured rather than assumed
This is NOT a silent outage: the value is carried to the boundary by runtime/job-schedule.ts (toBoundaryJobSchedule / timezoneOf), reaches CronJobAdapter.schedule, and croner constructed WITH a callback throws on a non-member zone. AppPlugin catches that per job and logs it loudly:
[AppPlugin] Background job FAILED TO SCHEDULE — it will never run (error level + jobScheduleFailuresTotal)
[AppPlugin] Some background jobs are declared but NOT scheduled
So the failure mode is a job that does not run, reported at boot, per job. What is missing is the earlier and cheaper refusal: defineJob / JobSchema.parse accepts timezone: 'UTC+8' at authoring and build time and says nothing, and the author learns about it from a boot log in whatever environment first starts the app.
Why this is not automatic once the columns are validated
sys_job.timezone is a WRITE-ONLY mirror — DbJobAdapter.upsertJobRow writes it and nothing reads it back — so the column's new valueDomain never judges the value the scheduler actually honours. The two tiers are genuinely separate doors, which is the whole reason this needs its own card rather than riding on #15872.
Proposed shape (for the implementing seat to verify, not a ruling)
Refine the timezone field with the predicate the same package already exports, so a non-member fails at parse with a located Zod issue instead of at boot. The interesting questions for the seat, and why this is not a one-liner:
- Is it breaking? It narrows what
defineJob accepts, so an app whose job carries a zone the platform cannot honour stops BUILDING instead of booting-and-not-running. That is the intended direction, but it is a behaviour change on a published schema and wants the changeset and review tier that implies.
- The probe is environment-sensitive.
Intl.DateTimeFormat membership depends on the runtime's ICU. A build machine and a deployment target that disagree would produce a build-time refusal for a zone the deployment could honour, or vice versa — worth a deliberate answer rather than an accident.
- Does the interval / once branch need anything? No: only the cron branch carries a zone.
sys_job.timezone also has no valueDomain counterpart on the boundary type (JobSchedule.timezone in packages/spec/src/contracts/job-service.ts is a plain string?). Whether the boundary should also be judged, or only the authoring tier, is part of the same decision.
Re-check
git show origin/main:packages/spec/src/system/job.zod.ts | sed -n '14,18p'
git grep -n "isValueDomainMember" -- packages/spec/src/shared/value-domain.zod.ts
git grep -n "timezoneOf" -- packages/runtime/src/job-schedule.ts
Generated by Claude Code
Found while implementing #15872 (
valueDomain: 'iana_time_zone'onsys_job.timezoneandsys_report_schedule.timezone). Retrofitting the AUTHORING tier was outside that card's scope — it is about two object columns — so it is carded here rather than widened into that PR. Unassigned; for triage.The reading
Measured on
origin/mainatdacb73f4f.packages/spec/src/system/job.zod.ts:A bare
z.string(). After #15872 the platform validates this exact concept in four places —sys_business_unit.timezone,sys_organization.timezone(#14238), and nowsys_job.timezoneandsys_report_schedule.timezone— all through one predicate,isValueDomainMember('iana_time_zone', value), exported from@objectstack/spec/sharedand living in the same package as the schema above. The authoring door for a background job's cron zone does not use it.Consequence, measured rather than assumed
This is NOT a silent outage: the value is carried to the boundary by
runtime/job-schedule.ts(toBoundaryJobSchedule/timezoneOf), reachesCronJobAdapter.schedule, and croner constructed WITH a callback throws on a non-member zone.AppPlugincatches that per job and logs it loudly:So the failure mode is a job that does not run, reported at boot, per job. What is missing is the earlier and cheaper refusal:
defineJob/JobSchema.parseacceptstimezone: 'UTC+8'at authoring and build time and says nothing, and the author learns about it from a boot log in whatever environment first starts the app.Why this is not automatic once the columns are validated
sys_job.timezoneis a WRITE-ONLY mirror —DbJobAdapter.upsertJobRowwrites it and nothing reads it back — so the column's newvalueDomainnever judges the value the scheduler actually honours. The two tiers are genuinely separate doors, which is the whole reason this needs its own card rather than riding on #15872.Proposed shape (for the implementing seat to verify, not a ruling)
Refine the
timezonefield with the predicate the same package already exports, so a non-member fails at parse with a located Zod issue instead of at boot. The interesting questions for the seat, and why this is not a one-liner:defineJobaccepts, so an app whose job carries a zone the platform cannot honour stops BUILDING instead of booting-and-not-running. That is the intended direction, but it is a behaviour change on a published schema and wants the changeset and review tier that implies.Intl.DateTimeFormatmembership depends on the runtime's ICU. A build machine and a deployment target that disagree would produce a build-time refusal for a zone the deployment could honour, or vice versa — worth a deliberate answer rather than an accident.sys_job.timezonealso has novalueDomaincounterpart on the boundary type (JobSchedule.timezoneinpackages/spec/src/contracts/job-service.tsis a plainstring?). Whether the boundary should also be judged, or only the authoring tier, is part of the same decision.Re-check
Generated by Claude Code