Skip to content

fix(spec)!: a time value carries no zone — ClockTimeValueSchema refuses a Z or an offset, with an ADR-0087 disposition for stored defaults (#20740) - #20763

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-20740-clock-time-zone-less
Sep 30, 2026
Merged

os-justin merged 4 commits into
mainfrom
claude/issue-20740-clock-time-zone-less

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20740

Clause-②: no (narrowing)

ClockTimeValueSchema refuses a Z or a UTC offset. A time field default, a time action-param default or a submitted time action param that carries a zone is now refused when it is authored or submitted, and no longer only when an insert falls back to it. The record validator already refuses such a value on write. The D2 conversion time-default-utc-suffix-dropped drops a Z or a zero offset from a stored time default. A non-zero offset stays as stored and is reported as a TODO. D3 entry: time-default-zone-refused. @objectstack/spec minor, BREAKING.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 13 documentable anchor(s).

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

  • content/docs/data-modeling/object-extensions.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/getting-started/quick-start.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/protocol/kernel/i18n-standard.mdx (via objectExtensions (literal, a string literal in apply))
  • content/docs/protocol/objectql/schema.mdx (via objectExtensions (literal, a string literal in apply))

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

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object timeDefaultUtcSuffixDropped), retiredFromLoadPath (symbol, a field of const object timeDefaultUtcSuffixDropped), objectExtensions (literal, a string literal in apply))

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
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 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 a51920f5fb1059ae6e8c7b1a96aa785f1da5d248 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4fd02f155fc28257680306618cd839fabedcefa3 — the merge of head 674893bd03233a4701d8f6c97177aace8f2eef2c into base a51920f5fb1059ae6e8c7b1a96aa785f1da5d248, 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 4fd02f155fc28257680306618cd839fabedcefa3 && git checkout 4fd02f155fc28257680306618cd839fabedcefa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a51920f5fb1059ae6e8c7b1a96aa785f1da5d248 674893bd03233a4701d8f6c97177aace8f2eef2c && git checkout -B drift-repro a51920f5fb1059ae6e8c7b1a96aa785f1da5d248 && git merge --no-ff 674893bd03233a4701d8f6c97177aace8f2eef2c

node scripts/docs-audit/affected-docs.mjs --json a51920f5fb1059ae6e8c7b1a96aa785f1da5d248

⚠️ 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 a51920f5fb1059ae6e8c7b1a96aa785f1da5d248 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 674893bd03233a4701d8f6c97177aace8f2eef2c
Local-runs: none

Read: card #20740 (body and its four comments — triage 5902560607, claim 5902687569, os-dev-report 5903255874, ruling 5903277309), PR #20763 (body, nine files, the net diff against main at a51920f5fb, merge base 97005aed04), the tree at origin/main and at the head through git show and git grep only, and the head's check-runs last.

① Derived judgments

The accept-set change — right. ClockTimeValueSchema (packages/spec/src/data/field-value.zod.ts:376) loses the optional trailing zone group (Z|[+-]([01]\d|2[0-3]):?[0-5]\d)? and nothing else; the message gains "no time zone". The narrowed string set is the set core's private readsAsWallClock reads (packages/core/src/utils/temporal-comparand.ts:246: two-digit fields, hours 0-23, minutes and seconds 0-59, an optional fraction of one or more digits) — read side by side, the two spellings admit the same strings, which is what the dev measured over 1.6M strings. The record validator's time arm on main (packages/objectql/src/validation/record-validator.ts:1310-1314) refuses a zoned time of day with invalid_time (sentence invalid_time_zoned), so what the spec now refuses at authoring the write door already refused. This is the direction triage asked for, with no second time-of-day regex written.

Readers, each through valueSchemaFor(def, 'stored') (field-value.zod.ts:620-629), verified on main:

  • FieldSchema.defaultValue gate — field.zod.ts:2407 via checkLiteralDefaultValue (default-value-shape.ts:180). Expression envelopes return before it and tokens go to the per-token table, so NOW() and CEL defaults on a time field are untouched, as the changeset says. The refusal names the field and the literal (Field "x" (time): the default "10:00Z" …), which the new pins read ((time), "10:00Z").
  • ActionParamSchema.defaultValue gate — action.zod.ts:508. A field-backed param without its own type returns before the gate (if (!p.type) return), so the conversion skipping such params is the matching move: nothing of that shape fails to parse.
  • validateActionParams — it lives in packages/spec/src/ui/action-params.zod.ts:185-215 (code invalid_shape, message Action param "at" (time): …) and packages/runtime/src/action-execution.ts:1376 calls it; the card's "action-execution.ts" names the caller, not the home. The new submit-door pin calls the spec function directly, which is the function the runtime calls.
  • Two further readers the card did not name, checked and unaffected: import-mapping-target.ts:153 reads only object-shaped schemas (compound parts), and objectql's shapeSchemaFor (record-validator.ts:1393, 1546) is gated to reference, file and structured-JSON types, so a time write never reaches it. The record write path's accept set is unchanged by this PR.

Public surface. The exported ClockTimeValueSchema narrows; ClockTimeValue stays string; no key, export or type moves. No generated artifact on the head still carries the old zone group (git grep for the (Z|[+-]([01] fragment: 0 hits outside CHANGELOGs), and no shipped prose on the head still says a time may carry a zone (0 hits for "optional zone" and variants across content, packages/spec/src, skills, examples, .changeset). The two falsified comments at :83 and :375 are corrected. Shipped time defaults with a zone on main: 0 (grep over packages, examples, skills, apps, content, non-test), which matches the changeset's census sentence.

The D2 conversion time-default-utc-suffix-dropped — right.

  • Recogniser: the suffix matcher /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/ is exactly the old zone group anchored at the end, and the wall-clock half is judged by the narrowed ClockTimeValueSchema itself, so only strings the old stored form admitted are touched: 10:00z and 25:00Z (never admitted) are left alone, which the control pins. A recogniser of the retired shape, not a second time-of-day rule.
  • Zero vs non-zero: /[1-9]/ on the suffix. Z, +00:00, +0000, -00:00, -0000 drop; +08:00, -0530, +00:30, +10:00 become TODOs. Right.
  • Reach against the schema's holders: objects[].fields and objectExtensions[].fields (ObjectExtensionSchema carries fields and refuses actions, so there is no extension action to walk); objects[].actions[].params and actions[].params; and element:button properties.action is the only InlineActionSchema holder (component.zod.ts:2326). BulkActionParamSchema spells default, not defaultValue, and has no valueSchemaFor gate, so it is rightly out of reach. Paths compose as the pins expect (mapCollection yields actions[0]; a nested action gets objects[0].actions[0]).
  • Fixture: 6 notices = two object fields, the object-nested action param, the extension field, the stack action param, the inline action param; the non-zero offset and the text field are untouched. Counted right.
  • Registration: toMajor: 18, retiredFromLoadPath: true, retiredAfter: '17.5.0' (main's packages/spec/package.json is 17.5.0, and two entries on main already carry that label); MAJOR_18_CONVERSIONS order 48 is unused before this diff. ADR-0087's pre-GA rule ("a retiredFromLoadPath conversion when lossless, and one D3 semantic entry per retirement family in every case, in the same release") is the shape the PR ships.

The D3 entry time-default-zone-refused. The entry file and its generated copy in migrations/registry.ts are the same text, and the copy sits in sorted position inside the os-generated semantic:18 region (after tenant-timeouts-unit-in-key, before time-update-interval-sub-day-retired). STEP18_RATIONALE order 50 is unused before this diff. conversionIds links the D2 id.

A stored row with a non-zero offset, followed through the tree.

  • On load: every stored-row read seam calls convertStoredItem (metadata-protocol/src/protocol.ts:4742), which replays the chain with retired entries (stored.ts pins includeRetired: true) and passes only onNotice; the TODO is collected nowhere there, the item is returned byte-identical, and no read seam parses it. So the row loads, silently, with '10:00+08:00' still in it, and each insert that falls back to it is refused invalid_time exactly as on main. A Z or zero-offset default is rewritten on load with the [Protocol] stored object/… carries a pre-protocol shape warning and loads canonical.
  • In os migrate meta --stored: convertStoredItemDetailed collects todos; a row with a TODO and no notice is recorded outcome: 'skipped' with a reason, and formatStoredMigrationReport prints under that row TODO time-default-utc-suffix-dropped: "10:00+08:00" left as stored at objects[0].fields.starts_at.defaultValue — Field "starts_at" is a time with no time zone … (stored-migration.ts:437-441). The changeset's "which os migrate meta --stored lists" and the D3's "listed … as a TODO naming the field or param" hold.
  • At parse: ObjectSchema refuses at fields.starts_at.defaultValue (the new pin), and saveMetaItem parses with the current schema, so a Studio re-save is refused until the value is rewritten. "Fails the schema wherever it is parsed" holds.

Sentences tested; the ones that need a word.

  • Changeset and the STEP18_RATIONALE fragment: "the zone-less wall clock … that the record validator already enforces on write". Over-broad by one clause if read as the validator's accept set: on main the write door also admits a four-digit-year ISO instant on a time field and stores its UTC time of day (temporal-comparand.ts:308), which the stored form never admitted, before or after this PR. True as "the validator already refuses the zone", and that is the sense the sentence is used in. The field-value.test.ts pin title ("as the record validator refuses it") is the exact spelling. There is no moment at which it is false; a precision note, not a defect.
  • Changeset: "Before, it parsed and each insert that fell back to it was refused 400 VALIDATION_FAILED / invalid_time on a field the caller never sent." True on main: applyFieldDefaults (engine.ts:11817) runs before validateRecord (:11896), and the time arm refuses the zoned wall clock.
  • Changeset FROM/TO: 10:00+08:00 is 02:00 UTC — right; the D3's 08:00+08:00 is 00:00 UTC — right.
  • Changeset: "It ships as minor under the launch-window convention" — the clause the pending 20671 changeset carries, and check-changeset-no-major refuses major.
  • Changeset "Unchanged" list — verified above for the token and expression branches; the date and datetime schemas are untouched by the diff.
  • Conversion TSDoc "an author is refused at parse, and data at rest and os migrate meta replay it" — matches applyConversions (retired skipped by default) and the stored and chain paths.
  • PR body: every sentence is a subset of the changeset's and holds at the same moments.
  • Every sentence above is true on main the moment this PR lands; none depends on another PR, with the one label caveat in ③.

② Semver level

Clause-②: no (narrowing)

.changeset/20740-time-value-zone-less.md: @objectstack/spec: minor, the BREAKING banner, Clause-②: no (narrowing), and an adr-0087: registered marker naming exactly the two ids this diff adds (time-default-utc-suffix-dropped, time-default-zone-refused). All nine changed files are @objectstack/spec source, tests or the changeset, so the one package named is the one that publishes. The diff widens nothing: the only schema edit is a regex losing an optional group, and the registry rows are additive ledger entries, not accept-set widenings — so no (narrowing) is the right arm, as the claim declared and as the PR body and changeset spell it. The check-runs holding the changeset gates are green (③).

③ Boundary flags

Dev report 5903255874, each flag:

  • deviations[0] (long PR body): answered — the body at this read is the short form the ruling asked for.
  • deviations[1] (the conversion reaches action params and the inline button action): right, and inside the claimed surface — ActionParamSchema is one of the named readers, and a stored action row with a zoned time param default fails the same parse; ruling 5903277309 accepted it.
  • deviations[2] (commit trailers): the four commits carry the model-free trailer pair AGENTS.md:452-455 prescribes and the PR footer carries the session-URL form; answered.
  • deviations[3] to [5] (no labels, assignee, worktree cleanup): the PR now carries needs:contract-review with the card; nothing owed.
  • open_questions[0] (the 20671 changeset's "each insert that falls back to that default [is] refused"): ruled A, leave it. Judged right here: the sentence stays true for the one case that still reaches an insert (a stored non-zero-offset default), both changesets ship in one release, this PR's own changeset states the before and after, and editing another PR's pending changeset here would trip check-empty-changeset.
  • out_of_scope_findings[0] (core's readsAsWallClock and the spec's ClockTimeValueSchema are two spellings of one set): not a defect of this PR — the dev showed spec cannot import core, and the residual step is core consuming the spec schema, a packages/core edit outside this claim. Escalated to the seat as a filing decision: the triage's one-rule direction is half done, and only a carrier keeps it from being lost.
  • out_of_scope_findings[1] (objectql isZonedTimeOfDay and the conversion's suffix matcher): right that they are neighbours, not one rule — isZonedTimeOfDay only picks a refusal sentence and admits lowercase z and any two digits, while the conversion must match exactly the retired shape and nothing else.
  • The dev's ablation, gate and downstream-test counts are the dev's; not re-run here (read-only). The pins they name are present in the diff.

Seat-owned conditions before enqueue, not defects at this head:

Check-runs on the head, deduped by name keeping the newest started_at (46 runs, 35 names): 30 success, 5 skipped, 0 failed, none still running. Skipped: Auto Label and Check PR Size (their newest runs at 03:09Z are skipped; the earlier 03:01Z runs succeeded), Build Docs, Console Pin Gate and Packed-tarball smoke (opt-in), by path and label filters. Green: Lint & Repo Gates, Check Changeset, Build Core, Test Core (1/6 to 6/6 and the rollup), Type Check (consumer gates, debt ledger, source gates, workspace), TypeScript Type Check, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (1/3 to 3/3 and the rollup), Dogfood Verify CLI, Spec property liveness, Governed Surface Queue Guard, Check Documentation Links, Flag docs affected by code changes, filter, and the four PR-hygiene checks.

Implemented-by: claude/issue-20740-clock-time-zone-less
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T03:33Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.

  • ① precision note, on the changeset's "the zone-less wall clock … that the record validator already enforces on write": kept. What the write door stores for a time field is the zone-less wall clock, because it folds a four-digit-year instant to its time of day before storing. So the sentence is true of the stored form. No new head.
  • ③ out_of_scope_findings[0] (core consuming the spec schema): recorded in ruling 5903277309, not filed. The two spellings admit the same strings today.
  • Landing follows once needs:contract-review is stripped and the checks are green again, after the seat re-reads the Version Packages PR chore: version packages #20639 condition.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Director audit of contract-review record 5903527216 (head 674893bd03) · 2026-09-30T03:47Z

Director seat (objectstack#12708, session_01AsCNgFBs8HCjwhyHQsFbx3), on the maintainer's 「项目总监契约复审」. The record was read against this seat's own reading of the diff, card #20740 and the trees at origin/main: the narrowed regex against core's readsAsWallClock (temporal-comparand.ts:246-250); the three readers through valueSchemaFor(def, 'stored') (default-value-shape.ts:180, action.zod.ts:508, action-params.zod.ts:205); the D2 recogniser (the old zone group anchored at the end, zero vs non-zero by /[1-9]/) and the fixture's six notices; mapPageComponents reaching nested components through mapComponentTree; retiredAfter: '17.5.0' against packages/spec 17.5.0 and its two peers on main; MAJOR_18 order 48 and STEP18_RATIONALE order 50 unused before the diff; the shipped-text census at the head (0 hits for a zone-carrying time outside CHANGELOGs; content/docs/protocol/objectql/types.mdx:468 already says a zone is refused). Concur: PASS at this head. No second record. Clause-②: no (narrowing) is the right arm under AGENTS.md's closed pair (nothing widens; the registry rows are ledger entries).

Two notes, neither moving the head:

  1. The one-rule residue has no carrier. The record's ③ says it plainly — "only a carrier keeps it from being lost" — and then leaves it "recorded in ruling 5903277309, not filed". Two spellings of one set (core's readsAsWallClock, the spec's ClockTimeValueSchema) with no pin between them drift silently on the next edit of either. Before enqueue, the seat either files the carrier card (core consumes the spec schema, or a pin asserting agreement — a packages/core claim) or writes on [finding] spec: ClockTimeValueSchema still admits a zone-suffixed time of day, so a Field.time default of 10:00Z publishes and then fails every insert that falls back to it #20740 that the direction is retired for good. A ruling line is not a carrier; a label is.
  2. The label condition is real. retiredAfter: '17.5.0' and the changeset's disposition are right against main today; if Version Packages chore: version packages #20639 (17.6.0) lands first, both move — the record's own condition, to be re-read at enqueue, as written.

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

Labels

Projects

None yet

2 participants