Skip to content

fix(objectql)!: a time field is a zone-less wall clock — a zone-suffixed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (#20671) - #20721

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20671-time-write-arm-core-rule
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20671-time-write-arm-core-rule

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20671

Clause-②: no (narrowing)

The record validator's time arm now judges a written value by @objectstack/core's one temporal rule, isUninterpretableTemporalComparand('time', value), the rule the time comparand door asks since PR #20668. That is how #20525 moved the date / datetime arm. A time field is a zone-less wall clock (triage 5895825766): a time of day with a Z or an offset is refused with VALIDATION_FAILED / 400, field code invalid_time, and a sentence that says what to do. An extended-year instant is refused too. Nothing reaches a driver, so it is never a 500. The unanchored hasDate test is gone.

Base fa0a4b661 (this branch's merge base). Head 9b426f8ab.

Reproduced first, then after

POST /api/v1/data/:object then a read-back through POST /api/v1/data/:object/query. The process ran in TZ=America/New_York. PostgreSQL 16.13 was a private server at Asia/Shanghai. Memory is RestServer over InMemoryDriver, from a scratch probe that was not committed. The card's table reproduced on every cell.

written to a time memory, base SQLite, base PostgreSQL, base head, all three
"+010000-01-01T10:00:00Z" (the card) 201, read back verbatim 201, verbatim 500 DATABASE_ERROR 400 invalid_time
"9999-12-31T23:00:00-02:00" (UTC year 10000) 201, verbatim 201, verbatim 500 400 invalid_time
"10:00Z" (the card) 201, "10:00Z" 201, "10:00Z" 201, "10:00:00" 400, the zone sentence
"10:00+08:00", "10:00:00+0800" 201, verbatim 201, verbatim 201, "10:00:00" 400, the zone sentence
"10:00:00.250Z" 201, verbatim 201, verbatim 201, "10:00:00.250" 400, the zone sentence
"2026-07-15 10:00Z" (a space and a zone) 201, "10:00:00" the same the same 400 invalid_time
"10:00", "10:00:00" (the controls) 201, "10:00:00" the same the same unchanged
"10:00:00.250" 201, "10:00:00.250" the same the same unchanged
"2026-07-15T10:00:00Z", "2026-07-15T18:00:00+08:00", "2026-07-15 10:00" 201, "10:00:00" the same the same unchanged
"07/15/2026 10:00", "x2026-07-15T10:00:00Z", "{now}", the number 36000000 400 invalid_time the same the same unchanged
" " (blank) 201, null the same the same unchanged

The zone sentence, in English: "Slot is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant". Every other refusal keeps the existing "must be a valid time (HH:MM or HH:MM:SS)".

The change

  • packages/objectql/src/validation/record-validator.ts, the time arm:
    • the verdict is readable && !isUninterpretableTemporalComparand(t, value), the date / datetime arm's line;
    • readable holds the write door to what the comparand door exempts on purpose. A number stays refused as a written time (a comparand may be epoch milliseconds), and a {placeholder} stays refused (it is filter vocabulary, judged by classifyFilterToken from @objectstack/spec/data). A blank is missing before the arm, as before;
    • the private timeOfDay / hasDate patterns are deleted;
    • a private isZonedTimeOfDay chooses the sentence, never the verdict: a time of day plus Z / z / an offset whose wall-clock half core's rule reads. So "25:00Z" gets the plain sentence.
  • packages/spec/src/system/validation-message.ts: one message key, invalid_time_zoned, in en / zh-CN / ja-JP / es-ES. It is a rendering variant of the existing wire code invalid_time, which does not change. See the scope section for why it is here.
  • content/docs/protocol/objectql/types.mdx: the time input sentence said "with an optional fractional part and Z/offset". It now says no zone, and that an epoch number is refused. The number was already refused at base: 36000000 answered 400 on all three.

PM hypotheses, which held

  • H1 held. At fa0a4b661 core's predicate refuses "10:00Z", "10:00+08:00", "+010000-01-01T10:00:00Z" and "9999-12-31T23:00:00-02:00", measured on core's dist. The arm asks it. The one addition is the write door's type gate above. The predicate answers false for a number, a {placeholder} and a blank, which are comparand exemptions, and the old arm refused the first two as written values.
  • H2 held. A full ISO instant with a four-digit year is admitted and stores its UTC time of day (ADR-0053 D-C1: "A Date / epoch-ms / full-timestamp value folds to its UTC time-of-day"). It is pinned as a control on the engine, on REST over SQLite and PostgreSQL, and on the memory driver. No needs_decision is raised on it.
  • H3 held. The wire code stays invalid_time. fail(code, constraint, messageKey) goes to buildFieldError, then to renderValidationMessage(messageKey), and that reads BUILTIN_VALIDATION_MESSAGES in packages/spec. So the prescription has to live there. Details are in the scope section.
  • H4: not a clean reuse. The seat answered it in-seat as A (5899587971, by ADR-0104 D1): a row already stored with a zone-suffixed time keeps its value, with no value-shapes report, as PRs fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) #20524 / fix(objectql)!: a temporal string is written on a real calendar day, and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date (#20525) #20547 did for date / datetime. Measured:
    • (a) valueShapeViolation has one caller, the scan (scan-value-shapes.ts:155). The write path does not call it. Its sibling isScannableValueShapeField IS on the write path: ObjectQL.objectHasCoveredValueField decides from it whether an object reads the adr-0104-value-shapes flag and passes valueShapeStrict to the validator. Adding time there changes no time verdict, because the arm reads no strictness flag. It does make every object whose only covered field is a time read the flag, and it makes the boot line announce a warn mode that does not govern time.
    • (b) ADR-0104 D1 defines what a passed flag means: "no stored value of the covered classes fails valueSchemaFor(field, 'stored')", and "the covered classes are exactly the validator's own non-media branch — REFERENCE_VALUE_TYPES … and STRUCTURED_JSON_TYPES". Covering time changes that fact. Every deployment that already holds the flag, including every fresh datastore that attests it at creation, would never re-run the scan, so its rows would not be reported. The findings would also block a gate whose strictness the time arm never reads. And the spec's valueSchemaFor(time) itself admits "10:00Z" (measured true), so the scan could not reuse its own predicate for this.
    • Nothing is rewritten, as triage requires. The options and the four-axis analysis are in the os-dev-report on record validator: a time field written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671.
  • H5 held. No driver changes. A refused value never reaches a driver: the recording-driver pin shows zero writes, and the REST pin counts zero writes.

Scope: two packages/spec edits, one kept and one reverted

The claim's file surface did not name packages/spec. Both edits are explained here, as the claim asks for a breach.

Kept: packages/spec/src/system/validation-message.ts, the invalid_time_zoned key. The card needs it. Triage rules that a suffix "is refused with a prescription: drop the suffix, or use a datetime field for an instant". A refusal's sentence can only come from that catalog. Measured on spec's dist: renderValidationMessage({ messageKey: 'invalid_time_zoned_absent_probe', label: 'Slot' }) renders "Slot (invalid_time_zoned_absent_probe)", the resolution order's step 4, a coding-error fallback. With the key it renders the sentence above, and in zh-CN it renders "时段是不带时区的时刻:…". The spec test "every locale defines every message key" makes all four locales required, and it passes: en / zh-CN / ja-JP / es-ES each have 38 keys. The key does not widen a published type or export:

  • the declared type of BUILTIN_VALIDATION_MESSAGES does not change: a record of locale to a record of message key to template;
  • no export is added: check:api-surface answers "@objectstack/spec public API surface + factory signatures unchanged ✓";
  • FieldErrorCode does not change;
  • check-widening-tells --declaration no judged validation-message.ts against its declared surface and found no widening tell.

What a deployment gains is one more translation key it may override, validation.field.invalid_time_zoned. @objectstack/spec publishes dist (files[]), and the key ships in 4 dist files, next to invalid_datetime as a positive control. So the changeset lists @objectstack/spec: patch.

Reverted: ClockTimeValueSchema in packages/spec/src/data/field-value.zod.ts. Commit 691bfabd6 narrowed it to refuse a zone, and b5d95181d reverts it with a normal revert commit. The arm stands without it. With the spec schema left wider, at 9b426f8ab:

  • spec: 575 files, 16960 tests;
  • objectql: 336 files, 6679 tests;
  • rest, with live PostgreSQL: 228 files, 4420 passed / 22 skipped;
  • driver-memory: 63 files, 1451 tests;
  • runtime action-params-enforcement.test.ts: 5 / 5;
  • dogfood field-zoo-value-shape.test.ts: 45 / 45.

All passed. No parity pin or gate reds on the difference. What the wider schema leaves open is reported to the seat as a finding rather than fixed here:

  • FieldSchema accepts Field.time with defaultValue: '10:00Z';
  • with this PR, each engine.insert that falls back to that default is refused, 400 invalid_time, on a field the caller never sent (measured on a596fad76);
  • the action-param door (validateActionParams, strict under ADR-0104 D2) still admits '10:00Z' for a time param (measured []).

The readers of ClockTimeValueSchema are all through valueSchemaFor: checkLiteralDefaultValue (the FieldSchema.defaultValue gate and the action-param defaultValue gate), validateActionParams (runtime action-execution.ts:1376), and import-mapping-target.ts. The last reads only object-shaped schemas, so time never reaches it. The objectql scan's shapeSchemaFor never sees time. Metadata shipped in this repo authors no zoned time value:

  • 0 zoned time-of-day literals in examples, packages/platform-objects, packages/create-objectstack and skills;
  • positive control: 6 plain wall-clock literals in examples;
  • the population is 4 time field declarations in examples and 1 in skills, and none carries a defaultValue.

The commit 691bfabd6 stays on this branch as a ready reference for the spec seat, with its pins.

Tests

  • packages/objectql/src/engine-time-write-zone-less.test.ts (new, 5 tests, recording driver).
    • 9 zoned, 7 unread-instant and 9 already-refused values, each on insert, update and a multi-row update, and through engine.validate. Each asserts code VALIDATION_FAILED, fields exactly slot / invalid_time, and zero driver writes.
    • The sentence is asserted by the catalog key the refusal renders: the zone key for the 9, the plain key for the rest. The words themselves are not pinned.
    • The positive control has 12 values, a Date among them, and each reaches the driver as written.
    • A one-rule corpus pin: a string is refused as a written time exactly when core refuses it as a time comparand, except {now}. The number is asserted as the other write-only refusal.
  • packages/objectql/src/validation/record-validator.test.ts, one pin flipped. '14:30:00Z' and '08:15:00+02:00' were pinned as accepted; they are now refused with invalid_time and the zone sentence. That keeps a load-bearing assertion of the new rule.
  • packages/rest/src/data-temporal-write-real-day-iso.test.ts, a new it on the SQLite cell and the live PostgreSQL cell.
    • The card's values are 400 on create and on PATCH, with no write.
    • "10:00", "10:00:00", "10:00:00.250" and the two full instants read back identically.
  • packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts (new, 2 tests). Under America/New_York, each spelling the door admits is stored as its wall clock and found by it.

Reverse verification. The fix was committed first. scripts/ablation-replace.mjs replaced the arm's readable line with one that admits every string and Date. The anchor went 1 → 0 and the blob eb565fe32fe5 → 2b0d369b76c9. objectql was rebuilt, and ablation-dist-preflight found the marker in 4 built files.

  • objectql, the 2 files: 11 failed / 106 passed. The new file's 4 refusal tests went red and its positive control stayed green. The flipped pins went red too.
  • REST: 2 failed / 10 passed. The [#20671] it went red on SQLite and on live PostgreSQL, and every other it stayed green.
  • Restore leg: blob == HEAD and git diff HEAD is empty. After a rebuild, the preflight found the marker absent from all 14 built files and the tree clean. objectql went 117 / 117 and REST 12 / 12, both [#20671] cells included.

Verification at 9b426f8ab

  • The suite counts in the scope section above.
  • typecheck exit 0 for spec, objectql, rest and driver-memory.
    • The test-typecheck ledgers held: spec 53 files / 251 errors, objectql 40 / 234, rest 0.
    • --listFiles lists the new objectql test and the REST file. driver-memory's tsconfig.json includes src/**/*.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 110 commands. 107 exited 0.
    • check:skill-examples first exited 3 because the client packages had no dist. It was re-run, exit 0, after building them.
    • --ran reads "110 derived famil(ies) accounted for — 107 run, 3 NOT-MEASURED".
    • check:api-surface answered "unchanged ✓", and check:docs "226 generated files in sync".
    • check:nul-bytes scanned 9333 files and found no raw control bytes. check:driver-conformance reads 50 covered cells, 0 DEBT.
    • check-adr-0087-registration reads the changeset as "BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", exit 0. check-changeset-no-major and check-empty-changeset exited 0.
  • Lint, narrowed and declared (the repo-wide pnpm lint is CI's). eslint --no-inline-config --format json over the 6 changed .ts files: 6 files, 0 errors, 0 warnings.
    • Population: eslint.config.mjs's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} and packages/** objects cover all 6.
    • Invariance: --print-config shows no parserOptions.project or projectService on any of them. Type-aware linting is off, so this diff cannot move a verdict on an untouched file.

NOT MEASURED:

  • check:dual-build-cjs-loads and check:type-check-debt exited 3, PREREQUISITE NOT MET: no whole-workspace dist. The container restarted twice during this run, so a whole-workspace build was not attempted.
    • Scoped reading: the CJS entries load: @objectstack/objectql . has 178 exports and ./core 52, @objectstack/spec/system 400 and @objectstack/spec/data 528.
    • The four changed packages typecheck, as above.
  • check:query-options-erasure: it was killed with the container (exit 137) after its self-test passed, and it was not re-run. CI's Lint & Repo Gates runs it.
  • MySQL, turso and MongoDB: not provisioned. The refusal sits in the engine, in front of every driver.

Acceptance notes (not filed)

  • /import: measured after the change on all three backends.
    • 10:00Z and 10:00+08:00 time cells are per-row refusals, from the import's own reader, before this door. They were refused there before this PR too: parseDateCell runs first and never hands this arm a suffix.
    • An offset-bearing instant cell 9999-12-31T23:00:00-02:00 is stored as 01:00:00, its UTC clock, while the write door refuses the same string. The import converts before the door, both answers can be defended, and this PR leaves it as it was.
  • An Invalid Date is still admitted by all three temporal arms, as before. Only an engine caller can send one (JSON cannot carry one), so no public door reaches it.
  • The changeset's "Who is affected" states the narrowing, including a zone-suffixed literal defaultValue on a time field.

Generated by Claude Code

… is refused in its own sentence

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…t this card's surface

This reverts commit 691bfab. The write
arm stands without it; the spec narrowing is reported as a finding for
the spec lane instead.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via invalid_time (literal, a string literal in validateOne))
  • content/docs/protocol/objectql/types.mdx (via invalid_time (literal, a string literal in validateOne))
What this run could not see
  • 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 — 138 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 cbaf04c1fd7a595236701bea02280c0f8b34e16e → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 cbaf04c1fd7a595236701bea02280c0f8b34e16e → 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: 9b426f8abf8b3618231e6a9e1dd7d27a9b723c81
Local-runs: none

PR #20721 on card #20671. The head was confirmed unmoved at the sha above (branch claude/issue-20671-time-write-arm-core-rule, merge base with origin/main fa0a4b661). Inputs: the card body and its five comments (triage 5895825766, claim 5897560982, os-dev-report 5899533181, seat answer 5899587971, claim amendment 5899626916), the PR body and file list, the net diff against main, and the head's check-runs. Read-only throughout: git reads of the fetched branch and of origin/main, REST reads of the board; nothing built, run or re-run.

① Derived judgments

Each accept-set and public-surface change the diff implies, judged against triage 5895825766, ADR-0053 D-C1 and core's isUninterpretableTemporalComparand on origin/main.

  1. RIGHT — the time arm asks core's one rule. record-validator.ts now judges a written time by readable and not isUninterpretableTemporalComparand('time', value), the same line the date / datetime arm uses on main (lines 1215-1264, the record validator: the temporal write arms trust Date.parse — date 2026-02-30 is stored verbatim (500 on PostgreSQL), datetime 2026-02-30T10:00:00Z rolls over to March 2, and a non-ISO datetime is read in the host zone #20525 shape triage named). The private timeOfDay pattern and the unanchored hasDate test are deleted, as triage directed.
  2. RIGHT — what is refused now. A time of day with Z / z or a ±HH:MM / ±HHMM offset (10:00Z, 10:00+08:00, 10:00:00+0800, 10:00:00.250Z); an instant whose UTC year has no four-digit spelling (+010000-01-01T10:00:00Z, 9999-12-31T23:00:00-02:00, -000001-01-01T10:00:00Z, and a Date of one); an impossible day (2026-02-30T10:00:00Z); a spelling outside core's ISO datetime form (2026-07-15 10:00Z, lower-case t / z). Core's readsAsWallClock regex admits no suffix, and keepsTimeOfDay is false wherever the datetime rule spells the UTC year outside four digits, so each is refused with VALIDATION_FAILED / invalid_time before any driver write. D-C1: "the canonical text carries no zone". Triage: "a time field is a zone-less wall clock", "an extended-year instant is not a time of day".
  3. RIGHT — what stays admitted. A bare wall clock HH:MM[:SS[.fraction]] in range (10:00, 10:00:00, 10:00:00.250, 10:00 trimmed); a full ISO instant with a four-digit UTC year, including the zone-naive YYYY-MM-DDTHH:MM and YYYY-MM-DD HH:MM read as UTC (2026-07-15T10:00:00Z, 2026-07-15T18:00:00+08:00); a valid Date with a four-digit UTC year. Each folds to its UTC time of day, D-C1's own sentence ("A Date / epoch-ms / full-timestamp value folds to its UTC time-of-day"). Pinned on insert, update, multi-row update and engine.validate (objectql, recording driver, zero writes), on REST create and PATCH over SQLite and a live PostgreSQL cell, and on the memory driver under America/New_York.
  4. RIGHT — the write-door type gate. readable is value instanceof Date, or a string classifyFilterToken reads as no placeholder. A number stays refused: core exempts a finite number as an epoch-millisecond comparand, the base arm refused it, and the date / datetime arm takes the same stance (the stored form is text). A {placeholder} stays refused: core steps around it as the resolver's vocabulary, so without the gate {now} would have been admitted. A boolean, object or array stays refused as at base. A blank is missing before the arm (isMissing trims), as at base. An Invalid Date stays admitted as at base and as on the other two arms: core leaves it unjudged, and only an engine caller can send one — carried, not this card's.
  5. RIGHT — the sentence, and the wire code. fail('invalid_time', undefined, key) keeps code: 'invalid_time'; the third argument is buildFieldError's messageKey, which renderValidationMessage reads from BUILTIN_VALIDATION_MESSAGES and which the returned FieldValidationError (field, code, message, label, optional constraint / value / options) never carries. isZonedTimeOfDay picks invalid_time_zoned only for a zone-suffixed time of day whose wall-clock half core reads, so 25:00Z gets the plain sentence; it chooses no verdict. FieldErrorCode is untouched. The four locale strings carry no tracker number and each states the prescription triage ruled: drop the suffix, or use a datetime field for an instant.
  6. RIGHT — no packages/spec schema moves. The net diff touches packages/spec/src/system/validation-message.ts only (one key, four locales). 691bfabd6 narrowed ClockTimeValueSchema (field-value.zod.ts +13/-4, plus pins in two spec test files and two lines in the arm) and b5d95181d is its exact inverse (the same four files, 5 insertions / 28 deletions against 28 / 5), a normal revert whose message names the reverted sha. Neither field-value.zod.ts nor those tests are in the PR's file list. The catalog key widens no published type or export: BUILTIN_VALIDATION_MESSAGES keeps its declared type (a record of locale to a record of message key to template), no export is added, FieldErrorCode is unchanged, and check:api-surface reports unchanged on the head (Type Check · consumer gates, success). What a deployment gains is one override hook, validation.field.invalid_time_zoned, on an existing wire code: a rendering variant, not a payload key.
  7. RIGHT — no driver change. No driver source is in the diff; the driver-memory file is a test. The refusal precedes every driver, and the PR's no-write pins hold that on the recording driver and through REST.

The seat answer 5899587971, judged. ADR-0104 D1 on origin/main (lines 761-767) reads verbatim: "The fact strict enforcement needs is: no stored value of the covered classes fails valueSchemaFor(field, 'stored'). The covered classes are exactly the validator's own non-media branch — REFERENCE_VALUE_TYPES … and STRUCTURED_JSON_TYPES", and its gate description adds "The scanner and the validator share one predicate." The answer quotes the text correctly. The measurements it rests on hold on origin/main: valueShapeViolation's one caller is scan-value-shapes.ts:155; isScannableValueShapeField is on the write path through ObjectQL.objectHasCoveredValueField (engine.ts:9408-9413); ClockTimeValueSchema's regex carries an optional zone group, so valueSchemaFor(time) admits 10:00Z. A time report through os migrate value-shapes would therefore either amend D1's "exactly" or run a second predicate the ADR rules out, and Prime Directive 13 puts an ADR amendment with the maintainer. Option A is the one answer inside the accepted ADR, and it is the #20524 / #20547 precedent for date / datetime. The PR's behaviour matches A: no scan change, no rewrite, the changeset states that a stored row keeps its value and that a re-sent one is refused naming the field. Triage's report-half wording is not implemented; the seat wrote that triage and the maintainer keep the veto, and this record makes the departure visible.

PR body, sentence by sentence where a fact can be checked, the seat-edited first line and H4 bullet included. Fixes #20671 is consistent with the seat's A and with the passing closing-target check. Base and head shas: true. "the rule the time comparand door asks since PR #20668": true (2473e2687 moved temporal-comparand.ts and the door). "That is how #20525 moved the date / datetime arm": true. The H4 bullet's (a) and (b): true, as measured above. The revert pair: true. The spec locale-parity test exists (validation-message.test.ts:34). Test counts: true (5 tests; 9 zoned, 7 unread-instant, 9 already-refused, 12 accepted; one pin flipped; one it per REST cell; 2 memory tests). Suite counts, dist measurements, the ablation legs and the live-PostgreSQL readings are the dev's, coherent with the diff and not re-run here. One coverage fact for the reader: the only CI job that sets OS_TEST_POSTGRES_URL is Temporal Conformance, and it runs driver-sql, core, formula, driver-memory, driver-mongodb, service-analytics and metadata-protocol's live files, not the rest package; so the REST PostgreSQL cell of the new it is exercised by the dev's local run alone, while its SQLite cell runs in Test Core. That is the file's existing dialect-axis contract (the sibling [#20549] it has the same shape), a repo-wide gap and not this PR's.

Shipped prose, two imprecisions that are not defects. types.mdx says "a full ISO 8601 timestamp with a four-digit year folds" where the rule is the UTC year (9999-12-31T23:00:00-02:00 is spelled with four digits and refused). The changeset's "only a memory or SQLite deployment can hold one" reaches past the measured dialects (MySQL, turso and MongoDB were not provisioned). The error-catalog.mdx line the drift check flagged stays true.

② Semver level

  • .changeset/20671-time-write-zone-less.md grades @objectstack/objectql: minor with a BREAKING banner and the adr-0087 HTML-comment marker reading not-required (no-migration-prescription) — RIGHT under the launch-window convention (check-changeset-no-major refuses major until GA; breaking-ness rides the banner plus the marker). The category is in the gate's set (the [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992 changeset uses it) and Check Changeset is success on the head, twice. Nothing authorable is removed or renamed, so no FROM → TO mapping is owed; the one authorable value the narrowing reaches, a zoned literal defaultValue on a time field, is named under "Who is affected", and the spec-side admission of that default is carried to the spec lane rather than fixed here.
  • @objectstack/spec: patch — RIGHT. The act adds no export, no type change, no wire key and no authorable key, so it is not a public-surface widening under the WHICH LEVEL rule's own examples; the group is fixed, so the release bumps minor from the objectql entry either way.
  • Clause-②: no (narrowing) — RIGHT. No new key on a published payload: FieldValidationError is unchanged and the message key never reaches the wire. The claim's amendment condition (a published type or export of objectql or core changes) did not fire. The level axis stands down on no.
  • The changeset's prose was checked against the diff and core: the two refused classes, the admitted controls, the four doors and the no-write claim are what the code does.

③ Boundary flags

  • H1–H5: H1 held (core's predicate refuses the four values by construction, above). H2 held and is pinned as a control. H3 held (fail to buildFieldError to renderValidationMessage to the spec catalog, with the label (key) fallback at line 345 for a key the catalog lacks). H4 was falsified as a clean reuse and answered A in-seat, judged above. H5 held.
  • Two files outside the original claim (validation-message.ts, types.mdx): covered by claim amendment 5899626916; the first is the only home of the ruled prescription, the second a sentence this PR made false. Answered.
  • minor for objectql under the launch window, the BREAKING banner and the ADR-0087 marker: answered in ②.
  • Three gates NOT MEASURED locally, answered by the head's check-runs as this act read them: check:dual-build-cjs-loads runs in Build Core — success; check:type-check-debt runs in Type Check · debt ledger — success; check:query-options-erasure runs in Lint & Repo Gates — in_progress at the reading, not waited on.
  • Check-runs on the head at that reading: 40 runs; every completed one is success or skipped, none failed. In progress: Lint & Repo Gates, Type Check · workspace, Test Core shards 1, 3, 4, 5 and 6 (shard 2 success). Success: Build Core, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (three shards and the rollup), Dogfood Verify CLI, Build Docs, Governed Surface Queue Guard, Check Changeset, the three claim guards, Spec property liveness, Type Check · source gates / consumer gates / debt ledger. This record is the contract verdict; the queue guard reads greenness itself, and the landing waits for every check.
  • One non-GitHub MCP call (add_repo objectstack-ai/objectui, read access, attached nothing): a read-only producer census of the sibling repo, reported in the os-dev-report. Acceptable; no action.
  • Acceptance notes: the /import offset-bearing instant cell is converted to its UTC clock by import-coerce.ts (its time arm reads a bare HH:MM[:SS] or the UTC clock of an ISO instant) before the door sees it, so the two doors answer one string differently at a year-10000 edge; the converter is not this card's surface and what it stores is a valid wall clock. Left as noted. The Invalid Date is pre-existing on all three arms with engine-only reach. Left as noted.
  • out_of_scope_findings: [0] the spec ClockTimeValueSchema still admitting a zone, carried to the spec seat on [PM seat] domain:spec · seat 2 — 🟢 marchtian · session_016njDy8ozy9B9Ns5Y8kAWEK #18549 per the claim amendment; [1] the /import time-fraction round trip, filed as /import: a time cell with milliseconds (10:00:00.250), exactly as /export writes it, is refused per row as invalid_date, so the export does not re-import #20722 per the report. Both are stated by this record's inputs and lie outside its input set; neither blocks this PR.
  • Escalation: none. Triage's "reported through os migrate value-shapes" is discharged by the seat's A under ADR-0104 D1; if the maintainer wants such rows enumerated, that is an ADR-0104 amendment card of its own, as the seat wrote.

Implemented-by: claude/issue-20671-time-write-arm-core-rule
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 22:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 63bfe69 Sep 29, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20671-time-write-arm-core-rule branch September 29, 2026 22:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…commits that decided them (objectstack-ai#20729)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the eighth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/services/service-analytics/src/**` and nothing else. By the
seat's census at the claim (`5899485578`), it is the largest package in
the lane that no in-flight work holds. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines
in 22 files, covering 14 numbers**:

- 42 census sites (every census site this package has);
- 34 sites in test comments, which the census defers.

The raw scan found no dead site the gate's grammar cannot see (see
Acceptance notes), so there is no third class this time.

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **13 distinct shas**. No number in this package has an ADR or
ruling record of its own in the repository (a grep of `docs/adr/` for
all 14 finds none, and a grep of the rest of `docs/` finds none either),
so every anchor is a commit, per ruling C's order. No number was
dropped.

Only comments changed. Every touched source file keeps its line count
(78 lines out, 78 in, over 22 files), so no line citation into these
files moves. 2 of those 78 lines hold no dead citation: they are reflow
lines, listed under Wordings below. No code token moves (see the guard
below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3),
`objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the
decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them
resolves. Over the whole diff, added minus removed is 0 or negative for
every number, and no number is new to the diff. No PR number is the
citation on an added line: the two `PR #N` spellings in scope became
their pull request's squash commit, and `objectstack-ai#16750` stays only as the
convenience link beside `ed7243d52`, on the line it already stood on.

Eight dead sites are left on purpose, all of them test strings (see the
list below).

One more file: a `patch` changeset for `@objectstack/service-analytics`,
because the rewritten docblocks and inline comments ship (see Changeset
below).

The `AnalyticsResultWithDrill` type and its four sidecar members are not
touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and
`objectstack-ai#1752` all resolve).

## Census: `service-analytics`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-analytics/`. Each run counts as a reading
only because its board frontier equals the newest issue number, read by
a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-analytics sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z |
enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after),
18,548 numbers | 1,161 | **42** | 42 | 10 | 10 |
| after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186
pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers
| 1,119 | **0** | 0 | 0 | 0 |

The before count matches the seat's census at the claim and A1 (42
sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did
not move it. The whole-repo drop is 42, exactly this diff's census
sites. The `resolves` tally is 32,991 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. The after run was taken on `967d73531`; the head
`82d2b40b2` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `service-analytics/src` (162 files). It
takes its verdicts from the before census's own board reading rather
than from a second enumeration: a number is dead when that census
reported it `allocated-but-absent`, and alive when that census judged it
on this board anywhere (its `--list` extraction) and did not report it.
The 21 numbers the census never saw, because they stand only in test
files or strings here, were read one by one on the issues endpoint: 17
answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 |
| after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 |

Its src-comment column equals the census's 42, which is the control on
the second instrument. The 3,410 live citations and the 20 cross-repo
citations are the same in both readings, and the drop of 76 citations is
exactly the rewritten sites. A third, raw reading (every `#` followed by
2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84
dead before, 3,522 and 8 after; its residue equals the gate's residue
site for site, and it sees no dead site beyond the gate.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for each pair).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one
measure's own `filter` (the third producer, lowered onto
`aggregations[].filter`) is refused on both ObjectQL doors with
`INVALID_FIELD` / 400 naming the measure, folded into the one member
view, with insertion order keeping every earlier refusal's message. The
last line of its message names `objectstack-ai#11461` as the card it settles. New to
the sweep |
| `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope
resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one
constructor, so `queryDataset`'s catch re-throws them instead of reading
their words, every message byte-unchanged; plus the source-derived
wording-collision guard. Named in its diff only (18 added lines carry
the tag). New to the sweep |
| `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593):
`explicitDateRangeWindow` is the one reading of `dateRange`'s array arm
on all four faces, and an array that is not two string bounds is refused
with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only
(its changeset file is `17124-daterange-array-arm-arity.md`). New to the
sweep |
| `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure
is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400,
keyed on the `EXPRESSION_METRIC_TYPES` partition shared with
`NativeSQLStrategy`. Its message records the two failure modes the lines
describe (`driver-sql` blaming a `function` key, the in-memory evaluator
answering `null` per bucket). Named in its diff only. New to the sweep |
| `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an
aggregate a datetime measure's field type cannot carry, scoped to
temporal source fields. The squash commit of the pull request that was
`objectstack-ai#16778`; its subject carries the number. New to the sweep |
| `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's
consumer-local `executeAggregate` config mirrors (the plugin options and
`AnalyticsServiceConfig`) narrow `aggregations[].method` to
`AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in
its diff only. New to the sweep |
| `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset
vocabulary is lowered once and the rest refused, the `[range, range]`
fallback is removed from the faces it reached, and the shared
conformance kit holds them. The squash commit of the pull request that
was `objectstack-ai#17015`. New to the sweep |
| `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is
asked at the analytics door, and its bridge to the `security` service
resolves an explicit three-way (absent admits; throwing or method-less
denies at `error`, finding F3 in its message). The squash commit of the
pull request that was `objectstack-ai#16860`. New to the sweep |
| `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members,
`getDriverForObject?` and `resolveEffectiveDatasource` among them,
adopted onto `IDataEngine`, and `getObject` typed. Its subject names it.
Stage 5's and the spec stage's anchor |
| `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle`
accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type
table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The
spec stage's anchor |
| `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an
absent security service from a broken one, so a broken one refuses the
query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on
the pulls endpoint too). New to the sweep |
| `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's
headline names `objectstack-ai#16918` as the card it answers, and its diff writes the
line (`admission-bridge-resolution.test.ts:120`) |
| `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`,
not `error.details.code`; the commit that wrote this very line. The
`runtime` stage's anchor |
| `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures
fail loud, and the same commit renames
`metadata/src/utils/schema-sync-errors.ts` to
`packages/types/src/driver-error-classification.ts`, the move the line
describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest`
and `runtime` stages |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 13), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,135 commits). Each of the
14 numbers answers 404 on the issues endpoint, read one by one;
`objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls
endpoint too.

## Wordings to check

- **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7;
`[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside
the new sha.
- **The boolean rows, `measure-result-type.ts:115-116` and
`aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A,
landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became
「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d
(objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card;
`ed7243d52`'s message records the decision itself. Line 116 of the first
file and line 66 of the second are the 2 reflow lines: each keeps the
`objectstack-ai#16750` it already carried.
- **PR numbers, `read-scope-resolution-envelope.test.ts:25` and
`refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became
「Commit 5d12b16's refusal」, the pull request's squash commit.
- **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became
「commit 54b3d1d was made to remove it」, the form stage 6 used.
- **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130
forbids」 became 「the exact move commit 54b3d1d ruled out」; its message
says the fix is the declaration, not a luckier string.
- **`read-scope-resolution-envelope.test.ts:161`.** The verb after the
number moved from present to past tense with the sha.
- **`measure-expression-both-strategies.test.ts:45` and `:166`.**
「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a
added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal
(commit 017130a)」.
- **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit
0da638c's kit」, the conformance kit that commit built.
- **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit
017130a:」, whose message records the two ways the engine failed.
- **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit
6a180e4 moved it there」; that commit's diff is the rename.

## The 8 sites left

- **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all
`describe` / `it` titles:
  - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`);
  - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`);
  - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`);
- `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and
`refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`).
- There is no operator string, generated file or quoted ruling carrying
a dead number in this package. It has no generated file at all.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes never visited, base `cbaf04c1f` against head.
Template literals are therefore read in context. It ran over all 22
touched `.ts` files.

- Real run: 26,705 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns
is in」): 0 files changed, as expected (exit 0).
- Positive control, a code token added in `plugin.ts` (`field: a.field,`
given `as string`): DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/service-analytics`
(`.changeset/20596-service-analytics-provenance-anchors.md`) is
included. Its body is stage 7's, word for word, with the package name
changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build (a cache miss for this package, so
`dist` is this head's source), the rewritten comments reach `dist`:
`399ecad58` 6 times in each of `dist/index.js`, `index.cjs`,
`index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and
once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6`
once in each JS file and twice in each declaration file; `017130a09`
once in each JS file. Positive controls: the unchanged line 「none of the
coverage: a compiled measure's own」, in the same docblock as the shipped
rewrite at `objectql-strategy.ts:744`, is found once in each of the four
files, and the unchanged line 「back into line. Widening it here again
would not be a local matter」 beside the shipped rewrite at
`analytics-service.ts:559` once in each declaration file. A
never-written negative phrase appears nowhere in `dist`. None of the 14
dead numbers is left anywhere in `dist`.

## Gates (head `82d2b40b2`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 11 citations across 10 files; 10 resolve and
1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that
already stood on its line).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands:
all 56 derived at dispatch, plus `check:engine-double-contract`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. Each ran with its exit code captured before any
pipe, and all 62 exit 0. `--ran`, fed each command with its exit code,
reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A
full `turbo run build` of `./packages/*` and `./packages/*/*` ran first
under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/service-analytics test`: 137 files pass
and 3,216 tests pass. That is every test file in the package, the 12
touched ones included.
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc
--noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162
files under `src/`, the 137 test files and all 22 touched files
included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0
warnings. All 22 are in eslint's own population (`isPathIgnored` is
false for each; a `dist` file, as the control, is ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 23 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package:
- `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#`
is excluded too, which is the claim's 7. The eighth is
「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The
numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve.
- `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All
resolve; `objectstack-ai#2149`, which the census never judged, was read on its own.
  - `option #N`: none.
So nothing here needed a rewrite beyond the gate, and the raw scan
agrees.
- **「This card」 phrases are left.** 113 lines in 39 files of this
package speak of 「this card」, 「that card」 or 「the card」. They carry no
number, neither instrument sees them, and most sit in blocks whose
numbers still resolve. Stage 7 rewrote two such lines as lost referents;
here none is changed, because the phrase runs through the whole package
and rewriting a subset would be arbitrary.
- **Prose that names `queryDataset`'s catch, not changed.** Nine comment
lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in
the private `answerDataset`, whose docblock calls it the body of
`queryDataset`, so the lines still hold at the level of the public
method. This is not a dead citation, so it is outside this stage.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` →
`399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` →
`017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` →
`0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` →
`5d12b16e7`.
- **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since
moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`).
They touch `packages/spec`, `packages/metadata/package.json`,
`pnpm-lock.yaml`, docs and changesets, and no file under
`service-analytics` or in this diff, so no merge was taken; the merge
queue rebuilds on the merged generation. One of them, `671d4c164`,
declares the four drill-through sidecars on `AnalyticsResult` in the
spec. This diff leaves the local `AnalyticsResultWithDrill` untouched,
as the claim requires.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 protocol:system size/m tests tooling

Projects

None yet

2 participants