Skip to content

Commit 6acb37e

Browse files
feat(platform-objects,plugin-auth): sys_business_unit.timezone and sys_organization.timezone — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238) (#15874)
* feat(platform-objects,plugin-auth): sys_business_unit.timezone and sys_organization.timezone (#14238) — wip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * feat(platform-objects): translate the two timezone columns; type the enumeration probe in the pin (#14238) — wip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99a5bc6 commit 6acb37e

11 files changed

Lines changed: 535 additions & 0 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
feat(platform-objects,plugin-auth): `sys_business_unit.timezone` and `sys_organization.timezone` — the organization hierarchy carries the IANA zone a date boundary is computed in (#14238)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) A NON-BREAKING ADDITION, registered here in writing because ADR-0087's registries have no additive entry kind (their three tables are semantic TODOs, retired keys and retired defs, and `spec-changes.json`'s `added[]` is the release-time export diff of `@objectstack/spec`, which platform-object columns are not on). Two nullable `text` columns are added to two `isSystem` platform objects; no metadata key, export, config field or stored shape is renamed, retired, re-typed or tombstoned, so `objectstack migrate meta` has nothing to rewrite and no consumer has to change anything. The physical columns are provisioned by boot schema-sync, which is additive-only (`initObjects` creates missing columns and never alters existing ones). MIGRATION NOTE, as the ruling requires it stated: existing deployments resolve to UTC until the root default is set — every pre-existing row reads null in both columns, null on `sys_organization.timezone` means UTC, and null on `sys_business_unit.timezone` means inherit (parent chain, then the organization, then UTC), so a deployment computes every date boundary in UTC after upgrading exactly as it did before, until an administrator sets `sys_organization.timezone`. -->
9+
10+
Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238.
11+
12+
**The gap.** No platform object carried a timezone, so every application that has to answer "when does this day / week / period end?" invented a column of its own — on its tenant object, its team object or its user — and two apps in one deployment would disagree about when Tuesday ended, with nothing to report. A date boundary decides *which record exists*, not how one is shown: a monthly duty "due on the 5th" expires at midnight, and in UTC+8 that midnight is 08:00 UTC.
13+
14+
**What lands.**
15+
16+
- `sys_business_unit.timezone` — `text`, optional, `maxLength: 64`, `valueDomain: 'iana_time_zone'`, no default, in the Hierarchy group. Null means **inherit**: the nearest ancestor up the `parent_business_unit_id` chain that carries a value, then `sys_organization.timezone`, then `UTC`.
17+
- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`.
18+
- plugin-auth registers `sys_organization.timezone` as an ADR-0105 D7 extension field (the collision guard proves better-auth's organization schema owns no `timezone` at the pinned version) and as generically editable under the ADR-0092 D2 identity write guard — the same tier as `require_mfa` and the group-structure fields. A root default the guard stripped on every administrator write would be a column nobody can set. `sys_business_unit` is `managedBy: 'platform'` and needs no entry.
19+
20+
**The inheritance is a documented contract, not a mechanism.** Measured on the tree: nothing on the platform walks `parent_business_unit_id` *upward* to resolve an attribute. The three existing walkers (plugin-sharing's business-unit graph, plugin-approvals' recursive department approver, plugin-security's delegated-admin frontier) all descend to a unit's *descendants* and read no column beyond the parent link, `active` and `organization_id`. **No resolver API ships with this change** — the ruling holds option B ("the effective zone for this record") for a second consumer — so an application resolving a boundary reads the columns and walks the chain itself, in the order above. Nothing on the platform reads either column yet; both docblocks say so, so the next author does not read inheritance onto a field that stores what was written.
21+
22+
**Validated on write.** Both columns declare `valueDomain: 'iana_time_zone'` — the ruling's own precondition (「rather than shipping an unvalidated text column」), met now that the record validator reads the key (#14168 / #15161). A non-member written to either column (`Mars/Olympus`, `Europe/Munich`, `UTC+8`) is refused with the ADR-0114 field code `value_domain` and `constraint.valueDomain`; membership is the shared `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC` — the very fallback this contract names. `UTC` is admitted, and pinned.
23+
24+
**One shape, on purpose.** The platform's own two earlier IANA columns disagree with each other — `sys_job.timezone` (`maxLength: 100`, no default) and `sys_report_schedule.timezone` (`maxLength: 64`, default `UTC`), neither validated. The ruled pair takes 64 (the smaller precedent, and twice the domain's real ceiling: the enumeration's longest name on the repo's Node baseline is 30 characters, the longest tzdb link 32) and no schema default on either column (a default on the unit would mean "stop inheriting"; one on the organization would give UTC two spellings). Those two precedent columns are not retrofitted here — outside the ruling's scope, carded separately.
25+
26+
**Not the home.** `sys_user` (option C): two people in different zones owning work in the same period would compute different boundaries for what the business considers one period. A per-user zone is a display preference on top of an org-resolved boundary, not a substitute for it. This change is distinct from the settings door's `localization.timezone` (the deployment-wide default analytics buckets dates in today); how the two relate is the future resolver's question.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
534534
label: "Require Multi-Factor Auth",
535535
help: "When true, every member of this organization must enroll an authenticator app to access data."
536536
},
537+
timezone: {
538+
label: "Timezone",
539+
help: "IANA time zone (e.g. UTC, Asia/Shanghai) date boundaries are computed in for this organization — the root default every business unit without a zone of its own inherits. Unset means UTC."
540+
},
537541
parent_organization_id: {
538542
label: "Parent Organization",
539543
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
@@ -857,6 +861,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
857861
label: "Parent Business Unit",
858862
help: "Self-reference for the org tree. Null = root of tenant."
859863
},
864+
timezone: {
865+
label: "Timezone",
866+
help: "IANA time zone (e.g. Asia/Shanghai) date boundaries are computed in for this unit. Leave unset to inherit the parent unit's zone, then the organization's, then UTC."
867+
},
860868
organization_id: {
861869
label: "Organization",
862870
help: "Tenant scope. Null in single-tenant; auto-stamped in multi-tenant."

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
534534
label: "Exigir autenticación multifactor",
535535
help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos."
536536
},
537+
timezone: {
538+
label: "Zona horaria",
539+
help: "Zona horaria IANA (p. ej. UTC, Asia/Shanghai) en la que se calculan los límites de fecha de esta organización: el valor raíz que hereda toda unidad de negocio sin zona propia. Sin definir significa UTC."
540+
},
537541
parent_organization_id: {
538542
label: "Parent Organization",
539543
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
@@ -857,6 +861,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
857861
label: "Unidad de negocio principal",
858862
help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant."
859863
},
864+
timezone: {
865+
label: "Zona horaria",
866+
help: "Zona horaria IANA (p. ej. Asia/Shanghai) en la que se calculan los límites de fecha de esta unidad. Déjala sin definir para heredar la zona de la unidad de negocio principal, luego la de la organización y, por último, UTC."
867+
},
860868
organization_id: {
861869
label: "Organización",
862870
help: "Ámbito del tenant."

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
534534
label: "多要素認証を必須化",
535535
help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。"
536536
},
537+
timezone: {
538+
label: "タイムゾーン",
539+
help: "この組織で日付の境界を計算する IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないすべてのビジネスユニットが継承するルートの既定値です。未設定の場合は UTC です。"
540+
},
537541
parent_organization_id: {
538542
label: "Parent Organization",
539543
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
@@ -857,6 +861,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
857861
label: "親ビジネスユニット",
858862
help: "組織ツリーの自己参照。null = テナントのルート。"
859863
},
864+
timezone: {
865+
label: "タイムゾーン",
866+
help: "このユニットで日付の境界を計算する IANA タイムゾーン(例: Asia/Shanghai)。未設定の場合は親ビジネスユニット、次に組織のタイムゾーン、最後に UTC を継承します。"
867+
},
860868
organization_id: {
861869
label: "組織",
862870
help: "テナントスコープ。"

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
534534
label: "强制多因素认证",
535535
help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。"
536536
},
537+
timezone: {
538+
label: "时区",
539+
help: "该组织计算日期边界所用的 IANA 时区(如 UTC、Asia/Shanghai)——所有未自行设置时区的业务单元都继承的根默认值。未设置表示 UTC。"
540+
},
537541
parent_organization_id: {
538542
label: "Parent Organization",
539543
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
@@ -857,6 +861,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
857861
label: "上级业务单元",
858862
help: "组织树的自关联字段。Null 表示租户根节点。"
859863
},
864+
timezone: {
865+
label: "时区",
866+
help: "该业务单元计算日期边界所用的 IANA 时区(如 Asia/Shanghai)。留空则依次继承上级业务单元的时区、组织的时区,最后为 UTC。"
867+
},
860868
organization_id: {
861869
label: "组织",
862870
help: "租户范围。"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #14238 — the organization hierarchy carries the IANA zone a date boundary is
5+
* computed in. Maintainer ruling 2026-09-02 (option A, verbatim 「同意」):
6+
* `sys_business_unit` gains a nullable `timezone` that inherits down the
7+
* `parent_business_unit_id` chain, and `sys_organization` gains `timezone` as
8+
* that chain's root default. No resolver API (option B waits for a second
9+
* consumer); `sys_user` is not the home (option C refused).
10+
*
11+
* This file pins the DECLARED shape, in three parts:
12+
*
13+
* 1. both columns exist, on `text`, optional, and declare
14+
* `valueDomain: 'iana_time_zone'` — the ruling's own precondition: 「if not,
15+
* the engine seat sequences this card behind it rather than shipping an
16+
* unvalidated text column」. A column that lost the declaration would be
17+
* exactly the shape the ruling refused, and every other assertion here
18+
* would still pass over it;
19+
* 2. the two columns are ONE shape — same type, optionality, bound and domain,
20+
* no default on either. The card's thesis is that every author invents this
21+
* column differently, and the platform's own two precedents
22+
* (`sys_job.timezone`: 100, no default; `sys_report_schedule.timezone`: 64,
23+
* default `UTC`; neither validated) already disagree in three dimensions.
24+
* The ruled pair must not become a third and a fourth spelling;
25+
* 3. the declared domain admits `UTC`, the fallback the contract names for a
26+
* wholly unset chain, and the declared bound admits every zone the runtime
27+
* enumerates. Why the first is not automatic — `Intl.supportedValuesOf`
28+
* omits `UTC`, so a column judged against the enumeration would refuse the
29+
* platform's own default — is measured and pinned beside the predicate in
30+
* `packages/spec` (`value-domain.test.ts`), once; this file asks the
31+
* predicate the column actually inherits and does not re-implement it.
32+
*
33+
* The write-path half — a non-member WRITTEN to either column is refused with
34+
* the ADR-0114 code `value_domain` — lives in plugin-auth's
35+
* `org-hierarchy-timezone-write-contract.test.ts`, the one package that depends
36+
* on both the columns and the evaluator.
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
import { isValueDomainMember } from '@objectstack/spec/shared';
41+
import { SysBusinessUnit } from './sys-business-unit.object';
42+
import { SysOrganization } from './sys-organization.object';
43+
44+
type ColumnShape = {
45+
type?: unknown;
46+
required?: unknown;
47+
maxLength?: unknown;
48+
valueDomain?: unknown;
49+
defaultValue?: unknown;
50+
readonly?: unknown;
51+
group?: unknown;
52+
};
53+
54+
const unitColumn = () => (SysBusinessUnit.fields as Record<string, ColumnShape>).timezone;
55+
const orgColumn = () => (SysOrganization.fields as Record<string, ColumnShape>).timezone;
56+
57+
/** The keys on which the two ruled columns must agree — "one spelling". */
58+
const SHAPE_KEYS = ['type', 'required', 'maxLength', 'valueDomain', 'defaultValue', 'readonly'] as const;
59+
60+
describe('#14238 — sys_business_unit.timezone and sys_organization.timezone', () => {
61+
it('reads the real declarations, not an empty probe', () => {
62+
// Vacuity control: a renamed column or a changed export would otherwise let
63+
// every assertion below pass over `undefined`.
64+
expect(SysBusinessUnit.name).toBe('sys_business_unit');
65+
expect(SysOrganization.name).toBe('sys_organization');
66+
expect(unitColumn()).toBeTypeOf('object');
67+
expect(orgColumn()).toBeTypeOf('object');
68+
});
69+
70+
it.each([
71+
['sys_business_unit', unitColumn],
72+
['sys_organization', orgColumn],
73+
])('%s.timezone is an optional, domain-validated, bounded text column', (_object, column) => {
74+
const c = column();
75+
expect(c.type).toBe('text');
76+
// Nullable, by the ruling's word: on the unit "null" means INHERIT, on the
77+
// organization it means UTC. `required: true` would make both meanings
78+
// unreachable.
79+
expect(c.required).toBe(false);
80+
// The ruling's precondition — the one line that turns an unvalidated text
81+
// column into a validated one. `VALUE_DOMAIN_FIELD_TYPES` is `{text}`, so
82+
// the declaration is also the reason the type above must stay `text`.
83+
expect(c.valueDomain).toBe('iana_time_zone');
84+
expect(c.maxLength).toBe(64);
85+
// No schema default on either column, deliberately: on the unit a default
86+
// would mean "stop inheriting"; on the organization it would give UTC two
87+
// spellings (unset on rows that predate the column, 'UTC' on rows minted
88+
// after it). The contract has one: unset resolves to UTC.
89+
expect('defaultValue' in c).toBe(false);
90+
// `stripReadonlyFields` runs on the update path BEFORE the validator, so a
91+
// readonly column is one an administrator could never set — and the root
92+
// default is, by the ruling's word, a value an administrator sets.
93+
expect(c.readonly ?? false).toBe(false);
94+
});
95+
96+
it('the two columns are ONE shape — the platform does not invent it twice', () => {
97+
const pick = (c: ColumnShape) => Object.fromEntries(SHAPE_KEYS.map((k) => [k, c[k]]));
98+
expect(pick(unitColumn())).toEqual(pick(orgColumn()));
99+
});
100+
101+
it('the unit column lives in the Hierarchy group — it is resolved along the hierarchy', () => {
102+
expect(unitColumn().group).toBe('Hierarchy');
103+
});
104+
105+
it('the declared domain admits the fallback the contract names (`UTC`) and refuses a zone that does not exist', () => {
106+
// Asked of the predicate the write path calls (`isValueDomainMember`), under
107+
// the domain the column actually declares — not of a re-implementation.
108+
const domain = unitColumn().valueDomain as 'iana_time_zone';
109+
expect(isValueDomainMember(domain, 'UTC')).toBe(true);
110+
expect(isValueDomainMember(domain, 'Asia/Shanghai')).toBe(true);
111+
// Shape-valid and nonexistent — the case a `pattern` cannot refuse.
112+
expect(isValueDomainMember(domain, 'Mars/Olympus')).toBe(false);
113+
});
114+
115+
it('the declared bound admits every zone the runtime enumerates, with room for the tzdb links it omits', () => {
116+
// A sourced bound, not an alignment convenience: the enumeration's longest
117+
// name on the repo's Node baseline is 30 characters and the tzdb caps each
118+
// path component at 14, so 64 is twice the domain's real ceiling. If a
119+
// future ICU ever enumerates a name the bound refuses, this goes red
120+
// instead of the column silently refusing a legal zone.
121+
// `Intl.supportedValuesOf` is ES2022; the package's `lib` predates it, so
122+
// the call is typed here rather than the whole program's lib widened.
123+
const intl = Intl as unknown as { supportedValuesOf(key: 'timeZone'): string[] };
124+
const longest = Math.max(...intl.supportedValuesOf('timeZone').map((z) => z.length));
125+
expect(longest).toBeLessThanOrEqual(unitColumn().maxLength as number);
126+
// The longest identifier in the tzdb itself is a backward link the
127+
// enumeration omits and the probe admits — 32 characters, still under half
128+
// the bound.
129+
const longestLink = 'America/Argentina/ComodRivadavia';
130+
expect(longestLink.length).toBe(32);
131+
expect(isValueDomainMember('iana_time_zone', longestLink)).toBe(true);
132+
expect(longestLink.length).toBeLessThanOrEqual(unitColumn().maxLength as number);
133+
});
134+
});

0 commit comments

Comments
 (0)