Skip to content

Commit 795d254

Browse files
committed
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
1 parent 2de7e53 commit 795d254

7 files changed

Lines changed: 500 additions & 0 deletions

File tree

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.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
const longest = Math.max(...Intl.supportedValuesOf('timeZone').map((z) => z.length));
122+
expect(longest).toBeLessThanOrEqual(unitColumn().maxLength as number);
123+
// The longest identifier in the tzdb itself is a backward link the
124+
// enumeration omits and the probe admits — 32 characters, still under half
125+
// the bound.
126+
const longestLink = 'America/Argentina/ComodRivadavia';
127+
expect(longestLink.length).toBe(32);
128+
expect(isValueDomainMember('iana_time_zone', longestLink)).toBe(true);
129+
expect(longestLink.length).toBeLessThanOrEqual(unitColumn().maxLength as number);
130+
});
131+
});

packages/platform-objects/src/identity/sys-business-unit.object.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,55 @@ export const SysBusinessUnit = ObjectSchema.create({
132132
group: 'Hierarchy',
133133
}),
134134

135+
// #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the
136+
// IANA zone a date boundary ("when does this day / week / period end?") is
137+
// computed in for work that belongs to this unit. A boundary decides WHICH
138+
// record exists, not how one is shown: a monthly duty "due on the 5th"
139+
// expires at midnight, and in UTC+8 that midnight is 08:00 UTC, so work
140+
// finished at 09:00 local on the 5th is recorded late unless the zone is
141+
// known. Nullable — null means INHERIT: the nearest ancestor up the
142+
// `parent_business_unit_id` chain that carries a value, then the
143+
// organization's `sys_organization.timezone` (the chain's root default),
144+
// then `UTC`.
145+
//
146+
// ⚠️ The inheritance is a DOCUMENTED CONTRACT, not a mechanism. Measured on
147+
// the tree while this column landed: nothing on the platform walks this
148+
// chain UPWARD to resolve an attribute. The three existing walkers of
149+
// `parent_business_unit_id` — plugin-sharing's `BusinessUnitGraphService`
150+
// (`business-unit-graph.ts`, the `unit_and_subordinates` subtree walk),
151+
// plugin-approvals' recursive department approver (`approval-service.ts`)
152+
// and plugin-security's delegated-admin frontier (`delegated-admin-gate.ts`)
153+
// — all descend from a unit to its DESCENDANTS and read no column beyond
154+
// the parent link, `active` and `organization_id`. No resolver API ships
155+
// with this column (the ruling: option B, "the effective zone for this
156+
// record", waits for a second consumer), so a reader that needs the
157+
// effective zone walks the chain itself, in exactly the order above, and
158+
// nothing on the platform reads this column yet. Do not read inheritance
159+
// onto a field that does not inherit: the column stores what was written.
160+
//
161+
// Validated on write by `valueDomain: 'iana_time_zone'` (#14168 / #15161 —
162+
// the ruling's own precondition, 「rather than shipping an unvalidated text
163+
// column」): membership is the shared `Intl.DateTimeFormat` probe, never
164+
// the `Intl.supportedValuesOf('timeZone')` enumeration, which omits `UTC`
165+
// — the very fallback this contract names. `maxLength: 64` follows
166+
// `sys_report_schedule.timezone`, the platform's other IANA column that
167+
// pairs a bound with the `UTC` default; the enumeration's longest name on
168+
// the repo's Node baseline is 30 characters and the tzdb caps each path
169+
// component at 14, so 64 is twice the domain's real ceiling and the smaller
170+
// of the two precedents (`sys_job.timezone` says 100, neither declares a
171+
// domain — the residue card). No `defaultValue`, deliberately: an explicit
172+
// default here would mean "stop inheriting", which is the opposite of what
173+
// an unset unit means.
174+
timezone: Field.text({
175+
label: 'Timezone',
176+
required: false,
177+
maxLength: 64,
178+
valueDomain: 'iana_time_zone',
179+
description:
180+
'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.',
181+
group: 'Hierarchy',
182+
}),
183+
135184
organization_id: Field.lookup('sys_organization', {
136185
label: 'Organization',
137186
// Optional: single-tenant deployments have no organization row (org-scoping

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,51 @@ export const SysOrganization = ObjectSchema.create({
220220
description: 'When true, every member of this organization must enroll an authenticator app to access data.',
221221
}),
222222

223+
// #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the
224+
// organization's IANA zone, the ROOT DEFAULT of the business-unit chain. A
225+
// `sys_business_unit` whose `timezone` is null, and whose ancestors' are
226+
// all null, resolves to this value; a null here resolves to `UTC`. So an
227+
// existing deployment computes every date boundary in UTC until an
228+
// administrator sets this column — exactly what it did before the column
229+
// existed — and the changeset's migration note says so in those words.
230+
//
231+
// No `defaultValue`, deliberately: "UTC" has ONE spelling in this contract
232+
// (unset), not two (unset on rows that predate the column, 'UTC' on rows
233+
// minted after it). Not a limitation — better-auth's `organization/create`
234+
// inserts through the ObjectQL engine (`objectql-adapter.ts`), so a schema
235+
// default WOULD reach new rows; it is omitted so that a reader handles one
236+
// shape instead of a pair that means the same thing.
237+
//
238+
// Nothing on the platform reads this column yet, and no resolver ships with
239+
// it (the ruling: option B waits for a second consumer) — the documented
240+
// resolution order lives on `sys_business_unit.timezone`. ⚠️ Distinct from
241+
// the settings door's `localization.timezone` (service-settings, tenant
242+
// scope, default `UTC`), the deployment-wide default analytics buckets
243+
// dates in today; how the two relate is the resolver's question, not this
244+
// column's, and this column does not read or write that setting.
245+
//
246+
// Owned by objectql, not better-auth: an ADR-0105 D7 extension field,
247+
// registered in plugin-auth's `MANAGED_EXTENSION_FIELDS` (whose collision
248+
// guard proves better-auth's organization schema owns no `timezone` at the
249+
// pinned version) and generically editable under the ADR-0092 D2 whitelist
250+
// (`MANAGED_EXTENSION_EDITABLE_FIELDS`) — the path `require_mfa` above and
251+
// the D6 group-structure fields below take. ⚠️ Same shape as
252+
// `sys_business_unit.timezone` by design (`text`, optional, `maxLength: 64`,
253+
// `valueDomain: 'iana_time_zone'`, no default): the card's thesis is that
254+
// every author invents this column differently, and the platform's own two
255+
// precedents (`sys_job`, `sys_report_schedule`) already disagree on length,
256+
// default and validation — the ruled pair is one spelling, pinned in
257+
// `org-hierarchy-timezone.test.ts`.
258+
timezone: Field.text({
259+
label: 'Timezone',
260+
required: false,
261+
maxLength: 64,
262+
valueDomain: 'iana_time_zone',
263+
description:
264+
'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.',
265+
group: 'Configuration',
266+
}),
267+
223268
// ── Group structure (ADR-0105 D6) ────────────────────────────
224269
//
225270
// ⛔ REPORTING DIMENSION ONLY. These fields describe how organizations roll

0 commit comments

Comments
 (0)