Skip to content

feat(spec,analytics,lint,platform-objects,app-shell): a dataset measure can aggregate the time between two datetime fields of each record - #25903

Merged
objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/project-thread-4m2bvo
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/project-thread-4m2bvo

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #25778

Clause-②: yes (widening)
Metadata-protocol: added

Summary

A dataset measure can now aggregate the time between two datetime fields of each record. This is item 1 of the card. Item 2 (comparing against "now minus N days" where N lives on a related record) needs its own key, so it is split to #25808 as the order directed.

{ name: 'avg_hours_to_resolve', aggregate: 'avg', duration: { from: 'created_at', to: 'resolved_at', unit: 'hours' } }
  • Spec (packages/spec/src/ui/dataset.zod.ts): DatasetMeasureSchema gains an optional duration: { from, to, unit } in place of field.
    • from and to are two bare column names of the dataset's own object.
    • unit is seconds | minutes | hours | days.
    • aggregate is avg | min | max | sum.
    • Refused at parse: a path, an expression or * in either column, the same column twice, any other unit, a counting or missing aggregate, and field / derived / currency beside it.
    • No expression language and no SQL enter the metadata (ADR-0021).
  • Analytics:
    • The compile refuses a column not declared datetime with 400 DATASET_INVALID.
    • NativeSQLStrategy computes to - from per row in double precision, per dialect: SQLite julianday, PostgreSQL EXTRACT(EPOCH …), MySQL TIMESTAMPDIFF(MICROSECOND …) / 1.0E6. It divides by the unit and wraps the result in the aggregate, or in the conditional aggregate when the measure has a scoped filter.
    • The draft preview computes the same value from the rows.
    • Nulls in either column are excluded. A to before its from counts as negative. Over a group with no record carrying both, sum answers 0 and the others null. The 0 is answered in the statement (withEmptyGroupIdentity, as for every other measure since fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL #25953), so it sorts and pages as 0.
  • Fail-closed: the measure is deliberately absent from cube.measures. Every other path refuses it with 400 INVALID_FIELD, naming the measure: a granularity bucket, a field-to-field filter, an object served through middleware or an external datasource, an unknown dialect, or a non-SQL driver. The field-level read gate judges both columns. getMeta lists the measure, and the CUBE.MEASURE spelling it lists is served too.
  • os validate: new gating rule measure-duration-field-type-refused, at datasets[N].measures[M].duration.from / .to, with an os explain entry. dataset-field-unknown now covers both columns.
  • Console (ui/packages/app-shell, in this repo since the objectui merge): the dataset inspector's base-object change now clears a duration measure with the other measures that name the old object's fields. Before this it was kept as if it were a plain row count, because it has no field.
  • Docs: data-modeling/analytics.mdx gets a "Duration measures" section, and deployment/validating-metadata.mdx explains the rule. The liveness ledger drills duration.{from,to,unit}.

Tests

  • packages/services/service-analytics/src/__tests__/dataset-duration-measure.test.ts: live on SQLite and PostgreSQL, with 7 cases on each driver (14/14 in total).
    • Covers values per unit and aggregate, nulls, a negative duration, an empty group, a measure filter, a derived measure, ordering with limit and offset (the all-NULL group pages as 0 on both drivers), and the cube door.
    • Rejections assert code + status: granularity, objectql-only, and date / number / text columns.
  • native-sql-duration-measure.test.ts (23):
    • exact SQL per dialect, joins, temporal repair, the unknown-dialect decline, presentation, and the empty-group identity in the statement;
    • the preview evaluator;
    • service gates: no mint, the read gate (including a dimension sharing the measure's name), dry-run, preview, the unparsed-dataset door, compile guards, getMeta, the CUBE.MEASURE spelling, and the $field refusal.
  • packages/spec/src/ui/dataset-measure-duration.test.ts (18), packages/lint/src/validate-dataset-measure-duration.test.ts (12), and the ADR-0122 isomorphism pins.
  • ui/packages/app-shell/.../DatasetDefaultInspector.firstPickKeepsCount-12848.test.tsx: the move fixture gains a duration measure, and the move clears it. The test fails with the predicate reverted.
  • Full suites: service-analytics 4842 pass at 8821a8d4, with 1 pre-existing failure ("the server is not on UTC", because the local PG runs on UTC). Lint 7310 pass. tsc --noEmit is clean for service-analytics, and the typecheck of lint, platform-objects and spec exits 0.
  • Not measured locally: the @object-ui/app-shell type-check. @object-ui/components fails its own declaration build in this container at the main tip, in files this diff does not touch, so CI's Type Check UI is the reading for it. It is green on this head.

Review

An independent full review ran on Fable. Round 1 was CHANGES REQUESTED, with 1 must-fix, 2 should-fix and 4 nits:

  • the read-gate escape when a dimension shares the name;
  • MySQL DECIMAL arithmetic;
  • getMeta not listing the measure;
  • the CUBE.MEASURE spelling;
  • docs and comment nits.

All of them are applied except F6 (refusing a dimension/measure name collision at parse), which is a narrowing and so is out of scope. Three delta reviews followed, the last one after the ui/ merge, and each answered ACCEPT. The verdict record is in a comment on this PR.

Acceptance notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW

claude added 13 commits October 25, 2026 13:44
…docs, changeset, ledger

- lint: `measure-duration-field-type-refused` at `duration.from` / `.to`, with an
  `os explain` entry; `dataset-field-unknown` checks both columns.
- spec: `DATASET_DURATION_FIELD_TYPE`; the unit's type alias shares its schema's
  name; liveness drills `duration.{from,to,unit}`.
- docs: the duration measure in analytics.mdx and validating-metadata.mdx.
- changeset: minor on spec, service-analytics and lint.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
…s for the duration unit type

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
- The field read gate resolves a duration measure first, as every other seam
  does: a dimension sharing its name no longer hides its two columns.
- MySQL divides by a DOUBLE literal, so the arm is double precision like the
  other two; the docs and changeset claim live parity for SQLite and
  PostgreSQL only.
- getMeta lists a registered dataset's duration measures with their aggregate,
  label and format, and every seam accepts the `<cube>.<name>` spelling it
  lists them under, its scoped filter included.
- Docs: path and expression refusals happen at parse, not in the lint rule.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
…-4m2bvo

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
…the base object changes

A duration measure has no `field`, so `measureNamesNoField` read it as a
plain row count and kept it across a base-object change, though its `from` /
`to` name two of the old object's fields.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/lint, @objectstack/platform-objects, @objectstack/service-analytics, @objectstack/spec, touching 60 documentable anchor(s). ⚠️ 8 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/analytics.mdx (via DatasetMeasureSchema (symbol, a top-level const))

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class), generateSql (symbol, a method of class AnalyticsService))
  • content/docs/releases/v17/17-6.mdx (via AnalyticsService (symbol, a top-level class), DatasetSchema (symbol, a top-level const), compileDataset (symbol, a top-level function))
  • content/docs/releases/v17/17-7.mdx (via AnalyticsService (symbol, a top-level class))

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
  • 8 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 12 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 — 144 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 0c3722882633d2941810df0ab542280407958f3f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0c3722882633d2941810df0ab542280407958f3f

⚠️ 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 0c3722882633d2941810df0ab542280407958f3f → 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

Independent full review (Fable), four rounds, verbatim. Reviewer: an isolated Fable agent that did not write the change, fed the card, the rulings and the code. Round 1 reviewed the whole diff at 224e9c31; rounds 2 to 4 are delta reviews by the same reviewer. Placeholder spellings (CUBE.MEASURE) stand in for angle-bracket text the sanitizer would eat.

The commit ids quoted below were taken before the sign-off trailers were reworded for the push. Trees are identical, and the pushed ids are: 224e9c31 → eda01f1f9, 8b3172c4 → b2ddd3515, c639af10 → 78be55464, 9792a940 → 634fe6bd0, 683ec1d45 → 7caa56687, 53d867beb → 24e5933a2.

Round 1 — whole diff

Verdict: CHANGES REQUESTED

Findings

  1. must-fix — packages/services/service-analytics/src/analytics-service.ts:553 (durationColumns): if (!duration || declaredMemberEntry(cube, member, 'any')) return false;. The escape fires whenever a dimension shares the measure's name, which DatasetSchema allows (uniqueness is checked per list, packages/spec/src/ui/dataset.zod.ts:632-646). The gate then falls to name(member, 'measure', …), which returns without pushing anything for a measure the cube does not carry (~line 531), while every other seam (mint, assertCallerMembersResolvable, and NativeSQLStrategy.generateSql at strategies/native-sql-strategy.ts:1308) resolves the duration first. Scenario: REST body.dataset inline (any caller) with dimensions: [{name:'x', field:'category'}, {name:'category',…}], measures: [{name:'x', aggregate:'avg', duration:{from:'opened_at', to:'resolved_at', unit:'hours'}}], selection measures:['x'], dimensions:['category'] → opened_at/resolved_at are never judged by the field-level read gate and the native statement aggregates them for a caller denied both fields. A duration measure can never be in cube.measures by construction, so the escape protects nothing: drop the declaredMemberEntry clause and add a gate test (readable set without resolved_at, same-named dimension → PERMISSION_DENIED/403, nothing runs).

  2. should-fix — packages/services/service-analytics/src/duration-measure-sql.ts:78-79 and :94. TIMESTAMPDIFF(MICROSECOND, …) / 1000000.0 is exact-value arithmetic on MySQL: BIGINT / DECIMAL yields a DECIMAL with scale 0 + div_precision_increment (4), then / 3600.0 adds 4 more, so hours are rounded to 1e-8 and AVG to 1e-12. Input: 5000 s under unit: 'hours' → MySQL 1.38888889, PG/SQLite 1.3888888888888888. The changeset ("the same number on each") and content/docs/data-modeling/analytics.mdx ("answers the same number on each") overclaim with no live MySQL cell (Prime Directive chore: version packages #10). Fix: CAST(TIMESTAMPDIFF(MICROSECOND, from, to) AS DOUBLE) / 1000000 (double arithmetic through the unit divisor), pin the text in native-sql-duration-measure.test.ts, or narrow the claim to the two tested dialects.

  3. should-fix — analytics-service.ts:3241 (getMeta): measures are enumerated from cube.measures, so a registered dataset's duration measures are absent from the discovery surface the runtime serves (packages/runtime/src/domains/analytics.ts:179) while the cube door answers them ("Machine-readable surfaces must not lie"). Fix: append compiled.durations entries (name, type: aggregate, title, format) when the cube is a compiled dataset, with a pin beside cube-meta-description-format.test.ts.

  4. nit — content/docs/data-modeling/analytics.mdx:109-113 says a relationship path or expression in from/to is reported by measure-duration-field-type-refused; those are refused at parse by DatasetMeasureDurationSchema, and the lint rule judges declared type only (packages/lint/src/validate-dataset-measure-aggregates.ts:700-727). Split the sentence.

  5. nit — packages/services/service-analytics/src/dataset-compiler.ts:460 re-spells the identifier regex as DURATION_BASE_COLUMN although packages/spec/src/data/analytics-column-reference.ts:99 promises "never a second spelling". The regex is not re-exported from any barrel, so it cannot be imported today; export ANALYTICS_BASE_COLUMN from @objectstack/spec/data and import it, or drop the promise.

  6. nit — packages/spec/src/ui/dataset.zod.ts:632-646: a dimension and a measure may share a name, which puts two columns under one alias in the native SELECT and is the precondition of finding 1. Refusing the collision at parse is Metadata-protocol: narrowed; a separate card is fine.

  7. nit (untested paths) — the cube-prefixed spelling resolution.avg_hours (accepted for cube members via declaredMemberEntry) is not recognised by durationMeasureIn and ends in an "unknown measure" refusal — loud, but inconsistent and unpinned. Also unpinned: the $field cross-field decline, the middleware/external-object decline, and compareTo with a duration measure (all ride assertDurationMeasuresServed, low risk).

Checked and fine

  • Spec shape: duration optional beside field/derived/currency, all cross-exclusions and the from === to, missing/counting aggregate, unit enum, bare-identifier regex, aliases and DATASET_NO_SQL guidance; no expression language (ADR-0021); DatasetSchema refinement skips the field requirement for duration only; 18 spec tests pass.
  • SQL arithmetic: SQLite ROUND(julianday Δ × 86400000)/1000 on canonical UTC ISO text (Z accepted), PG EXTRACT(EPOCH FROM timestamptz − timestamptz) (absolute, DST-safe), MySQL argument order (to − from); negative durations preserved; NULL in either column drops the row; sum folds to 0 and avg/min/max stay null via emptyGroupValueFor(duration.aggregate); unit division; measure filters take CONDITIONAL_AGGREGATE_SQL; numberMeasures presentation; fields[].type === 'number'; result labels come from dataset.measures.
  • Live cells: dataset-duration-measure.test.ts ran on SQLite and Postgres (14 tests, both cells confirmed in verbose output), each asserting values with toBe, one raw statement, zero engine aggregates; rejections assert code + status. native-sql-duration-measure.test.ts (18) passes.
  • Fail-closed: duration absent from cube.measures; mint and assertCallerMembersResolvable skip it; assertDurationMeasuresServed sits inside the query() retry loop and in generateSql (instanceof NativeSQLStrategy, so the fallback delegate is refused too); canHandle declines 'unknown' dialect; granularity/$field/middleware/external routes refuse INVALID_FIELD/400 naming the measure; preview evaluator mirrors Math.round(ms)/1000/unit; DatasetExecutor fill reads the duration aggregate; supplementary per-filter and derived passes verified live; durationMeasureIn is own-key only; inline-dataset expression text refused 403 before compile; compile guards for unparsed shapes (DATASET_INVALID).
  • Compile-time type gate parity with lint (formula, date, time, number, text refused; untyped/unresolved skipped; created_at accepted); declaredValueShape is wired by the service in both compile paths. dataset-field-unknown covers duration.from/to. os explain text present; sibling parity tests (dataset-compiler, validate-dataset-measure-aggregates in both packages, validate-dataset-references, cube-meta-description-format) all pass.
  • Changeset: three packages minor, Metadata-protocol: added, Clause-②: yes (widening), no tracker numbers in runtime strings (scripts/check-doc-authoring.mjs green). Liveness ledger anchors resolve (pnpm --filter @objectstack/spec check:liveness green, state-counts current). Generated artifacts present in the diff; check:generated not run (dist may be rebuilding).

Round 2 — delta 224e9c31..8b3172c4 (the round-1 fixes)

Delta review for #25778, git diff 224e9c31..8b3172c4:

Verdict: ACCEPT

Findings in the delta

  1. nit (pre-existing, not introduced here — file a card) — packages/services/service-analytics/src/strategies/native-sql-strategy.ts:1300 and analytics-service.ts:578. The delta correctly maps a prefixed duration measure to its bare filter key, but an ordinary filtered measure asked on the cube door as CUBE.MEASURE still looks up measureFilters[measure] with the prefixed spelling while lookupMember strips the prefix: query({cube:'sales', measures:['sales.won_amount']}) answers an unfiltered SUM(amount) under the filtered measure's name, silently. The read gate uses the same key, so no field-gate bypass, only a wrong number. Out of this card's scope; worth its own fix with durationMeasureKey-style resolution for ordinary measures.

Checked

  • F1: durationColumns (analytics-service.ts:553-555) no longer escapes on declaredMemberEntry; a same-named dimension no longer hides from/to. Order keys naming both a duration and a declared member are judged as both (lines 584-588). New test "a dimension sharing its name does not hide its columns from the read gate" asserts PERMISSION_DENIED/403 and nothing executed.
  • F2: MySQL arm is / 1.0E6 (approximate-value literal → BIGINT / DOUBLE → DOUBLE, then DOUBLE / 3600.0 → DOUBLE); SQL pins updated; docblock, changeset and analytics.mdx now claim live parity for SQLite+PG only and "double precision" for MySQL, pinned as text. Claim is as narrow as the enforcement.
  • F3: getMeta appends compiledDatasetOf(cube)?.durations with type: aggregate, title: label ?? key, optional format; compiledDatasetOf is a same-reference check against datasetRegistry. Verified that ensureCube's mint/augment registers into the per-call requestScope, never the shared cubeRegistry, so the reference check still holds after queries that mint measures. Compiler sets label (registry locale, ?? name) and format; DurationMeasureSpec gains display-only optionals. cube-meta-description-format.test.ts still passes.
  • F7: durationMeasureKey accepts the bare key or the exact CUBE. prefix (own keys only, no any-prefix or flat forms). Traced to every seam: native generateSql (filter by bare key, alias as asked), execute fold/presentation, canHandle, read gate (measures, filters, order), mint skip, assertCallerMembersResolvable, assertDurationMeasuresServed, executor fill, preview. assertMeasureFields/assertMeasureFieldTypes return "nothing to check" for the prefixed duration as before. New tests cover getMeta, prefixed serve with scoped filter (AVG(CASE WHEN priority = $1 …) AS "resolution.high_hours"), read-gate 403 and off-native 400 under the prefix, and the $field filter refusal naming the measure.
  • F4 docs sentence split correctly; F5 comments scoped; .at(-1) replaced.
  • F6 deferred as a narrowing: agreed, out of scope.
  • Ran at HEAD 8b3172c4 with OS_TEST_POSTGRES_URL set: dataset-duration-measure.test.ts (14, both cells), native-sql-duration-measure.test.ts, cube-meta-description-format.test.ts — 42/42 pass. Liveness evidence anchors (compileDurationMeasure, durationOperandSql, resolveDurationMeasureSql, durationOf) are unchanged by the delta.

Round 3 — delta 8b3172c4..9792a940 (gate-driven: i18n bundle, ADR-0122 pins)

Second delta review for #25778, git diff 8b3172c4..9792a940a:

Verdict: ACCEPT

Findings in the delta

None.

Checked

  • c639af10: the four generated metadata-forms bundles each gain exactly one row, "measures.duration", in the same shape and position as the sibling measures.derived row (en "Duration", es-ES "Duración", ja-JP "所要時間", zh-CN "时长"); the translations read naturally for a time-between-two-instants measure. No source-hash residue in the diff. node scripts/check-i18n-bundles.mjs is green here ("10 package(s) — all bundles in sync, no undeclared authoring keys"). The changeset adds @objectstack/platform-objects: minor with one bullet and keeps Clause-② / Metadata-protocol: added; minor is the right level for a package the diff moves under an added kind, fixed group or not.
  • 9792a940: both new pins target real exports (DatasetDurationUnit, a bare z.enum; DatasetMeasureDurationSchema, a lazySchema(() => strictObject({...})) of two regex strings and the enum). dataset.zod.ts contains no .default(), .transform(), .catch(), .pipe(), .preprocess() or z.coerce, so z.input === z.infer holds by inspection, and the identical wrapper is already pinned isomorphic for DatasetDimensionSchema / DatasetMeasureSchema. The count 790 → 792 is moved in the section header, the test title, the toHaveLength, and the history comment (+2, with the two pin ids named). Pin test 3/3 green; node scripts/check-spec-parsed-alias.mjs --self-test (18 assertions) and the gate itself are green from the repo root: "1471 bare z.input aliases, 792 pinned isomorphic, 679 paired with an XParsed. OK".
  • The type equality itself is proved by check:test-typecheck, not by vitest; I did not run a repo-wide typecheck, per the no-build rule.
  • Nothing else in the diff: six files, no runtime code touched.

Round 4 — after merging main (objectui imported under ui/): the pin-count resolution, the deferred regeneration, and the console inspector fix

Third delta review for #25778, HEAD 53d867beb:

Verdict: ACCEPT

Findings in the delta

None. One follow-up to record, not a blocker: in ui/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx the measure row still offers the Field combo, the derived toggle and the currency picker on a duration measure; each produces a shape the spec refuses at save (field/derived/currency beside duration), so it fails loud, not silently. writeRowField (destructures field, spreads the rest) and patchMeasure both spread the row, so the duration key itself survives every edit.

Checked

  • Merge resolution: git diff origin/main HEAD -- packages/spec/src/type-alias-convention.pin.test.ts is exactly the two Iso_ui_dataset__* pins plus 786 → 788 in the header, the test title, the toHaveLength and a "786 -> 788 is Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778 … +2 added" history line beneath main's "-4 removed". Pin test 3/3; node scripts/check-spec-parsed-alias.mjs reports "1461 bare z.input aliases, 788 pinned isomorphic, 673 paired with an XParsed. OK" at HEAD.
  • 683ec1d45: the three ui.json additions (listViewGroupOrderColumn, GroupingField:orderBy) and the references index (1551 → 1541, kernel 30 → 29 pages after package-upgrade.zod.ts left) are main's changes caught up post-merge; nothing from this branch moved. Not regenerated here (no build), taken from the gates.
  • 53d867beb: measureNamesNoField adds m.duration === undefined; a duration measure now leaves objectChangePatch with the other field-naming measures. The 12848 fixture gains the duration row and the CONTROL expects it cleared. Ran from ui/ with ./node_modules/.bin/vitest run …firstPickKeepsCount-12848.test.tsx: 6/6 pass.
  • Other ui/ consumers: buildDatasetFieldHelpers (ui/packages/core/src/utils/dataset-format.ts) reads the result fields[], which the service types number with label/aggregate for a duration measure — DatasetPreview and DatasetWidget format and label it correctly; useDatasetCatalog's DatasetMeasureInfo, WidgetConfigPanel, ReportDefaultInspector.datasetMeasureOptions and CreateViewDialog read name/label/aggregate only; cli/src/commands/validate.ts and snapshotSummaryPlan.ts do not read measure keys. Nothing rebuilds a measure row key-by-key on save.
  • Changeset: @object-ui/app-shell: minor with one accurate bullet; Clause-② / Metadata-protocol unchanged.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test UI (11/16) is red, and the failure is not this PR's.

Test UI is not in REQUIRED_CONTEXTS. On this head, Type Check UI, Lint UI, Build & E2E UI and the other 15 Test UI shards are green.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3f3f0959dfa3f18bb00b136173e978c9afc079df
Local-runs: none
Scope: packages/spec/src/data/analytics-column-reference.ts, packages/spec/src/ui/dataset.form.ts, packages/spec/src/ui/dataset.zod.ts + packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/declaration-map/ui.json, packages/spec/export-origins/ui.json, packages/spec/json-schema.manifest/ui.json, packages/spec/liveness/dataset.json, packages/spec/liveness/state-counts/dataset.md, docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md, packages/spec/src/ui/dataset-measure-duration.test.ts, packages/spec/src/type-alias-convention.pin.test.ts, packages/services/service-analytics/src/dataset-compiler.ts, packages/services/service-analytics/src/duration-measure-sql.ts, packages/services/service-analytics/src/strategies/native-sql-strategy.ts, packages/services/service-analytics/src/strategies/types.ts, packages/services/service-analytics/src/preview-evaluator.ts, packages/services/service-analytics/src/dataset-executor.ts, packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/tests/dataset-duration-measure.test.ts, packages/services/service-analytics/src/tests/native-sql-duration-measure.test.ts, packages/lint/src/validate-dataset-measure-aggregates.ts, packages/lint/src/validate-dataset-references.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/lint/src/validate-dataset-measure-duration.test.ts, packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts (4), ui/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx and its 12848 test, content/docs/data-modeling/analytics.mdx, content/docs/deployment/validating-metadata.mdx, content/docs/references/index.mdx, content/docs/references/ui/dataset.mdx, .changeset/25778-dataset-duration-measure.md

Reviewed read-only against card #25778 (body, triage 6397322215, Claim 6398317568, os-dev-report 6407556287), the PR body, its two comments (docs-drift 6407514107, the dev's four-round Fable record 6407520493), the net diff of all 38 files, and the head's check-runs. The face is read off the committed tree with node scripts/pm/record-recognisers.mjs --template --paths ...: three published-schema hits (the three spec paths on the Scope line), so the schema face and this tier; the rest of the diff is read here too.

① Derived judgments

The new key. DatasetMeasureSchema.duration: { from, to, unit } is one optional strict object beside field / derived, with from and to held to one bare identifier (ANALYTICS_BASE_COLUMN, built from the same IDENTIFIER the existing COLUMN_PATH uses, so the two patterns cannot drift), unit a four-member enum of fixed-length units, and the measure's refinement refusing field, derived, currency, a missing or counting aggregate, and from === to, each at its own path. DatasetSchema's refinement skips the field-or-count requirement for a duration measure only. No expression and no SQL enter the metadata: ADR-0021 holds. A dataset without the key parses byte-for-byte as before (pinned). The accept set moves in one direction, wider. The alias table (start/end/since/until/elapsed/timeBetween) and the DATASET_NO_SQL guidance name the key in the refusal an author reads.

Generated surfaces match the source. api-surface/ui.json gains exactly the seven exported names (DATASET_DURATION_AGGREGATES, DATASET_DURATION_FIELD_TYPE, DATASET_DURATION_UNIT_SECONDS, DatasetDurationUnit as const and as type, DatasetMeasureDuration type, DatasetMeasureDurationSchema const); export-origins records DatasetDurationUnit as (type), the file's convention for a name exported as both (Dashboard, ChartType); declaration-map maps the three schema names to ui/DatasetDurationUnit and ui/DatasetMeasureDuration as DatasetDimensionSchema maps today; authorable-surface gains ui/DatasetMeasure:duration and the three ui/DatasetMeasureDuration:* keys; json-schema.manifest the two schema ids. The strictness ledger moves 215 to 216 on dataset.zod.ts 4 to 5: one new strictObject site. The liveness ledger drills duration with from / to / unit, each anchored on a real symbol of this diff (compileDurationMeasure, durationOperandSql, resolveDurationMeasureSql, durationOf), and the state count moves 28 to 31. The ADR-0122 pin test gains the two isomorphic pins and moves 786 to 788 in all four places it states the count. check:api-surface, check:export-origins, check:authorable-surface (which also rewrites the manifest), check:liveness (which flags a STALE state-count) and check:strictness-ledger all ran green on this head (Lint lanes, Spec property liveness); check:declaration-map is not in CI and was read by hand.

SQL generation, correctness and injection. Per-row seconds are spelled once per dialect in duration-measure-sql.ts: SQLite ROUND((julianday(to) - julianday(from)) * 86400000.0) / 1000.0 over canonical UTC text (the Z suffix is a SQLite-accepted timezone indicator; julianday float noise sits well under the millisecond the rounding absorbs); PostgreSQL CAST(EXTRACT(EPOCH FROM (to - from)) AS DOUBLE PRECISION) (an interval, so DST-safe on timestamptz); MySQL TIMESTAMPDIFF(MICROSECOND, from, to) / 1.0E6 (argument order gives to - from; the 1.0E6 DOUBLE literal keeps the arithmetic approximate, which is the round-1 should-fix applied). The divisor is ${DATASET_DURATION_UNIT_SECONDS[unit]}.0, read from the one frozen table the preview divides by. Negative durations are kept as data; a NULL in either column drops the row from every aggregate; sum over nothing folds to 0 and avg/min/max stay null through the existing emptyGroupValueFor. Injection-safe on both tested dialects by construction: the only caller-shaped text reaching the statement is the two column names, held to ^[A-Za-z_][A-Za-z0-9_]*$ at parse (spec), again at compile (DURATION_BASE_COLUMN, for a dataset that reaches the compiler unparsed, with the REST inline door refusing non-column text 403 before compile), and the unit is read by own-key only; with joins the columns are emitted quoted and base-qualified through the same qualifyAndRegisterJoin every measure uses. The compile refuses a column declared anything but datetime with 400 DATASET_INVALID, naming the column and its type; an untyped or unresolved column is left to the field gate and the database, which is the posture field already has. Both columns pass the field-level read gate (durationColumns in namedQueryFields, resolved before the cube member so a same-named dimension cannot hide them; the round-1 must-fix, with its 403 test), the measure mint skips it, assertCallerMembersResolvable skips it, and assertDurationMeasuresServed refuses 400 INVALID_FIELD naming the measure on every strategy the retry loop lands on that is not the native one, generateSql included. canHandle declines a duration on an 'unknown' dialect so the emitter's bare throw is unreachable by routing. The CUBE.MEASURE spelling getMeta lists is resolved to the bare key at every seam (durationMeasureKey), and getMeta lists the measure from the compiled dataset of that same cube object, so the discovery surface no longer omits what the cube door answers.

Preview agrees with SQL. durationOf computes Math.round(toMs - fromMs) / 1000 / unitSeconds, the same millisecond rounding and unit division as the SQLite arm, nulls out when either instant is missing, and hands the per-row numbers to the existing aggregate arms, so sum over nothing is 0 and avg is null as on the SQL face; pinned on the same fixture values the live test asserts (12.75 hours, 90 minutes, 0.75 seconds, the all-null group). The live test runs the identical fixture on SQLite and on PostgreSQL and asserts every cell with toBe against JS arithmetic, one raw statement and zero engine aggregates per pass. DatasetExecutor's empty-group fill reads the duration's aggregate from the sidecar, so a filtered duration's supplementary pass fills like any measure.

Lint agrees with the runtime. measure-duration-field-type-refused refuses exactly the pairs the compiler refuses (anything declared but datetime), under the same skips (unresolved name is dataset-field-unknown's, untyped is not judged), one finding per column at datasets[N].measures[M].duration.from / .to, with an os explain entry; the duration measure returns before the aggregate-times-field-type leg, which would otherwise read a field it has not. dataset-field-unknown now covers both columns through the same checkFieldPath. The rule rides the existing gating registry entry on all three commands and the dataset write door. The measure-aggregate-field-type-refused explanation and the compiler's temporal remedy both now point the time-between question at the new key instead of a stored "days open" number; no stale copy of the old sentence remains outside the historical migration entry, which is correctly untouched.

ui/ inspector. After #25825 the console is built from ui/apps/console (packages/console/build-dist.sh clones and pins nothing; .objectui-sha is gone from the tree; objectui is frozen at its final tip and archived per the D1 ruling), so ui/packages/app-shell is the one live copy and the right place for the consumer update. measureNamesNoField adds m.duration === undefined, so a base-object change clears a duration measure with the other field-naming measures instead of keeping it as a row count whose from / to name the old object's fields. The 12848 CONTROL's expected measure list excludes the new fixture row, so the test fails with the predicate reverted. Other ui/ readers of measures read name / label / aggregate or the result fields[], which the service types number. Follow-up, not a blocker: the row still offers the Field combo, derived toggle and currency picker on a duration measure; each is refused by the spec at save, so it fails loud.

Docs. analytics.mdx and validating-metadata.mdx state what the code does and no more: live parity claimed for SQLite and PostgreSQL only, MySQL as double-precision text, the empty-group answers, the 400 INVALID_FIELD routes, and that comparing against a value on another object is not part of this (it is #25808). The two generated reference pages and the index counts are what build-docs emits (Build Docs and the docs guards are green).

② Semver level

Clause-②: yes (widening) and Metadata-protocol: added are declared line-initially on the PR body and in the changeset, and both are right: a new optional key, nothing refused that was accepted before. One changeset, five packages, each minor: @objectstack/spec (the key and six new exports), @objectstack/service-analytics (the compile, the native-SQL face, the preview, the gates, getMeta), @objectstack/lint (a new gating rule id and its export), @objectstack/platform-objects (the generated form bundles gain one row in four locales), @object-ui/app-shell (the inspector predicate). Under the rule that a kind takes at least minor on every package the diff moves, minor on app-shell is required, not over-declared; all five are in the one fixed group at this head in any case. No export removed, no breaking banner owed, no ADR-0087 disposition owed. The changeset body carries the author-facing migration line (nothing to do) and no tracker number in a runtime string. Check Changeset is green.

③ Boundary flags

  • Item 2 of the card (a now-anchored comparand with an offset read across a relationship) is correctly split to Analytics datasets: no filter compares a datetime column against now minus an SLA held on a related record #25808 as the triage asked; it needs its own key and a ruling the [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 line declined. The PR closes Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778 on item 1 alone, which is what the triage dispatched.
  • F6 not applied (refuse a dimension and a measure sharing a name): agreed, that is Metadata-protocol: narrowed and outside a widening card. The precondition is defused here by resolving the duration first in every seam.
  • No wire unit on result fields, no duration editor in the console, no granularity bucket on a duration: each is stated as a follow-up and each fails closed today. Not blockers.
  • MySQL pinned as text only: the Temporal Conformance job that would run MySQL live is opt-in and skipped on this head. The docs and changeset claim live parity for SQLite and PostgreSQL only, which is as narrow as the enforcement.
  • Out-of-scope finding, prefixed ordinary-measure filter. query({ cube, measures: ['CUBE.won_amount'] }) runs the unfiltered SUM(amount) under a filtered measure's name, because measureFilters[measure] is read by the spelling asked while lookupMember strips the prefix. Pre-existing, measured in-process, not introduced or widened by this diff: the one line this PR edits there resolves the bare key for durations only and leaves ordinary measures as they were. Out of this card's scope, so not a must-fix here, but it is a silent wrong number on the discovery surface's own spelling and it is still unfiled: the os-dev-report lists a ready-to-file draft and no issue_create for it. It must be filed as a card before this card closes, with durationMeasureKey-style resolution for ordinary measures as the obvious fix.
  • Out-of-scope finding, NULL ordering of an all-NULL sum group. A pushed-down SUM over a group where no row carries both instants is SQL NULL when the statement orders it (NULLS LAST on SQLite, NULLS FIRST on PostgreSQL) and is folded to 0 only afterwards, so order plus limit by that measure can page differently per driver. The mechanism is the pre-existing post-order fold for every sum; a duration measure makes the all-NULL group more common (any group with no complete pair) but does not change the rule. Out of scope and not a must-fix; the live test excludes that group from its ordering case and says why. It must also be filed as a card (candidate fix: emit COALESCE(SUM(...), 0) for a sum so the fold happens inside the statement, or order after the fold); it has no draft yet.
  • Check-runs on this head at review time. Green: Check Changeset, Check PR Size, Spec property liveness, Governed Surface Queue Guard, the three claim and part-of guards, Flag docs affected, Check Documentation Links, Dogfood Regression Gate, Auto Label, filter, Lint and Repo Gates, every Lint lane (docs and code guards, ESLint, PM dispatch-gates, repo and tooling gates, CI release and engine gates, the three scoped lanes), Type Check source gates, Build Docs, Build and E2E UI, Test Core 6/8. Still running: Test Core 1, 2, 3, 4, 5, 7, 8 of 8; Test UI 1 through 16 of 16; Build Core; Type Check UI; Type Check workspace, debt ledger and consumer gates; Lint UI. Skipped (opt-in): Temporal Conformance, Dogfood shards, Dogfood Verify CLI, Packed-tarball smoke, 18.x upgrade harness. No failure. This PASS is conditional on the running lanes finishing green; the dev did not measure @object-ui/app-shell type-check locally, so Type Check UI is the lane to watch. The PR is still a draft with mergeable state blocked.
  • PR-body corrections owed (none blocks). The Tests section says dataset-duration-measure.test.ts is "14/14 on each driver": the file holds 7 cases per cell, 14 is the two-cell total (the os-dev-report repeats the per-driver phrasing). The two pre-existing findings are described as measured but the body does not say they are unfiled. The Tests section names tsc --noEmit clean for service-analytics only; the body should say that app-shell type-check was not measured locally and is CI's to judge.

Implemented-by: claude/project-thread-4m2bvo
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

…ataset form's Duration row

The measures repeater's `duration` row adds one leaf to the dataset form and
one authored row label per translated locale: the positive control goes
679 -> 680 and the dataset panel's leaf count 38 -> 39.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 76866719f70f24b3ac7471c378bd7cf678bcf007
Local-runs: none
Scope: packages/spec/src/data/analytics-column-reference.ts, packages/spec/src/ui/dataset.form.ts, packages/spec/src/ui/dataset.zod.ts + packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts, packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts

This is a delta over 6407737885, the PASS of record at 3f3f0959dfa3f18bb00b136173e978c9afc079df. The head moved by one commit, and unexplainedPathsBetween names exactly the two platform-objects test files on the Scope line: seven lines in, three out, two toBe pins moved (679 to 680, 38 to 39) and three comment lines added. Nothing else in the tree changed between the two heads (git diff --stat of the two shas lists those two files and no other), so every judgment in 6407737885 stands as written; this record re-derives only the two pins and the state of CI. The three schema hits are the same three spec paths, unchanged since the record of record, so the face and the tier are unchanged.

① Derived judgments

Each new count follows from the PR's own dataset.form.ts and generated rows, and from nothing else. The whole of the PR's change to any form or any bundle is one declared repeater child in dataset.form.ts, { field: 'duration', label: 'Duration' }, appended to the measures repeater's nine-row fields list, and one generated entry per locale bundle, "measures.duration": { label }, reading Duration / Duración / 所要時間 / 时长 in en, es-ES, ja-JP and zh-CN. No other *.form.ts and no other bundle moves in the PR's net diff against the merge-base 6e0597f2, and main has not moved on either test file, any *.metadata-forms.generated.ts or any *.form.ts since that merge-base (git diff --stat merge-base to origin/main over those paths is empty), so there is no other source for either count to have come from.

  • 38 to 39 (DATASET_LEAVES.length, report-form test). leavesOf(datasetForm, 'dataset') counts every string prop of the en bundle's dataset.sections.* entries plus every string prop of dataset.fields[path] for each row formRows(datasetForm) reaches. Read off the en bundle at the head: four sections, each label + description, 8; eight top-level rows (name, label, description, object, include, filter, dimensions, measures), each label + helpText, 16; six dimension row properties, label only, 6; nine measure row properties, label only, 9. 8 + 16 + 6 + 9 = 39. Before the PR the measure repeater had eight children, so 38, which is the pin the merge-base holds. The one new leaf is dataset.fields["measures.duration"].label, present in the bundle because the extractor emits one label key per declared repeater child (the [Decision] Repeater row-property localisation: 604 catalog leaves across four locales — who authors the translated copy? #17508 rule the form's own comment cites) and reached by the walker because the row is in datasetForm. A label-only child adds exactly one leaf; the row declares no helpText, so there is no second.

  • 679 to 680 (the positive control, lifecycle test). For each translated locale the control counts en catalog keys ending in .label for which the locale's flattened bundle has a value that differs from the en value. The PR adds exactly one .label key to the en catalog (dataset.fields.measures.duration.label, Duration) and one value for it in each of the three locale bundles, and each of Duración, 所要時間 and 时长 differs from Duration, so the count moves by exactly one in every locale, from the merge-base's 679 to 680. The sibling predicates in the same test, the all-three and the per-locale echo sets, stay empty because none of the three renderings is a byte copy of the en source. The new duration key also carries .meta({ title: 'Duration' }) in dataset.zod.ts, equal to the form label, which is the convention repeater-row-properties.test.ts and repeater-item-titles.test.ts hold every enumerated row child to.

Each test is still a real control; no assertion was loosened. Both pins remain exact equalities (toBe(680), toBe(39)), not ranges or lower bounds, and both keep their failure messages. The lifecycle control still runs per locale inside the for over the three catalogs, so a parser matching everything cannot produce it, and the DARK leg beneath it, the synthetic two-locale catalog that separates the two echo predicates, is untouched. The report-form control still asserts the dataset panel is excluded by name and still asserts datasetEchoes is empty over all 39 leaves, so a wrong sweep of the dataset panel is still noticed rather than trusted. The three added comment lines state the cause of each move in the file's own ledger convention (which key, which repeater, authored in all three locales), matching the twenty-odd prior entries above them, and the report-form header comment is updated to 39 as well. The commit message's causal claim is the one this record derives.

No other pin over the same data needs to move with these two. Swept every hard-coded toBe(N), .size).toBe(N) and toHaveLength(N) across packages/platform-objects/src/apps/translations/*.test.ts at the head. Every other count is over a population this row does not join: the lifecycle subtree (32 / 16 / 16), the object form's collapsed sections (107 / 118 / 96 / 11), the bare-type display pairs (29 registry types, 20 leaves), the report panel's own 44 leaves, the field panel's 11 row properties and 10 unwalked children, and each ledger's DECISIONS sizes. The dataset-panel ledger's bounds over this same panel are inequalities that the row satisfies (PANEL_LEAVES.length greater than 35, now 39; ROW_PROPERTIES.length at least 13, now 15), and its decidedInside exclusion holds because measures.duration is in no decision. repeater-row-properties.test.ts derives its population from the forms, pins only FORMS.length at 15, and requires of each row child a declared label, an en leaf equal to that label, a value in every locale and no en echo; the duration row meets all four. The Lint-lane i18n guards (check-i18n-bundles, check-i18n-stale-fill, check-i18n-walk-parity) ran green at 3f3f095 over bundles that are byte-identical at this head. Outside platform-objects, Test Core's other seven shards attested green at 3f3f095, which covers repeater-item-titles.test.ts and every other spec and service reader of datasetForm.

Why Test Core (5/8) was red, read from the record rather than the dev. The shard's only failing command was packages/platform-objects pnpm run test; no other package in the shard annotated a failure. The per-test job log could not be read from this seat (the Actions log redirect to the Azure blob host is refused by the egress proxy with a 403 tunnel), so the set of red tests inside that package is derived, not observed: the sweep above finds exactly two platform-objects assertions that a new translated repeater-row label can move, and the delta moves exactly those two. Test Core (5/8) was green on the merge-base 6e0597f2, red at 3f3f095, and is running on this head; its result is the observation this derivation is conditional on.

② Semver level

Unchanged from 6407737885: Clause-②: yes (widening), Metadata-protocol: added, one changeset, five packages at minor. The delta touches two test files in @objectstack/platform-objects, which the changeset already names at minor for its generated form bundles; a test-only change owes no further changeset entry and none was added. Check Changeset is green on this head.

③ Boundary flags

  • Check-runs on this head at review time (60 runs; 25 green, 5 skipped, 30 running, 0 failed). Completed green: Check Changeset, Check PR Size, Check Documentation Links, Flag docs affected, Governed Surface Queue Guard, the three claim and part-of guards, Spec property liveness, Dogfood Regression Gate, Auto Label, filter, Lint and Repo Gates, every Lint lane (docs and code guards, ESLint, PM dispatch-gates, repo and tooling gates, CI release and engine gates, the three scoped lanes), Type Check source gates, Build Docs, Build and E2E UI. Skipped (opt-in): Temporal Conformance, Dogfood shards, Dogfood Verify CLI, Packed-tarball smoke, 18.x upgrade harness. Still running: Build Core, Lint UI, Type Check UI, Type Check workspace, debt ledger and consumer gates, Test Core 1 through 8 of 8, Test UI 1 through 16 of 16. No failure at the time of reading. This PASS is conditional on the running lanes finishing green, and on Test Core (5/8) in particular, which is the one lane whose red this delta exists to clear. The PR is still a draft with mergeable state blocked.

  • Test UI (11/16) was also red at 3f3f095, and the dev's account names only Test Core (5/8). The failing assertion is ui/scripts/__tests__/check-pre-install-import-graph.test.ts (finds no pre-install gate reaching a package), with the finding unresolved: scripts/objectstack-pin.mjs. That is main's break, not this PR's: ui/scripts/objectstack-pin.mjs does not exist at the merge-base, at this head or at origin/main (deleted by release(ui-merge): one release lane for both scopes — @object-ui joins the fixed group, @objectstack/console builds from ui/apps/console, the objectui pin is deleted (#25711) #25745 when the objectui pin was retired), while ui/.github/workflows/ci.yml still runs node scripts/objectstack-pin.mjs build at all three; the gate derives its population from the workflow files, so it walks to a file that is gone. The same check-run is red on the merge-base commit 6e0597f2 itself (two runs, both failure), and the gate's inputs (ui/scripts, ui/.github, .github/workflows) are byte-identical between the merge-base and this head. This PR touches nothing under ui/ except the two app-shell inspector files. Not a finding against the PR and not this delta's to fix, but it will go red again on this head and it blocks the merge until main repairs it; the PR body should say so rather than leave the lane unexplained, and the dev's os-dev-report should name it beside the 5/8 explanation.

  • Everything carried from 6407737885 still stands: the two unfiled out-of-scope findings (the prefixed ordinary-measure filter and the NULL ordering of an all-NULL sum group) must still be filed as cards before Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778 closes; the PR-body corrections (14 is the two-cell total, app-shell type-check not measured locally) are still owed; Type Check UI remains the lane to watch for the app-shell change.

Implemented-by: claude/project-thread-4m2bvo
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

…-4m2bvo

# Conflicts:
#	packages/spec/src/type-alias-convention.pin.test.ts
The merge of main at 493a6c4 (#25813) deferred content/docs/references/index.mdx.
Regenerated from the merged tree: main's column-apply-on page and SeedColumn
beside this branch's dataset duration schemas — 1544 schemas, 199 pages.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f4f3e4108e1bd9f2c23deee956b9f14098a4cd6f
Local-runs: none
Scope: packages/spec/src/data/analytics-column-reference.ts, packages/spec/src/ui/dataset.form.ts, packages/spec/src/ui/dataset.zod.ts + packages/spec/src/type-alias-convention.pin.test.ts, packages/lint/src/rule-explanations.ts

This is a delta over 6407978732, the PASS of record at 76866719f70f24b3ac7471c378bd7cf678bcf007, carried to 97934f05bf46dcf0881269a2f079bb0c9ce24fb0 by Regen-provenance 6408332614. The head moved by two commits: 7eb5576a4, a merge of origin/main at 493a6c4a5 (#25813, the seed applyOn column) made with os-regen-merge.sh, whose only hand-resolved conflict is type-alias-convention.pin.test.ts; and f4f3e4108, the regeneration of content/docs/references/index.mdx that the merge deferred. unexplainedPathsBetween names the two paths on the Scope line after the plus sign. Every judgment in 6407978732 stands as written: this record re-derives only the merge resolution, the auto-merged lint ledger, the regenerated index and the state of CI. The three schema hits are the same three spec paths, byte-identical in their hunks to the record of record (shown below), so the face and the tier are unchanged.

① Derived judgments

789 is the count the head actually holds, and all three statements of it agree. The test computes pins by reading its own source and matching every line that begins export type Iso followed by a word and = Assert. Counted with that exact regex over the head file: 789. The header comment at line 283 reads 789 isomorphic aliases, the it title at line 1713 reads still declares all 789 isomorphic pins, and the assertion at line 2603 is expect(pins).toHaveLength(789). The prose guard beneath the assertion matches every N isomorphic WORD phrase in the file and demands each start with the pin count; the only two such phrases at the head are the header and the title, both 789, so that guard passes too. The arithmetic the dev gives is the one the file holds: main's 787 (786 at the prior merge-base 2a728ba2, plus #25761's Iso_data_columnApplyOn__ColumnApplyOnSchema) plus this PR's two duration pins.

No pin from either side was lost, and none was duplicated. Set-compared the Iso_ names at four shas. Main at 493a6c4a minus the head: empty. Head minus main: exactly Iso_ui_dataset__DatasetDurationUnit and Iso_ui_dataset__DatasetMeasureDurationSchema, which are exactly the two names 97934f05 added over the record-of-record base. Main minus that base: exactly Iso_data_columnApplyOn__ColumnApplyOnSchema, present at the head at line 786 under its own // data/column-apply-on.zod.ts section. Duplicate names at the head: none. The two duration pins sit in the // ui/dataset.zod.ts block between DatasetDimensionSchema and DatasetMeasureSchema, in the alphabetical order the block keeps, with the [#25778] provenance comment above them that the record of record read.

The three conflict hunks were resolved as a union, re-derived rather than picked. Reproduced the merge with git merge-file over the three versions (ours 97934f05, base 6e0597f2, theirs 493a6c4a): exactly three conflicts, all of them count statements, none of them a pin. Hunk one, the header, 788 against 787, resolved to 789. Hunk two, the it title, same pair, resolved to 789. Hunk three, the ledger tail: ours carried a 786 to 788 is #25778 entry with toHaveLength(788), theirs carried a 786 to 787 is #25761 entry with toHaveLength(787). The head keeps main's #25761 entry verbatim, then appends this PR's entry with its arrow rewritten to read 787 to 789, the same six-line body, and toHaveLength(789). That is the file's own convention for parallel movements, stated in the ledger a dozen times above it (an entry authored off one count and re-derived on merge has its arrow start from the count that landed first). git diff from main to the head over this file is four hunks and nothing else: header, the two pins with their comment, the title, and the appended ledger entry with the moved assertion. Every import main added (M192, ./data/column-apply-on.zod.js, line 280) and every import the PR relies on (M160, ./ui/dataset.zod.js, line 248) is present once; no M module name is bound twice. The targets resolve at the head: dataset.zod.ts exports DatasetDurationUnit (line 220, a four-value enum) and DatasetMeasureDurationSchema (line 276); data/column-apply-on.zod.ts exports ColumnApplyOnSchema (line 20).

rule-explanations.ts is the clean union of both sides. Main's edit since the base is one sentence appended to TRANSLATION_TARGET_UNKNOWN_EXPLANATION (reports.NAME a report's name that a package of this stack declares), present once at the head. This PR's edit is the duration cross-reference sentence in MEASURE_AGGREGATE_FIELD_TYPE_REFUSED_EXPLANATION, the new MEASURE_DURATION_FIELD_TYPE_REFUSED_EXPLANATION constant (rule id measure-duration-field-type-refused, three paragraphs), and its one registration line in RULE_EXPLANATIONS between the JSON-stored-dimension entry and the React chart entries. The constant's name occurs exactly twice (declaration and registration) and the rule id exactly once, so there is no duplicated entry. The hunk bodies of the head-against-main diff are byte-identical to the hunk bodies of the record-of-record diff; only the registration hunk's line number moved, by the one line main added. The two sides touch regions some 1,400 lines apart, which is why git merged this file without a conflict.

The regenerated index closes. At the head the fourteen module rows sum to 199 pages and 1544 schemas, which is what the Total row and the front-matter description state. Each module's own Source: header carries the same pair as its summary row. Relative to main the only movement is UI, 169 to 171 schemas on the same 16 pages, with the dataset.zod.ts row gaining DatasetDurationUnit and DatasetMeasureDuration, exactly the two bare names the two new pins exempt. Relative to the merge commit 7eb5576a4, the regeneration moved only what main's #25813 owed the index: Data 29 to 30 pages and 178 to 180 schemas (a column-apply-on.zod.ts row with ColumnApplyOn, and SeedColumn on the seed.zod.ts row), and the Total from 198 to 199 pages and 1542 to 1544 schemas. The column-apply-on.mdx page the new row links exists at the head. The commit message's claim, 1544 schemas and 199 pages from the merged tree, is the one the file holds.

Everything else the PR carries is unchanged. packages/spec/llms.txt and content/docs/getting-started/quick-reference.mdx are byte-identical between the head and main. The net diff of the head against 493a6c4a lists 40 paths; the net diff of 97934f05 against its true merge-base 2a728ba2 lists the same 40 paths. For the 37 paths other than the three re-derived here, the hunk bodies (every line of each diff other than the index and @@ lines) are byte-identical between the two net diffs. That covers the three schema hits on the Scope line, the generated form bundles and the two platform-objects pins 6407978732 derived, the lint rules, the service-analytics compiler and executor, the surface manifests and liveness ledgers, the changeset and the two app-shell inspector files.

② Semver level

Unchanged from 6407978732: Clause-②: yes (widening), Metadata-protocol: added, one changeset, five packages at minor. The delta adds no changed file to the PR's net diff; the merge and the regeneration bring main's own content and re-derive three files the PR already touched. Check Changeset is green on this head.

③ Boundary flags

  • Check-runs on this head at review time (59 runs; 16 green, 5 skipped, 38 running, 0 failed). Completed green: Auto Label, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected, Governed Surface Queue Guard, the three claim and part-of guards, Spec property liveness, filter, and two Lint lanes (CI, release and engine gates; docs and code guards). Skipped (opt-in): Temporal Conformance, Dogfood shards, Dogfood Verify CLI, Packed-tarball smoke, 18.x upgrade harness. Still running: Build Core, Build and E2E UI, Lint UI, Lint ESLint, Lint PM dispatch-gates, Lint repo and tooling gates, the three scoped Lint lanes, the four Type Check lanes and Type Check UI, Test Core 1 through 8 of 8, Test UI 1 through 16 of 16. No failure at the time of reading. This PASS is conditional on the running lanes finishing green, and on Test Core (5/8) in particular, which the record of record left as the lane whose red the previous delta cleared and which has not yet reported on any head since. The PR is no longer a draft; mergeable state is blocked on the running lanes.

  • origin/main has moved past the merge. The merge took main at 493a6c4a (feat(spec,metadata-protocol): a seed dataset column can be written only when its row is created (columns.FIELD.applyOn: 'create') #25813). origin/main is now one commit further, d65cbb61 (docs(skills, spec): every published package declares one anchor-safe isDefault permission set #25930), which touches .changeset/25773-published-package-default-permission-set.md, content/docs/references/marketplace/marketplace.mdx, packages/spec/src/marketplace/marketplace.zod.ts and skills/objectstack-data/rules/security.md. The PR touches none of those four, so the next merge is conflict-free, but a two-dot diff against origin/main now shows those four as the PR's until it is taken; a reader of such a diff should subtract them. Not a finding against the PR.

  • Test UI (11/16) is still main's break, and will still go red here. The gate ui/scripts/__tests__/check-pre-install-import-graph.test.ts and ui/.github/workflows/ci.yml did not change on main between 2a728ba2 and 493a6c4a; the workflow still runs node scripts/objectstack-pin.mjs build and ui/scripts/objectstack-pin.mjs is still absent at the head. The derivation in 6407978732 stands unchanged, as does the ask that the PR body and the os-dev-report name it beside the Test Core (5/8) explanation.

  • Everything carried from 6407978732 still stands: the two unfiled out-of-scope findings (the prefixed ordinary-measure filter and the NULL ordering of an all-NULL sum group) must still be filed as cards before Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778 closes; the PR-body corrections (14 is the two-cell total, app-shell type-check not measured locally) are still owed; Type Check UI remains the lane to watch for the app-shell change.

Implemented-by: claude/project-thread-4m2bvo
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么
没有新改动。#25813 合并之后,合并队列因为计数冲突把本 PR 退了回来。开发者重新并入 main:类型别名钉现在是 789,也就是 main 的 787 再加本 PR 的 2 个;参考文档索引重新生成。

为什么改
两张 PR 都改了同一组计数,需要叠加。

风险与代价(含回滚)
无新增风险。Fable 增量复核逐条核对了两边的钉,一条没丢。

席位意见
建议合并。席位契约复核在新头 PASS(6409105319)。

你要做的
无。

席位核对

Seat domain:spec#4 (#18917) · session_01K2Tat8JEt4AhLASV4V4QWE · head f4f3e4108e1b · 2026-10-26T05:42Z · verdict ACCEPT (Tier S; the seat lands it through the merge queue). This supersedes seat record 6408340964 (head 97934f05bf46).

  1. Shape. 40 files, the same list as before. The queue dropped the PR with MERGE_CONFLICT after feat(spec,metadata-protocol): a seed dataset column can be written only when its row is created (columns.FIELD.applyOn: 'create') #25813 (493a6c4a) merged. The dev merged main with os-regen-merge.sh (7eb5576a4) and regenerated references/index.mdx (f4f3e4108).
  2. Contract review of record: delta PASS 6409105319 on this head at CONTRACT_REVIEW_TIER, a delta over 6407978732. It finds:
    • the pin test holds 789 Iso_ pins, with header, title and toHaveLength in agreement; main's set is fully kept, plus exactly this PR's two;
    • rule-explanations.ts keeps both sides;
    • the index.mdx arithmetic closes at 199 / 1544;
    • llms.txt and quick-reference.mdx are byte-identical to main;
    • the other 37 paths are byte-identical in their hunks.
  3. Gates. The dev's re-check on this head exits 0 on every gate: check-generated 14/14, check-spec-parsed-alias 1464/789/675, check:llms-txt, check:quick-reference-counts, check:i18n, and the pin, duration and lint tests. The queue runs the full suite. Test UI (11/16) is main's red (the dev's comment 6407707565).
  4. Landing. automerge_enable through the relay in this pass. The seat confirms MERGED and the close of Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778.

…-4m2bvo

# Conflicts:
#	packages/services/service-analytics/src/strategies/native-sql-strategy.ts
…n the statement, and the all-NULL group sorts and pages as it

Main now answers a measure's empty-group identity in the native statement
(COALESCE) instead of folding it after the rows come back, and orders with
NULLS LAST. The duration measure's statement takes the same wrap, so:

- the exact-SQL cases expect COALESCE(SUM(...), 0) on the filtered sum;
- the presentation case's client hands back the statement's 0;
- the live ordering case no longer leaves out group c (no record carrying
  both columns): ascending it pages as b (-3599.25), c (0), a (91800) on
  SQLite and PostgreSQL alike.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8821a8d40e7c4e93f7353433648dbb386b3123fa
Local-runs: none
Scope: packages/spec/src/data/analytics-column-reference.ts, packages/spec/src/ui/dataset.form.ts, packages/spec/src/ui/dataset.zod.ts + packages/services/service-analytics/src/strategies/native-sql-strategy.ts, packages/services/service-analytics/src/tests/dataset-duration-measure.test.ts, packages/services/service-analytics/src/tests/native-sql-duration-measure.test.ts

This is a delta over 6409105319, the PASS of record at f4f3e4108e1bd9f2c23deee956b9f14098a4cd6f. The head moved by two commits: b0bf2f6a3, a merge of origin/main at 38050ced7 (#25816) made with os-regen-merge.sh, whose only hand-resolved conflict is native-sql-strategy.ts; and 8821a8d40, which updates the two duration test files for the semantics that merge brought. The merge took fourteen main commits, among them d5f4c2a2 (#25953, the all-NULL sum group), the one that edits the same block of execute() the PR edits and that was dequeued against this PR. unexplainedPathsBetween names the three paths on the Scope line after the plus sign. Every judgment in 6409105319 stands as written: this record re-derives only the merge resolution, the duration measure's empty-group semantics under main's new rule, and the two test files. The three schema hits are the same three spec paths, byte-identical in their hunks to the record of record (shown below), so the face and the tier are unchanged.

① Derived judgments

The conflict was exactly the one #25953 predicted, and only that one. Reproduced the merge with git merge-tree --write-tree f4f3e4108 38050ced7: one conflicted path, native-sql-strategy.ts, content conflict. The two test files were not conflicted and the merge commit leaves them byte-identical to f4f3e4108; 8821a8d40 is the only commit that touches them, and it touches nothing else (2 files, 31 insertions, 19 deletions).

Nothing of #25953 was lost or reverted. Every added line of d5f4c2a2's hunk on native-sql-strategy.ts (the NULLS_LAST_SQL table, the exported orderClauseSql, withEmptyGroupIdentity, the rewritten execute() comment, the orderClauseSql and windowClauseSql call pair in generateSql's tail, and the two withEmptyGroupIdentity wraps in resolveMeasureSql) is present at the head, matched line for line. Every distinctive line #25953 removed (const folds, the folds.push line, the emptyGroupValueFor(this.lookupMember(...)?.type) read, the row[member] === null fold, the old inline ORDER BY builder) is absent at the head; the only removed lines still found are closing braces and the for (const row of rows) header, which belong to the number presenter loop that both sides keep. git diff origin/main pr-25903-new over this file is eight hunks, all of them this PR's duration work: the DurationMeasureSpec type import, the duration-measure-sql.js import, the canHandle decline and its durationMeasureOnUnknownDialectIn helper, the durations / durationOf lookup in execute(), the durationOf(member) short-circuit in the number presenter, the durationKey filter lookup and the resolveDurationMeasureSql dispatch in generateSql, and resolveDurationMeasureSql itself. The merge commit's version of the file is the head's (same blob, acdc2469).

The resolution took main's side of execute() and kept only what the presenter still reads. Against the record of record, the PR's execute() hunk shrank from a six-line edit of the fold loop (the durationOf(member)?.aggregate ?? ... read inside folds) to the two-line durations / durationOf lookup plus a comment. The fold loop is gone with main, and nothing of the PR re-adds a JS fold: folds occurs nowhere in the head file. The durationOf closure is still read once, by the number presenter's numberMeasures filter, which is why it survives. The removed fold is not needed by any duration path: presentAsNumber (packages/core/src/utils/aggregate-answer.ts) repairs only non-empty strings and returns every other value unchanged, so a NULL avg arrives at the caller as null and a '0' string from node-postgres arrives as 0; the dataset door's fillEmptyGroups (dataset-executor.ts) still runs after and is a no-op on a 0, as its header says.

The duration measure's empty-group rule is now #25953's rule, read from the same policy. resolveDurationMeasureSql wraps both its plain and its conditional aggregate in withEmptyGroupIdentity(duration.aggregate, ...), the function main added, which reads emptyGroupValueFor (spec data/aggregation-policy.ts) and wraps in COALESCE(..., 0) only when the policy declares an identity and the aggregate is not count / count_distinct. The policy declares 0 for sum, count and count_distinct and undefined for everything else. DATASET_DURATION_AGGREGATES is avg, min, max, sum; so a duration sum is wrapped and a duration avg / min / max is left bare, exactly the split main applies to an ordinary measure. The exact-SQL case pins both halves of that split in one statement per dialect: AVG(...) bare and COALESCE(SUM(CASE WHEN ... END), 0) on the filtered sum. This is the routing #25953's merge note asked the second-landing PR to take, in place of restoring the fold.

The test changes are controls, not loosened pins; each new expectation follows from the code. native-sql-duration-measure.test.ts: the three exact-SQL cases (sqlite, postgres, mysql) keep their full-statement toBe and now expect COALESCE(SUM(CASE WHEN priority = $1 THEN ... END), 0) AS "total_seconds", which is what withEmptyGroupIdentity('sum', CONDITIONAL_AGGREGATE_SQL.sum(operand, predicate)) emits; the avg half is unchanged and still bare. The presentation case changes one fixture cell, total_seconds: null to '0', because the statement no longer hands back a NULL for that group, and its expected output row is unchanged (avg_hours: null, total_seconds: 0): the avg null-passthrough is still asserted, and the '0'-to-0 presentation replaces the fold as the thing under test. dataset-duration-measure.test.ts: the live ordering case drops the runtimeFilter: { category: { $ne: 'c' } } that excluded the all-NULL group, adds an offset to page, and asserts page 0 is b -3599.25 and page 1 is c 0, with the statement containing both ORDER BY "total_seconds" ASC and COALESCE(SUM(. Re-derived from the fixture: a is 5400 + 86400 = 91800 seconds (t3 has no resolved_at), b is 0.75 + (-3600) = -3599.25, c has no row carrying both instants and so sums to the identity 0; ascending that is b, c, a. The case runs on both cells of CELLS (SQLite and the live PostgreSQL cell), so the page is asserted on both dialects. The per-group case that already asserted c total_seconds 0 and c avg_hours null is unchanged. No $ne exclusion of group c remains anywhere in the file. The dev's count (removing the wrap fails 7 tests) is not measured here (Local-runs: none); what is derivable is that the three exact-SQL toBe statements and the page-1 c 0 assertion on each cell cannot pass without the wrap.

Everything else the PR carries is unchanged. The net diff of the head against origin/main lists 40 paths; the net diff of f4f3e4108 against 493a6c4a5 lists the same 40 paths. For the 37 paths other than the three re-derived here, the hunk bodies (every line of each diff other than the index and @@ lines) are byte-identical between the two net diffs. That covers the three schema hits on the Scope line, the generated form bundles, the platform-objects pins and the type-alias-convention.pin.test.ts ledger derived in the two previous records, the lint rules, the dataset compiler and executor, duration-measure-sql.ts, the surface manifests and liveness ledgers, the changeset and the two app-shell inspector files.

② Semver level

Unchanged from 6409105319: Clause-②: yes (widening), Metadata-protocol: added, one changeset, five packages at minor. The delta adds no changed file to the PR's net diff. Check Changeset is green on this head.

③ Boundary flags

Implemented-by: claude/project-thread-4m2bvo
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么
#25953(全空的求和分组按 0 排序和分页)合并后,与本 PR 改了同一段代码,合并队列又把本 PR 退了回来。开发者重新并入 main,手工解决了 native-sql-strategy.ts 一个文件:execute() 取 main 的写法,时长度量的求和改用 main 新加的 withEmptyGroupIdentity,空分组在 SQL 里就答 0。两份时长测试随之更新,原先排除的空分组 c 现在也纳入分页断言。

为什么改
两张 PR 都改了空分组的处理,需要按 main 的新规则统一。

风险与代价(含回滚)
无新增风险。Fable 增量复核逐行确认 #25953 的改动全部保留,时长的 avg/min/max 仍保持 NULL,与普通度量的规则一致;其余 37 个文件与上一版逐字相同。

席位意见
建议合并。席位契约复核在新头 PASS(6410232496)。

你要做的
无。

席位核对

Seat domain:spec#4 (#18917) · session_01K2Tat8JEt4AhLASV4V4QWE · head 8821a8d40e7c · 2026-10-26T07:04Z · verdict ACCEPT (Tier S; the seat lands it through the merge queue). This supersedes seat record 6409116784 (head f4f3e4108e1b).

  1. Shape. 40 files, the same list as before. The queue dropped the PR with MERGE_CONFLICT after fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL #25953 (d5f4c2a2) merged. The dev merged main at 38050ced7 with os-regen-merge.sh (b0bf2f6a3), hand-resolving native-sql-strategy.ts, and updated the two duration tests (8821a8d40).
  2. Contract review of record: delta PASS 6410232496 on this head at CONTRACT_REVIEW_TIER, a delta over 6409105319. It finds:
    • every line fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL #25953 added to native-sql-strategy.ts is present and every line it removed stays removed; the file's diff against main is only this PR's duration work;
    • a duration sum is wrapped in COALESCE(..., 0) through withEmptyGroupIdentity, and avg / min / max stay bare, the same split main applies to ordinary measures;
    • the test changes are real controls: full-statement pins on three dialects, and the ordering case now pages b, c, a on both cells, re-derived from the fixture;
    • the other 37 paths are byte-identical in their hunks.
  3. Gates. The dev's re-check on this head exits 0: duration 14/14 and unit 23/23 on live SQLite and PostgreSQL, fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL #25953's own test, full service-analytics (one known non-UTC test aside), tsc, check-generated --fix 14/14. The queue runs the full suite. The head predates ci(ui-merge): the pre-install import graph reads the repository root's workflows; objectui's prompt files move to the root .github/prompts/ #25957, so Test UI (11/16) may still show main's old red; it is not a required context.
  4. Nit, not blocking. The PR body's note that the all-NULL sum group is excluded and filed as analytics native SQL: an all-NULL sum group is folded to 0 after ORDER BY / LIMIT ran on the NULL, so SQLite and PostgreSQL return different sorted pages #25907 is stale: fix(service-analytics): an all-NULL sum group sorts and pages as the 0 it answers, the same on SQLite and PostgreSQL #25953 fixed it and this head pages the group.
  5. Landing. automerge_enable through the relay in this pass. The seat confirms MERGED and the close of Analytics datasets: no measure for the difference between two datetime columns (cycle time, per-stage duration, SLA overrun) — #16737 refused the wrong path, nothing provides the right one #25778.

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

Projects

None yet

2 participants