feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain - #16296
Conversation
…dule.timezone against the IANA domain Both columns predate `valueDomain` and disagreed in three dimensions at once (length 100 vs 64, default none vs 'UTC', validation neither). This closes the validation dimension only: both now declare `valueDomain: 'iana_time_zone'`, the same declaration `sys_business_unit.timezone` / `sys_organization.timezone` carry, and the same shared `Intl.DateTimeFormat` membership probe. The reader measurement that decides what this is worth is recorded beside each declaration: `sys_report_schedule.timezone` is read back into croner by `ReportService.nextRunAt`, whose catch turned a non-member zone into a silent fall back to `interval_minutes` (the wrong instant, permanently), while `sys_job.timezone` is written and never read. Defaults and bounds are deliberately left unconverged and pinned as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 29dd8c7deadc561a6cd3364354b202cd417cb1d4 && git checkout 29dd8c7deadc561a6cd3364354b202cd417cb1d4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7beaaa32ccb1295eeaec73bdca4aea36e713232d 0491ac9db33877c85adda29574d38304aa2bd882 && git checkout -B drift-repro 7beaaa32ccb1295eeaec73bdca4aea36e713232d && git merge --no-ff 0491ac9db33877c85adda29574d38304aa2bd882
node scripts/docs-audit/affected-docs.mjs --json 7beaaa32ccb1295eeaec73bdca4aea36e713232d
|
Correction — #16229 is ruled (option C, 13:51Z), so this PR's
|
Part of #15872 — the validation dimension only. The bound convergence row stays open on that card; see "What is deliberately not landed" below.
Clause-②: yes
needs:contract-reviewis hung on this PR and on the card. This seat does not clear it and does not release this PR.What this changes
valueDomain: 'iana_time_zone'onsys_job.timezoneandsys_report_schedule.timezone— the same declaration and the same sharedIntl.DateTimeFormatmembership probe thatsys_business_unit.timezone/sys_organization.timezonealready carry (#14238). Four columns, one spelling of "is this a real zone". A written non-member is now refused with the ADR-0114 field error codevalue_domain.Also in the diff: a pin test for both columns, and three prose sites that my own change made false (the
#14238pin test's docblock and the two identity object comments all stated in as many words that these two precedents were "neither validated").The card's FIRST STEP, which decides its severity — done before any declaration was touched
The card said this was not measured and that it decides the severity. It was measured at the actual consumption sites. The two readers behave completely differently, and only one of them was dangerous.
sys_report_schedule.timezone-> plugin-reports: it schedules at the WRONG INSTANT, silently and permanently. Not a throw, and not a fall back to UTC.ReportService.rowFromSchedule) and handed to croner innextRunAt:new Cron(cron, { timezone }).nextRun(from).Cronconstructs fine with an invalid zone and throws fromnextRun().nextRunAtcatches that and falls back tofrom + interval_minutes, loggingReportService: invalid cron '...'; falling back to interval— a warning that names the cron expression, which was fine, rather than the timezone, which was not.dispatchDue->advanceSchedule->nextRunAtruns it on every sweep, so it never self-corrects.sys_job.timezone-> service-job: nothing reads it.DbJobAdapter.upsertJobRowwrites it; its threesys_jobread sites takeid/run_count/failure_countonly. The tree's singlerow.timezoneread belongs tosys_report_schedule— same search shape, one fires, the other is zero, so this is a measurement rather than a blind spot.toBoundaryJobSchedule->CronJobAdapter.schedule-> croner). There croner is constructed with a callback, which does throw on a non-member, andAppPlugincatches it per job asBackground job FAILED TO SCHEDULE — it will never run(error level plusjobScheduleFailuresTotal).DbJobAdapter.scheduleawaits the cron adapter beforeupsertJobRow, so that path cannot even write a non-member into the column. The door this declaration actually closes is the other one: a direct write from Studio, REST or a script, which had no validation at all.This falsifies nothing the PM assumed and confirms assumption 2.4: the two consumers do differ, which is exactly why the measurement was the card's first step.
What is deliberately NOT landed, and why the card stays open
The bound convergence (100 -> 64 on
sys_job) is dropped. The triage comment forbids narrowing without reading what the column physically holds, and there is no deployment data readable from this repo. What I could measure, offered so the decision can be taken on evidence:maxLengthis not only a write bound — it reaches DDL.driver-sql's drift checker plans anarrow_varcharop at severityerror, category destructive, for a declared bound below the physical column's: "metadata caps at 64 chars but the column allows 100 — narrowing may truncate.os migrate apply --allow-destructive."timezoneis not keyed onsys_job(its only index is{ fields: ['name'], unique: 'global' }), and the emitter's rule iskeyable = keyed ? keyableTextLength(field) : null— an unkeyed text field is TEXT, somaxLengthnever reaches its DDL, and thenarrow_varcharbranch is gated onisCharacterColumn. A column of some other provenance is what cannot be ruled out from here.America/Argentina/Rio_Gallegosat 30 characters; the longest tzdb link,America/Argentina/ComodRivadavia, is 32 and the probe admits it; a 65-character string is refused. So after this PR, 100 admits nothing 64 would not — the convergence is cosmetic on the write path and carries only the DDL question. That is an argument about the domain, though, not a reading of the data, which is precisely what the triage comment says is not sufficient.The defaults are deliberately NOT converged (
sys_report_schedulekeeps'UTC',sys_jobkeeps none) — a default here is a consumer semantic, not a shape question. Both non-convergences are pinned in the new test so that leaving them alone stays a decision rather than becoming a drift someone repairs by reflex.Published-surface measurement (clause ②)
A real ablation: build at head, swap the four changed sources back to
dacb73f4fby blob (proved on disk: each file's hash equal to the base blob and unequal to the head blob), rebuild withtsupinvoked directly so no turbo cache is on the path, snapshot, restore, prove the restore (all four blobs equal their HEAD blob,git diff HEADempty), rebuild, and re-prove the marker is back indist/withscripts/ablation-dist-preflight.mjs(16 built files carry it). Rebuilds proved by mtimes, not assumed.dist/**/*.d.ts,*.d.mts) are BYTE-IDENTICAL. The type surface does not move.dist/index.js,dist/index.mjs,dist/audit/index.js,dist/audit/index.mjscarry exactly two non-comment additions each — the twovalueDomain: "iana_time_zone"lines.dist/identity/index.jsanddist/identity/index.mjshave zero non-comment hunks: comment-only, so not a surface change.So clause ② is yes on the accept set, not on the type surface: a write this package accepted is now rejected. Worth stating plainly because a
.d.ts-only census would have answered "no" here with confidence — for a package that publishes object schemas as data, the accept/reject change lives indist/**/*.js.Verification
Gate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(re-derived after the changeset landed: 46 families became 55). All 55 run green at0491ac9db, the commit this PR carries, on a clean tree — exit codes captured by redirect-then-read, never through a pipe.pnpm --filter @objectstack/platform-objects test— 36 files, 539 tests pass. The new file is proved in the swept set by a targeted verbose re-run: 7 of 7 pass.pnpm --filter @objectstack/platform-objects typecheck— clean, but that is NOT a reading about the new test file: the package's tsconfig excludes**/*.test.ts, so--listFilescounts 0 for it and 0 for the existing#14238pin test. Measured separately with a config that includes them: 0 errors in the new file, which was in the swept set; the only 3 errors are pre-existing in the untouchedsrc/feature-gate-guard.test.ts.pnpm check:type-check-debt— 12 ledger entries re-measured, 140 raw errors, none above its recorded number. Needed the CI-shaped 6144 MB ceiling: at a tighter cap it OOMs and exits 3, which isPREREQUISITE NOT METand not a red.check:i18nandcheck:i18n-stale-fillgreen with 9 packages all in sync, andgit statusis empty after a full workspace build — no generated baseline, form or snapshot moved.valueDomainis not an extracted string.check:keyed-text-bounds,check:dts-closure(71 built packages swept),check:dual-build-cjs-loads,check:adr-0087-registration,check:nul-bytes— green.Three gates first returned exit 3 =
PREREQUISITE NOT METon an unbuilt tree. Those are recorded as NOT MEASURED and re-run green afterturbo run build; none of them was ever a finding.Changeset
minoron@objectstack/platform-objects— the act, not the commit type: a declared shape on a publishedisSystemobject narrows what it accepts. The ADR-0087 disposition isnot-required (no-migration-prescription), which is the card's own binding reading rather than a flag invented to satisfy a gate:valueDomainis themin/max/maxLengthtransition-gate class, so only a written value is judged, a stored non-member is never re-read, and no DDL is planned because no bound moved.Findings raised, not fixed here
Both are outside this card and were filed unassigned after a dedup search (REST
/search/issuesanswers 403 on this session — repo-scoped endpoints only — so one targeted MCPsearch_issueswas used instead, with this card returning as a firing control; no open card covered either):timezonesilently discards a report schedule's cron and fires it on the interval cadence forever — and the create-time guard cannot see it #16291 — plugin-reports' create-time guard is blind to the timezone half of its own input, andnextRunAt's warning names the wrong one. This PR closes the write door for new rows; it does not repair either of those.CronScheduleSchema.timezonein packages/spec is an unvalidatedz.string()— the authoring tier for a job cron zone does not use the membership predicate its own package exports #16292 —CronScheduleSchema.timezoneinpackages/specis an unvalidatedz.string(), in the same package that exports the membership predicate.Neither is addressed here and both remain open.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code