Skip to content

UsageSummary omits cache-token fields the aggregator returns, and JsonSchema.type is a scalar where the format uses an array #608

Closed

Description

@serge-ivo

Two declared types are narrower than the values they describe — found by the first typecheck of worker code

6d44ac7 (#599) put worker sources and tests under tsc for the first time. Two production type defects surfaced and were deliberately left for their own ticket.

1. UsageSummary["totals"] omits fields aggregateUsage returns

workers/api/src/lib/usage.ts:319 — the declared totals shape lacks cacheReadTokens and cacheWriteTokens, which aggregateUsage computes and returns.

The console already works around it: store/console/src/pages/Usage.tsx:35 declares its own parallel shape with those fields optional. So the data crosses the wire, the UI reads it, and the type says it does not exist — which means the compiler cannot help anyone who touches either side.

This is the same drop-on-the-way-out shape #547 fixed for daily. Second instance, same file.

2. JsonSchema.properties[].type is a scalar where the format uses an array

workers/api/src/lib/connectors/types.ts:111 declares type as string. JSON Schema draft-07 permits string[], and behaviourToolSchema uses the array form. So a legal schema this codebase itself produces does not satisfy the type meant to describe it.

Why they belong together

Both are the same defect: a declared type narrower than the value it describes, surviving because nothing compiled the code that would have caught it. Both were invisible for the same reason and became visible on the same day, which is the argument for one guard rather than two fixes.

It is also the type-system instance of the pattern this batch has been removing everywhere else — a stored or declared fact that the runtime contradicts (#587 runtime.status, #588 RunHealth, #592 attempts, #596 waiting_until). Here the contradiction is between a type and its own producer.

Acceptance criteria

  1. UsageSummary["totals"] carries every field aggregateUsage returns, and Usage.tsx consumes that type instead of a parallel declaration. The parallel shape is the tell — remove it, do not sync it.
  2. JsonSchema.properties[].type accepts string | string[], and behaviourToolSchema typechecks against it without a cast.
  3. A test asserts the declared return type matches what the function actually returns for aggregateUsage — a compile-time assertion is now genuinely enforced after Three tsconfigs exclude test files and CI typechecks none of them, so a type-level guard in any worker test is inert #599, so this is the first issue that can rely on one.
  4. Check whether other API response types have a parallel console-side declaration. Two instances of one shape (The Usage chart shows 4.2M tokens for the day the 250M ceiling was tripped — the daily series drops the cache columns, which are 98.2% of what the ceiling counts #547, this) suggests a habit; report the count with its denominator per ADR 0002 rather than fixing only the two known.

Verified vs inferred

Activity

  1. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in c93c4dd and f6f1a54. Both defects fixed, AC4 measured, and the measurement is much larger than the issue supposed.

    1 — UsageSummary["totals"] (AC1, AC3)

    lib/usage-shape.ts is now the single declaration of the GET /v1/usage body. store/console/src/pages/Usage.tsx type-imports it and its four parallel declarations (Bucket, Day, Unmetered, UsageData) are gone — removed, not synced, as the issue asked. usageFigures.ts's PayerCoverage was a third copy and went the same way.

    The import is import type and esbuild erases it, so it adds no bundle byte and no runtime edge. The direction matters: the reverse edge (the API Worker depending on the published @proagentstore/sdk) stays refused for the reasons lib/normalize-speech.ts records — a new exports subpath and a build step in front of wrangler deploy — and none of those apply to a type the compiler deletes.

    AC3, two guards, because either alone is passable:

    The route deliberately keeps that literal rather than an annotated const body: workers/mcp/src/instance-tools/account.test.ts parses the same line, and annotating the response broke it. Two tests now read that one line; it is commented as load-bearing.

    The tell that this was overdue is that the test file had already written the workaround and dated it — cacheTotals() existed only to read fields the type denied, with a docstring saying "once the production type carries them this helper can be deleted". Deleted.

    2 — JsonSchema.properties[].type (AC2), and the live bug it was hiding

    Widened to string | string[]; behaviourToolSchema now returns JsonSchema and tool-registry.ts's as ToolDef["jsonSchema"] cast is gone, as is the double-unknown in behaviour-tools.test.ts.

    Widening it surfaced a real defect. routes/tools.ts's matchesType switched on type as a string, so an ARRAY fell through to default: return true — every property declared with the list form was accepted whatever was passed. behaviourToolSchema emits ["number","null"] on every set_behaviour field, so the tool-input validator did nothing at all for the tool most likely to be called with a value a model invented, and it looked exactly like a validator that worked.

    Measured: POST /v1/instances/:id/tools/set_behaviour {"technicality":"banana"} returned 200. It now returns 400 with Field "technicality" must be a number or null. The test fails on the old code — watched.

    The validator moved to lib/json-schema-validate.ts on the way past, and now has a direct unit suite that runs against the REAL generated set_behaviour schema rather than a paraphrase of it. Two reasons: it is a pure rule about a data format that was reachable only through a Hono app with a D1 double, and routes/tools.ts had zero headroom under the #302 ratchet — extracting it is the better test surface and what keeps the pin honest without raising it.

    AC4 — measured, and the answer is not "just these two"

    90 of 125 console-side API-response types are parallel declarations of a Worker shape — 72%.

    • Denominator (125): every distinct type argument to api<T>(…) under store/console/src, across 162 call sites, plus the named types they transitively reference to describe response data.
    • Numerator (90): those where workers/api/src carries its own declaration of the same shape. The remaining ~35 are not parallel because the Worker builds the body inline with no named type at all.
    • Drift guards: 0 of 90. Only two console tests read workers/api/src (surfaces.test.ts:71, toolLiterals.test.ts:29) and both guard string-literal vocabularies, not field sets. api<T> is an unchecked cast — packages/sdk/src/client.ts:157 is return data as T.
    • 22 are self-declared: 13 explicit Mirrors <worker type> comments plus stats-types.ts:2 declaring all nine of its types mirrored.

    The two known instances are neither representative nor exceptional — they are the harder subclass. UsageSummary.totals and .daily were anonymous object literals Worker-side, so there was no name to diff against, which is why no guard was possible before this change. Meanwhile three of their neighbours in the same file (Bucket ↔ UsageBucket, Unmetered ↔ UnmeteredUsageSummary, UsageData ↔ UsageSummary) were ordinary named parallels the issue never mentions. All five are now one declaration.

    At least 15 pairs disagree with the Worker today. The worst are wrong field names on both-sides-optional fields, where the compiler is silent by construction:

    Console Worker Disagreement
    KnowledgeDoc.createdAt producer writes addedAt wrong name, already papered over by a cast at IndexingTab.tsx:188
    Credential.history? types.ts:211 CredentialSummary.recoveryHistory? lib/credentials.ts:24 wrong name; console also lacks hasPassword/hasPin/hasRecoveryCodes/lastUsedAt
    TranslationConfigWire lib/use-gloss.ts:22 TranslationConfig routes/instances-translation.ts:53 worker's transliterate: boolean not declared console-side at all
    ProfileField Profile.tsx:9 ProfileFieldDef lib/profile.ts:6 worker private: boolean required, console private? — a privacy flag silently reading undefined
    SearchHit VectorsSection.tsx:30 VectorSearchResult agent-storage-types.ts:40 worker's id absent console-side
    Connection lib/teamwork.ts:25 ConnectionView lib/connections.ts:62 worker's sourceInstanceId absent console-side
    Agent / CatalogAgent AgentRow routes/agents.ts:13 both sides wrong about the same route — the list SQL (routes/agents.ts:233-237) selects creator_login, creator_name, creator_avatar, store_type, subscriber_count, none of which AgentRow declares

    I did not fix the other 88. Ninety rewrites is not this ticket, and several are genuine judgement calls (an optional field guarding an older API is sometimes right). What this change does establish is the shape that generalises: one Worker-owned declaration in a leaf module with no Env import, type-imported by the console.

    Recommend filing two follow-ups rather than one omnibus — say the word and I will have them written:

    • the ~15 measured disagreements, worst-first, starting with the two wrong field names (KnowledgeDoc.createdAt, Credential.history) since those are silently reading undefined in production today;
    • AgentRow under-declaring its own route's SELECT, which is a Worker-side defect the console merely inherits.

    Verified

    pnpm typecheck (11 projects), 9,156 unit tests (526 files), biome with --error-on-warnings, all six script gates including check-file-size, check-migrations --require-history, docs:drift, both tsconfig.test.json gates (#599), console tsc --noEmit and vite build. CI green on f6f1a54; API and Host workers deployed.

    One fix-forward, my error: c93c4dd went red on the #599 worker-test typecheck — usage-shape.test.ts used fileURLToPath(new URL(…)), which does not compile under workers/api/tsconfig.test.json because the DOM URL and node's are different types there. I ran that gate before adding the file and not after. Fixed in f6f1a54 using the form account.test.ts already uses.

  2. added
    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guards
    bugSomething isn't working
    backendBackend / Worker / API work
    on Aug 18, 2026
  3. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    Audit verdict — DELIVERED, all four criteria. Closing.

    Re-read against main today rather than taking the shipping comment at face value.

    AC1 — one declaration, consumed by the console. workers/api/src/lib/usage-shape.ts exists and is the single owner of the GET /v1/usage body. store/console/src/pages/Usage.tsx:25 is import type { UnmeteredUsageSummary, UsageBucket, UsageDay, UsageResponse } from ".../workers/api/src/lib/usage-shape" — a type-import, so esbuild erases it. The four parallel console declarations are gone, not synced, as the issue asked.

    AC2 — type accepts the array form. workers/api/src/lib/connectors/types.ts:120:

    properties: Record<string, { type: string | string[]; description?: string; [k: string]: unknown }>;

    with the reason recorded at :111.

    AC3 — the compile-time assertion exists. The aggregateUsage accumulator is annotated, so a column summed-but-undeclared fails in the file that sums it; usage-shape.test.ts additionally reads the route's return c.json({…}) literal and diffs its keys against the declared type.

    AC4 — measured, and it exceeded the issue's supposition. 90 of 125 (72%), 0 drift guards, ≥15 pairs already disagreeing. The issue asked for the number rather than the fix, and the number was produced. It is now #616, which carries the inventory and the worst-first list — so the arm is tracked, not dropped.

    One thing worth naming, because it is the best argument the issue made and it was borne out

    Widening type to string | string[] surfaced a live security-adjacent defect the issue did not predict: matchesType switched on type as a string, so the array form fell to default: return true. behaviourToolSchema emits ["number","null"] on every set_behaviour field, so the validator for the tool most likely to be called with a model-invented value validated nothing. POST /v1/instances/:id/tools/set_behaviour {"technicality":"banana"} returned 200 and now returns 400. That is the payoff for "a declared type narrower than the value it describes" being treated as a real class rather than two typos.

    Stale citations, recorded for the citation-decay measure

    All three of this issue's file:line citations no longer resolve to what they claimed — because the fix moved them, which is the benign case:

    • workers/api/src/lib/usage.ts:319 → now usageDay(); the shape moved to lib/usage-shape.ts.
    • store/console/src/pages/Usage.tsx:35 → now unrelated copy; the parallel declaration is gone.
    • workers/api/src/lib/connectors/types.ts:111 → now the doc comment explaining the widened type; the declaration is at :120.
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

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions