Skip to content

feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain - #16296

Draft
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-15872-timezone-value-domain
Draft

feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain#16296
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-15872-timezone-value-domain

Conversation

@claude

@claude claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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-review is 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' on sys_job.timezone and sys_report_schedule.timezone — the same declaration and the same shared Intl.DateTimeFormat membership probe that sys_business_unit.timezone / sys_organization.timezone already 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 code value_domain.

Also in the diff: a pin test for both columns, and three prose sites that my own change made false (the #14238 pin 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.

  • The value is read back off the row (ReportService.rowFromSchedule) and handed to croner in nextRunAt: new Cron(cron, { timezone }).nextRun(from).
  • Measured on croner 10.0.1 / Node v22.22.2: a callback-less Cron constructs fine with an invalid zone and throws from nextRun().
  • nextRunAt catches that and falls back to from + interval_minutes, logging ReportService: invalid cron '...'; falling back to interval — a warning that names the cron expression, which was fine, rather than the timezone, which was not.
  • Net effect before this PR: an admin's "every weekday 09:00 Asia/Shanghai" with a typo'd zone became "every 1440 minutes, forever". dispatchDue -> advanceSchedule -> nextRunAt runs it on every sweep, so it never self-corrects.
  • Per the triage comment's pre-registered escalation clause, that is the outcome that raises this card's severity. Recorded on the card rather than on a new one, as that clause requires.

sys_job.timezone -> service-job: nothing reads it.

  • DbJobAdapter.upsertJobRow writes it; its three sys_job read sites take id / run_count / failure_count only. The tree's single row.timezone read belongs to sys_report_schedule — same search shape, one fires, the other is zero, so this is a measurement rather than a blind spot.
  • The zone the scheduler honours travels in memory instead (toBoundaryJobSchedule -> CronJobAdapter.schedule -> croner). There croner is constructed with a callback, which does throw on a non-member, and AppPlugin catches it per job as Background job FAILED TO SCHEDULE — it will never run (error level plus jobScheduleFailuresTotal).
  • DbJobAdapter.schedule awaits the cron adapter before upsertJobRow, 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:

  • maxLength is not only a write bound — it reaches DDL. driver-sql's drift checker plans a narrow_varchar op at severity error, 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."
  • On a tree emitted by the current driver the risk is nil, but that is not the whole population. timezone is not keyed on sys_job (its only index is { fields: ['name'], unique: 'global' }), and the emitter's rule is keyable = keyed ? keyableTextLength(field) : null — an unkeyed text field is TEXT, so maxLength never reaches its DDL, and the narrow_varchar branch is gated on isCharacterColumn. A column of some other provenance is what cannot be ruled out from here.
  • The narrowing would refuse nothing the domain declaration does not already refuse. Measured on this Node baseline: 418 enumerated zones, longest America/Argentina/Rio_Gallegos at 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_schedule keeps 'UTC', sys_job keeps 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 dacb73f4f by blob (proved on disk: each file's hash equal to the base blob and unequal to the head blob), rebuild with tsup invoked directly so no turbo cache is on the path, snapshot, restore, prove the restore (all four blobs equal their HEAD blob, git diff HEAD empty), rebuild, and re-prove the marker is back in dist/ with scripts/ablation-dist-preflight.mjs (16 built files carry it). Rebuilds proved by mtimes, not assumed.

  • All 22 published declaration files (dist/**/*.d.ts, *.d.mts) are BYTE-IDENTICAL. The type surface does not move.
  • 6 published runtime files differ, each hunk classified: dist/index.js, dist/index.mjs, dist/audit/index.js, dist/audit/index.mjs carry exactly two non-comment additions each — the two valueDomain: "iana_time_zone" lines. dist/identity/index.js and dist/identity/index.mjs have 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 in dist/**/*.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 at 0491ac9db, 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 --listFiles counts 0 for it and 0 for the existing #14238 pin 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 untouched src/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 is PREREQUISITE NOT MET and not a red.
  • check:i18n and check:i18n-stale-fill green with 9 packages all in sync, and git status is empty after a full workspace build — no generated baseline, form or snapshot moved. valueDomain is 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 MET on an unbuilt tree. Those are recorded as NOT MEASURED and re-run green after turbo run build; none of them was ever a finding.

Changeset

minor on @objectstack/platform-objects — the act, not the commit type: a declared shape on a published isSystem object narrows what it accepts. The ADR-0087 disposition is not-required (no-migration-prescription), which is the card's own binding reading rather than a flag invented to satisfy a gate: valueDomain is the min/max/maxLength transition-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/issues answers 403 on this session — repo-scoped endpoints only — so one targeted MCP search_issues was used instead, with this card returning as a firing control; no open card covered either):

Neither is addressed here and both remain open.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 6 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/field-types.mdx (via iana_time_zone (literal, a string literal in SysJob; a string literal in SysReportSchedule))
  • content/docs/data-modeling/fields.mdx (via iana_time_zone (literal, a string literal in SysJob; a string literal in SysReportSchedule))
  • content/docs/data-modeling/validation-rules.mdx (via iana_time_zone (literal, a string literal in SysJob; a string literal in SysReportSchedule))
  • content/docs/protocol/kernel/i18n-standard.mdx (via timeZone (literal, a string literal in SysJob; a string literal in SysReportSchedule))
  • content/docs/protocol/objectql/schema.mdx (via iana_time_zone (literal, a string literal in SysJob; a string literal in SysReportSchedule))
What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7beaaa32ccb1295eeaec73bdca4aea36e713232dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 29dd8c7deadc561a6cd3364354b202cd417cb1d4 — the merge of head 0491ac9db33877c85adda29574d38304aa2bd882 into base 7beaaa32ccb1295eeaec73bdca4aea36e713232d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7beaaa32ccb1295eeaec73bdca4aea36e713232d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Correction — #16229 is ruled (option C, 13:51Z), so this PR's needs:contract-review now rests on the criterion rather than on its absence

domain:engine dispatching seat, 2026-09-06T16:0xZ. ⛔ No label touched; this replaces the reason on the record, not the state.

What I said, and why it was stale

On card #15872 I endorsed keeping needs:contract-review hung here on the grounds that this PR's shape — all 22 published declaration files byte-identical, only runtime files moving — sits on an unruled criterion question, and that under an unruled criterion the conservative side is the labelled one.

The criterion was ruled at 2026-09-06T13:51:15Z (5559665612, director seat, decision batch #55, maintainer 「同意」), and #16229 is now pm:blocked / domain:skills. My framing has been stale since then and I repeated it afterwards. Correcting it here rather than leaving the reviewer to work from a superseded reason.

The ruled criterion, and what it says about this PR

  1. Clause ② is anchored on two machine-readable facts: (a) an exported symbol or signature moves on a published .d.ts under files[], or a new key lands on a published payload — the mechanical floor; (b) the accept set of a published door narrows, or a declared surface moves.
  2. A rejection that was deliberate … is never a "false rejection" however wrong its prose; removing it is a narrowing/widening of a ruled surface and is yes.

This PR declares valueDomain: 'iana_time_zone' on sys_job.timezone and sys_report_schedule.timezone. ⇒ a written non-member that is stored today is refused after it: the accept set of a published door narrows. That is limb (b) directly.

Clause-②: yes — and now on a ruled basis, not on the absence of one. The label stays for the reason the criterion gives, ⛔ not because nobody had decided.

⭐ Worth stating for anyone re-deriving this: the byte-identical declarations are not a counter-argument. Limb (a) is a floor, not the definition — a change can clear clause ② on the accept-set limb while moving no declared symbol at all, which is exactly this PR's shape. Reading "declarations unchanged ⇒ no" would invert the ruling.

The ruling also adds a burden of proof on any no in the adjacent lane: a Clause-②: no on a rejection-removing diff must quote the published contract text the rejection contradicts, and a no without that citation is illegible. Not applicable here — this PR adds a refusal — but it is the rule the next round in this family will meet.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant