Skip to content

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

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15872-timezone-value-domain
Sep 6, 2026
Merged

feat(platform-objects): sys_job.timezone and sys_report_schedule.timezone are validated against the IANA domain#16296
zhuangjianguo merged 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

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Contract review (clause ②) — PASS, bound to head 0491ac9db33877c85adda29574d38304aa2bd882

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:0xZ. Reviewed at CONTRACT_REVIEW_TIER by a dedicated reviewer seat; every citation re-verified against the tree by this seat. Tier disclosure as recorded on #16308 (5562566510): the reviewer's tier is asserted, never measured, and the substitute control is that the citations are checkable — which is what was done.

1. ⭐ My own Zone 2 was falsified, and the correction is the interesting part

I briefed that both limbs fire. Limb 1 does not fire. All 22 published .d.ts / .d.mts are byte-identical between the two legs (sha256 equal; audit/index.d.ts f18aeac7d298500a, identity/index.d.ts 6d0e8bfd11d41699, index.d.ts 8280e6965622bd74 at both). The timezone member of SysJob (d.ts :21610) and SysReportSchedule (:17741) is a 6232-character text identical at both legs, and at base it already reads valueDomain?: "iana_time_zone" | "iso_4217_currency" | "iso_3166_alpha2" | undefined;.

The cause is structural and verified in the tree: Field.text spreads a FieldInput-typed parameter —

field.zod.ts:2220  export type FieldInput = Omit<Partial<Field>, 'type'>;
field.zod.ts:2223  text: (config: FieldInput = {}) => ({ type: 'text', ...config } as const),

— so the literal 'iana_time_zone' is widened before it can reach a .d.ts, and ObjectSchema.create's Pick<T,'fields'> (object.zod.ts:2826) never sees it. ⇒ the declared type surface does not move at all; the entire change lives in the emitted runtime (dist/audit/index.js|mjs and dist/index.js|mjs, exactly two non-comment added lines each: valueDomain: "iana_time_zone").

⭐ This is the sharpest illustration yet of why a .d.ts reading alone cannot answer clause ②: a real accept-set narrowing shipped with a byte-identical declaration surface.

2. Limb 2 fires — proved by runtime ablation, not by reading

Same validator on both legs (objectql is untouched by the diff), declarations swapped:

probe (update) base head
timezone: 'Mars/Olympus' ACCEPT REFUSE timezone:value_domain
'UTC+8', 'China Standard Time' ACCEPT REFUSE
'Asia/Shanghai', 'UTC', '', null ACCEPT ACCEPT

…on both objects. The refusal is loud, not a strip: record-validator.ts:655-659 tests membership, :668-674 returns fail('value_domain', …), :1178 if (errors.length > 0) throw new ValidationError(errors);. ⇒ Clause-②: yes is correct on limb 2 alone.

3. The question I was most worried about — answered by the published contract text

My Zone 2.5 asked whether a stored non-IANA row could now make a read or a boot fail. The answer is in field.zod.ts:1023, verified verbatim by this seat:

Checked on the WRITTEN value only (the min/max/maxLength transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code value_domain.

Corroborated by measurement: the validator has no read or boot mode (record-validator.ts:155 type Mode = 'insert' | 'update';); isMissing (:188-190) skips null / '', which is what sys_job's upsert writes; the boot value-shape scanner is blind to text by construction (valueShapeViolation short-circuits; 0 valueDomain hits against a firing control of 3); driver-sql reads valueDomain nowhere for DDL (control: maxLength 95 hits); and DbJobAdapter.upsertJobRow runs after the cron adapter accepted the schedule (:205) and is caught into a warn (:513-515), so even a refused upsert cannot fail boot. ⇒ no blocking finding.

Zone 2.4 confirmed: both identity/ edits are comment-only — 0 non-comment changed lines, against a firing control (the same filter on sys-job.object.ts yields the one valueDomain line), and dist/identity/index.d.ts is byte-identical. Both objects already declared the domain at base.

Zone 1 respected: Q1 (the length bound) stays open and the diff moves no length constraint — maxLength: 100 / 64 are unchanged context lines.

4. ⚠️ One finding I am escalating rather than closing

The changeset self-describes as "A NON-BREAKING ADDITION" and carries no **BREAKING** banner, while the PR declares Clause-②: yes — i.e. an accept-set narrowing. The reviewer classified it a note, on grounds this seat verified: AGENTS.md:1021-1022 defines breaking as "removes or renames anything an author can write", which a value-set narrowing is not; check-changeset-no-major.mjs:41-42 confirms minor is the ceiling in the launch window; and precedent #14238 (6acb37eb9) shipped the identical shape in this same package with no banner.

⛔ It does not block this PR — the in-package precedent is unambiguous. But a sibling review this round found the opposite convention in @objectstack/core, where two precedents of the same shape (d8024f0, the Plugin.type narrowing, and 4635f3e) do carry the banner. Two packages have drifted apart on whether a narrowing owes it. That is a policy question, not this author's, and this seat is filing it as a finding rather than resolving it here.

5. Landing

① this record, ② --pair 16296 exit 0, ③ 37/37 complete, 0 failing. Card #15872 carries no sibling PR. Released.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Correction — I released this PR 40 minutes ago on an incomplete reading. Pulled back out of the merge queue. Nothing landed.

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T22:3xZ. This reverses my own release action in 5562601809, and the reason is a piece of governing contract text that neither that review nor I looked for.

State right now: auto-merge disabled, PR returned to draft, so it is out of the queue. Verified by content on origin/main 0ea5f9d9f that it never landed — valueDomain: 'iana_time_zone' in sys-job.object.ts is 0 hits, against a firing positive control of 5 Field.text hits in the same file. ⇒ nothing reached the tree; this is a hold, not a revert.

What I missed

The review I accepted judged the missing **BREAKING** banner a note, on AGENTS.md:1021-1022 (breaking = "removes or renames anything an author can write", which a value-set narrowing is not) plus precedent #14238. I verified both citations, found them accurate, and released.

Neither survives contact with the more specific text, which a sibling review on #16363 found and which I have now read at this PR's own head:

scripts/check-changeset-no-major.mjs:55-66
End condition: at GA … From that point a required member on a published interface, an accept-set narrowing, or any compile-breaking change to implementers grades major … Until then it is NOT the carrier, and that is the whole cost of the window: a breaking change ships as minor, so the bump level tells a consumer nothing about whether the release breaks them. The mandatory information carriers for breaking-ness in the meantime are the **BREAKING** banner the author writes in the changeset body and the ADR-0087 migration-ledger dispositionThey are not documentation niceties

That text is repo-wide, not package-local; it names "an accept-set narrowing" in those words as the breaking shape; and it says in terms that the banner is mandatory and explicitly not a nicety. AGENTS.md:1021-1022 is a narrower statement about migration mappings and does not override it.

⇒ This PR narrows the accept set of a published door — that is measured, not disputed: Mars/Olympus, UTC+8, China Standard Time are accepted by the shipped build and refused after it. Its changeset says "A NON-BREAKING ADDITION" and carries 0 BREAKING and 0 adr-0087 markers. So the CHANGELOG that ships to consumers tells an upgrading author this release cannot break them, immediately before a write that worked yesterday is refused.

⚠️ And the gate cannot catch it: check-adr-0087-registration.mjs:572 classifies from the author's own declaration (/\*\*BREAKING/i on the body). No banner ⇒ classified non-breaking ⇒ it reports "1 non-breaking changeset(s) seen" and never asks. Verified: the same gate ran green on this PR.

Why I am holding rather than waving it through

I called this exact defect blocking on PR #16363 fifteen minutes before writing this. The only difference between the two PRs is which package they live in, and the text above is not package-scoped. Releasing this one while blocking that one would not be a judgement — it would be me protecting an action I had already taken. ⛔ Not acceptable.

What is NOT in question

The clause-② verdict stands, and so does the code. Clause-②: yes is correct (limb 2 fires; limb 1 correctly does not — all 22 published .d.ts are byte-identical). Every citation in 5562601809 was re-verified and holds. Conditions ② and ③ are still met. The tests, the identity-file comment-only edits, the write-only judgement (field.zod.ts:1023), the boot-safety analysis — all unaffected.

The entire remedy is text, in the d8024f0 shape (that changeset carries 2 BREAKING and 1 adr-0087 marker; this one carries 0 of each):

  1. Add a **BREAKING** accept-set narrowing …, shipped as minor under the launch-window convention banner to .changeset/platform-iana-timezone-columns.md, replacing the "A NON-BREAKING ADDITION" sentence.
  2. Add exactly one ADR-0087 disposition marker.
  3. State the delta a consumer actually needs: which timezone strings stop being accepted, and that stored rows are unaffected (field.zod.ts:1023"a stored value outside a domain declared later is never re-read and survives unrelated edits").

⛔ No code change. ⛔ No re-review of clause ② is owed once the text lands — the head will move, so a fresh condition-① record is owed, but the measurement above transfers.

On precedent #14238

It shipped the identical shape with no banner, so it is non-conforming with this text too. ⛔ That is not this author's problem and I am not asking anyone to fix it here. It is the subject of #16421, which I filed 20 minutes ago describing this as a divergence between two packages — that framing is now wrong, and I am correcting the card: there is a governing rule, and one package's precedent does not follow it.


Generated by Claude Code

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

⛔ This branch is ref-locked by a surviving merge-queue entry. The fix commit is ready and cannot be pushed until someone dequeues this PR.

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:2xZ. One human action is needed; everything else is done.

What is needed

PR page → "Remove from queue" (or the merge queue view for main). Nothing else — the fix commit is already written and validated.

The state, measured

The fix for the two findings in 5562629666 is committed locally as 1ce3c943781e78062127dc9cb5fe7032cf686b91 (parent 0491ac9db, one file, .changeset/platform-iana-timezone-columns.md, +37/−1, no .ts touched, bump unchanged). The push is refused by the server:

remote: error: GH006: Protected branch update failed for refs/heads/claude/issue-15872-timezone-value-domain.
remote: - A pull request for this branch has been added to a merge queue. Branches that
remote:   are queued for merging cannot be updated. To modify this branch, dequeue the
remote:   associated pull request.

Not a permissions problem — a probe push to a throwaway branch on the same remote succeeded (and was deleted). The cause is in this PR's own timeline, read from REST with a firing control (18 total events):

22:23:58  auto_merge_enabled
22:25:00  added_to_merge_queue
22:28:07  convert_to_draft          ← my hold
added_to_merge_queue = 1     removed_from_merge_queue = 0

Converting to draft turned auto-merge off but did not remove the queue entry, and a surviving entry ref-locks the branch. So this PR is stranded in a state where it cannot merge (it is a draft) and cannot be fixed (the ref is locked).

⚠️ That contradicts the tooling's own notice, which states a draft conversion removes queue membership. It removed auto-merge only. Recorded so the next seat does not repeat it: to hold a queued PR, dequeue first, then draft — never draft first. Also note disable_pr_auto_merge is not a dequeue; afterwards it answers "Can't disable auto-merge for this pull request", which reads like a failure but only means it was already off.

This seat cannot clear it: the dequeuePullRequest GraphQL mutation is not served here (GraphQL is disabled for this session entirely — the control query fails identically, so an empty merge-queue read from here is a transport reading, not a queue reading), and there is no REST merge-queue route.

⛔ What I deliberately did NOT do

  • Un-draft it. Its checks are green, so with a live queue entry that risks merging the very changeset this PR is being held for.
  • Close and reopen to clear the lock. It would probably work, but it is churn on someone else's PR to work around a lock, and the PR is inert where it sits — a draft cannot merge, and it has been skipped by the queue for ~50 minutes while fix(lint): validateFormLayout resolves a list-bound view's object for its default form #16417 merged straight through. Waiting costs only delay on a PR that is correctly being held. That is not a trade worth improvising on.

The fix that is waiting

Both findings from 5562629666 are discharged in the pending commit, and the gate reading moved as required:

gate before 0491ac9db after 1ce3c9437
check-changeset-no-major exit 0, no major exit 0, unchanged
check-adr-0087-registration this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen) 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition + [BREAKING] not-required (no-migration-prescription)

⭐ And the root cause of the old reading is now understood precisely: the file's only occurrence of the token was inside the words "A NON-BREAKING ADDITION", while the predicate is /\*\*BREAKING/i (check-adr-0087-registration.mjs:572) — NON-BREAKING carries no ** prefix, so it never matched. The changeset said "non-breaking" in prose and was classified non-breaking by machine, for the same reason, in the same breath.

The new body also states the consumer delta the old one lacked — which spellings stop being accepted (UTC+8, GMT+0800, China Standard Time, Mars/Olympus, a misspelled Asia/Shanghia), that every genuine zone identifier including UTC keeps working, and that stored rows are unaffected, quoting the published contract text at field.zod.ts rather than asserting it.

Once dequeued: git push from the prepared worktree, then this seat posts a fresh condition-① record bound to the new head, un-drafts, and re-enables auto-merge by hand.


Generated by Claude Code

Merged via the queue into main with commit 0a038cc Sep 6, 2026
42 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15872-timezone-value-domain branch September 6, 2026 23:22
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

⛔ This merged, with the defective changeset. My hold did not hold, and the reason is an assumption of mine that was wrong.

domain:engine dispatching seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-06T23:2xZ. Recording it here because the next person to hit this needs the mechanism, not a summary.

What landed

Verified by content on origin/main 0a038cc06, two probes with controls that exist independently of the change:

probe reading firing control
valueDomain: 'iana_time_zone' in sys-job.object.ts 2 Field.text = 5
platform-iana-timezone-columns.test.ts present yes 15 files in that dir

And the changeset that came with it, which is the problem:

**BREAKING banner hits   : 0
"NON-BREAKING ADDITION"  : 1
adr-0087 marker hits     : 1   (inert — the classifier never reached it)

⇒ the code is fine and was never in question. What shipped is a release note that tells an upgrading author this change cannot break them, immediately before a write that worked yesterday is refused.

Why the hold failed

I converted this PR to draft at 22:28Z to hold it. That turned auto-merge off but left the merge-queue entry alive (timeline: added_to_merge_queue = 1, removed_from_merge_queue = 0, control 18 events), and the surviving entry ref-locked the branch so the prepared fix commit 1ce3c943 could not be pushed. I reported that and asked for a dequeue.

Then I reasoned my way into waiting, in 5562905046:

the PR is inert where it sits — a draft cannot merge, and it has been skipped by the queue for ~50 minutes while #16417 merged straight through. Waiting costs only delay.

That was wrong on the load-bearing clause. A queued PR merges from the queue; the draft flag did not stop it. ⇒ drafting a queued PR does not hold it — only dequeuing does, and this seat has no dequeue (GraphQL is disabled here entirely; there is no REST merge-queue route). So a queued PR is not something I can hold at all, and I should have said exactly that instead of describing it as held.

⛔ The deeper error is the one worth carrying: "a draft cannot merge" was an assumption I never measured, and I used it as the premise for a decision not to act. Two real observations — it was a draft, and it hadn't merged in 50 minutes — were consistent with it, and I took that as confirmation. But "has not merged yet" is not "cannot merge". I would not have accepted that as a control reading; I accepted it as a safety argument, which is the same mistake wearing a different hat.

Remedy, in flight

A changeset is consumed at release, so while it sits unreleased on main it is still correctable by an ordinary PR — and that is time-sensitive, because after a release the wrong text is in a published CHANGELOG permanently. A round is dispatched now to fix this one file on main: the **BREAKING** banner in the d8024f0 shape, exactly one ADR-0087 disposition, and the consumer delta (which spellings stop being accepted, that every genuine IANA identifier including UTC still works, and that stored rows are unaffected — quoting field.zod.ts rather than asserting it). It opens Part of #15872, ⛔ not Fixes, since that card's length-bound row is ruled to stay open.

⭐ And the root cause of the original miss is worth stating once, because it is a trap anyone can fall into: the file's only occurrence of the token was inside the words "A NON-BREAKING ADDITION", while check-adr-0087-registration.mjs:572 matches /\*\*BREAKING/i. NON-BREAKING carries no ** prefix. The changeset asserted "non-breaking" in prose and was classified non-breaking by machine — for the same reason, in the same sentence.

⛔ Nothing here is asked of this PR's author. The contract review passed and the code is correct.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants