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.
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.timezoneis supposed to be "the user's zone if resolvable, else the org default, elseUTC". Measured against@objectstack/spec@17.2.0,@objectstack/platform-objects@17.2.0and@objectstack/plugin-security@17.2.0, the first two rungs are not reachable, so every duty created byduly_catalog_applygetsUTC.1. There is no per-user zone
sys_userdeclares exactly:No timezone, no locale. (
sys_user_preferenceis an open key/value bag — its documented examples arethemeandlocale. Writing atimezonekey 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.timezoneis 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()buildsctx.sessionas{ userId, organizationId, positions, roles, … }and carries no zone, andActionHandlerContextdeclaresrecord,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 returningundefinedand 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
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.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.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.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()insrc/actions/catalog.handlers.tsreturnsUTCas 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 intest/catalog-instantiate.test.tsagainstduly_duty.timezone's own declareddefaultValue, so the two cannot drift into two answers.