Uh oh!
There was an error while loading. Please reload this page.
refactor(service-analytics): derive the auto-bridge's engine view from the declared contracts - #12777
Conversation
…the declared contracts Replaces the consumer-local structural `DataEngineLike` in `service-analytics/src/plugin.ts` with the declared `IDataEngine` / `IObjectQLEngine` members - the #4251 B3 sweep pattern, and the second of the two sites named by #11833 (the first landed as PR #12011). The `aggregate` narrowing surfaced the mismatch the structural type hid: the local declaration typed `aggregations[].function` as `string` where the contract declares the six-value `AggregationFunction`. Closed by parsing with the spec enum itself at the forwarding site - not by widening back to `string` (which hid it) and not by a cast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
Positive control (a declared function reaches the engine as `function`) plus the refusal (a method outside the engine's six never reaches the engine, and answers in the bare-Error/undeclared-500 tier rather than a 400 that would blame the caller for host drift). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194kbQJxUvv2yvsGRtuXpP5
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2e8b88007378fd122b671fee5847c1aa4d2b354f && git checkout 2e8b88007378fd122b671fee5847c1aa4d2b354f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d5b4f83254ee1b7f53197073cfc6eee3025418a 3e041bcc699db79a08333ab2cbcc933e54472711 && git checkout -B drift-repro 4d5b4f83254ee1b7f53197073cfc6eee3025418a && git merge --no-ff 3e041bcc699db79a08333ab2cbcc933e54472711
node scripts/docs-audit/affected-docs.mjs --json 4d5b4f83254ee1b7f53197073cfc6eee3025418a
|
Uh oh!
There was an error while loading. Please reload this page.
Part of #11833
Replaces the consumer-local structural
DataEngineLikeinpackages/services/service-analytics/src/plugin.tswith the declaredIDataEngine/IObjectQLEnginemembers — the #4251 B3 sweep pattern, and the second and last of the two sites #11833 names. The first landed as PR #12011 (MERGED).Everything below is split into MEASURED (a command was run and its own output is quoted) and INFERRED (reasoning from what was read). Every claim names a file and a line.
Why this says
Part ofrather than a closing keywordAn earlier revision of this body carried a closing keyword, on the strength of the card's 2026-08-27 unlock note — "#12010 does NOT ride on this card closing" — and of the endorsed rule that when a dispatch names N sites and only M land, the closing keyword is what changes, not the scope. Both named sites do land here, so that read was available.
The PM has since measured the sub-issue structure and reversed that guidance: #11833 carries
has_children: truewithsub_issues_summary: {total: 1, completed: 0}. #12010 is a real GitHub sub-issue, so a closing keyword on this PR would auto-retire a parent showing 0 of 1 children complete — as a merge side effect rather than anyone's decision. The endorsed rule is untouched; what changed is that the reason to hold the keyword here is the sub-issue edge, not the scope.⭐ The card's question is fully answered by this PR. The PM will act on that deliberately after the merge, with the reason recorded on the card.
close/fixes/resolvesplus a number regardless of the surrounding sentence, so even a line explaining that this PR does not retire the card would retire it. Prose about the card uses other verbs on purpose.MEASURED — the five members line up
Re-read on
origin/mainat87d3f9a0a, not inherited from the dispatch table:execute?IDataEngine.execute?—contracts/data-engine.ts:250aggregateIDataEngine.aggregate—contracts/data-engine.ts:230getObject?IObjectQLEngine.getObject—contracts/objectql-engine.ts:207(and:90on the registry view), returningServiceObject or undefinedresolveEffectiveDatasource?IDataEngine.resolveEffectiveDatasource?—contracts/data-engine.ts:321getDriverForObject?IDataEngine.getDriverForObject?—contracts/data-engine.ts:342No member is a fork, so no contract moves and nothing is cast past. The replacement (square brackets standing in for angle brackets, per the note above):
getObjectwas the one that needed #12248 most. MEASURED: substituting the contract'sServiceObjectreturn produced no diagnostic at any of its call sites —plugin.ts:398(obj?.fields?.[relationshipName], reading.type/.reference), theDimensionLabelDeps.getObjectFieldswiring at:415(whose declared return is a record ofFieldMetaLite,dimension-labels.ts:39),pickDisplayFieldat:418,isExternalObjectat:566andgetObjectFieldNamesat:592. The 08-25 report predicted this substitution would "replace real typing with casts at ~10 call sites"; that prediction was made against the oldunknownreturn and no longer holds — fork 3's repair is what removed the cost.getDriverForObjectkeeps its call-site narrowing, as the contract asks it to.data-engine.ts:342's own docblock says consumers "keep narrowing the RETURN at the call site (aPickoverIDataDriveradmits the full contract value); what this member ends is each of them re-inventing the MEMBER." SoTemporalDriverSurfacesurvives — no longer as a re-declared member, but as the annotation on the two locals that read it (plugin.ts:508,:530).MEASURED — every member stays optional, and the profile is preserved exactly
aggregatestays required, exactly as the hand-written type had it: it is what thetypeof svc.aggregate === 'function'probe atplugin.ts:214uses to decide whether a registered'data'service qualifies at all. Every other member stays optional.getObjectis required onIObjectQLEngine, so thePartialwrapper around it is load-bearing rather than decoration — without it, a'data'service that is not ObjectQL stops satisfying this view. Nothing became required; a degraded boot does exactly what it did before.MEASURED — the
aggregateenum, the one careful spotSubstituting the contract member with no other change produced exactly one new diagnostic, and it is the predicted one:
That is the correct signal, and it is reported rather than smothered. What it says: the deleted structural type declared
aggregations[].functionasstring, the contract declares the six-valueAggregationFunction(data/query.zod.ts:149, reached throughAggregationNodeSchemaat:262), and nothing compiled the two against each other.First-hand verification of the #12209 refusal, not inherited.
ObjectQLStrategy.resolveMeasureAggregation(strategies/objectql-strategy.ts:1262) now refuses a custom-SQL measure at:1296, keyed onEXPRESSION_METRIC_TYPES, withinvalidMemberError(INVALID_FIELD/ 400). Read on this branch. Its docblock is also explicit that it deliberately does not key on "method is not one of the six", because an enum-invalid metric type "is OUR bug — the undeclared-500 tier".How the gap is closed. Not by widening
functionback tostring(that is what hid it), and not by a cast. The bridge parses the incoming method with the spec's own enum:One vocabulary, no local literal list to drift, and
AggregationFunction's error map already carries the retiredarray_agg/string_aggprescriptions.AggregationFunctionis already a runtime import in this package (dataset-compiler.ts:4), so this adds no dependency. The refusal is a bareErrorin the undeclared-500 tier, matching the tieringdataset-refusal.ts's module header assigns to internal-invariant and host-drift arrivals — enveloping it as a 400 would blame the caller for a cube they did not write. Reviewed and accepted by the PM againstobjectql-strategy.ts:1288-1294, which assigns an enum-invalid method to exactly that tier: this implements the prescribed tiering rather than inventing one, and no caller-visible accept/reject behaviour moves.INFERRED: no authored analytics can trigger it. The one reachable producer of a non-aggregate method is refused earlier by #12209;
resolveMeasureAggregationotherwise returns either a surviving metric type or one of a six-element alias list (objectql-strategy.ts:1318).MEASURED — ablation at the compiler, with a control leg
Predictions were written to a file before either leg ran. Shared note, stated because it changes how the numbers read: this package's tsc program carries 10 pre-existing errors, all in
src/__tests__/(it has notypecheckscript; it is covered throughcheck:type-check-coverage's DEBT ledger, recorded at 10). So tsc exits2in both legs and the exit code is not the verdict — the verdict is whether a diagnostic lands onsrc/plugin.ts.function: a.method.TS2322onsrc/plugin.tsreadingType 'string' is not assignable to type '"min" | "max" | …'.tsc exit = 2, 12 errors. The predictedTS2322appeared verbatim, atsrc/plugin.ts(289,11), with the predicted elaboration. Honest correction: the count was 12, not 11 — the mutation also orphaned the helper, producing a second diagnosticsrc/plugin.ts(80,10): error TS6133: 'parseEngineAggregateFunction' is declared but its value is never read. That is an artifact of the mutation, not a second finding, and it is reported rather than rounded away.origin/main's original structural type (main'splugin.tsalready forwardsa.methodverbatim), written by redirectinggit show origin/main:PATHinto the file — the form that does not stage.src/plugin.ts— green.tsc exit = 2, 10 errors,plugin.ts diagnostics: (none). GREEN.That pair is the point: the unchecked forward typechecks clean on
maintoday and does not after this PR. The #4251 drift, demonstrated live at this seam.Discipline, each leg: mutation confirmed on disk by grepping the injected marker and the displaced anchor (leg 1: injected 1, displaced 0; leg 2: original interface 1, derived type 0, unchecked forward 1) and by comparing
git hash-objectagainst the HEAD blob — never a baregit diff --stat.trap 'restore' EXIT INT TERMon an absolute path fromgit rev-parse --show-toplevel. Restore withgit checkout HEAD -- ABSPATH, thendisk == index == HEADproved three ways (git diff --quiet,git diff --cached --quiet, emptygit status --porcelain) plus a blob-hash equality check, with an empty hash treated as failure. After both legs the tree was back at blob01ef2ea1c.MEASURED — verification
All at final commit
3e041bcc6(branch head; merge base87d3f9a0a). Dependency closure built first, so every type judgement reads a freshly built.d.tsrather than a stale one: the upstream-closure build for@objectstack/service-analytics→os-verify-lock: VERDICT command-exit 0 · held the lock 157s.pnpm --filter @objectstack/service-analytics typecheckis the pnpm zero-match trap here, and was NOT run as a result. This package declares notypecheckscript (packages/services/service-analytics/package.json:16—buildandtestonly), so that command matches nothing, runs nothing and exits0. Substituted with the invocationcheck:type-check-coverageitself uses for this ledger entry:npx tsc --noEmit -p tsconfig.jsonin the package →EXIT=2, 10 errors, byte-identical to the pre-change baseline, zero onsrc/plugin.ts. The 10 are the ledger's recorded count and all sit insrc/__tests__/.pnpm --filter @objectstack/service-analytics test→Test Files 83 passed (83)·Tests 1805 passed (1805)(baseline 82 / 1803; this PR adds one file with two cases) ·VERDICT command-exit 0pnpm --filter @objectstack/service-analytics build→DTS ⚡️ Build success in 4015ms,check-dts-emitted: 1/1 declared declaration file(s) present·VERDICT command-exit 0pnpm lint(full repo,eslint . --no-inline-config, not narrowed) →VERDICT command-exit 0Gate union re-derived live on the actual changed set,
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr confirming the tree ofobjectstack-ai/objectstackat3e041bcc6, the--repoassertion holding, and 3 paths vs merge base under three-dot semantics. ⭐ It named nocontractfamily. All named families ran; each quotes its own verdict line:check:changeset-gate-self-testsEXIT=0— 118 + 212 + 116 assertionscheck:cross-package-test-inputsEXIT=0— "20 package(s) read outside themselves, all declared"check:objectql-double-limitEXIT=0— "287 double(s) graded … none new"check:objectui-changesetEXIT=0— "objectui-range --self-test: all checks passed"check:page-declaration-shapeEXIT=0— "34 page entries across 2267 sources … all reach the kernel"check:pm-half-statesEXIT=0— "1515 cases pass"check:published-filesEXIT=0— "69 publishable package(s) of 78"check:slot-lookupEXIT=0— "ratchet holds: 107 unswept site(s) in 25 file(s), none new"check:test-source-aliasEXIT=0— "72 packages with tests scanned"check:type-source-resolutionEXIT=0— "94 tsc program(s) across 77 packages scanned"check-adr-0087-registration.mjsEXIT=0— "adds no declared-breaking changeset"check-changeset-no-major.mjsEXIT=0— "introduces nomajorbump"check-ci-filter-parity.mjsEXIT=0— "all 109 declared cross-package glob(s) … covered"check-comment-mask-adoption.mjsEXIT=0— "20 private comment-stripper(s) … all 20 recorded"check-cross-package-test-inputs.mjsEXIT=0— "all declared, and turbo.json hashes every declared glob"check-empty-changeset.mjsEXIT=0— "No empty-frontmatter changeset introduced"check-plugin-teardown-shape.mjsEXIT=0— "64 Plugin implementation(s) across 4892 source(s)"docs-audit/check-affected-docs.mjsEXIT=0docs-audit/check-drift-comment.mjsEXIT=0— "56 cases pass across 5 fixture diff(s)"pm/release-rehearsal-clone.mjs --self-testEXIT=0— "self-test passed"check:query-options-erasure(convention)EXIT=0— "ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new"check:engine-double-contract(convention)EXIT=0— "691 pinned, 134 in the DEBT ledger, 3 exempt"check:where-matcher(convention)EXIT=0— "309 matcher(s) discovered, 309 answer the combinator battery correctly or refuse it loudly"check:type-check-coverage(convention)EXIT=0— "65/78 workspace packages type-checked … 13 in the DEBT ledger"check:type-check-debt(convention, the ratchet)EXIT=0— "31 ledger entr(ies) re-measured in 212.7s, 1687 raw tsc error(s) total, none above its recorded number" — run after the fullturbo run buildover the packages closure (70 successful, 70 total), as the gate requirescheck:nul-bytesEXIT=0— "scanned 7111 text file(s) … no raw ASCII control bytes"node scripts/pm/check-half-states.mjsinvocation exited 3, and says so itself — "trigger-file index gathered nothing, so this result says NOTHING about whether the board carries half-states. It is not a clean board and it is not a dirty one — it is no reading at all." Itspnpm check:pm-half-statesself-test form is green (above); the board scan needs an index this container does not have. NOT MEASURED.service-analytics's DEBT ledger entry stays at 10; nothing is lowered and nothing is raised.Tests added
src/__tests__/aggregate-bridge-function-vocabulary.test.ts, driving the real plugin auto-bridge through thefakePluginContextharness this package already uses:function: 'sum'. Without it, case 2 could pass because nothing reaches the engine for reasons unrelated to the vocabulary.callsis empty), the message names the offending method, the aggregation and the whole legal vocabulary, and the error carries nocode, pinning the deliberate undeclared-500 tiering rather than leaving it to chance. The drift is staged with a cube object that never metCubeSchema's parse, which is the arrival path the tiering is written for.Changeset: shipped, and here is the argument both ways
PR #12011 shipped
skip-changeset, and that precedent was not copied across unexamined.skip-changeset: four of the five members are a pure alias swap, the replaced type is file-private, and the emitted JS for those is unchanged.aggregatenarrowing adds a real runtime boundary — a newthrowpath that did not exist — and changes what a malformed host cube produces fromnullper bucket into a loud, attributed refusal. "Unreachable via any authored path today" is an argument about likelihood, not about whether behaviour moved; it moved.Shipped as
patchon@objectstack/service-analytics(.changeset/analytics-bridge-engine-aggregate-vocabulary.md). No label is needed as a result, so nothing was written to the PR's labels.Fences
Zero
packages/spec— MEASURED, the diff against the merge base is three files: the plugin, the new test, the changeset. Nothing undercontent/docs/releases/**,docs/adr/**,.claude/**,skills/**,AGENTS.mdorCLAUDE.md. Draft; ready was not flipped and auto-merge was not armed.Out-of-scope finding
Filed as #12776 (unassigned, not fixed here; triage owns its routing):
StrategyContext.executeAggregate(packages/spec/src/contracts/analytics-service.ts:300) declaresaggregations[].methodasstringwhileIDataEngine.aggregatedeclares the six-value enum — the same slot, two types, one layer up from this seam. It is the reason the bridge needs a runtime parse instead of a compile-time guarantee, and repairing it is an accept-set narrowing on a published contract, which is a spec-seat call rather than a consumer-side one.Generated by Claude Code