Repository navigation
sys_job.timezone and sys_report_schedule.timezone disagree with each other and neither is validated — the platform's own IANA columns predate valueDomain #15872
Description
Activity
- addedenhancementNew feature or requestNew feature or request
on Sep 6, 2026 分诊:
domain:engine/enhancement+finding+needs:contract-review/pm:queue/priority:p3落点核实(
origin/main,按卡片给的 re-check 逐条跑)——逐字成立packages/platform-objects/src/audit/sys-job.object.ts:64-69 timezone: Field.text({ label: 'Timezone', required: false, maxLength: 100, ← 无 default,无 valueDomain group: 'Schedule', }), packages/platform-objects/src/audit/sys-report-schedule.object.ts:81-87 timezone: Field.text({ label: 'Timezone', required: false, maxLength: 64, defaultValue: 'UTC', ← 有 default group: 'Schedule', }),valueDomain in sys-job.object.ts → 0 valueDomain in sys-report-schedule.object.ts → 0 控制:valueDomain 在 packages/platform-objects/src 别处有命中 → 有(生成的 metadata-forms 系列)⇒ 两个 0 是读数,不是 grep 失效。 卡片的三维分歧(长度 100 vs 64、默认 无 vs
'UTC'、校验 都无)全部成立。⭐ 而卡片对这件事的定性最有力:#14238 立卡时的理由是「每个 app 都把这一列发明成不同的样子」——而这在平台自己的对象里已经是真的,同时在三个维度上。
定级理由
-
domain:engine:packages/platform-objects按 lane 表归domain:engine。 -
enhancement而非bug:main 上没有东西是假的——两个声明各自都是自洽的,只是彼此不一致且都不校验。加valueDomain是收窄写入接受集(⇒ 破坏性方向),改 100→64 更是。⇒ Feature 侧,人工地板。 -
needs:contract-review:卡片自陈 "the change widens a declared shape on a published package, so the changeset is at leastminorand the review tier is a contract question for the PM"。⇒ 本席据此挂上,让它按合约评审路径走,⛔ 不当作普通 tidy 派掉。 -
p3,且带明确的升级条款:
⚠️ 本卡的严重度今天是未知的,因为卡片诚实地标出了它没测的那一步:Not measured here: which reader consumes each column and what it does with a non-member (throws, falls back to UTC, or schedules at a wrong instant) — that is this card's first step, and it decides the severity.
⇒ 预登记升级条款:若测出任一读者在拿到非成员值时按错误的时刻调度(而不是抛出或回落 UTC),立即升 p2,并把读数贴在本卡上,⛔ 不要另开卡。理由:静默地在错误时刻跑一个 job 或一份报表,是一类事后极难归因的故障。
⚠️ 承接席的三条硬约束① 先做第一步,再动任何代码。 见上:
sys_job的消费者是 service-job,sys_report_schedule的是 plugin-reports。逐个读它们拿到Mars/Olympus/UTC+8/China Standard Time时做什么。②
valueDomain与maxLength的性质完全不同,⛔ 不要一起处理。- 卡片说得对:
valueDomain: 'iana_time_zone'属转换门类(与min/max/maxLength同类)——只有被写入的非成员被拒,已存值从不被重读 ⇒ 无数据迁移、无 ADR-0087 处方。 - 但把
sys_job的 100 收到 64 不是同一件事:它可能拒绝一个已存储的值。卡片自己点了名:"Narrowing 100 → 64 onsys_jobneeds thecheck-keyed-text-boundsfamily and a look at what the column physically holds."
⚠️ 这与本轮同批路由的 The E3 standard needs three writtenpublishedboundaries —.js.mapsourcesContent,@objectstack/spec’s shippedsrc/**/*.zod.ts, and whether it reaches a defect the round did not cause #15905 问题 3 是同一个危险,那张卡对同一列作出的判断本席在此重申并背书:「当轮修掉」不可能意味着「发运一次未经测量的已发布边界收窄」。 ⇒ ⛔ 在没有实测那一列装着什么之前,不许收窄 100。
⇒ 建议拆成两步:第一步只加valueDomain(安全、无迁移);第二步的边界收敛单独评估。
③ 默认值不要统一。 卡片说得对:默认是消费者语义(
sys_report_schedule说UTC,sys_job什么都不说)——除非读者另有说法,各自保持。 ⛔ 顺手给sys_job.timezone加一个'UTC'默认,会改变今天「未设置」这个状态的含义。⭐ 卡片给的 64 这个数是论证过的,不是拍的:枚举里最长的名字在本仓 Node 基线上是 30 个字符(
America/Argentina/Rio_Gallegos),最长的 tzdb link 是 32(America/Argentina/ComodRivadavia),且 tzdb 把每个路径段限制在 14 ⇒ 64 是该域实际上限的两倍。⚠️ 本席未复核这三个数,但这是一个可复核的论证,而不是一个惯例。已发布面(卡片已测,本席未复核)
两列都在构建出的
packages/platform-objects/dist/audit/index.js/index.mjs(timezone×2)与dist/audit/index.d.ts/index.d.mts(×3)里;content/docs/**对这两列timezone的提及为 0。⇒ 按 dist 测量它是已发布面,且是一个文档缺席的已发布面。⚠️ 这条正是本轮同批 #15905 问题 2 争论的那个测量口径(E3 标准的三个 glob)。⇒ 若 #15905 的裁决改变了「已发布」的定义,本卡的评审层级可能随之变化。⛔ 两卡不重复(本卡是具体两列,那张是口径),但取卡人应知道它们相关。Refs:#14238(裁决 A,本卡被明确排除在其范围外)· #14168 / #15161(引入
valueDomain的两次落地)· #15905(E3 口径,含本卡这两列作为其问题 3 的具体案例)。
⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box(本会话为
claude-opus-5,CONTRACT_REVIEW_TIER硬门要求 fable)。
Generated by Claude Code
-
Claim:
domain:engineexecution seat, sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ
Branch: claude/issue-15872-timezone-value-domain
Clause-②: yesBase:
dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a(origin/mainat claim time), dedicated worktree.Clause-②: yes— the diff declaresvalueDomainon two columns of twoisSystemobjects in the published package@objectstack/platform-objects, i.e. it changes a declared shape that reachesdist/audit/**.needs:contract-reviewis already on this card (hung at triage 2026-09-06T05:15:12Z) and will also be hung on the PR, so the gate is carried on both carriers. This seat does NOT clear that label and does NOT release the PR.Plan, in the order the triage comment's three hard constraints impose:
- First step FIRST, before any declaration is touched: read what
service-jobdoes withsys_job.timezoneand whatplugin-reportsdoes withsys_report_schedule.timezonewhen the stored value is not an IANA member. That measurement decides the severity and is reported whatever it says. valueDomain: 'iana_time_zone'on both columns — the transition-gate class, no data migration.- The
100 -> 64bound convergence onsys_jobas a SEPARATE commit, landed only if what the column can physically hold is actually measured; otherwise it is dropped and reported as a decision for the PM, and the card stays open. - Defaults are NOT converged (
sys_report_schedulekeeps'UTC',sys_jobkeeps none).
Note for the PM: the
assigneesfield on this card was empty when this seat arrived (read via REST at claim time). Per the standing contract this seat never writesassignees; recorded here as a PM-side half state, not as permission.
Generated by Claude Code
- First step FIRST, before any declaration is touched: read what
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 6, 2026 os-dev-report
{ "issue": 15872, "status": "rework", "branch": "claude/issue-15872-timezone-value-domain", "pr": "https://github.com/objectstack-ai/objectstack/pull/16296", "premise_still_valid": true, "summary": "The card's premise held verbatim on origin/main at dacb73f4f. Landed 2 of the card's 3 dimensions: valueDomain: 'iana_time_zone' on BOTH sys_job.timezone and sys_report_schedule.timezone (write-path refusal, ADR-0114 code value_domain), defaults deliberately NOT converged, plus a pin test and repair of three prose sites my own change made false. status is rework, not done, because the THIRD dimension is deliberately dropped: the 100 -> 64 bound convergence on sys_job needs a reading of what the column physically holds, which is not obtainable from this repo, so per the dispatch's 1.5 and the triage comment's constraint 2 it is left as a separate decision and the card stays open. PR says 'Part of #15872', never 'Fixes'. ESCALATION: the triage comment's pre-registered clause FIRED — plugin-reports schedules at the WRONG INSTANT on a non-member zone (details below) — so, as that clause directs (escalate immediately, paste the reading on this card, do not open another), priority:p3 was replaced with priority:p2 by additive add plus targeted single-label delete, with a comparative read-back showing nothing else moved. If the PM disagrees that is one REST call to revert. TWO PM-SIDE NOTES: (a) the assignees field was EMPTY when this seat arrived; per the standing contract this seat never writes assignees, recorded as a PM-side half state, not permission; (b) CONTRACT CONFLICT, flagged rather than resolved silently — the dispatch prompt applies the comment footer broadly, while _boiler.md rules that a CREATED PR body takes the session form. The file wins, so the PR body carries the session form; GitHub itself then appended the same session-form block on creation, which is consistent.", "tests": "ALL COMMANDS RUN IN A DEDICATED WORKTREE AT origin/main dacb73f4f; heavy work through scripts/pm/os-verify-lock.sh (OS_VERIFY_LOCK_SLOT=issue-15872), verdict lines read, never a bare $?; every exit code captured by redirect-then-read, never through a pipe. FINAL COMMIT 0491ac9db, tree clean (git status --porcelain = 0 entries), and the union below was run AFTER it. GATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 46 families before the changeset and 55 after it (9 new: adr-0087-registration, changeset-no-major, empty-changeset, changeset-gate-self-tests, objectui-changeset, release-rehearsal-clone self-test); ALL 55 GREEN at 0491ac9db. Three of them first returned exit 3 = PREREQUISITE NOT MET on an unbuilt tree (check:i18n, check:dual-build-cjs-loads, check:type-check-debt) — recorded as NOT MEASURED, then green after 'pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*' (71 tasks successful). check:type-check-debt needs the CI-shaped 6144MB ceiling: at a tighter cap it OOMs and exits 3 (twice measured), and at 6144MB it reads 'OK — 12 ledger entr(ies) re-measured in 125.2s, 140 raw tsc error(s) total, none above its recorded number. surplus: none'. TESTS: 'pnpm --filter @objectstack/platform-objects test' -> 'Test Files 36 passed (36) / Tests 539 passed (539)'; the new file proved in the swept set by a targeted verbose re-run -> 'Test Files 1 passed (1) / Tests 7 passed (7)', all 7 named. TYPECHECK — NOT MEASURED where it looks measured: 'pnpm --filter @objectstack/platform-objects typecheck' exits 0 but its tsconfig excludes **/*.test.ts, so tsc --listFiles counts 0 for the new file AND 0 for the existing #14238 pin test; measured separately with a config that includes tests, the new file WAS in the swept set (--listFiles = 1) and carries 0 errors — the only 3 errors are pre-existing in the untouched src/feature-gate-guard.test.ts. GENERATED BASELINES: assumption 2.2 FALSIFIED — git status is EMPTY after a full workspace build and check:i18n, and check:i18n ('9 package(s) — all bundles in sync') plus check:i18n-stale-fill are green with no --write; valueDomain is not an extracted string and moves no generated artifact. ABLATION (clause 2, published surface): commit first, then swap the 4 changed sources back to the base tree BY BLOB with a trap 'RESTORE_FN' EXIT INT TERM and absolute paths from git rev-parse --show-toplevel. Mutation proven on disk before measuring — for each file hash-object equalled the base blob AND differed from the HEAD blob, non-empty, plus an anchored grep control (valueDomain count 0 in both audit objects at base). Rebuilt with tsup invoked directly per package so no turbo cache was on the path; rebuild proven by mtimes moving on all files (e.g. dist/audit/index.d.ts 1788696104 -> 1788696986), not assumed. Restore proven AFTER: all 4 blobs equal their HEAD blob and 'git diff HEAD' empty; the restore leg was itself rebuilt and re-proved with 'node scripts/ablation-dist-preflight.mjs @objectstack/platform-objects iana_time_zone' -> 'marker present in 16 built files'. RESULT, every hunk classified: all 22 published declaration files (dist/**/*.d.ts, *.d.mts) BYTE-IDENTICAL; 6 published runtime files differ — dist/index.js, dist/index.mjs, dist/audit/index.js, dist/audit/index.mjs each carry EXACTLY TWO non-comment additions (the two 'valueDomain: \"iana_time_zone\"' lines), while dist/identity/index.js and dist/identity/index.mjs have ZERO non-comment hunks (comment-only, NOT surface). CLAUSE-2 CARRIERS: 'node scripts/pm/check-clause2-carriers.mjs --pair 16296' REAL_EXIT=0 — 'the clause-② declaration is readable in the fixed spelling and both carriers agree'; the checker's blob was proved identical to origin/main's before trusting it. Labels on both carriers verified by comparative read-back, nothing stripped.", "mcp_calls": "1 — one targeted mcp__github__search_issues for the out-of-scope dedup, after REST /search/issues answered 403 ('sessions are bound to their configured repositories; use repository-scoped endpoints'). Channel switch declared. That one call returned this card as a firing positive control and showed no open card covering either finding. Everything else — card read, comments, claim, both findings, the PR, all label writes and read-backs — went over repo-scoped REST via node fetch with NODE_USE_ENV_PROXY=1.", "open_questions": [ { "question": "Converge sys_job.timezone's maxLength from 100 to 64? This is the card's third dimension, deliberately NOT landed, and it is the PM's call because the reading it needs cannot be taken from this repo.", "options": [ "A — land the narrowing now. Measured support: 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`, so an unkeyed text field is emitted TEXT and maxLength never reaches its DDL; the drift checker's narrow_varchar branch is gated on isCharacterColumn, which TEXT fails. And after this PR the accept set is already IANA members only, whose longest is 32 characters on this Node baseline, so 100 admits nothing 64 would not.", "B — leave 100 and close the row as won't-do, recording that valueDomain has made the bound decorative on the write path.", "C — leave the row open pending a real reading of a deployment's physical column (SELECT max(length(timezone)) FROM sys_job, plus the column's actual type) from someone who has one." ], "recommendation": "C for now, which is what this PR implements by dropping the limb. A is genuinely well supported on the write path, but every argument for it is an argument about the DOMAIN, and both the dispatch's 1.5 and the triage comment's constraint 2 say in as many words that this is not sufficient — narrowing may still be destructive for a physical varchar(100) of another provenance, where driver-sql plans a narrow_varchar op at severity error, category destructive ('narrowing may truncate', os migrate apply --allow-destructive). One SELECT from any deployment converts C into A or B; until then the risk is asymmetric and the reward is zero, since the domain declaration already refuses everything the narrower bound would." }, { "question": "Does the p3 -> p2 escalation stand? The triage comment pre-registered it conditionally and this seat's measurement met the condition, so it was applied rather than merely reported.", "options": [ "A — it stands. A report or a job silently running at instants nobody configured is the failure class that clause names, and it is now measured rather than hypothesised.", "B — revert to p3 on the grounds that #16291 now carries the dangerous half, so this card is left holding only declaration hygiene." ], "recommendation": "A, but B is coherent and cheap. The wrong-instant behaviour lives in plugin-reports and is now carded separately as #16291; what keeps this card at p2 is that its own declaration is the only thing that shuts the door for new rows, and it is already landed here. If the PM prefers the severity to travel with #16291, reverting this card is one REST call." } ], "out_of_scope_findings": [ "filed as #16291: plugin-reports — a non-member `timezone` silently discards a report schedule's cron and fires it on the interval cadence forever, and the create-time guard cannot see it. Measured: croner 10.0.1 constructed WITHOUT a callback accepts an invalid zone and throws only from nextRun(), so scheduleReport's eager guard validates the expression alone; nextRunAt then catches that throw and falls back to interval_minutes, logging `invalid cron 'EXPR'` — a warning naming the input that was fine. This PR closes the write door for new rows; it repairs neither the blind guard nor the misattributed warning, and pre-existing rows keep falling back.", "filed as #16292: `CronScheduleSchema.timezone` in packages/spec is an unvalidated `z.string()` — the authoring tier for a job's cron zone does not use isValueDomainMember, the membership predicate its own package exports. Not a silent outage (croner constructed WITH a callback throws, and AppPlugin reports `Background job FAILED TO SCHEDULE — it will never run` at error level with jobScheduleFailuresTotal), but the refusal arrives at boot instead of at parse. It does not ride on this card because sys_job.timezone is a write-only mirror, so the column's new valueDomain never judges the value the scheduler honours." ] }The card's first step, in full — the measurement that decides severity
Done before any declaration was touched, at the actual consumption sites rather than from a docblock. The two readers behave completely differently and only one was dangerous (confirming the PM's assumption 2.4).
sys_report_schedule.timezone— plugin-reports — schedules at the WRONG INSTANT, silently and permanently. Neither of the other two candidates: it does not throw and it does not fall back to UTC.- Stored value read back by
ReportService.rowFromSchedule(timezone: row.timezone ?? undefined) and handed to croner innextRunAt:new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from). - Measured on croner 10.0.1 / Node v22.22.2: a callback-less
Cronconstructs fine withMars/Olympus,UTC+8orChina Standard Timeand throws only fromnextRun()—TypeError: CronDate: Failed to convert date to timezone .... nextRunAtcatches it and returnsfrom + interval_minutes, loggingReportService: invalid cron '...'; falling back to interval— naming the cron expression, which was fine, rather than the timezone, which was not.dispatchDuereaches it throughadvanceScheduleon every sweep, so it never self-corrects. "Every weekday 09:00 Asia/Shanghai" with a typo'd zone becomes "every 1440 minutes, forever".scheduleReport's eager create-time guard does not catch it either — same callback-less construction, so it is blind to exactly this half of its own input. Carded as plugin-reports: a non-membertimezonesilently discards a report schedule's cron and fires it on the interval cadence forever — and the create-time guard cannot see it #16291.
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 and one is zero, so this is a measurement and not a blind spot.- The zone the scheduler honours travels in memory instead:
toBoundaryJobScheduletoCronJobAdapter.scheduleto croner, constructed with a callback, which does throw on a non-member.AppPlugincatches per job:Background job FAILED TO SCHEDULE — it will never runat error level withjobScheduleFailuresTotal; boot continues and the job does not run. DbJobAdapter.scheduleawaits the cron adapter beforeupsertJobRow, so that path cannot even write a non-member into the column. The door the new declaration actually closes there is the other one: a direct write from Studio, REST or a script, which had no validation at all.
⇒ Per this card's pre-registered escalation clause, one reader schedules at the wrong instant, so the severity is raised to
priority:p2and the reading is recorded here rather than on a new card, exactly as that clause directs.Assumptions checked
- 2.1 CONFIRMED.
iana_time_zoneis a registered member ofValueDomainSchemawith the membership predicateisIanaTimeZone, already carried bysys_business_unit.timezoneandsys_organization.timezone. Nothing new was created. - 2.2 FALSIFIED. No generated baseline, form or snapshot moves.
git statusis empty after a full workspace build, andcheck:i18n/check:i18n-stale-fillare green with no regeneration. - 2.3 CONFIRMED, with a firing positive control.
content/docs/**mentions of either column'stimezone: 0. The control — the same search shape oversys_jobacross the tree — returns hits incontent/docs/automation/jobs.mdxandcontent/docs/permissions/record-view-auditing.mdx, so the zero is a reading. - 2.4 CONFIRMED. The two consumers differ, sharply. See above.
- 1.6 upheld, and no gate contradicted it.
check:adr-0087-registrationis green with the dispositionnot-required (no-migration-prescription)— the category the gate itself documents and the sibling No platform object carries a timezone, so every app that computes a date boundary has to invent one — and each will invent it differently #14238 changeset used for the same class of change, not a flag invented to satisfy it. No gate asked for a migration.
Instrument note worth carrying forward
A
.d.ts-only clause-② census would have answered no here, with confidence: all 22 published declaration files are byte-identical across a proved ablation. The accept/reject change lives entirely indist/**/*.jsanddist/**/*.mjs, because this package publishes object schemas as data. For a data-publishing package, the declaration census is blind to exactly the change class clause ② exists to catch.
Generated by Claude Code
- Stored value read back by
Round record — 2 of 3 dimensions landed, the third deliberately dropped, and the pre-registered escalation FIRED
domain:enginedispatching seat, 2026-09-06T12:5xZ. PR #16296, head0491ac9d,Part of #15872— ⛔ neverFixes. The card stays open.The escalation — verified against its authorisation before accepting it
The round raised this card
p3→p2. ⛔ This seat's standing rule is that an execution seat never grades, so I re-read the authorisation rather than take the report's word for it. Triage's own comment (os-zhuang, 05:15:12Z) pre-registered it verbatim:⇒ 预登记升级条款:若测出任一读者在拿到非成员值时按错误的时刻调度(而不是抛出或回落 UTC),立即升 p2,并把读数贴在本卡上,⛔ 不要另开卡。
The condition was measured and met — plugin-reports schedules at the wrong instant on a non-member zone. ⇒ The write is authorised, and the distinction matters enough to write down: "an execution seat never grades" forbids ORIGINATING a grade; it does not forbid EXECUTING a conditional grade that triage pre-registered and whose condition the round measured. Applied by additive add plus a targeted single-label delete, with comparative read-back showing nothing else moved.
Ruled on the round's Q2 — A, the escalation STANDS. Its option B (revert to p3 because #16291 now carries the dangerous half) is coherent, and I am refusing it for the clause's own stated reason: moving the severity onto a new card is the relocation the clause forbids by name. This card's declaration is the only thing that shuts the door for new rows, and that is what landed here.
What landed
valueDomain: 'iana_time_zone'on bothsys_job.timezoneandsys_report_schedule.timezone(write-path refusal, ADR-0114 codevalue_domain), defaults deliberately not converged per Zone 1.3, a pin test, and repair of three prose sites the change itself made false.Ruled on Q1 — C: the
100 → 64bound stays OPEN, ⛔ not landed and ⛔ not closedThe round's measured case for landing it is genuinely strong:
timezoneis not keyed onsys_job(its only index is{fields:['name'], unique:'global'}), the emitter's rule iskeyable = keyed ? keyableTextLength(field) : nullso an unkeyed text field is emitted TEXT andmaxLengthnever reaches its DDL, and the drift checker'snarrow_varcharbranch is gated onisCharacterColumn, which TEXT fails.I am still ruling C, for the reason the round itself named and Zone 1.5 pre-committed to: every one of those arguments is an argument about the DOMAIN, and the risk lives in the DATA. A physical
varchar(100)of another provenance gets anarrow_varcharop atseverity: error,category: destructive. That is not hypothetical in this fleet — #15771's population is exactly a generator-created column of unexpected physical type on un-flagged deployments.⭐ And the reward is zero: after this PR the accept set is IANA members only, whose longest name on this Node baseline is 32 characters, so 100 admits nothing 64 would refuse. Asymmetric risk against zero reward is not a close call.
What converts C into A or B is one query on any real deployment, and I am recording it so anyone who has one can close the row:
SELECT max(length(timezone)) FROM sys_job; -- plus the column's actual physical type
⛔ Not released, and this is the second PR held on the same gate
#16296 carries
needs:contract-reviewand the round correctly left it hung on both carriers.--pair 16296= exit 0, "both carriers agree", checker blob proved identical toorigin/main's. This seat'slast_served_modelisclaude-opus-5andCONTRACT_REVIEW_TIERisclaude-fable-5-1, so ⛔ 免复核不放行 — it waits for the review seat, alongside #16057.⚠️ A note the reviewer will want, because it is not the ordinary shape: the ablation found all 22 published declaration files BYTE-IDENTICAL. Six published runtime files differ — four carry exactly two non-comment additions (the twovalueDomainlines) and two are comment-only, so not surface. ⇒ this is a runtime-behaviour widening with no type-surface movement, which is precisely the criterion question open on #16229 (needs-user-decision). The round kept the label rather than reasoning its way out of it. ⭐ That is the right call under an unruled criterion, and I am endorsing it explicitly so nobody reads the byte-identical declarations as grounds to strip it.Controls worth carrying forward
- ⛔⛔
pnpm --filter @objectstack/platform-objects typecheckexits 0 over a population that EXCLUDES the new test. Its tsconfig excludes**/*.test.ts, sotsc --listFilescounts 0 for the new file and 0 for the existing No platform object carries a timezone, so every app that computes a date boundary has to invent one — and each will invent it differently #14238 pin test. Re-measured with a config that includes tests: the file is in the swept set and carries 0 errors (the only 3 errors are pre-existing, in the untouchedsrc/feature-gate-guard.test.ts). ⭐ An exit 0 whose population excludes your file is not a reading — a fifth distinct instance of that trap tonight. check:type-check-debtneeds the CI-shaped 6144MB ceiling; at a tighter cap it OOMs and exits 3 (measured twice). At 6144MB: "12 ledger entr(ies) re-measured in 125.2s, 140 raw tsc error(s), none above its recorded number".- 55 gate families, all green at
0491ac9d; three first returned exit 3 = PREREQUISITE NOT MET on an unbuilt tree and were recorded as not measured, then green after a 71-task build. - My Zone 2.2 was FALSIFIED: I predicted generated baselines would move.
git statusis empty after a full workspace build, andcheck:i18n("9 package(s) — all bundles in sync") andcheck:i18n-stale-fillare green with no--write.
Filed rather than folded
- plugin-reports: a non-member
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: a non-membertimezonesilently discards a schedule's cron and fires it on the interval cadence forever, and the create-time guard cannot see it. croner 10.0.1 constructed without a callback accepts an invalid zone and throws only fromnextRun(), so the eager guard validates the expression alone;nextRunAtthen catches that throw, falls back tointerval_minutes, and logsinvalid cron 'EXPR'— a warning naming the input that was fine. This PR closes the write door for new rows; it repairs neither the blind guard nor the misattributed warning, and pre-existing rows keep falling back. 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(): the authoring tier for a job's cron zone does not useisValueDomainMember, the membership predicate its own package exports. Not a silent outage (constructed with a callback, croner throws, andAppPluginreportsBackground job FAILED TO SCHEDULE — it will never runat error level), but the refusal arrives at boot instead of at parse.
State
Card returns to
pm:queuewith the bound row (Q1) open.⚠️ pm:queuehere does not mean dispatchable — the remaining row waits on a reading this repo cannot produce.
Generated by Claude Code
- ⛔⛔
os-dev-report
{
"issue": 15872,
"status": "done",
"branch": "claude/fix-iana-timezone-changeset-breaking-banner",
"pr": "#16430",
"premise_still_valid": true,
"summary": "Corrected .changeset/platform-iana-timezone-columns.md, the changeset PR #16296 landed on origin/main at 0a038cc, before the next release consumes it. It described an accept-set narrowing (valueDomain: iana_time_zone on sys_job.timezone and sys_report_schedule.timezone) as "A NON-BREAKING ADDITION" and carried zero two-asterisk BREAKING banners; the token occurred only inside those words, with no asterisk prefix, so check-adr-0087-registration.mjs classified the changeset non-breaking. The body now opens with a two-asterisk BREAKING banner in the shape of the in-repo precedent (.changeset/core-plugin-type-closed-set.md at d8024f0), keeps the bump at minor (major is refused repo-wide during the launch window), keeps exactly one ADR-0087 disposition marker with its category unchanged at not-required (no-migration-prescription) and its text no longer opening with the words that caused the miss, and states the consumer delta: which spellings stop being accepted (UTC-offset forms such as UTC+8 / GMT+0800 / +08:00, Windows/CLDR names such as China Standard Time, shape-valid non-existent zones such as Mars/Olympus), that every genuine IANA identifier keeps working including UTC (the Intl.DateTimeFormat probe, not the Intl.supportedValuesOf enumeration that omits UTC, and UTC is sys_report_schedule.timezone own default), and that stored rows are unaffected, quoted from the published contract at packages/spec/src/data/field.zod.ts. The measured analysis the changeset already carried is kept verbatim. One file changed, 46 insertions / 1 deletion, text only. PR is draft and carries skip-changeset (it declares no release of its own; the Check Changeset job counts diff-filter=A under .changeset/*.md and this PR adds no file there).",
"tests": "THE GATE THAT HAD TO CHANGE, node scripts/check-adr-0087-registration.mjs (exit code captured before any pipe: cmd > file 2>&1; EXIT=$?). | BEFORE (same command, changeset unmodified) EXIT=0, printed: "check-adr-0087-registration: this PR adds no declared-breaking changeset (0 non-breaking changeset(s) seen)." | AFTER EXIT=0, printed: "check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition." then the row ".changeset/platform-iana-timezone-columns.md [BREAKING] not-required (no-migration-prescription)". The mandated flip happened. | node scripts/check-changeset-no-major.mjs EXIT=0, printed: "This diff introduces no major bump." plus "LEVEL AXIS: NOT MEASURED - no clause-2 declaration was readable for this PR" (no pull_request payload locally; CI reads it there). NOT MEASURED reported as such, not as a pass. | Direct classification with the gate own exported functions, on the file: BEFORE breaking=false signals=[]; AFTER breaking=true signals=["BREAKING"]. findMigrationPrescription returns null in both, so the no-migration-prescription exemption is not contradicted; readDisposition parses one marker, category no-migration-prescription, justification 1132 chars (40 minimum). Marker count on the file is 1 (the gate refuses two). | DERIVED FAMILY: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (script derives its own change set from the merge base; --repo assertion held: answer from objectstack-ai/objectstack at e09cbfe) => 17 runnable families, 10 by path + 7 declared whole-tree. All 17 run, all EXIT=0: check-adr-0087-registration (--base origin/main and --self-test), check-changeset-no-major (both), check-closing-keyword-parity (both), check-comment-mask-corpus, check-empty-changeset (both), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:driver-memory-census, check:nul-bytes, check:objectui-changeset, check:published-files, check:refd-timer-probe, check:watch-hint-literal. | Four of those first returned PREREQUISITE NOT MET in the fresh worktree (missing yaml / typescript / @typescript-eslint/parser) - read as NOT MEASURED, never as green - and were re-run to a real EXIT=0 after pnpm install (EXIT=0). | Also run because the derivation flags its roster as sitting under .changeset (silence is evidence in neither direction there): node scripts/check-changeset-fixed.mjs EXIT=0, printed ".changeset/config.json fixed group is in sync with 69 public workspace packages." And node scripts/check-partof-closing-keyword.mjs --self-test EXIT=0 (66 cases), relevant because the PR body says Part of #15872 and carries no closing keyword for it or for #16296. | DECLARED NARROWING of pnpm lint, with all three pieces of evidence. (1) Population read from eslint own config: every files entry in eslint.config.mjs names JS/TS extensions (ts,tsx,mts,cts,js,jsx,mjs,cjs) and none names Markdown. (2) Count read from --format json: npx eslint --no-inline-config --format json on the one changed file returns one result, errorCount 0, single message "File ignored because no matching configuration was supplied" - zero rules applied, zero files linted. (3) Invariance: the diff moves no .ts, no eslint config and no input to a type-aware program, so no untouched file verdict can move. CI runs the repo-wide sweep regardless. | BYTES: grep -naP over the control-byte class on the changed file and on the PR body found nothing (scan exit 1). No ablation was performed - nothing in this card is a runtime behaviour claim of mine; the runtime facts quoted in the changeset come from the published contract text and from an Intl.DateTimeFormat membership probe run here (UTC, Asia/Shanghai, Asia/Kolkata, Europe/Kyiv, Etc/GMT-8 accepted; UTC+8, GMT+0800, China Standard Time, Mars/Olympus refused). | Union re-run is on the final commit: every gate above ran at e09cbfe, which is HEAD of the pushed branch (git rev-parse --short HEAD = e09cbfe). No commit was made after the union.",
"mcp_calls": "0 - the whole run used repo-scoped REST (probed first: GET /repos/objectstack-ai/objectstack returned HTTP 200), so no MCP GitHub call was made. gh CLI is absent in this container.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
zhuangjianguo commented
on Sep 7, 2026 CollaboratorAuthorMore actionsThe changeset defect this card shipped is repaired on
origin/main— verified by content. ⛔ This card STAYS OPEN.domain:enginedispatching seat, sessionsession_01ARYe3yQTQCUFm5qPYNgKaJ, 2026-09-07T01:2xZ.What was wrong, and it was mine
#16296 landed with a changeset whose ADR-0087 comment read "A NON-BREAKING ADDITION of one field property to two existing columns". It is not. Declaring
valueDomain: 'iana_time_zone'on two published columns is an accept-set narrowing: a string the shipped build stored without complaint is refused from this release on. I released #16296 on a review that citedAGENTS.md:1021-1022("removes or renames") and never reached the governing text, which is a gate docblock rather thanAGENTS.md—scripts/check-changeset-no-major.mjs:55-66:an accept-set narrowing, or any compile-breaking change to implementers grades
major[at GA]… Until then it is NOT the carrier… 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 disposition… They are not documentation niceties.A changeset is consumed at release, so the defect was live and time-boxed: it would have been read into the published notes.
Repaired, established by content
⛔ Not from the API's
mergedfield.origin/main9ac5eee3e,.changeset/platform-iana-timezone-columns.md:probe before ( 3e7ef9c23)after ( 9ac5eee3e)**BREAKING0 1 NON-BREAKING ADDITION1 0 control — file present 1 1 firing control — valueDomain(predates the fix)4 4 The banner now names the act and its level in the repo's own terms, matching the
d8024f0:.changeset/core-plugin-type-closed-set.mdprecedent:BREAKING accept-set narrowing on two published columns, shipped as
minorunder the repo's launch-window convention for breaking changes. […] During the launch window the bump level is not the carrier of breaking-ness and says nothing about whether a release breaks you; this banner is the carrier, and the ADR-0087 disposition at the foot of this changeset is the other one.⚠️ Note for anyone reading the bump level: it is stillminor, and that is correct — under the launch-window conventionmajoris refused by the gate and the level is deliberately not the carrier. Do not "fix" the level.⛔ Why this card stays open
The remediation PR opened with
Part of #15872, not a closing keyword. The row this card is still open on is unchanged and is a decision, not work: the two columns'maxLengthstill differ (100 vs 64), and converging them narrows a physicalvarchar(100)—driver-sql'snarrow_varchar, severityerror, category destructive ("narrowing may truncate"). What a deployment physically holds is not readable from this repo.pm:queueis retained; ⛔ this seat does not decide that row.⭐ The lesson, recorded because it cost a landed defect
「Verifying the citations you were given is not the same as asking whether stronger text exists.」 The operative policy for changeset carriers lives in
scripts/check-*.mjsdocblocks, not inAGENTS.mdalone. A review that stops at the citations it was handed can be complete and still wrong.
Generated by Claude Code
Cross-reference — #15905 question 3 ruled (director seat, decision batch #64, 2026-09-07)
The E3 standard does not reach a published defect a round noticed but did not cause and does not touch; carding this one (the
sys_job.timezone100 vssys_report_schedule.timezone64 inconsistency, no value domain) was the correct disposition. Whoever takes this card measures the stored values before narrowing either column.
Generated by Claude Code
状态转换:
pm:awaiting-maintainer→needs-user-decision(2026-09-09)维护者回批逐字:「B 桶 · 要你的判断,应转 needs-user-decision —— 23 张 转决策卡」。
判据:欠判断不是动作 —— 公开契约形状。
sys_job.timezone与sys_report_schedule.timezone彼此不一致且都没有校验 —— 平台自己的 IANA 列早于valueDomain存在。统一它们要改已发布的平台对象形状,属协议/公开契约变化(SKILL.md:391),⇒ 恒交维护者。按
SKILL.md:110/:126:决定待做 =needs-user-decision。⇒ 此前为误分类。同笔摘
pm:awaiting-maintainer;domain:engine、enhancement、finding、priority:p2留下。⚠️ 根因见 #17017。
Generated by Claude Code
维护者速读
事情:平台自己的两个时区列(
sys_job.timezone、sys_report_schedule.timezone)原本互不一致且不校验。校验这一半已经落地(#162960a038cc06:两列都加了valueDomain: 'iana_time_zone',写入Mars/Olympus会被拒,带 pin 与 changeset;5570344368 实测);报表调度读列时遇坏值回退到 interval 的问题另有卡 #16291。剩下的是两列的边角差异:长度上限 100 vs 64、默认值无 vs'UTC'——IANA 名最长 30 字符,两个上限都装得下,今天没有任何写入会因此被拒或放过。选项:A(推荐,关卡) 缺陷已修,不再动两列形状;上限差异是惰性的,
'UTC'默认是报表调度读者的既定契约;下次有人碰这两个文件时顺手统一(承接者:无)。B 统一到 #14238 的形状(上限 64、无默认)——改已发布平台对象列,要 BREAKING 标注与 ADR-0087 转换,换零行为变化。C 只统一上限到 64,默认各留。你要做的:回一个字母,A / B / C。
- ① 项目长远合理性:统一形状更整洁,但差异惰性、收益零;A 不增不减。
- ② 实际业务拉动:零——真正的缺陷(不校验)已关;plugin-reports: a non-member
timezonesilently discards a report schedule's cron and fires it on the interval cadence forever — and the create-time guard cannot see it #16291 另行。 - ③ 防 AI 犯错:写坏值今天已被
valueDomain响亮拒绝;上限与默认不改变任何拒收。 - ④ 创业阶段不扩散:A 零工;B 一整套契约变更的机械成本换零收益。
推荐 A(回退 C)。置信缺口:plugin-reports: a non-membertimezonesilently discards a report schedule's cron and fires it on the interval cadence forever — and the create-time guard cannot see it #16291(报表读者对坏值回退到 interval)不在本卡范围,仍开。
裁后执行
- A ⇒ 关 completed,摘
needs-user-decision与finding。 - B / C ⇒
domain:engine回pm:queue,Clause-②: no(收窄),changeset 按 ADR-0087 转换。
总监席第 20 场(
session_01Tep4AYXZvyBA7jsvne5KZV)按决策箱勤务补齐四棱块与速读,2026-09-09T05:1xZ;⛔ 非裁决。
Generated by Claude Code
Ruling recorded — A: the defect is fixed; the two columns' remaining bound/default differences stay; card closes (director seat, summon #20, decision batch #107 item 4, 2026-09-09T05:2xZ)
Provenance (who / verbatim / where): maintainer, live PM chat with this seat (
session_01Tep4AYXZvyBA7jsvne5KZV,os-bill), 2026-09-09T05:2xZ, replying to batch #107 in which this card was item 4 with the four-facet block and 速读 at 5596202106 recommending A. Reply, verbatim: 「其他同意」 — item 4 = A.Ruled. The validation gap this card found is closed on
main: bothsys_job.timezoneandsys_report_schedule.timezonedeclarevalueDomain: 'iana_time_zone'(PR #16296,0a038cc06, with the pinplatform-iana-timezone-columns.test.tsand its changeset; re-verified at 5570344368). The remaining differences —maxLength100 vs 64 and default none vs'UTC'— are inert (no IANA name exceeds 30 characters; the'UTC'default is the report scheduler's declared contract) and are not converged by a contract change (B and C refused). If either file is next touched for another reason, the bound may be aligned to 64 in passing — 承接者:无; not a card.State, one write: closed
completed;needs-user-decisionandfindingremoved;enhancement · priority:p2 · domain:enginekept.
Generated by Claude Code
Found while implementing #14238 (maintainer ruling A:
sys_business_unit.timezoneandsys_organization.timezone, bothtext/ optional /maxLength: 64/valueDomain: 'iana_time_zone'/ no default). Retrofitting the two older columns was explicitly outside that ruling's scope, so it is carded here rather than widened into that PR. Unassigned — for triage.The reading
Measured on
origin/mainat7b6825477, each file individually (a lane-wide grep fortime_?zonereturns 20+ hits and is not a reading about a file):valueDomainsys_job—packages/platform-objects/src/audit/sys-job.object.ts:64Field.text({ required: false, maxLength: 100 })sys_report_schedule—packages/platform-objects/src/audit/sys-report-schedule.object.ts:81Field.text({ required: false, maxLength: 64, defaultValue: 'UTC' })'UTC'grep -c valueDomainin both files = 0. So the card #14238 was raised on — "every app invents the column differently" — is already true inside the platform's own objects, in three dimensions at once (length, default, validation), and since #14168 / #15161 the platform has the one declaration that closes the third dimension and these two columns do not carry it.Consequence
A write of
Mars/Olympus,UTC+8orChina Standard Timeto either column is stored. Whatever reads the column at schedule time (service-job forsys_job, plugin-reports forsys_report_schedule) meets the bad value later, far from its cause. Not measured here: which reader consumes each column and what it does with a non-member (throws, falls back to UTC, or schedules at a wrong instant) — that is this card's first step, and it decides the severity.Published surface (measured, Zone-1 rule)
Both columns are in the built
packages/platform-objects/dist/audit/index.js/index.mjs(timezone×2) anddist/audit/index.d.ts/index.d.mts(×3).content/docs/**mentions of either column'stimezone: 0. So this is a published surface by the dist measurement, and a docs-absent one.Proposed shape (for the implementing seat to verify, not a ruling)
valueDomain: 'iana_time_zone'on both. It is the transition-gate class (min/max/maxLength): only a WRITTEN non-member is refused; stored values are never re-read, so no data migration and no ADR-0087 prescription.America/Argentina/Rio_Gallegos), the longest tzdb link 32 (America/Argentina/ComodRivadavia), and the tzdb caps each path component at 14 — 64 is twice the domain's real ceiling. Narrowing 100 → 64 onsys_jobneeds thecheck-keyed-text-boundsfamily and a look at what the column physically holds.sys_report_schedulesaysUTC,sys_jobsays nothing); keep each as its reader expects unless the reader says otherwise.Both objects are
isSystem; the change widens a declared shape on a published package, so the changeset is at leastminorand the review tier is a contract question for the PM, not for this card.Re-check