Skip to content

fix(loadtesting): back every capability's returns with a typed response schema - #450

Merged
sourabhd-cbu merged 7 commits into
feat/capability-registryfrom
fix/loadtesting-response-schemas
Oct 1, 2026
Merged

sourabhd-cbu merged 7 commits into
feat/capability-registryfrom
fix/loadtesting-response-schemas

Conversation

@sourabhd-cbu

Copy link
Copy Markdown
Collaborator

Context

External contract review of capability/loadtesting.capability-index.json (build 43eba70) found that every capability declared response fields no schema backs — 21 of 21, 75 fields. Root cause: all 21 resolve to the shared SuccessEnvelope, whose data is a typeless object with no properties. So returns carried the whole payload contract as a flat list of names — no types, no nesting.

Two knock-on effects the reviewer called out, both real:

  • describeCapability returns an empty schema for all 21 capabilities.
  • Drift is undetectable — scripts/check-contract.py walks returns → schema, and with the schema empty it can never catch a field the API stopped returning.

Fix (headline)

Per-capability response schemas composed with allOf, as tm did in its index — a shared field like runId is defined once and the capabilities carrying it can't drift apart:

"200": { "schema": { "allOf": [
  { "$schema": "SuccessEnvelope" },
  { "properties": { "data": { "$schema": "GetLoadTestRunReportData" } } }
] } }
  • Added a typed <Cap>Data schema for all 21 capabilities; each returns field is now a backed property.
  • scripts/check-contract.py capability/loadtesting.capability-index.json → 0 unbacked (was 21 / 75).
  • Types asserted only where certain (ids, *Sec, booleans, arrays, date-time/uuid formats). Three genuinely ambiguous fields (metrics for trends, kpiDeltas, estimatedVuHoursRange) are backed with a description but no asserted type — deliberately not guessing types.

Also (derivable contract fixes from the same review)

  • One-line description on all 9 entities — the field listProducts routes on (was 0/9).
  • format: date-time on the 11 ISO timestamp params + ifVersion; format: uuid on the compare run-ids.
  • minimum/maximum on all 6 limit params, from their declared caps.
  • example on 5 high-leverage create/search/compare params.
  • updateLoadTest guidance: ifVersion is opt-in optimistic concurrency; omitting it is last-write-wins.
  • searchLoadTests guidance: hasMore has no cursor (top-N — narrow the query), so it isn't a dead-end page-2 signal.
  • Reset the stale scripts/contract-baseline/loadtesting.json to its now-clean state.

Deliberately not changed

  • "3 writes declare no required body fields" — false positive for us: stopLoadTestRun is path-only, startLoadTestRun is an all-optional re-run, updateLoadTest is a partial update. createLoadTest/estimate/compare already declare required fields.
  • listActiveLoadTestRuns truncation signal — negligible; active runs are concurrency-capped.

Validation

  • tsc --noEmit: clean · eslint: clean · 726/726 tests pass · check-contract.py (loadtesting): exit 0.
  • Updated the two tests that asserted the old "loadtesting ships no entity descriptions" gap; they now assert descriptions are present and meet the shared ≤140-char / not-a-name-restatement quality bar.

Note (out of scope)

npm run check:contract runs tm && loadtesting; tm still fails (2 pre-existing empty-schema caps), so the chain short-circuits before loadtesting runs. Our index passes when checked directly — making the CI step gate loadtesting needs tm fixed too, or the && split so both run independently.

…se schema

Every capability resolved its 2xx to the shared SuccessEnvelope, whose data is a
typeless object — so returns carried the whole payload contract as a flat list of
names with no types, describeCapability returned an empty schema for all 21, and
the check-contract drift guard could never catch a dropped field. Add a per-
capability <Cap>Data schema and compose each 2xx as allOf[SuccessEnvelope, {data}],
so describeCapability exposes real field types and drift is detectable.
scripts/check-contract.py now reports 0 unbacked for loadtesting (was 21/75).

Also, derivable contract fixes:
- one-line description on all 9 entities (the field listProducts routes on)
- format date-time on the ISO timestamp params + uuid on compare run ids
- minimum/maximum on the limit params from their declared caps
- examples on high-leverage create/search/compare params
- updateLoadTest guidance: ifVersion opt-in vs last-write-wins semantics
- searchLoadTests guidance: hasMore has no cursor (top-N, narrow the query)

Tests updated: the two assertions that documented the missing entity
descriptions now assert they are present and meet the shared quality bar.
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: d3058ad6-eae7-462e-a167-576b19106abd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

…+ seconds

The report returns durationSec in whole seconds. Add guidance on
getLoadTestRunReport (and a durationSec schema description) telling the agent to
render it to the user as minutes + seconds (e.g. 330 -> '5m 30s'), keeping the
raw seconds only for calculations. Presentation-only — no BE/unit change.
…tent + missing returns)

compareLoadTestRuns is the regression check for runs of the SAME test, but the
contract said only 'compare two completed runs' and declared just
kpiDeltas/transactionDeltas — while the endpoint also returns baseline,
candidate (each with testId), slaVerdictChanges and a warnings[] array. Most
importantly the BE's 'runs are of different tests — comparison may be misleading'
signal lives in warnings, so the agent never saw it and would present a cross-
test delta as valid.

- intent: state it compares runs of the SAME test; cross-test is allowed but flagged
- returns + CompareLoadTestRunsData schema: add baseline, candidate,
  slaVerdictChanges, warnings (baseline/candidate carry runId/testId/startedAt)
- guidance: prefer same-test runs; always surface warnings before reporting
The BE compare now mirrors the Compare Runs page: runs of different
tests are rejected (DIFFERENT_TESTS), both runs must be finished and
share test types, and deltas come from the page's own data sources.

- intent: two executions of one test; not for different tests; use
  getLoadTestHistoricalTrends for more than two runs
- guidance: pick both runs from listLoadTestRuns (status=terminal),
  older = baseline, "latest" = latest vs previous finished run; signed
  pctChange; how the transaction metric is chosen; SLA-changed warning
- drop the "check baseline.testId == candidate.testId" advice (testId is
  a config version; runs across an edit legitimately differ)
- groupBy values (transaction/url/label/threadGroup/scenario); corrected
  metrics / regressedOnly / pctChangeMin descriptions; 404 response
- typed kpiDeltas / transactionDeltas / slaVerdictChanges rows
- listProjectLoadTestRuns no longer suggests cross-test comparison
… the unservable-metrics warning

Follows load-testing-backend#3135 review: the BE no longer offers a raw url axis for transaction deltas (url rows are keyed by request name, which merged distinct URLs), matching the Compare Runs page allowlist. Warnings now also list requested metrics compare cannot serve.
…ent the new warnings

Follows load-testing-backend#3135 review round 2: a metric missing on either run now reports direction "unknown" (regressed null) instead of "unchanged"; warnings also name no-data metrics and runs whose SLAs were not evaluated.

@sarve-shreyas sarve-shreyas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Traced how the index is actually consumed (bind.ts, index-loader.ts, register.ts, check-contract.py) and ran the suite plus the contract gate in a throwaway worktree: 737/737 tests pass, and check-contract.py on the loadtesting index reports 0 unbacked. The PR does what it says.

Six inline comments below. Two things that don't anchor to a changed line:

The fix lands one level below the root cause

The PR's stated problem is "data is a typeless object with no properties" — but 20 of the 79 new <Cap>Data properties are themselves typeless containers, including every list endpoint's row array (tests, runs, projects) plus kpis, transactions, slaVerdicts, errorsByCategory, meta, config, plan, vuHours, concurrency, report, metrics, aliases.

So both stated benefits are only partly delivered: describeCapability still says nothing about a row's fields, and check-contract.py still cannot detect drift inside them. searchLoadTests guidance promises "each row carries testId, projectId, name, testType and framework" and listProjectLoadTestRuns promises "each run carries a tags array" — no schema backs either, so the API can stop returning them silently. At minimum, giving the four row arrays an items schema would close most of it.

The reset baseline currently verifies nothing

scripts/contract-baseline/loadtesting.json is inert on this branch:

  • this branch's check-contract.py has no --baseline handling at all — grep -c against_baseline → 0, the argument is silently ignored and the strict main() path runs
  • npm run check:contract short-circuits on tm's failure before reaching the loadtesting invocation anyway

Both confirmed by running them. So nothing in CI verifies the 0-unbacked state this PR establishes, and a future edit that re-breaks it lands unnoticed. The PR body names the short-circuit but not the unimplemented flag. (--baseline has since been implemented on main, so this may resolve itself on rebase.)

One stale comment outside the diff

src/tools/capability-registry/types.ts:198 — the EntityDoc.description doc comment still reads "loadtesting has 0 of 9 today". This PR makes that 9 of 9 and updates the two tests that documented the same gap, but not the comment.

"description": "ISO-8601 timestamp; only tests updated after it.",
"example": "2026-08-01T00:00:00Z"
"example": "2026-08-01T00:00:00Z",
"format": "date-time"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is enforced, not documentation — and it rejects values the guidance asks for.

format: "date-time" is run through FORMATS["date-time"] in bind.ts:139, whose regex requires a full T/space plus HH:MM:SS. The param is described only as an "ISO-8601 lower bound", and date-only is valid ISO-8601. listLoadTestRuns guidance goes further and says "To find a run by date, pass sinceIso/untilIso".

Concrete failure: an agent asked "which runs ran on Sept 12?" sends sinceIso=2026-09-12 (or 2026-09-12T10:30Z, no seconds) and invokeCapability refuses locally with 'sinceIso' must be a date-time — before any request goes out. That call previously reached the API.

bind.ts's own docstring names this as the failure mode to avoid: "a validator that rejects a correct value is worse than no validator."

Affects 11 params (also 349, 355, 454, 460, 805, 811, 1436, 1442, 1558, 1564), 8 of which carry no example to hint at the required shape. Either widen to accept date-only, or add an example to all 11 plus a guidance line.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ccd5a6: dropped the enforced date-time format from all 11 sinceIso/untilIso/updatedSince params — the BE parses them with new Date(), so date-only and no-seconds values are valid there. Each now documents both shapes ("2026-09-12" or "2026-09-12T10:30:00Z") and has an example.

"type": "string",
"description": "Optimistic-concurrency guard (ISO timestamp of the version being edited)."
"description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).",
"format": "date-time"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question rather than a defect — what does version actually look like?

ifVersion gets an enforced format: "date-time", but the version token a caller must round-trip is declared as a bare {"type": "string"} in CreateLoadTestData (2237), UpdateLoadTestData (2250) and GetLoadTestData (2266) — this PR deliberately declines to assert a format there.

If that token is ever anything other than strict RFC3339 (an epoch, a hash, v3), the optimistic-concurrency path becomes unreachable: the agent reads version from getLoadTest, passes it as ifVersion, and bind rejects it client-side.

Asserting on the consumer side of a value you don't constrain on the producer side is the risk. Either assert the format on version too, or drop it from ifVersion.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asserted it on the producer side instead (3ccd5a6): version is always the BE's updated_at via toISOString() (util/agentVersionToken.js), so CreateLoadTestData / UpdateLoadTestData / GetLoadTestData now declare version as format date-time, matching ifVersion.

},
"baseline": {
"type": "number",
"description": "Null when either run lacks the value."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These declare a non-null type while their own description says the value is null.

11 fields in CompareLoadTestRunsData: kpiDeltas.items.baseline/candidate/absChange/pctChange, transactionDeltas.items.* plus regressed, and slaVerdictChanges.items.from/to.

type: "number" excludes null, so the schema contradicts its own description and any consumer validating a real comparison response fails on the first null delta.

The repo already has the right encoding — nullable: true appears 676 times in tm.capability-index.json, and index-loader.ts:564 calls it out as exactly the absent-vs-explicitly-null distinction a caller cannot otherwise make.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ccd5a6: the 11 CompareLoadTestRunsData fields that can be null are now nullable: true (tm's encoding).

"ListLoadTestsData": {
"type": "object",
"properties": {
"tests": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typeless container — see the summary comment.

This is one of 20 <Cap>Data properties that declare no shape, which is the PR's own root cause relocated one level down. The row arrays (tests, runs, projects) are the highest-value ones to fix, since guidance already promises specific fields inside them that nothing backs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the row arrays in 3ccd5a6: tests (list + search), projects and runs (per-test, per-project, active) now have item schemas taken from the BE shapers, so the fields the guidance promises are backed. The rest stay open by design for now — kpis / aliases are metric-keyed maps, and report / meta / config / plan are pass-through blocks; happy to type those in a follow-up.

"values": [
"transaction",
"label",
"threadGroup",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth confirming against the backend before shipping — this is a hard enum, not guidance.

values is enforced as a closed allowlist by bind.ts:305. Any axis the backend accepts but these four omit is now refused client-side, with no override.

Two things suggest the set is being narrowed by inference rather than from the API: commit 3d1833a removed url from this list after the fact, and the sibling getLoadTestRunReport.groupBy names a completely different set in prose (url/browser/location/transaction/step) while deliberately declaring no values at all.

If the backend's accepted set isn't known precisely, prose guidance is the safer encoding than an enum.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against the backend: load-testing-backend#3135 accepts exactly transaction / label / threadGroup / scenario for compare (AGENT_TXN_GROUP_BY; anything else is a 400) — url was dropped there because url rows are keyed by request name. The report endpoint's groupBy is a different parameter with its own set (ALLOWED_GROUPBY), hence the prose there. Keeping the enum so the client rejects what the BE would.

@@ -28,7 +28,20 @@
],
"responses": {
"200": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlining the schema drops the response description, and orphans the named Success.

Replacing {"$response": "Success"} with an inline schema loses "Standard success envelope; operation data under data" across all 21 capabilities — so describeCapability's 200 is now the only status with no description, while 400/401/404/500 keep theirs. That's the one place the envelope gets explained.

It also leaves the named Success response at line 2362 referenced by nothing — the dangling-definition class that check-contract.py's own docstring flags as something a live probe had to find.

tm's inline 200s use {description, schema}. Matching that keeps the prose; then either delete Success or keep referencing it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ccd5a6: all 21 inline 200s carry the success-envelope description again, and the unreferenced named Success response is removed.

… schemas, 200 prose

- sinceIso / untilIso / updatedSince (11 params) drop the enforced
  date-time format: bind rejected date-only values ("2026-09-12") and
  timestamps without seconds before the request left, while the BE parses
  any new Date()-valid ISO-8601 value. Each now documents both shapes and
  carries an example.
- version (create/update/get) declares format date-time — the BE emits
  updated_at via toISOString() — so the ifVersion format it round-trips
  into is asserted on the producer side too.
- CompareLoadTestRunsData delta fields that can be null (baseline,
  candidate, absChange, pctChange, regressed, from, to) are marked
  nullable: true, the convention tm already uses.
- Row arrays get item schemas from the BE shapers: tests (list + search),
  projects, runs (per-test, per-project, active) — backing the fields the
  guidance promises (testId/projectId/name/testType/framework, tags, …).
- Inline 200s keep the success-envelope description; the now-unreferenced
  named Success response is removed.
- types.ts: EntityDoc comment no longer claims loadtesting authors 0 of 9.
@sourabhd-cbu

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review — addressed in 3ccd5a6 (replies inline). On the two summary items: the EntityDoc "0 of 9" comment is updated. On the inert baseline: agreed that nothing in CI on this branch checks the 0-unbacked state — the --baseline handling exists on main, so it resolves once this branch is rebased/merged; I've kept the reset baseline file so it's correct when that lands.

@sarve-shreyas sarve-shreyas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Re-verified against 3ccd5a6 rather than the head I originally reviewed.

Verified on this head: 737/737 tests pass, tsc clean, and check-contract.py on the loadtesting index reports 0 capabilities / 0 fields unbacked.

Fixed from the earlier review:

finding status
Enforced date-time rejecting valid date-only inputs fixed — enforced params 12 → 1
ifVersion asserting a format version never declared fixed — format: date-time now asserted on version in all three schemas, with a description naming it as updated_at
Non-null types on fields documented as null fixed — 11 nullable added to CompareLoadTestRunsData
200 losing its description, Success orphaned fixed — 200 is {description, schema}, and Success is deleted rather than left dangling
Stale types.ts comment fixed

Asserting the format on version as well as ifVersion was the stronger of the two available fixes — producer and consumer now agree, which also makes the one remaining enforced date-time correct rather than a leftover.

Typed row schemas: shapeless containers went 23 → 17, and the half that mattered landed — the list endpoints' row arrays (tests, runs, projects) now carry items, so the fields the guidance promises are finally backed. What remains is concentrated in GetLoadTestRunReportData (kpis, transactions, slaVerdicts, errorsByCategory, meta), GetLoadTestQuotaData and the two metrics trend objects. Worth a follow-up, not a blocker.

Two things deliberately not blocking:

  • The groupBy value set is the Load Testing team's call to make, not the registry's. Withdrawn.
  • scripts/contract-baseline/loadtesting.json is inert on this branch — --baseline has no handling in check-contract.py here, so nothing in CI yet verifies the 0-unbacked state this PR establishes. It is implemented on main, so a rebase picks it up.

@sourabhd-cbu
sourabhd-cbu merged commit 5a62cef into feat/capability-registry Oct 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants