Skip to content

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

@zhuangjianguo

Found while implementing #14238 (maintainer ruling A: sys_business_unit.timezone and sys_organization.timezone, both text / 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/main at 7b6825477, each file individually (a lane-wide grep for time_?zone returns 20+ hits and is not a reading about a file):

object declaration bound default valueDomain
sys_job — packages/platform-objects/src/audit/sys-job.object.ts:64 Field.text({ required: false, maxLength: 100 }) 100 none none
sys_report_schedule — packages/platform-objects/src/audit/sys-report-schedule.object.ts:81 Field.text({ required: false, maxLength: 64, defaultValue: 'UTC' }) 64 'UTC' none

grep -c valueDomain in 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+8 or China Standard Time to either column is stored. Whatever reads the column at schedule time (service-job for sys_job, plugin-reports for sys_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) and dist/audit/index.d.ts / index.d.mts (×3). content/docs/** mentions of either column's timezone: 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)

  • Declare 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.
  • Converge the bound on 64, the value 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 chose and justified: the enumeration's longest name on the repo's Node baseline is 30 characters (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 on sys_job needs the check-keyed-text-bounds family and a look at what the column physically holds.
  • The default is a consumer semantic (sys_report_schedule says UTC, sys_job says 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 least minor and the review tier is a contract question for the PM, not for this card.

Re-check

git show origin/main:packages/platform-objects/src/audit/sys-job.object.ts | sed -n '64,69p'
git show origin/main:packages/platform-objects/src/audit/sys-report-schedule.object.ts | sed -n '81,87p'
git show origin/main:packages/platform-objects/src/audit/sys-job.object.ts | grep -c valueDomain              # 0
git show origin/main:packages/platform-objects/src/audit/sys-report-schedule.object.ts | grep -c valueDomain  # 0

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊: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 least minor and 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 on sys_job needs the check-keyed-text-bounds family and a look at what the column physically holds."
      ⚠️ 这与本轮同批路由的 The E3 standard needs three written published boundaries — .js.map sourcesContent, @objectstack/spec’s shipped src/**/*.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

  2. claude commented on Sep 6, 2026

    @claude
    Contributor

    Claim: domain:engine execution seat, session session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15872-timezone-value-domain
    Clause-②: yes

    Base: dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521a (origin/main at claim time), dedicated worktree.

    Clause-②: yes — the diff declares valueDomain on two columns of two isSystem objects in the published package @objectstack/platform-objects, i.e. it changes a declared shape that reaches dist/audit/**. needs:contract-review is 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:

    1. First step FIRST, before any declaration is touched: read what service-job does with sys_job.timezone and what plugin-reports does with sys_report_schedule.timezone when the stored value is not an IANA member. That measurement decides the severity and is reported whatever it says.
    2. valueDomain: 'iana_time_zone' on both columns — the transition-gate class, no data migration.
    3. The 100 -> 64 bound convergence on sys_job as 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.
    4. Defaults are NOT converged (sys_report_schedule keeps 'UTC', sys_job keeps none).

    Note for the PM: the assignees field on this card was empty when this seat arrived (read via REST at claim time). Per the standing contract this seat never writes assignees; recorded here as a PM-side half state, not as permission.


    Generated by Claude Code

  3. claude commented on Sep 6, 2026

    @claude
    Contributor

    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 in nextRunAt: new Cron(cron, { timezone: schedule.timezone || 'UTC' }).nextRun(from).
    • Measured on croner 10.0.1 / Node v22.22.2: a callback-less Cron constructs fine with Mars/Olympus, UTC+8 or China Standard Time and throws only from nextRun() — TypeError: CronDate: Failed to convert date to timezone ....
    • nextRunAt catches it and returns from + interval_minutes, logging ReportService: invalid cron '...'; falling back to interval — naming the cron expression, which was fine, rather than the timezone, which was not.
    • dispatchDue reaches it through advanceSchedule on 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-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 #16291.

    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 and one is zero, so this is a measurement and not a blind spot.
    • The zone the scheduler honours travels in memory instead: toBoundaryJobSchedule to CronJobAdapter.schedule to croner, constructed with a callback, which does throw on a non-member. AppPlugin catches per job: Background job FAILED TO SCHEDULE — it will never run at error level with jobScheduleFailuresTotal; boot continues and the job does not run.
    • DbJobAdapter.schedule awaits the cron adapter before upsertJobRow, 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:p2 and the reading is recorded here rather than on a new card, exactly as that clause directs.

    Assumptions checked

    • 2.1 CONFIRMED. iana_time_zone is a registered member of ValueDomainSchema with the membership predicate isIanaTimeZone, already carried by sys_business_unit.timezone and sys_organization.timezone. Nothing new was created.
    • 2.2 FALSIFIED. No generated baseline, form or snapshot moves. git status is empty after a full workspace build, and check:i18n / check:i18n-stale-fill are green with no regeneration.
    • 2.3 CONFIRMED, with a firing positive control. content/docs/** mentions of either column's timezone: 0. The control — the same search shape over sys_job across the tree — returns hits in content/docs/automation/jobs.mdx and content/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-registration is green with the disposition not-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 in dist/**/*.js and dist/**/*.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

  4. claude commented on Sep 6, 2026

    @claude
    Contributor

    Round record — 2 of 3 dimensions landed, the third deliberately dropped, and the pre-registered escalation FIRED

    domain:engine dispatching seat, 2026-09-06T12:5xZ. PR #16296, head 0491ac9d, Part of #15872 — ⛔ never Fixes. 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 both sys_job.timezone and sys_report_schedule.timezone (write-path refusal, ADR-0114 code value_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 → 64 bound stays OPEN, ⛔ not landed and ⛔ not closed

    The round's measured case for landing it is genuinely strong: timezone is not keyed on sys_job (its only index is {fields:['name'], unique:'global'}), the emitter's rule is keyable = keyed ? keyableTextLength(field) : null so an unkeyed text field is emitted TEXT and maxLength never reaches its DDL, and the drift checker's narrow_varchar branch is gated on isCharacterColumn, 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 a narrow_varchar op at severity: 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-review and the round correctly left it hung on both carriers. --pair 16296 = exit 0, "both carriers agree", checker blob proved identical to origin/main's. This seat's last_served_model is claude-opus-5 and CONTRACT_REVIEW_TIER is claude-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 two valueDomain lines) 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 typecheck exits 0 over a population that EXCLUDES the new test. Its tsconfig excludes **/*.test.ts, so tsc --listFiles counts 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 untouched src/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-debt needs 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 status is empty after a full workspace build, and check:i18n ("9 package(s) — all bundles in sync") and check:i18n-stale-fill are green with no --write.

    Filed rather than folded

    State

    Card returns to pm:queue with the bound row (Q1) open. ⚠️ pm:queue here does not mean dispatchable — the remaining row waits on a reading this repo cannot produce.


    Generated by Claude Code

  5. claude commented on Sep 6, 2026

    @claude
    Contributor

    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

  6. zhuangjianguo commented on Sep 7, 2026

    @zhuangjianguo
    CollaboratorAuthor

    The changeset defect this card shipped is repaired on origin/main — verified by content. ⛔ This card STAYS OPEN.

    domain:engine dispatching seat, session session_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 cited AGENTS.md:1021-1022 ("removes or renames") and never reached the governing text, which is a gate docblock rather than AGENTS.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 merged field. origin/main 9ac5eee3e, .changeset/platform-iana-timezone-columns.md:

    probe before (3e7ef9c23) after (9ac5eee3e)
    **BREAKING 0 1
    NON-BREAKING ADDITION 1 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.md precedent:

    BREAKING accept-set narrowing on two published columns, shipped as minor under 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 still minor, and that is correct — under the launch-window convention major is 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' maxLength still differ (100 vs 64), and converging them narrows a physical varchar(100) — driver-sql's narrow_varchar, severity error, category destructive ("narrowing may truncate"). What a deployment physically holds is not readable from this repo. pm:queue is 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-*.mjs docblocks, not in AGENTS.md alone. A review that stops at the citations it was handed can be complete and still wrong.


    Generated by Claude Code

  7. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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.timezone 100 vs sys_report_schedule.timezone 64 inconsistency, no value domain) was the correct disposition. Whoever takes this card measures the stored values before narrowing either column.


    Generated by Claude Code

  8. huangyiirene commented on Sep 9, 2026

    @huangyiirene
    Collaborator

    状态转换: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

  9. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    维护者速读

    事情:平台自己的两个时区列(sys_job.timezone、sys_report_schedule.timezone)原本互不一致且不校验。校验这一半已经落地(#16296 0a038cc06:两列都加了 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 ⇒ 关 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

  10. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    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: both sys_job.timezone and sys_report_schedule.timezone declare valueDomain: 'iana_time_zone' (PR #16296, 0a038cc06, with the pin platform-iana-timezone-columns.test.ts and its changeset; re-verified at 5570344368). The remaining differences — maxLength 100 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-decision and finding removed; enhancement · priority:p2 · domain:engine kept.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions