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
Conversation
…s_organization.timezone (#14238) — wip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…enumeration probe in the pin (#14238) — wip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…g-hierarchy-timezone
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2256ed6235e4ced5340b0ecf007a85862c0b8976 && git checkout 2256ed6235e4ced5340b0ecf007a85862c0b8976
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e75a9040b02b835ecfab00e1af878f5d7d6cf028 d18d078b8d2ea8635475c19c590572bc71fe941f && git checkout -B drift-repro e75a9040b02b835ecfab00e1af878f5d7d6cf028 && git merge --no-ff d18d078b8d2ea8635475c19c590572bc71fe941f
node scripts/docs-audit/affected-docs.mjs --json e75a9040b02b835ecfab00e1af878f5d7d6cf028
|
Closes #14238
Maintainer ruling A (director summon #8, card comment 5507501355, 2026-09-02, verbatim 「同意」) — settled, and not reopened here:
sys_business_unitgains a nullabletimezonethat inherits down theparent_business_unit_idchain;sys_organizationgainstimezoneas that chain's root default; no resolver API (option B waits for a second consumer);sys_useris not the home (option C refused); ADR-0087 registration as a non-breaking addition whose migration note states that existing deployments resolve to UTC until the root default is set; both columns declarevalueDomain: 'iana_time_zone'(the ruling's own precondition — met by #14168 / #15161, re-measured on the base:isValueDomainMember×4 inrecord-validator.ts, liveness rowlive,VALUE_DOMAIN_FIELD_TYPES=['text']).Clause-②: yes— two platform objects widen their declared shape — so this PR is for contract review;needs:contract-reviewis hung on the PR and the card in the same stroke, after this PR exists.The two columns as written
sys_business_unit.timezonepackages/platform-objects/src/identity/sys-business-unit.object.tsField.text({ required: false, maxLength: 64, valueDomain: 'iana_time_zone' }), nodefaultValueparent_business_unit_idwith a value, thensys_organization.timezone, thenUTCsys_organization.timezonepackages/platform-objects/src/identity/sys-organization.object.tsUTCmaxLength: 64, against the two precedents (A3).sys_job.timezonesays 100 with no default;sys_report_schedule.timezonesays 64 withdefaultValue: 'UTC'; neither declares a domain. 64 is the smaller precedent and the one that already pairs a bound with theUTCdefault this contract names — and it is twice the domain's real ceiling: on the repo's Node baseline (v22.22.2) the enumeration's longest name is 30 characters (America/Argentina/Rio_Gallegos), the longest identifier in the tzdb is a 32-character backward link (America/Argentina/ComodRivadavia, which the enumeration omits and the probe admits), and the tzdb caps each path component at 14. The bound is pinned against the enumeration so a future ICU cannot silently outgrow it.No
defaultValue, on either column — and the migration note is literal. On the unit a schema default would mean "stop inheriting", the opposite of what an unset unit means. On the organization, "root default" in the ruling is the root of the inheritance chain, not a schema default: a schema'UTC'would give UTC two spellings (unset on every row that predates the column,'UTC'on rows minted after it) while every reader still has to handle the unset rows. Measured, not forced: better-auth'sorganization/createinserts through the ObjectQL engine (objectql-adapter.ts→dataEngine.insert), so a schema default would reach new rows; it is omitted so the contract has one spelling. Hence the note reads exactly as the ruling requires: existing deployments resolve toUTCuntil the root default is set — every pre-existing row is null in both columns, null on the organization isUTC, null on the unit is inherit. Boot schema-sync provisions both columns additively (initObjectscreates missing columns, never alters existing ones).plugin-auth.
timezonejoinsMANAGED_EXTENSION_FIELDS.sys_organization(the ADR-0105 D7 collision guard now judges it against better-auth's organization schema at the pinned version — that test ran and is green) andMANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization, the tierrequire_mfaand the D6 group-structure fields sit in. Without the second, the ADR-0092 D2 identity write guard strips the key on every user-context write and the root default is a value nobody can set.sys_business_unitismanagedBy: 'platform', so the guard never judges it and it has no entry — pinned as deliberate, not left to read as an omission.A5 — what inherits down
parent_business_unit_idtoday: nothingThe reading, from the tree rather than from the card: three things walk
parent_business_unit_id— plugin-sharing'sBusinessUnitGraphService(business-unit-graph.ts, BFS from a unit to its descendants forunit_and_subordinates;bu-tree-recompute.tsnames the exact columns read:parent_business_unit_id,active,organization_id), plugin-approvals' recursive department approver (approval-service.ts, descendants) and plugin-security's delegated-admin frontier (delegated-admin-gate.ts, descendants). Every one descends; none resolves an attribute upward, and theIBusinessUnitGraphServicecontract (packages/spec/src/contracts/sharing-service.ts) has no ancestor walk. So this card ships two columns, not two columns plus a walk: the inheritance is a documented contract — the resolution order is written on both columns and in the changeset, and both docblocks say that nothing on the platform reads the column yet, so the next author does not read inheritance onto a field that stores what was written. That is the legitimate outcome under the ruling's own "no resolver API".ADR-0087 — how a non-breaking addition is registered here
The ADR-0087 registries have no additive entry kind: their three tables are semantic TODOs, retired keys and retired defs, and
spec-changes.json'sadded[]is the release-time export diff of@objectstack/spec, which platform-object columns are not on. Asemantic/entry would print a spurious TODO to every consumer runningmigrate meta. The registration in writing is therefore the changeset's ADR-0087 disposition marker —not-required (no-migration-prescription), spelled out as a NON-BREAKING ADDITION carrying the UTC migration note — the vocabularycheck-adr-0087-registrationpins to the ADR's addendum. Its verdict on this branch: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". Changeset:@objectstack/platform-objectsminor,@objectstack/plugin-authminor (the act widens a published declared shape and the editable whitelist).Tests
packages/platform-objects/src/identity/org-hierarchy-timezone.test.ts— 7 pins: both columns exist ontext, optional,valueDomain: 'iana_time_zone',maxLength: 64, nodefaultValue, noreadonly; the two are ONE shape; the unit column is in the Hierarchy group; the declared domain admitsUTCandAsia/Shanghaiand refusesMars/Olympusthrough the shared predicate; the bound admits every enumerated zone and the 32-character link.packages/plugins/plugin-auth/src/org-hierarchy-timezone-write-contract.test.ts— drivesvalidateRecordover the two real object definitions:Mars/Olympus,Europe/Munich,UTC+8,China Standard Timerefused on insert AND update withcode: 'VALIDATION_FAILED',name: 'ValidationError'(the two discriminatorsmapDataErrorkeys the 400 on) andfields[0] = { field: 'timezone', code: 'value_domain', constraint: { valueDomain: 'iana_time_zone' } }; the refusal is the domain's, not the bound's;UTCadmitted (the A4 pin), plusEtc/UTC,Asia/Shanghai,Asia/Kolkata,America/Argentina/ComodRivadavia; absent and null left alone on both objects; the identity write guard passestimezoneun-stripped on a user-contextsys_organizationupdate and the column then judges it;slugin the same payload is stripped (control that the guard ran);sys_business_unitneeds no whitelist.managed-extension-fields.test.ts— the declared-AND-editable pin forsys_organization.timezone, beside the D7 collision sweep that now covers it.Checks — all on head
d18d078b8(after mergingorigin/main7b6825477; main touched none of this PR's paths), exit codes captured before any pipe, verdict lines quoted from the gatespnpm --filter '@objectstack/plugin-auth...' build(the closure, on the merged head) —VERDICT command-exit 0.pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 src/identity/org-hierarchy-timezone.test.ts—Tests 7 passed (7);pnpm --filter @objectstack/platform-objects typecheck— exit 0. ⚠ That typecheck is not a measurement of the new test file: the package'stsconfig.jsonexcludes**/*.test.ts(pre-existing, not touched here). Measured separately with a scratch tsconfig that extends the package's and includes the file (--listFilesshows it: 1): 0 errors — it caught and I fixed one real error on the way (Intl.supportedValuesOfis not ontypeof Intlunder the package'slib).pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/org-hierarchy-timezone-write-contract.test.ts src/managed-extension-fields.test.ts src/sys-user-locale-write-contract.test.ts—Tests 83 passed (83);pnpm --filter @objectstack/plugin-auth typecheck— exit 0, includingcheck:test-typecheck: OKwith the debt ledger unchanged (10 files / 94 errors / 23 pinned).node scripts/pm/dispatch-gates.mjson the merged head (11 paths vs merge base7b6825477) and all EXIT 0:check-adr-0087-registration(self-test +--base origin/main),check-changeset-no-major(both),check-empty-changeset(both),check:changeset-gate-self-tests,check:objectui-changeset,check:cross-package-test-inputs,check:doc-authoring,check:org-identifier,check:page-declaration-shape,check:slot-lookup,check:test-source-alias,check:where-matcher,check:objectql-double-limit,check:logger-receiver-detach,check-keyed-text-bounds(both),check-comment-mask-adoption(both),check-registry-log-declared(both),check-plugin-teardown-shape(both),check-system-context-census(both),check-platform-object-tenancy-census(both — "84 platform-namespace objects, 58 in the machinery's reach, 26 outside it"),check-reference-carrier-shape(both),check-ci-filter-parity,check:nul-bytes,check:published-files,check:dts-closure(28 built packages, 100/100),check:type-source-resolution.PREREQUISITE NOT MET(exit 3), declared rather than read as green:check:i18n,check:i18n-walk-parity,check:dual-build-cjs-loadseach read the built CLI and 31 packages this worktree has not built (consoleamong them), which is not a build that fits the foreground cap; CI runs them. Measured equivalent for the bundle-drift half ofcheck:i18n: a freshpnpm i18n:extracton the committed tree changes 0 files.check:i18n-stale-fillOK (10 bundle sets, no new stale fills).check:i18n-coveragenot run — it counts the examples' own declared surface and folds the platform-objects bundle away.pnpm lintnot run: farm-scale, CI-owned; declared as a narrowing without the eslint JSON count, so it is "not run", not a measurement.Generated output
pnpm i18n:extractregenerated the platform-objects bundles (en/zh-CN/ja-JP/es-ESobjects bundles and the three source-hash companions); the four new leaves in the three translated locales were then hand-translated — translated-locale values are hand-written by design — and the companions dropped them from the fill list on the next merge-mode run. Nopackages/specchange, so no spec artifacts; the tenancy census is unchanged.Readings against the dispatch brief's assumptions
2de7e5352) and on the merged head; the last row of A2 was measured per file, not per lane.localization.timezone(service-settings, tenant scope, defaultUTC,valueDomain: 'iana_time_zone'), which analytics buckets dates in today.sys_organization.timezoneis therefore a second tenant-level home; the column's docblock names the distinction and leaves the relation to the future resolver. Not relitigated here.sys_organization's platform-owned columns are engine-writable but no product surface reaches them — the data door answers 405 (apiMethods: ['get', 'list']) and better-auth's update endpoint does not carry them #15873 — the root default is engine-writable but no product surface reaches asys_organizationwrite (apiMethods: ['get', 'list']answers 405 on the data door; better-auth'sorganization/updatecarriesname/slug/logoonly). Every earlier platform-owned column on that object has the same reach; changingapiMethodson an identity table is an ADR-0092 / managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591 decision.Residue, not fixed here
sys_job.timezoneandsys_report_schedule.timezonedisagree with each other and neither is validated — the platform's own IANA columns predatevalueDomain#15872 —sys_job.timezone(100, no default) andsys_report_schedule.timezone(64,UTC), neither validated. Published by the Zone-1 measurement: both are inpackages/platform-objects/dist/audit/index.js/index.mjs(×2) anddist/audit/index.d.ts(×3);content/docsmentions 0. The E3 standard would fix a published-surface defect in-round; the dispatch brief forbids widening into these two under ruling A — the tension is reported for the PM rather than resolved by this seat.sys_organization's platform-owned columns are engine-writable but no product surface reaches them — the data door answers 405 (apiMethods: ['get', 'list']) and better-auth's update endpoint does not carry them #15873 — as above.tsconfig.jsonexcludes**/*.test.ts, so itstypecheckscript never reads the package's tests; the type-check ratchet's ledger owns that.⛔ Draft; not armed, not flipped. Contract review tier.
Generated by Claude Code