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
30 changes: 30 additions & 0 deletions .changeset/16421-clause2-direction-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': patch
---

fix(spec): record the shipped `sys_job` / `sys_report_schedule` IANA narrowing in the ADR-0087 ledger (#16421)

Clause-②: no

`#16296` gave `sys_job.timezone` and `sys_report_schedule.timezone` the
`valueDomain: 'iana_time_zone'` declaration. That is a write-time narrowing — a
string these columns used to accept is now refused with the ADR-0114 field code
`value_domain` — and it shipped with no breaking-change marker at all, so the
repo's own detector classified it non-breaking and asked for no ADR-0087
disposition. Measured on the shipped changeset, not inferred.

The ledger now carries a `semantic` entry for it
(`platform-timezone-columns-iana-domain-refused`, protocol 18). Nothing is
re-released and nothing is ratified in silence: the entry states what narrowed,
the one-line fix per offending row (write the canonical zone id, or clear the
column), and the fact that a stored non-member is still readable and still
returned unchanged — it fails only on the row's next write. For
`sys_report_schedule` that refusal is the point: a non-member zone was silently
discarding the cron expression and falling back to `interval_minutes` forever.

No authorable key, export, config field or stored shape moves, and no DDL is
planned — this is a record of a change that already shipped, published so that
`objectstack migrate meta`'s consumers can read it.

Maintainer ruling, director summon #17, decision batch #2 item 1, option B
(#16421 comment 5572145955, 2026-09-07), quoted verbatim and untranslated: 「同意」.
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1040,8 +1040,8 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
3. **Add a changeset for anything that publishes.** Feature, functional improvement or fix — run `pnpm changeset`
(or add a `.changeset/*.md` entry) describing it before committing. A bug fix in a released package takes a
**`patch`** changeset — never none, and ⛔ never `skip-changeset`: that label is for a diff that publishes
nothing from any released package. A PR that declares `Clause-②: yes` takes at least **`minor`** instead —
the widening it declares is what makes it more than a patch, whatever else the diff fixes.
nothing from any released package. The declaration is `Clause-②: yes|no` plus at most one arm from the closed pair
`(widening)`/`(narrowing)`: `yes` takes at least **`minor`**, `(narrowing)` is BREAKING, `no (widening)` malformed.
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the
Expand All @@ -1051,9 +1051,9 @@ registry? Add it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes
imply **opposite** liveness-ledger dispositions.
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the
changeset body, enforced by `pnpm check:adr-0087-registration` (CI step *Require an ADR-0087 disposition on a
declared-breaking changeset*). ⛔ The categories are NOT copied here — the gate prints the full set when it fails.
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
categories are NOT copied here — the gate prints the full set when it fails.
4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it
does not ship.** The `Console Pin Gate` job builds objectui at the pinned `.objectui-sha` against **current** `main`,
so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'platform-timezone-columns-iana-domain-refused',
surface:
'The two platform audit time-zone columns — `sys_job.timezone` and '
+ '`sys_report_schedule.timezone` — carrying a string that is not a member of the '
+ 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).',
replacement:
'The canonical IANA zone id the deployment meant, written in the spelling the tzdb '
+ 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member '
+ 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the '
+ '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would '
+ 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never '
+ 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only '
+ 'the deployment holds, which is what makes this entry semantic rather than a D2 '
+ 'conversion.',
reason:
'#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared '
+ 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a '
+ 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value '
+ 'already stored outside the domain is never re-read against it, no DDL is planned, '
+ 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped '
+ 'it says so in those words, and this entry does not contradict it. What the '
+ 'changeset had no way to carry is that a deployment holding such a value now has '
+ 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code '
+ '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing '
+ 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to '
+ 'croner, whose throw was caught and turned into a silent fall back to '
+ '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 '
+ 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, '
+ 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own '
+ 'breaking-change detector classified it non-breaking and asked for no ADR-0087 '
+ 'disposition at all — measured on the shipped changeset. #16421 closed that hole '
+ '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose '
+ 'banner) and this row is the other half of the same ruling: the narrowing that '
+ 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. '
+ 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B '
+ '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change '
+ 'no transform can apply are `schedule-flow-acting-organization-required` (protocol '
+ '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a '
+ 'deployment judgement, registered anyway because the prescription is real.',
acceptanceCriteria:
'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the '
+ 'deployment is an IANA member. The one-line fix per offending row: write the '
+ 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the '
+ 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no '
+ 'reader at all. Rows already holding a member parse and behave byte-identically to '
+ 'before; rows holding none are readable, are returned unchanged, and fail only on '
+ 'their next WRITE. A report schedule that was silently running on '
+ '`interval_minutes` resumes its cron cadence once its zone is a member — that '
+ 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two '
+ 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately '
+ 'still unconverged and are NOT part of this entry; no member is longer than 32 '
+ 'characters on the current Node baseline, so neither bound admits anything the '
+ 'domain does not.',
};
55 changes: 55 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9676,6 +9676,61 @@ const step18: MigrationStep = {
+ 'who was previously outside an `assignedProfiles` list and could nonetheless open the page '
+ 'is the pre-existing state, not a regression introduced by the removal.',
},
{
id: 'platform-timezone-columns-iana-domain-refused',
surface:
'The two platform audit time-zone columns — `sys_job.timezone` and '
+ '`sys_report_schedule.timezone` — carrying a string that is not a member of the '
+ 'IANA time-zone database (`Asia/Shangai`, `Europe/Munich`, `UTC+8`, `PST`).',
replacement:
'The canonical IANA zone id the deployment meant, written in the spelling the tzdb '
+ 'uses: `Asia/Shanghai`, `Europe/Berlin`, `America/Los_Angeles`. `UTC` is a member '
+ 'and is admitted — membership is the shared `Intl.DateTimeFormat` probe, never the '
+ '`Intl.supportedValuesOf(\'timeZone\')` enumeration, which omits `UTC` and would '
+ 'refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never '
+ 'repaired on the deployment\'s behalf: the correct zone behind a typo is a fact only '
+ 'the deployment holds, which is what makes this entry semantic rather than a D2 '
+ 'conversion.',
reason:
'#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared '
+ 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a '
+ 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value '
+ 'already stored outside the domain is never re-read against it, no DDL is planned, '
+ 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped '
+ 'it says so in those words, and this entry does not contradict it. What the '
+ 'changeset had no way to carry is that a deployment holding such a value now has '
+ 'WORK TO DO: the next write of that row is refused with the ADR-0114 field code '
+ '`value_domain`, and until then `sys_report_schedule.timezone` keeps doing the thing '
+ 'the narrowing exists to stop — `ReportService.nextRunAt` hands a non-member zone to '
+ 'croner, whose throw was caught and turned into a silent fall back to '
+ '`interval_minutes`, so "every weekday 09:00 Asia/Shanghai" became "every 1440 '
+ 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, '
+ 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own '
+ 'breaking-change detector classified it non-breaking and asked for no ADR-0087 '
+ 'disposition at all — measured on the shipped changeset. #16421 closed that hole '
+ '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose '
+ 'banner) and this row is the other half of the same ruling: the narrowing that '
+ 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. '
+ 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B '
+ '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change '
+ 'no transform can apply are `schedule-flow-acting-organization-required` (protocol '
+ '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a '
+ 'deployment judgement, registered anyway because the prescription is real.',
acceptanceCriteria:
'Every `sys_job.timezone` and `sys_report_schedule.timezone` value stored in the '
+ 'deployment is an IANA member. The one-line fix per offending row: write the '
+ 'canonical zone id (`UPDATE … SET timezone = \'Asia/Shanghai\'`), or clear the '
+ 'column — `sys_report_schedule` documents a `UTC` default and `sys_job` has no '
+ 'reader at all. Rows already holding a member parse and behave byte-identically to '
+ 'before; rows holding none are readable, are returned unchanged, and fail only on '
+ 'their next WRITE. A report schedule that was silently running on '
+ '`interval_minutes` resumes its cron cadence once its zone is a member — that '
+ 'resumption, not the absence of an error, is how the fix is verified. ⚠️ The two '
+ 'columns\' `maxLength` (100 vs 64) and defaults (none vs `UTC`) are deliberately '
+ 'still unconverged and are NOT part of this entry; no member is longer than 32 '
+ 'characters on the current Node baseline, so neither bound admits anything the '
+ 'domain does not.',
},
{
id: 'plugin-auto-restart-never-reinitialised',
surface:
Expand Down
79 changes: 70 additions & 9 deletions scripts/bump-objectui.selftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,37 @@ bad() { echo " ✗ $*" >&2; FAILED=$((FAILED + 1)); }
case_begin() { CASE="$1"; echo " • ${CASE}"; }

# case_5 additionally needs a copy of the digest script `bump-objectui.sh`
# calls and the `isEntrypoint` helper it imports, alongside the script under
# test — mirroring `objectui-changeset-digest.mjs`'s own self-test fixtures
# (which copy the same trio for the same reason).
DIGEST_SCRIPT="${SCRIPT_DIR}/objectui-changeset-digest.mjs"
INVOKED_AS_SCRIPT="${SCRIPT_DIR}/invoked-as.mjs"
# calls, plus every first-party module that script imports, alongside the script
# under test — mirroring `objectui-changeset-digest.mjs`'s own self-test
# fixtures, which stage the same closure for the same reason.
#
# ⭐ The module list is DERIVED, not typed (#16421). It was two names here
# (`objectui-changeset-digest.mjs`, `invoked-as.mjs`) and the same two names in
# three other places; when the digest script gained one import, this file was the
# THIRD staging site to go red, and what a reader saw was not an
# `ERR_MODULE_NOT_FOUND` they could act on: the staged digest died as a
# subprocess, so `range_walkable` never returned one of its two verdicts and
# case 5 failed on the refusal's wording instead. (⚠️ `bump-objectui.sh` itself
# is NOT at fault and is not to be touched for this: it swallows no stderr, and
# its `WALK_RC` branch already separates a verdict from a no-answer and refuses
# to offer `--unshallow` for a crash. See #18354 for what IS carded.) The
# derivation lives in `first-party-closure.mjs`, shared with the JS sites.
#
# ⚠️ THE BASENAME IS SPELLED ALONE AND THE DIRECTORY IS INTERPOLATED ONTO IT —
# the same discipline the declaration above states for CHANGESET_NAME, and for
# the same machine reason: `dispatch-gates` reads a quoted literal carrying a
# separator as a DECLARED WATCHED PATH, so writing the repo-relative path as one
# bare token here gives this gate a path population and CONTRADICTS the
# `no-path-population` marker above. Measured, not theorised —
# `check:pm-dispatch-gates`: "no family both DECLARES no path population and
# names paths anyway (contradicted: check:objectui-bump)". ⛔ Do not tidy this
# back into a single literal.
DIGEST_BASENAME='objectui-changeset-digest.mjs'
REPO_ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIGEST_ENTRY="scripts/${DIGEST_BASENAME}"
# The same script, absolute, for the two cases that run it from THIS checkout
# rather than from a sandbox — those need no staging at all.
DIGEST_SCRIPT="${REPO_ROOT_DIR}/${DIGEST_ENTRY}"

# Case 5's fixture changeset BASENAME — interpolated into a changeset path
# everywhere it is used, never spelled as one bare path literal: that path
Expand Down Expand Up @@ -123,13 +149,48 @@ new_framework() {
}

# Same, plus a byte copy of the digest script `bump-objectui.sh` shells out to
# and the `invoked-as.mjs` helper it imports — needed only by cases that do NOT
# pass `--no-changeset` and so actually reach the changeset section.
# and every first-party module that script imports — needed only by cases that
# do NOT pass `--no-changeset` and so actually reach the changeset section.
#
# ⛔ The closure is written to a file and its exit code read on its own line: a
# `node ... | while read` pipeline would hand this function the exit code of
# `while`, so a derivation that died would stage NOTHING and look like it worked,
# which is the failure shape this whole change exists to remove.
new_framework_with_digest() {
local d="$1" pin="${2-}"
new_framework "$d" "$pin"
cp "$DIGEST_SCRIPT" "${d}/scripts/objectui-changeset-digest.mjs"
cp "$INVOKED_AS_SCRIPT" "${d}/scripts/invoked-as.mjs"
local list="${d}/.closure.txt" rel
# ⚠️ Each path is emitted with its OWN trailing newline, and the loop below
# still reads an unterminated last line. Both halves, because the first time
# this was written with `join('\n')` the final path — `first-party-closure.mjs`
# itself — was silently dropped by `read`, and the sandbox then failed with the
# SAME ERR_MODULE_NOT_FOUND this derivation exists to prevent, from a list that
# had named the file correctly.
node --input-type=module -e "
import { firstPartyModuleClosure } from '${REPO_ROOT_DIR}/scripts/first-party-closure.mjs';
for (const rel of firstPartyModuleClosure('${DIGEST_ENTRY}', { root: '${REPO_ROOT_DIR}' })) console.log(rel);
" > "$list" 2>"${list}.err"
local ec=$?
if [[ $ec -ne 0 ]]; then
printf 'bump-objectui.selftest: could not derive the digest closure (exit %s)\n' "$ec" >&2
cat "${list}.err" >&2
return "$ec"
fi
local staged_count=0
while read -r rel || [[ -n "$rel" ]]; do
[[ -z "$rel" ]] && continue
mkdir -p "${d}/$(dirname "$rel")"
cp "${REPO_ROOT_DIR}/${rel}" "${d}/${rel}"
staged_count=$((staged_count + 1))
done < "$list"
# A derivation that came back short stages a sandbox that looks runnable and
# is not, so the count is ASSERTED here rather than discovered three cases
# later as "the objectui range does not walk".
if [[ "$staged_count" -lt 2 ]]; then
printf 'bump-objectui.selftest: the digest closure staged only %s file(s) — expected the script and its imports\n' "$staged_count" >&2
return 1
fi
rm -f "$list" "${list}.err"
}

# A throwaway objectui repo with a REAL changeset commit — commit A (the
Expand Down
Loading
Loading