Skip to content

fix(service-analytics): type a formula measure column from its declared returnType (#16236) - #17537

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-16236-formula-return-type-channel
Sep 10, 2026
Merged

fix(service-analytics): type a formula measure column from its declared returnType (#16236)#17537
os-sales merged 3 commits into
mainfrom
claude/issue-16236-formula-return-type-channel

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #16236

FieldSchema.returnType declares what a formula field computes, and that key's own JSDoc names dataset measures first among its consumers. That consumer could not read it: the one channel from the host to AnalyticsService for a field's declared metadata — AnalyticsServiceConfig.sourceFieldMeta — returned three members and none of them was returnType. So measureResultType received the bare string 'formula' and every formula measure column was described number, including formulas declared 'text' or 'date'.

Located by TEXT — both line numbers on the card had rotted, and so had the fix

what card / triage said measured on origin/main @ bea76c928
returnType declaration field.zod.ts:1399 packages/spec/src/data/field.zod.ts:1483
the host callback analytics-service.ts:603 packages/services/service-analytics/src/analytics-service.ts:694
its private field analytics-service.ts:835
the call site analytics-service.ts:1821 (the ADR-0021 result-column enrichment; the only one)
the real host wiring packages/services/service-analytics/src/plugin.ts:1051

That last row is the one nobody had named, and it decided decision 1 below. The plugin already holds the whole field definition — dataEngine().getObject(object).fields[field] — and hands three EXTRACTED keys to the service.

The reproduction, driven before anything was written

packages/services/service-analytics/src/__tests__/formula-return-type-measure.test.ts section B, on unchanged origin/main:

{"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}],
 "fields":[{"name":"first_label","type":"number","label":"First Label"},
           {"name":"latest_due","type":"number","label":"Latest Due"}]}

Both values are strings; both descriptors say number.

⚠️ Why the pair is still reachable — re-measured, not inherited. The card's live control predates the compile leg of #16099, and AGGREGATE_FIELD_TYPE_COMPATIBILITY does not list formula under min or max. It is nonetheless not refused today: dataset-compiler.assertAggregateFieldTypeCompatible executes only the TEMPORAL rows — if (!TEMPORAL_SOURCE_FIELD_TYPES.has(fieldType)) return; — by its own documented scope, and formula is outside that class. Section B carries a standing control for exactly this (the pair is REACHABLE), so the branch this PR adds is reached by the tree as it ships. If that full-table leg is ever executed the branch becomes unreachable and inert — never wrong. See acceptance notes.

Decision 1 — the callback's shape: widen the hook. ⛔ Not the field definition

Triage left this open: 「是给 sourceFieldMeta 的返回值加一个 returnType,还是把字段定义整个交给 measureResultType 而不是三个抽出来的键。⭐ 后者一次解决"下一个需要第四个键的规则",但扩大了传给分析层的元数据面 —— 权衡后说明选了哪个。」

Chosen: widen the return with one declared, optional member. The trade-off weighed, with the deciding argument the one the host wiring supplies:

  • What the alternative really widens. The extraction at plugin.ts:1051 is not merely a projection — it is a NARROWING that keeps the data engine's internal field shape out of a published contract. sourceFieldMeta is reached through the exported AnalyticsServiceConfig, so handing the definition through would publish whatever dataEngine().getObject().fields[field] happens to be as the hook's declared type. That is a much larger and less deliberate surface than "one more key", and it is owned by a different layer.
  • What it costs to document. The hook's JSDoc says who reads each key ("Feeds three chains", now four). A hook that hands over everything cannot carry that sentence, so the next reviewer loses the ability to see what the analytics layer actually consumes — the property that made this card findable at all.
  • What the alternative buys, honestly: the next rule needing a fifth key repeats this edit. Accepted. One declared key per consumer, each arriving with the note that says who reads it, is the cheaper failure mode than an open surface nobody can audit.
  • Compatibility, measured on the built dist/index.d.ts: the member is optional, so a host returning today's three-member shape still satisfies the contract and gets today's behaviour on every column. Hosts on AnalyticsServicePlugin need no change — the plugin relays the key.

⛔ Nothing in packages/spec moves. returnType already exists there and this card only READS it, which is what keeps the change in this lane.

plugin.ts is outside the declared write surface — what obliges it

The dispatch order's expected write surface named analytics-service.ts, measure-result-type.ts, strategies/types.ts and tests. packages/services/service-analytics/src/plugin.ts:1051 is not on it, and it is edited here. What obliges it:

plugin.ts is the only producer of the value the widened hook declares. sourceFieldMeta is not an author-facing option — measured, not assumed: AnalyticsServicePluginOptions contains 0 occurrences of sourceFieldMeta (firing control on the identical grep shape, same 95-line block: queryCapabilities = 1). The plugin SYNTHESISES the hook from dataEngine().getObject(object).fields[field] and hands it to the service. So widening the declaration without editing this file ships a key that is declared, read by measureResultType, and never populated by the shipped host — a capability the runtime does not deliver. The alternative is not "a smaller diff"; it is a hook nobody fills.

Forced, not convenient, and the boundary is visible in the diff: the edit is one destructuring member plus its note. It adds no branch, narrows nothing, and relays returnType exactly as type, max and currencyConfig.defaultCurrency are already relayed on the two lines above it. Nothing else in plugin.ts moves. ⛔ The justification is not that a sibling PR touched the file.

Collision re-checked at write time rather than inherited from the claim: all 19 open PRs' file lists re-read, 0 touch anything under packages/services/service-analytics/src/ — with a firing control on that zero (the same probe correctly reports PR #17454 holding packages/plugins/plugin-auth/src/auth-schema-config.ts, count 1, over 242 file rows read).

docs-drift-check — the one row, answered on the tree the bot named

Read on c4703f2324c4c1743591ca1907361e43eef2c2aa (the merge of this head into base), ⛔ not on this worktree's older base.

content/docs/plugins/packages.mdx — NOT falsified. Own reading, not inherited from the sibling delivery on this package; the two reasons it was re-checkable are answered directly:

The page names the symbol at :502 (import { AnalyticsServicePlugin } from '@objectstack/service-analytics';) and :506 (await kernel.use(new AnalyticsServicePlugin({ /* … */ }));), inside one "For Enterprise Applications" composition example with the options elided. It also carries :17 (a table row listing service-analytics among 16 platform services) and :213:219 (the package's own entry: "Multi-driver analytics with built-in NativeSQL and ObjectQL strategies (the lowest-priority InMemoryStrategy is not built in …)", plus a README link) — strategy composition, not a config surface.

The sentence that decides it is the blockquote at :508: "The official plugin and service packages above export plugin classes you instantiate (new XPlugin(options)) — check each package's README.md for its option shape." The page DELEGATES the option shape rather than stating it, so there is no sentence about this plugin's config surface for a new member to falsify.

Both re-check reasons answered on their own terms rather than waved past:

  1. This diff touches plugin.ts, so the construction path is in scope. It is — and the page's only construction site elides the options entirely ({ /* … */ }), so it states nothing about that path beyond "you instantiate it with options".
  2. A new member on the config hook is exactly where a falsification would live. It cannot land here, because sourceFieldMeta is not on the type this page's delegation points at: AnalyticsServicePluginOptions carries 0 occurrences of it (control: queryCapabilities = 1). The member added is on AnalyticsServiceConfig, which the plugin builds internally.

The delegation target was measured too, since a page that says "read the README" makes the README load-bearing: packages/services/service-analytics/README.md contains 0 occurrences of sourceFieldMeta and 0 of returnType (firing control, identical shape, same file: AnalyticsServiceConfig = 4). Its :174 sentence — "AnalyticsServiceConfig is the wider surface the plugin builds — it adds logger, strategies, fallbackService, … and the dataset resolvers on top of the plugin options above" — already omitted sourceFieldMeta before this diff and is an open-ended list, and this diff adds a member to that hook's RETURN rather than a key to AnalyticsServiceConfig, so its accuracy is unmoved in both directions. See acceptance notes.

The emitter-blind half — a measured zero where there is one, and read hits where there are not

The bot's own caveat is carried rather than ignored: it says its checkout 「carried uncommitted changes, so the commit above does not fully identify what was read」, so the hand search is scoped to the WHOLE corpus, not to its one-row list. 402 doc files on the bot's tree, 181 of them hand-written (excluding the auto-generated references/ and release-owned releases/). Probes are by INPUTS and concepts, not by emitter identifiers, and every count carries a firing control of the identical grep shape on the same corpus.

probe all hand-written verdict
sourceFieldMeta 0 0 measured zero
measureResultType 0 0 measured zero
DimensionType 2 0 measured zero in hand-written prose
formula NEAR "return/result type" 1 0 measured zero in hand-written prose
returnType 9 4 read below
fields[].type / "measure column" 1 1 read below
AnalyticsResult 6 2 read below
"dataset measure" 6 4 read below
CONTROL formula 71 48 fires
CONTROL dataset 63 36 fires
CONTROL analytics 90 49 fires
CONTROL AnalyticsServicePlugin 1 1 fires — and is the bot's own row

The eleven hand-written hits were READ, not counted. None is falsified:

  • data-modeling/formulas.mdx — the closest call, and the one that moves in the right direction. At :128 it says "Consumers read the declared returnType instead of re-parsing the expression (record-title eligibility, for one: a formula is title-eligible only when its returnType is 'text')". It names one consumer, explicitly as one of several, and states nothing about a measure column's type. This PR adds a consumer, so the sentence becomes more true rather than less. Its authoring examples at :99:107 and :415:421 are unaffected — no key is added and no accepted value moves.
  • data-modeling/field-types.mdx:417 and data-modeling/validation-rules.mdx:352 — key tables giving returnType as 'number' | 'text' | 'boolean' | 'date', the second adding "Optional inferred result type". Both still exact: this PR reads those four members and adds none, and the optionality they state is precisely the absent tier written down above.
  • getting-started/common-patterns.mdx:143 — an authoring example, returnType: 'number'. Unaffected.
  • data-modeling/analytics.mdx:216 — "each measure column carries its label and format on the result fields". Names label and format; says nothing about type, which is the key this PR moves.
  • api/data-api.mdx:434 — "Each entry carries name and type and, when the producer declares them, label, format, currency, percentScale and builtinAggregate". A statement about which MEMBERS exist and which are optional; this PR changes neither, only the VALUE of type for one population. Its response example at :414:429 shows revenue_sum and count as "number" — a sum and a count over non-formula fields, both outside this PR's min/max-over-formula population, so the example stays exactly correct.
  • api/client-sdk.mdx:319 — reads result.fields[0].name. Not type.
  • data-modeling/queries.mdx:387, deployment/validating-metadata.mdx:169, protocol/objectql/query-syntax.mdx:940, ui/reports.mdx:195 — all about measure NAMING and chart axis keying, none about column typing.

⇒ One row owed, answered NOT falsified on the bot's tree with its reasoning re-derived; emitter-blind sweep over all 181 hand-written pages: no falsified page, four measured zeros each with a firing control.

Decision 2 — ⚠️ the mapping is a TRANSLATION, and one invariant keeps it one

Triage raised this to a hard condition: 「returnType 的四个成员 → DimensionType 的 wire 词汇(string / number / boolean / time / geo)。textstringdatetime ⛔ 不要直接把 returnType 的字面量当 wire 词用 —— 两套词汇不是一回事。」

FORMULA_RETURN_TYPE_RESULT in measure-result-type.ts:

declared returnType verdict why
text 'string' TRANSLATED — text is not a wire word
date 'time' TRANSLATED — date is not a wire word
number no correction identical to the NUMERIC_VALUE_TYPES row: the producer's number is already right
boolean no correction identical to the BOOLEAN_VALUE_TYPES row: three readings disagree on what min/max over a boolean returns

The pin that stops this degrading into a pass-through is not four expectations — it is one invariant: ⛔ NO member of returnType is answered by its own spelling. Four hand-written expectations would let a relay survive on the two rows where the two vocabularies happen to share a spelling. All four members satisfy the invariant here, so a relay reintroduced anywhere reds on every row it touches — including a fifth member added to the spec enum later. The enum walked is read off FieldSchema itself, so a member added upstream lands as an exhaustiveness failure rather than falling silently into the "cannot answer" tier.

⚠️ The absent-returnType tier is WRITTEN DOWN, not implied

Triage: 「在 measureResultType 的表里明确记一行「formulareturnType 缺席 ⇒ 不作答,保留生产者给的词」,⛔ 不要让它成为一条只存在于代码路径里的隐含行为。」

Three places, none of them a code path:

  1. A row in the module header's field-type table| `formula`, `returnType` ABSENT | formula | no correction — do not answer, keep the word the producer minted |.
  2. Its own header section, ### formulawith NOreturnType — a ROW, not an implied code path, which also states the same verdict for a word outside the declared four.
  3. Named test cases in both suites, including an end-to-end one (an UNPROVEN formula keeps the producer's word). The enum-walk suite's formula fixture wires { type: 'formula' } with no returnType, so it drives the absent tier on the live path as a side effect of existing coverage.

The bucket in measure-result-type.test.ts is renamed not-on-this-inputdeclared-elsewhere: on the declared FieldType alone the verdict is still no correction, but the answer is no longer unreachable.

RED before GREEN, proven by state

Staged deliberately so the card's thesis is isolated rather than asserted, each reading taken from a COMMITTED tree:

commit state reading
372f3b12a pin only, production untouched RED — 7 failed, 10 passed. AssertionError: expected 'number' to be 'string' · expected 'number' to be 'time'
563dbe756 rule implemented, channel still unwired RED — 2 failed, 15 passed. Section A fully green, section B still expected 'number' to be 'string' / 'time' ⇒ the rule can answer and the CHANNEL is what was missing. That is the card's thesis, measured.
91021a6c8 channel wired GREEN — 150 passed across this pin plus measure-result-type.test.ts and preview-column-enrichment.test.ts

Ablation — the observed direction, not the predicted one. The translation table was mutated into a relay (text: 'text', date: 'date', number: 'number', boolean: 'boolean') from committed state:

HEAD blob   = 9af67774a5cf5553311867352d939d565928bf87
before hash = 9af67774a5cf5553311867352d939d565928bf87   (tree proven at HEAD before mutating)
after hash  = d7fd9a60138df7ba22eba8181471a0579050f422
occurrence counts on the exact text touched:
  deleted `date: MEASURE_RESULT_TYPE_TEMPORAL,`  before 1 → after 0
  injected `date: 'date',`                       before 0 → after 1

Observed: RED, 8 failed / 9 passed, and the anti-pass-through invariant is among them, failing on all four members. Restored by STATE, never by exit code: git hash-object back to 9af67774a, git diff HEAD empty, git status --porcelain empty, occurrence counts back to 1 / 0. The restore is git checkout HEAD -- $ABSOLUTE_PATH in an EXIT INT TERM trap, never a bare git checkout --.

The instrument reads src, not dist: the test imports ../measure-result-type.js relative, inside its own package, and the staged RED at 563dbe756 changed its result with no build in between — which is the proof rather than the claim.

Verification

  • Gates: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN — a DERIVED zero, reconciled with node scripts/pm/dispatch-gates.mjs --ran RANFILE --repo objectstack-ai/objectstack, every command recorded as COMMAND :: exit CODE. All 61 exit 0.
    • Three first exited 3 = PREREQUISITE NOT MET = NOT MEASURED, all one cause (unbuilt workspace): check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. The prerequisite was satisfied with lint.yml's own turbo run build --filter='./packages/*' --filter='./packages/*/*' (VERDICT command-exit 0, 72/72 tasks) and all three re-run to exit 0. ⛔ None was rounded up.
  • pnpm --filter @objectstack/service-analytics typecheck — exit 0. It reaches *.test.ts: an earlier run failed FROM inside the new test file (error TS2305 on an ExecutionContext import path), which is direct evidence of coverage rather than a claim about it.
  • pnpm --filter @objectstack/service-analytics test108 files, 2313 tests, all passing.
  • pnpm --filter '@objectstack/service-analytics^...' build — VERDICT command-exit 0.
  • pnpm lint was run WHOLE, not narrowedeslint . --no-inline-config, 6584 files, 0 errors, 0 warnings, exit 0, at 91021a6c8. File count read from --format json; the population is eslint's own resolution of ., not an estimate. (For the record the config enables no type-aware linting at all — eslint.config.mjs says so at line 328 with its own positive control — so this diff could not have moved an untouched file's verdict either way.)
  • Every heavy run went through scripts/pm/os-verify-lock.sh; its VERDICT command-exit line is the verdict quoted above, never a bare $?.

Clause-② — both limbs measured

Clause-②: yes

  • Limb A — does it widen the accept set? YES. AnalyticsServiceConfig.sourceFieldMeta's declared return goes from a three-member object to a four-member one, so a host MAY now return a key the hook did not previously accept.
  • Limb B — new key on a published payload, or a new exported symbol? YES. Measured on the built dist/index.d.ts, not assumed: sourceFieldMeta there now reads { type?: string; defaultCurrency?: string; max?: number; returnType?: string }. AnalyticsResult.fields[].type also changes its observable value for formula measure columns.
    • ⭐ Measured the other way too, so the reading is a reading: FORMULA_RETURN_TYPE_RESULT and FormulaReturnType are not package-public — measure-result-type.ts is not re-exported from src/index.ts, and dist/index.d.ts carries 0 declarations of either name (its one textual mention, line 957, is a JSDoc {@link} in prose). So Limb B stands on the hook and the payload, not on those two.

This matches the seat's claim-time declaration, taken on the mechanical floor; measuring it did not move it.

Acceptance notes — noted, not filed

Changeset

.changeset/16236-formula-return-type-measure-column.md, minor on @objectstack/service-analytics — a published behaviour change (fields[].type moves for formula measure columns) plus an additive member on a published hook. Not skip-changeset: both halves ship inside the package's files[].

Authored by Claude Code in session session_01ToDPcx9AESFubJkDiFMtKW. (This line is prose, not the footer block: measured on this PR, a REST body EDIT appends its own bare footer, so a body that ships one of its own is stored with two.)


Generated by Claude Code

Reproduces #16236 end to end: a `min` over a formula field declaring
`returnType: 'text'` and a `max` over one declaring `'date'` are both
described `type: "number"` by `AnalyticsResult.fields[]`, beside string
values. Section A pins the returnType -> DimensionType translation and the
anti-pass-through invariant; section B is the reproduction plus the
reachability control.

Committed RED on purpose so the red/green readings are taken from state.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
… the wire type

`measureResultType` takes the aggregated field's declared
`FieldSchema.returnType` as a third input and TRANSLATES it into the
`DimensionType` vocabulary — `text` -> `string`, `date` -> `time` — never
relaying the literal. `number` and `boolean` answer "no correction", each
for the reason its own FieldType row already carries.

The absent-returnType tier is written down as a row in the module's table,
not left as an implied code path: an unproven formula keeps the word its
producer minted.

The channel is still unwired at this commit, so the end-to-end reproduction
stays RED while the rule itself is GREEN — the card's thesis isolated.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
…to the measure rule

`AnalyticsServiceConfig.sourceFieldMeta` gains an optional fourth member on
its return, `returnType`, and the ADR-0021 result-column enrichment passes it
to `measureResultType` in the same call that already reads `type`. The plugin
relays the key straight off the engine's declared field, beside the three it
already extracted, so a host on the plugin needs no change.

Widening this hook was preferred over handing the rule the whole field
definition: the extraction at the host is also a NARROWING that keeps the data
engine's internal field shape out of a published contract, and it is what lets
the hook document who reads each key.

The enum-walk suite's `formula` bucket is renamed from `not-on-this-input` to
`declared-elsewhere` — on the declared FieldType alone the verdict is still no
correction, but the answer is no longer unreachable.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 7 documentable anchor(s).

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

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 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 edaf3b2f7832805d4d7c544fb88d696ff8056924packageMentionDocs.

Which tree this was computed on

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

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

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

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

docs-drift-check — the one row answered, plus the emitter-blind half

Read on the tree the bot named, c4703f2324c4c1743591ca1907361e43eef2c2aa (the merge of this head into base), fetched into a ref of my own — ⛔ not on a worktree cut from an older main. Full working in the PR body; the verdicts and the sentences behind them:

content/docs/plugins/packages.mdx — NOT falsified. Own reading. The page names the symbol only at :502 (the import) and :506 (await kernel.use(new AnalyticsServicePlugin({ /* … */ }));, options elided), plus :17 (a table row listing service-analytics among 16 platform services) and :213:219 (the package entry: multi-driver analytics, NativeSQL/ObjectQL strategies, README link). The deciding sentence is the blockquote at :508: "The official plugin and service packages above export plugin classes you instantiate (new XPlugin(options)) — check each package's README.md for its option shape." The page DELEGATES the option shape rather than stating it, so there is no sentence about this plugin's config surface for a new member to falsify.

⛔ The prior finding was used as a starting point only. Both stated re-check reasons were answered on their own terms:

  1. This diff touches plugin.ts, so the construction path is in scope. It is — and the page's only construction site elides the options entirely, so it states nothing about that path beyond "you instantiate it with options".
  2. A new member on the config hook is exactly where a falsification would live. It cannot land here: sourceFieldMeta is not on the type this page's delegation points at. AnalyticsServicePluginOptions carries 0 occurrences of it — firing control on the identical grep shape over the same 95-line block, queryCapabilities = 1. The member added is on AnalyticsServiceConfig, which the plugin builds internally.

The delegation target was measured too, since "read the README" makes the README load-bearing: packages/services/service-analytics/README.md has 0 occurrences of sourceFieldMeta and 0 of returnType (control, identical shape, same file: AnalyticsServiceConfig = 4).

The emitter-blind half

The bot's caveat is carried, not waved past — it says its checkout 「carried uncommitted changes, so the commit above does not fully identify what was read」 ⇒ the hand search is scoped to the WHOLE corpus, not to its one-row list. 402 doc files on that tree, 181 hand-written (excluding auto-generated references/ and release-owned releases/). Probes are by INPUTS and concepts, each with a firing control of the identical grep shape on the same corpus.

probe all hand-written verdict
sourceFieldMeta 0 0 measured zero
measureResultType 0 0 measured zero
DimensionType 2 0 measured zero in hand-written prose
formula NEAR "return/result type" 1 0 measured zero in hand-written prose
returnType 9 4 read, not counted
fields[].type / "measure column" 1 1 read, not counted
AnalyticsResult 6 2 read, not counted
"dataset measure" 6 4 read, not counted
CONTROL formula 71 48 fires
CONTROL dataset 63 36 fires
CONTROL analytics 90 49 fires
CONTROL AnalyticsServicePlugin 1 1 fires — the bot's own row

All eleven hand-written hits were read. None falsified. The closest call moves the right way: data-modeling/formulas.mdx:128 says "Consumers read the declared returnType instead of re-parsing the expression (record-title eligibility, for one …)" — it names one consumer explicitly as one of several and says nothing about a measure column's type, so adding a consumer makes it more true. data-modeling/analytics.mdx:216 names label and format on measure columns, not type. api/data-api.mdx:434 states which MEMBERS exist and which are optional — neither moves here, only the VALUE of type for one population — and its example at :414:429 shows a sum and a count over non-formula fields, both outside this PR's min/max-over-formula population, so it stays exactly correct. The two key tables (field-types.mdx:417, validation-rules.mdx:352) still give exactly the four returnType members this PR reads and none is added.

One row owed, answered NOT falsified; emitter-blind sweep over all 181 hand-written pages finds no falsified page and four measured zeros, each with a firing control.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review (clause ②) — PASS · head 91021a6c8a3ef21ab02378b0c089603e5546a6f8

  • Implemented-by: branch claude/issue-16236-formula-return-type-channel — a mode:subagent dev has no session of its own, so its branch is its identity.
  • Reviewed-by: session_01ToDPcx9AESFubJkDiFMtKW — the domain:services execution seat, default judgement tier (a non-spec seat's Clause-② review is self-review plus gates, by design).
base    : main @ edaf3b2f7
card    : #16236 · declaration Clause-②: yes · needs:contract-review on both carriers
surface : 6 files — 1 changeset, 2 tests, analytics-service.ts, measure-result-type.ts, plugin.ts
reviewed: 2026-09-10T20:28Z

① Derived judgments — every accept-set and public-surface move, named

  1. One new optional member on a published hook's return type. AnalyticsServiceConfig.sourceFieldMeta becomes (object, field) => { type?; defaultCurrency?; max?; returnType? } | undefined. ⇒ yes is right on BOTH limbs, not only the mechanical floor the claim rested on: Limb A because it widens what a host may legally return, Limb B because it is a new key on a published payload.
  2. measureResultType gains a third input and TRANSLATES ittextstring, datetime; number and boolean answer "no correction", each for the reason its own FieldType row already carries. This was triage's hard condition (「⛔ 不要直接把 returnType 的字面量当 wire 词用 —— 两套词汇不是一回事」) and it is implemented as a translation with an anti-pass-through invariant pinned: the ablation mutates the table into a relay and all four members fail.
  3. plugin.ts is FORCED, and the argument is measured rather than asserted. sourceFieldMeta has 0 occurrences in AnalyticsServicePluginOptions (firing control on the identical grep shape, same block: queryCapabilities = 1) — the plugin synthesises the hook from the data engine's field. ⇒ Widening the declaration without editing that file would ship a key that is declared, read, and never populated by the shipped host. That is the difference between forced and convenient, and the PR body states it that way.
  4. The shape choice is argued, not defaulted. Widening the hook was taken over "hand measureResultType the field definition" because the extraction at plugin.ts is itself a narrowing — it keeps the engine's internal field shape out of the published config — and a hook that hands everything over cannot carry the per-key "who reads this" note that made this card findable in the first place. The cost is stated rather than hidden: a fifth key repeats the edit.
  5. Red line held. packages/spec is untouched; FieldSchema.returnType is read, never moved. That is what keeps this card in this lane, and it was the first thing the dispatch order fenced.
  6. Triage's "write it down" requirement is met. The absent-returnType tier is recorded in the rule's own table rather than left as an implicit code path — which is exactly what triage asked for (「⛔ 不要让它成为一条只存在于代码路径里的隐含行为」).
  7. The premise holds but its REASON moved, and that is carried as a standing control rather than smoothed. AGGREGATE_FIELD_TYPE_COMPATIBILITY does not list formula under min/max — yet the pair still compiles, because assertAggregateFieldTypeCompatible executes only the temporal rows by its own documented scope. ⇒ If No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099's full-table leg ever lands, no formula measure reaches a column descriptor and this translation becomes inert rather than incorrect. Stated in the report, pinned as a control, and not presented as an accept-set change — because it is not one: this diff moves the accept set in neither direction on that gate.

② semver grading

@objectstack/service-analytics: minor, one package moved. Clause-②: yes requires minor or above ⇒ satisfied. The changeset opens with a behaviour-change banner scoped to the affected population (any dataset measure aggregating a formula field), which is the honest scope: no member is added or removed on AnalyticsResult, only the value of fields[].type for that population changes — and changes from wrong to right.

③ Boundary flags — all answered, none escalated

  • plugin.ts outside the dispatched write surface → answered by judgment 3. Accepted.
  • Gates: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. ⭐ Three families first exited 3 = PREREQUISITE NOT MET (unbuilt workspace) and were not left there: the prerequisite was satisfied with lint.yml's own turbo build filter (72/72 tasks) and all three re-run to a real exit 0. ⛔ No 3 was rounded up; the difference is that the prerequisite was met rather than declared unmeetable.
  • Lint run whole, not narrowedeslint . --no-inline-config, 6584 files by --format json, 0/0, exit 0 ⇒ no narrowing is claimed and none is owed.
  • docs-drift answered on the tree the bot named, one row NOT falsified with the deciding sentence quoted (packages.mdx:508 delegates the option shape to each package's README, so there is no sentence about this plugin's config surface to falsify), the delegation target measured too, and a whole-corpus emitter-blind read over all 181 hand-written pages with four measured zeros, each with a firing control. Accepted.
  • Three out-of-scope findings reported, not fixed, each with a named successor — including the README.md:174 open-ended list, correctly judged not a defect because it is a list ending "and the dataset resolvers", not a closed declaration, and because this diff adds to the hook's return rather than a key to AnalyticsServiceConfig. Accepted.

⭐ Worth recording beyond the verdict: the reproduction is a three-stage RED — pin-only RED (7 failed), rule-implemented-but-channel-deliberately-unwired RED (2 failed, section A green and section B still number), then GREEN once the channel is wired. That separates "the rule is right" from "the channel exists" by measurement instead of asserting the card's thesis. The ablation reports its direction as observed, proves the mutation on disk by blob hash and by occurrence counts on the exact text touched, and restores by state.

Verdict

PASS. Both carriers are stripped in the same stroke as this record, with a provenance comment citing it. Landing then waits on pre-check ③ only — every check green on this head, not a required subset; Lint & Repo Gates was still running at 20:27Z.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 10, 2026 20:49

Copy link
Copy Markdown
Collaborator

ACCEPT — 2026-09-10T20:52Z, head 91021a6c8a3ef21ab02378b0c089603e5546a6f8.

All three landing pre-checks, measured:

  1. In-seat Clause-② review PASS on record — comment 5625007112 above, in the shape the gate reads. ⛔ Not re-derived here.
  2. Both carriers cleared and machine-readcheck-clause2-carriers --pair 17537EXIT 0, with the C6-RECORD note naming that comment. Provenance on card A min/max over a formula field cannot be typed: sourceFieldMeta does not carry FieldSchema.returnType, the key whose own JSDoc names dataset measures as its consumer #16236 (5625015618); the card kept pm:dispatched, domain:services, priority:p3 and its assignee.
  3. Every check green on this head, not a required subsetdistinct: 33 | {success: 28, skipped: 5}, NOT GREEN: none.

Two things from the delivery worth keeping where the next reader will find them:

  • The reproduction is three-staged, and that is what makes the card's thesis measured rather than asserted. Pin-only ⇒ RED (7 failed). Rule implemented but the channel deliberately still unwired ⇒ RED (2 failed) — section A green, section B still number. Channel wired ⇒ GREEN (150 passed). The middle stage is the interesting one: it isolates "the rule is right" from "the channel exists", which is exactly the distinction this card was filed about.
  • All 61 gates ran to a real result. Three first exited 3 = PREREQUISITE NOT MET; rather than record three NOT MEASUREDs, the delivery satisfied the prerequisite with lint.yml's own turbo build filter (72/72 tasks) and re-ran all three to exit 0. --ran reconciles 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. ⛔ No 3 was rounded up — the difference is that the prerequisite was met, not declared unmeetable.

Flipped ready and armed. ⛔ Not approved and ⛔ not merged by this seat; the merge queue lands it.


Generated by Claude Code

@os-sales
os-sales enabled auto-merge September 10, 2026 20:49
@os-sales
os-sales added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit e526556 Sep 10, 2026
54 checks passed
@os-sales
os-sales deleted the claude/issue-16236-formula-return-type-channel branch September 10, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants