Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/org-hierarchy-timezone-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/platform-objects': minor
'@objectstack/plugin-auth': minor
---

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)

<!-- 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`. -->

Maintainer ruling 2026-09-02 (director summon #8), quoted verbatim and untranslated: 「同意」 — adopting option A on #14238.

**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.

**What lands.**

- `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`.
- `sys_organization.timezone` — the same shape, in the Configuration group: the **root default** of that chain. Null means `UTC`.
- 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.

**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.

**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.

**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.

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Require Multi-Factor Auth",
help: "When true, every member of this organization must enroll an authenticator app to access data."
},
timezone: {
label: "Timezone",
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."
},
parent_organization_id: {
label: "Parent Organization",
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
Expand Down Expand Up @@ -857,6 +861,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Parent Business Unit",
help: "Self-reference for the org tree. Null = root of tenant."
},
timezone: {
label: "Timezone",
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."
},
organization_id: {
label: "Organization",
help: "Tenant scope. Null in single-tenant; auto-stamped in multi-tenant."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Exigir autenticación multifactor",
help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos."
},
timezone: {
label: "Zona horaria",
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."
},
parent_organization_id: {
label: "Parent Organization",
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
Expand Down Expand Up @@ -857,6 +861,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Unidad de negocio principal",
help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant."
},
timezone: {
label: "Zona horaria",
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."
},
organization_id: {
label: "Organización",
help: "Ámbito del tenant."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "多要素認証を必須化",
help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。"
},
timezone: {
label: "タイムゾーン",
help: "この組織で日付の境界を計算する IANA タイムゾーン(例: UTC、Asia/Shanghai)。独自のタイムゾーンを持たないすべてのビジネスユニットが継承するルートの既定値です。未設定の場合は UTC です。"
},
parent_organization_id: {
label: "Parent Organization",
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
Expand Down Expand Up @@ -857,6 +861,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "親ビジネスユニット",
help: "組織ツリーの自己参照。null = テナントのルート。"
},
timezone: {
label: "タイムゾーン",
help: "このユニットで日付の境界を計算する IANA タイムゾーン(例: Asia/Shanghai)。未設定の場合は親ビジネスユニット、次に組織のタイムゾーン、最後に UTC を継承します。"
},
organization_id: {
label: "組織",
help: "テナントスコープ。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "强制多因素认证",
help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。"
},
timezone: {
label: "时区",
help: "该组织计算日期边界所用的 IANA 时区(如 UTC、Asia/Shanghai)——所有未自行设置时区的业务单元都继承的根默认值。未设置表示 UTC。"
},
parent_organization_id: {
label: "Parent Organization",
help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)."
Expand Down Expand Up @@ -857,6 +861,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "上级业务单元",
help: "组织树的自关联字段。Null 表示租户根节点。"
},
timezone: {
label: "时区",
help: "该业务单元计算日期边界所用的 IANA 时区(如 Asia/Shanghai)。留空则依次继承上级业务单元的时区、组织的时区,最后为 UTC。"
},
organization_id: {
label: "组织",
help: "租户范围。"
Expand Down
134 changes: 134 additions & 0 deletions packages/platform-objects/src/identity/org-hierarchy-timezone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14238 — the organization hierarchy carries the IANA zone a date boundary is
* computed in. Maintainer ruling 2026-09-02 (option A, verbatim 「同意」):
* `sys_business_unit` gains a nullable `timezone` that inherits down the
* `parent_business_unit_id` chain, and `sys_organization` gains `timezone` as
* that chain's root default. No resolver API (option B waits for a second
* consumer); `sys_user` is not the home (option C refused).
*
* This file pins the DECLARED shape, in three parts:
*
* 1. both columns exist, on `text`, optional, and declare
* `valueDomain: 'iana_time_zone'` — the ruling's own precondition: 「if not,
* the engine seat sequences this card behind it rather than shipping an
* unvalidated text column」. A column that lost the declaration would be
* exactly the shape the ruling refused, and every other assertion here
* would still pass over it;
* 2. the two columns are ONE shape — same type, optionality, bound and domain,
* no default on either. The card's thesis is that every author invents this
* column differently, and the platform's own two precedents
* (`sys_job.timezone`: 100, no default; `sys_report_schedule.timezone`: 64,
* default `UTC`; neither validated) already disagree in three dimensions.
* The ruled pair must not become a third and a fourth spelling;
* 3. the declared domain admits `UTC`, the fallback the contract names for a
* wholly unset chain, and the declared bound admits every zone the runtime
* enumerates. Why the first is not automatic — `Intl.supportedValuesOf`
* omits `UTC`, so a column judged against the enumeration would refuse the
* platform's own default — is measured and pinned beside the predicate in
* `packages/spec` (`value-domain.test.ts`), once; this file asks the
* predicate the column actually inherits and does not re-implement it.
*
* The write-path half — a non-member WRITTEN to either column is refused with
* the ADR-0114 code `value_domain` — lives in plugin-auth's
* `org-hierarchy-timezone-write-contract.test.ts`, the one package that depends
* on both the columns and the evaluator.
*/

import { describe, it, expect } from 'vitest';
import { isValueDomainMember } from '@objectstack/spec/shared';
import { SysBusinessUnit } from './sys-business-unit.object';
import { SysOrganization } from './sys-organization.object';

type ColumnShape = {
type?: unknown;
required?: unknown;
maxLength?: unknown;
valueDomain?: unknown;
defaultValue?: unknown;
readonly?: unknown;
group?: unknown;
};

const unitColumn = () => (SysBusinessUnit.fields as Record<string, ColumnShape>).timezone;
const orgColumn = () => (SysOrganization.fields as Record<string, ColumnShape>).timezone;

/** The keys on which the two ruled columns must agree — "one spelling". */
const SHAPE_KEYS = ['type', 'required', 'maxLength', 'valueDomain', 'defaultValue', 'readonly'] as const;

describe('#14238 — sys_business_unit.timezone and sys_organization.timezone', () => {
it('reads the real declarations, not an empty probe', () => {
// Vacuity control: a renamed column or a changed export would otherwise let
// every assertion below pass over `undefined`.
expect(SysBusinessUnit.name).toBe('sys_business_unit');
expect(SysOrganization.name).toBe('sys_organization');
expect(unitColumn()).toBeTypeOf('object');
expect(orgColumn()).toBeTypeOf('object');
});

it.each([
['sys_business_unit', unitColumn],
['sys_organization', orgColumn],
])('%s.timezone is an optional, domain-validated, bounded text column', (_object, column) => {
const c = column();
expect(c.type).toBe('text');
// Nullable, by the ruling's word: on the unit "null" means INHERIT, on the
// organization it means UTC. `required: true` would make both meanings
// unreachable.
expect(c.required).toBe(false);
// The ruling's precondition — the one line that turns an unvalidated text
// column into a validated one. `VALUE_DOMAIN_FIELD_TYPES` is `{text}`, so
// the declaration is also the reason the type above must stay `text`.
expect(c.valueDomain).toBe('iana_time_zone');
expect(c.maxLength).toBe(64);
// No schema default on either column, deliberately: on the unit a default
// would mean "stop inheriting"; on the organization it would give UTC two
// spellings (unset on rows that predate the column, 'UTC' on rows minted
// after it). The contract has one: unset resolves to UTC.
expect('defaultValue' in c).toBe(false);
// `stripReadonlyFields` runs on the update path BEFORE the validator, so a
// readonly column is one an administrator could never set — and the root
// default is, by the ruling's word, a value an administrator sets.
expect(c.readonly ?? false).toBe(false);
});

it('the two columns are ONE shape — the platform does not invent it twice', () => {
const pick = (c: ColumnShape) => Object.fromEntries(SHAPE_KEYS.map((k) => [k, c[k]]));
expect(pick(unitColumn())).toEqual(pick(orgColumn()));
});

it('the unit column lives in the Hierarchy group — it is resolved along the hierarchy', () => {
expect(unitColumn().group).toBe('Hierarchy');
});

it('the declared domain admits the fallback the contract names (`UTC`) and refuses a zone that does not exist', () => {
// Asked of the predicate the write path calls (`isValueDomainMember`), under
// the domain the column actually declares — not of a re-implementation.
const domain = unitColumn().valueDomain as 'iana_time_zone';
expect(isValueDomainMember(domain, 'UTC')).toBe(true);
expect(isValueDomainMember(domain, 'Asia/Shanghai')).toBe(true);
// Shape-valid and nonexistent — the case a `pattern` cannot refuse.
expect(isValueDomainMember(domain, 'Mars/Olympus')).toBe(false);
});

it('the declared bound admits every zone the runtime enumerates, with room for the tzdb links it omits', () => {
// A sourced bound, not an alignment convenience: the enumeration's longest
// name on the repo's Node baseline is 30 characters and the tzdb caps each
// path component at 14, so 64 is twice the domain's real ceiling. If a
// future ICU ever enumerates a name the bound refuses, this goes red
// instead of the column silently refusing a legal zone.
// `Intl.supportedValuesOf` is ES2022; the package's `lib` predates it, so
// the call is typed here rather than the whole program's lib widened.
const intl = Intl as unknown as { supportedValuesOf(key: 'timeZone'): string[] };
const longest = Math.max(...intl.supportedValuesOf('timeZone').map((z) => z.length));
expect(longest).toBeLessThanOrEqual(unitColumn().maxLength as number);
// The longest identifier in the tzdb itself is a backward link the
// enumeration omits and the probe admits — 32 characters, still under half
// the bound.
const longestLink = 'America/Argentina/ComodRivadavia';
expect(longestLink.length).toBe(32);
expect(isValueDomainMember('iana_time_zone', longestLink)).toBe(true);
expect(longestLink.length).toBeLessThanOrEqual(unitColumn().maxLength as number);
});
});
Loading
Loading