Skip to content

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

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-14238-org-hierarchy-timezone
Sep 5, 2026
Merged

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
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-14238-org-hierarchy-timezone

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Closes #14238

Maintainer ruling A (director summon #8, card comment 5507501355, 2026-09-02, verbatim 「同意」) — settled, and not reopened here: sys_business_unit gains a nullable timezone that inherits down the parent_business_unit_id chain; 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); 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 declare valueDomain: 'iana_time_zone' (the ruling's own precondition — met by #14168 / #15161, re-measured on the base: isValueDomainMember ×4 in record-validator.ts, liveness row live, VALUE_DOMAIN_FIELD_TYPES = ['text']). Clause-②: yes — two platform objects widen their declared shape — so this PR is for contract review; needs:contract-review is hung on the PR and the card in the same stroke, after this PR exists.

The two columns as written

column file shape group unset means
sys_business_unit.timezone packages/platform-objects/src/identity/sys-business-unit.object.ts Field.text({ required: false, maxLength: 64, valueDomain: 'iana_time_zone' }), no defaultValue Hierarchy inherit — the nearest ancestor up parent_business_unit_id with a value, then sys_organization.timezone, then UTC
sys_organization.timezone packages/platform-objects/src/identity/sys-organization.object.ts the same shape, byte for byte on every contract key Configuration UTC

maxLength: 64, against the two precedents (A3). sys_job.timezone says 100 with no default; sys_report_schedule.timezone says 64 with defaultValue: 'UTC'; neither declares a domain. 64 is the smaller precedent and the one that already pairs a bound with the UTC default 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's organization/create inserts through the ObjectQL engine (objectql-adapter.tsdataEngine.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 to UTC until the root default is set — every pre-existing row is null in both columns, null on the organization is UTC, null on the unit is inherit. Boot schema-sync provisions both columns additively (initObjects creates missing columns, never alters existing ones).

plugin-auth. timezone joins MANAGED_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) and MANAGED_EXTENSION_EDITABLE_FIELDS.sys_organization, the tier require_mfa and 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_unit is managedBy: '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_id today: nothing

The reading, from the tree rather than from the card: three things walk parent_business_unit_id — plugin-sharing's BusinessUnitGraphService (business-unit-graph.ts, BFS from a unit to its descendants for unit_and_subordinates; bu-tree-recompute.ts names 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 the IBusinessUnitGraphService contract (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's added[] is the release-time export diff of @objectstack/spec, which platform-object columns are not on. A semantic/ entry would print a spurious TODO to every consumer running migrate 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 vocabulary check-adr-0087-registration pins 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-objects minor, @objectstack/plugin-auth minor (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 on text, optional, valueDomain: 'iana_time_zone', maxLength: 64, no defaultValue, no readonly; the two are ONE shape; the unit column is in the Hierarchy group; the declared domain admits UTC and Asia/Shanghai and refuses Mars/Olympus through 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 — drives validateRecord over the two real object definitions: Mars/Olympus, Europe/Munich, UTC+8, China Standard Time refused on insert AND update with code: 'VALIDATION_FAILED', name: 'ValidationError' (the two discriminators mapDataError keys the 400 on) and fields[0] = { field: 'timezone', code: 'value_domain', constraint: { valueDomain: 'iana_time_zone' } }; the refusal is the domain's, not the bound's; UTC admitted (the A4 pin), plus Etc/UTC, Asia/Shanghai, Asia/Kolkata, America/Argentina/ComodRivadavia; absent and null left alone on both objects; the identity write guard passes timezone un-stripped on a user-context sys_organization update and the column then judges it; slug in the same payload is stripped (control that the guard ran); sys_business_unit needs no whitelist.
  • managed-extension-fields.test.ts — the declared-AND-editable pin for sys_organization.timezone, beside the D7 collision sweep that now covers it.

Checks — all on head d18d078b8 (after merging origin/main 7b6825477; main touched none of this PR's paths), exit codes captured before any pipe, verdict lines quoted from the gates

  • pnpm --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.tsTests 7 passed (7); pnpm --filter @objectstack/platform-objects typecheck — exit 0. ⚠ That typecheck is not a measurement of the new test file: the package's tsconfig.json excludes **/*.test.ts (pre-existing, not touched here). Measured separately with a scratch tsconfig that extends the package's and includes the file (--listFiles shows it: 1): 0 errors — it caught and I fixed one real error on the way (Intl.supportedValuesOf is not on typeof Intl under the package's lib).
  • 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.tsTests 83 passed (83); pnpm --filter @objectstack/plugin-auth typecheck — exit 0, including check:test-typecheck: OK with the debt ledger unchanged (10 files / 94 errors / 23 pinned).
  • Derived by node scripts/pm/dispatch-gates.mjs on the merged head (11 paths vs merge base 7b6825477) 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.
  • NOT MEASURED — PREREQUISITE NOT MET (exit 3), declared rather than read as green: check:i18n, check:i18n-walk-parity, check:dual-build-cjs-loads each read the built CLI and 31 packages this worktree has not built (console among them), which is not a build that fits the foreground cap; CI runs them. Measured equivalent for the bundle-drift half of check:i18n: a fresh pnpm i18n:extract on the committed tree changes 0 files. check:i18n-stale-fill OK (10 bundle sets, no new stale fills). check:i18n-coverage not run — it counts the examples' own declared surface and folds the platform-objects bundle away.
  • pnpm lint not 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:extract regenerated the platform-objects bundles (en / zh-CN / ja-JP / es-ES objects 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. No packages/spec change, so no spec artifacts; the tenancy census is unchanged.

Readings against the dispatch brief's assumptions

Residue, not fixed here

⛔ Draft; not armed, not flipped. Contract review tier.


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, touching 6 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/field-types.mdx (via iana_time_zone (literal, a string literal in SysBusinessUnit; a string literal in SysOrganization; a string literal in sys_organization))
  • content/docs/data-modeling/fields.mdx (via iana_time_zone (literal, a string literal in SysBusinessUnit; a string literal in SysOrganization; a string literal in sys_organization))
  • content/docs/data-modeling/validation-rules.mdx (via iana_time_zone (literal, a string literal in SysBusinessUnit; a string literal in SysOrganization; a string literal in sys_organization))
  • content/docs/deployment/seed-tenancy-repair.mdx (via sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/administrator-guide.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authorization.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/delegated-administration.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/permissions-matrix.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/positions.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/sharing-rules.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/kernel/i18n-standard.mdx (via timeZone (literal, a string literal in SysBusinessUnit))
  • content/docs/protocol/objectql/schema.mdx (via sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), iana_time_zone (literal, a string literal in SysBusinessUnit; a string literal in SysOrganization; a string literal in sys_organization))
  • content/docs/protocol/objectql/security.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v13.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v16.mdx (via sys_business_unit (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17.mdx (via sys_organization (symbol, a field of const object MANAGED_EXTENSION_EDITABLE_FIELDS; a field of const object MANAGED_EXTENSION_FIELDS; a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e75a9040b02b835ecfab00e1af878f5d7d6cf028packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2256ed6235e4ced5340b0ecf007a85862c0b8976 — the merge of head d18d078b8d2ea8635475c19c590572bc71fe941f into base e75a9040b02b835ecfab00e1af878f5d7d6cf028, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e75a9040b02b835ecfab00e1af878f5d7d6cf028 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No platform object carries a timezone, so every app that computes a date boundary has to invent one — and each will invent it differently

2 participants