Description
Found while implementing catalog instantiation (#5). Not fixed there: there is nothing in that card's file surface to fix — the gap is that no producer exists.
The mapping the product wants
duly_duty.timezone is supposed to be "the user's zone if resolvable, else the org default, else UTC". Measured against @objectstack/spec@17.2.0, @objectstack/platform-objects@17.2.0 and @objectstack/plugin-security@17.2.0, the first two rungs are not reachable, so every duty created by duly_catalog_apply gets UTC.
1. There is no per-user zone
sys_user declares exactly:
name, email, email_verified, two_factor_enabled, role, banned, ban_reason,
ban_expires, failed_login_count, locked_until, password_changed_at,
phone_number, phone_number_verified, must_change_password, mfa_required_at,
last_login_at, last_login_ip, ai_access, image, manager_id,
primary_business_unit_id, source, id, created_at, updated_at
No timezone, no locale. (sys_user_preference is an open key/value bag — its documented examples are theme and locale. Writing a timezone key there would be an application-level convention that no platform surface reads or writes.)
2. The org default is real, but not reachable from an action handler
ExecutionContext.timezone is the resolved tenant zone (localization settings: platform default → global → tenant, ADR-0053 Phase 2). It is not propagated into an action handler's context: buildSession() builds ctx.session as { userId, organizationId, positions, roles, … } and carries no zone, and ActionHandlerContext declares record, params, user, session, engine — none of which exposes it.
So a handler that wants the tenant zone has nowhere honest to read it. A ctx.user.timezone ?? … chain would be a tolerant consumer standing in for a producer that does not exist — it would silently keep returning undefined and nobody would learn the zone was never resolved.
Why it matters here specifically
Periods and due dates are computed in the duty's own zone. A global customer rolling out a catalog to three sites gets all three on UTC, which is a plausible-looking wrong due date rather than a visible failure — the exact shape the engine's refusal-to-guess (see #24) is designed to avoid one layer down.
Not a duplicate of #24
#24 is about validating the string in the field: a typo'd IANA zone saves clean and throws at dispatch. This is about there being no source to populate that field from in the first place. Both are real; fixing either leaves the other.
Options
- A. Add a
timezoneparam toduly_catalog_apply. One zone per apply, chosen by the admin doing the rollout. Smallest change, uses the declared param surface, and matches how rollouts actually happen (a site at a time). Does not model a per-person zone. - B. Application-level user zone — a
duly-owned object or asys_user_preferenceconvention, plus a resolver. Real per-person answer; a new store the platform does not know about, and a place for the value to go stale. - C. Ask the platform to carry
ExecutionContext.timezoneinto the action-handler context, then read the tenant default. Fixes it for every app, not just this one; not a change this repo can make. - D. Leave it at
UTCand document it as a deployment constraint.
Filing unassigned for triage — the choice is a product call.
Affects #20
The PM's assumption when dispatching #5 was that "the user's timezone if resolvable" had somewhere to resolve from. It does not. Whatever option is chosen, #20's deployment docs need to say what a duty's zone will actually be and what an operator has to do to get a non-UTC rollout right.
Where it is today
resolveDutyTimezone() in src/actions/catalog.handlers.ts returns UTC as a named function with this ladder written out, so there is exactly one place to add the rung when a source exists. It is pinned in test/catalog-instantiate.test.ts against duly_duty.timezone's own declared defaultValue, so the two cannot drift into two answers.
Activity
os-warren commentedon Sep 1, 2026
升级给维护者 ·
needs-user-decision— PM 座位,round 1。这是数据模型形状问题,不是实现细节,所以不代裁。一句话问题
duly_duty.timezone决定每一条职责的期间边界和到期日,但没有任何地方能推出这个值——sys_user不声明时区字段,ExecutionContext.timezone也不传进 action handler 的上下文。结果:清单实例化出来的每一条职责都是UTC。为什么这不是小事
Duly 卖给全球客户,"每月 5 号到期"必须回答"谁的 5 号"。今天的行为是静默错误:职责建出来带着 UTC,派发器照 UTC 算期间,到期日对一个在 UTC+8 或 UTC-5 的人可能差一整天——没有报错、没有日志、界面上完全正常。一个跨洲部署的客户会在审计时才发现。
#24(校验时区字符串合法)和这张卡是两件事:#24 管"填进去的值是不是真的 IANA 名",这张卡管"值从哪来"。
选项
sys_business_unit的对象扩展,或 duly 自己的一张映射表),职责从 owner 的单元继承sys_user对象扩展)duly_catalog_item)四维分析
实际业务需求 — 这个产品的目标客户就是多厂区、多站点、常常跨国的制造与公用事业。时区不是边缘情况,是主线。A 的粒度和客户的心智模型一致:时区是场所的属性,不是人的属性——"鹿特丹厂按欧洲时间"是客户会说的话,"张三按欧洲时间"不是。
长远合理性 — A 让时区和
business_unit同源,而business_unit已经是汇总、权限作用域的锚点,等于复用一棵已经在维护的树。B 新开一条几千行的维护面,且和组织树上的事实会各自漂移。防 AI 写错 — 这条分差最大。目录会被表格导入(#19)批量灌进来,也会被 AI 生成。A 下时区不出现在导入面上,生成方根本没机会填错;B 和 C 都要求每一行带一个 IANA 名,而错的名字今天连校验都没有(#24)。
创业阶段不扩散 — A 是一个对象扩展加一个解析函数;D 最省但等于不做;B 最贵。
建议
A,配合 #24 的校验,回退链写死为:owner 的业务单元 → 组织默认 →
UTC,且回落到UTC时在职责上留痕,让"没配"看得见而不是看起来正常。置信缺口(本分析看不见什么):我不知道你的目标客户里有多大比例是单时区的。如果绝大多数客户只有一个时区,那 D 加一个组织级默认就够了,A 是过度设计——这一条只有你能判断。另外我没有验证
sys_business_unit是否允许对象扩展;如果不允许,A 就变成 duly 自己维护一张单元→时区映射表,成本略升但结论不变。裁后执行
选 A:本卡改为「业务单元时区解析」,落
src/objects/+src/actions/catalog.handlers.ts的resolveBusinessUnit()旁边,与 #24 同批。选 D:本卡降为「组织级默认时区设置」,一张小卡。
选 B 或 C:本卡按所选形状重写,并要通知 #19(导入映射要多一列)。
Generated by Claude Code
os-warren commentedon Sep 1, 2026
Decision: parked. Not answering this in the app, and filed upstream instead.
The maintainer's call: do not build a timezone source now; raise it with the platform.
Filed as objectstack-ai/objectstack#14238 — no platform object carries a timezone at all. Measured on a live 17.2.0 boot:
sys_organization,sys_business_unitandsys_usereach scanned for any column matchingtime|zone|locale|lang|region|country|tz, and all three come back empty.That is what makes parking the right answer rather than a deferral. The four options on this card all reduce to "which object do we hang a
duly_-prefixed timezone field on", and the organization hierarchy — the answer I was going to recommend — is precisely where the platform is most likely to grow one. Inventingduly_business_unit_timezonenow buys a few months of correctness and costs a migration later, on the one field that decides which records exist.What remains true while this is parked, stated plainly
Every deployment computes period boundaries and due dates in UTC. For a customer in UTC+8 that means a duty due "the 5th" actually expires at 08:00 local on the 5th, and a weekly period starts at 08:00 Monday — so Monday-morning work before that hour lands in the previous week's task, which may already be closed. The error is silent: nothing reports it, and the numbers look internally consistent.
Correct today for customers on or near UTC. Wrong, in the harsh direction, for Asia-Pacific.
What this does not block
Nothing in M1/M2.
duly_duty.timezonestill exists and is still validated at write time (#24 — a zone the host cannot resolve is refused, so a typo cannot reach the dispatcher); it simply has no source to be populated from, so it stays at itsUTCdefault. When the platform grows a zone on the org hierarchy, the change here is to resolve the default from it — the field, its validation and the period engine's use of it are all already in place and do not move.Relabelling
pm:on-holdwith Restart-when: closed objectstack-ai/objectstack#14238. Removingneeds-user-decision— the decision was made.Generated by Claude Code
os-warren commentedon Oct 26, 2026
Re-asked under the template positioning (duly#127). The 2026-09-02 ruling was "先不考虑时区" for the pre-sales demo; the bar has moved to "a customer installs it and runs it without us", and a multi-site customer's first question is which clock a duty's period runs on.
四维分析(中文)
duly_duty.timezone字段已存在,sys_business_unit的演示数据带 IANA 时区,缺的只是"从哪里解析"。late_after同一类;全局单时区是零成本但在定位文档"全球销售"下是明摆着的缺口。选项:A 声明单时区(文档写明,字段保留但默认 UTC);B 创建时从负责人主业务单元解析,写一次、只读,
duly_catalog_sync作为重算入口(与late_after同规则);C 从sys_user个人时区解析(平台是否有该字段需先测)。推荐 B。 前提(可证伪):
sys_business_unit在 17.3.0 是否有可用的时区字段或可扩展字段,validate是否接受跨对象读。前提不成立 ⇒ 报 fork,回 A 并写明。工作量 B 约 1–2 天。Restart-when (if A is ruled):
docs/deployment/security.mdor README states single-timezone and the field's default; nothing else.Generated by Claude Code