Skip to content

A duty's timezone has no source to resolve from — every instantiated duty is UTC #26

Open

Description

@os-warren

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 timezone param to duly_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 a sys_user_preference convention, 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.timezone into 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 UTC and 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

  1. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    升级给维护者 · 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 名",这张卡管"值从哪来"。

    选项

    方案 客户可感知的代价
    A 时区挂在业务单元上(sys_business_unit 的对象扩展,或 duly 自己的一张映射表),职责从 owner 的单元继承 客户按厂区/站点配一次,之后所有人自动正确。配置量 = 单元数(几十),不是人数(几千)
    B 时区挂在人身上(sys_user 对象扩展) 语义最准(出差、远程都对),但要维护几千行;新人入职漏填就回落 UTC,又是静默错
    C 时区挂在岗位清单条目上(duly_catalog_item) 和职责本身同源,实例化时直接抄。但同一岗位跨厂区时就错了
    D 只保留组织级默认值 + 职责上手工覆盖 改动最小;跨时区客户等于手工逐条维护,实际上没解决

    四维分析

    实际业务需求 — 这个产品的目标客户就是多厂区、多站点、常常跨国的制造与公用事业。时区不是边缘情况,是主线。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

  2. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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_unit and sys_user each scanned for any column matching time|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. Inventing duly_business_unit_timezone now 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.timezone still 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 its UTC default. 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-hold with Restart-when: closed objectstack-ai/objectstack#14238. Removing needs-user-decision — the decision was made.


    Generated by Claude Code

  3. os-warren commented on Oct 26, 2026

    @os-warren
    CollaboratorAuthor

    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 同一类;全局单时区是零成本但在定位文档"全球销售"下是明摆着的缺口。
    • 防 AI 写代码犯错:解析来源必须唯一且在创建时固定;运行时每次重算时区是最容易出错的形态(夏令时、迁移人员)。写一次 + readonly 结构上最难写错。
    • 创业阶段不扩散需求:不新增对象,不新增配置面;只是给已有字段一个来源。

    选项: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.md or README states single-timezone and the field's default; nothing else.


    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