Skip to content

feat: align the SDK + spec with the live API contract (verified against production)#9

Open
muunkky wants to merge 5 commits into
XTraceAI:mainfrom
muunkky:cr/live-verified-reconciliation
Open

feat: align the SDK + spec with the live API contract (verified against production)#9
muunkky wants to merge 5 commits into
XTraceAI:mainfrom
muunkky:cr/live-verified-reconciliation

Conversation

@muunkky

@muunkky muunkky commented Jun 15, 2026

Copy link
Copy Markdown

Draft, opened early for discussion — happy to split, squash, or re-shape to fit your pipeline. Large diff (~4.9k lines, 22 files), so this write-up is the map; How to review at the bottom gives a reading order.

What this does

@xtraceai/memory and spec/memory.json now define a single contract that matches what the deployed API at api.production.xtrace.ai actually returns — the auth scheme, the error envelope, the rate-limit headers, and the PATCH /v1/memories/{id} shape. On top of that aligned base, the SDK gains a typed filter DSL, search auto-pagination, include on recall, and superseded-chain resolvers.

Every wire-shape claim here is verified against the live deployment, not inferred from the checked-in spec. The work is additive: the default auth scheme (Authorization: Bearer) is unchanged, no existing method changes behaviour, and package.json/CHANGELOG.md are left to your release flow.

Background — why this matters now

@xtraceai/memory is approaching a 1.0 release. That cut is a contract-locking event: once published, consumers are entitled to rely on the auth scheme, error codes, endpoint set, and wire shapes behaving as documented, and breaking any of them afterward carries a major-version cost. The problem this branch addresses is that, today, the published SDK and its OpenAPI document disagree — and they disagree in a consistent direction: the SDK speaks a newer wire than spec/memory.json describes. The spec lags the implementation.

Concretely, the deployed API returns an error envelope the spec doesn't document, sends rate-limit headers under names the spec doesn't list, accepts an auth scheme the spec doesn't bless, and serves a PATCH endpoint the spec annotates as removed. Locking 1.0 with those disagreements standing would bake silent drift into the contract: a consumer who codes to the spec would mishandle real responses, and the SDK would carry a header parser and error branches that never match production.

This branch resolves the disagreements by treating the deployed API as the source of truth and bringing both the SDK and the spec into line with it, with each wire-shape claim backed by a captured live response (recorded in docs/adr/ADR-003). It also lands four additive SDK ergonomics that ride the same reconciliation pass.

Goals / non-goals

Goals. Make the SDK and the spec agree with the live wire on auth, errors, rate limits, and the PATCH contract; do it without changing any shipping default; add the filter/search/recall ergonomics that the corrected surface makes coherent.

Non-goals. This does not change the default auth scheme, version the package, or touch CHANGELOG.md (left to your release flow — you're at 0.2.1). It does not model arbitrary memory field edits (the live PATCH contract is group-membership only). It does not claim the full error-class matrix is observed — see Risks.

The decisions that shaped this cut

Three cross-cutting decisions govern everything below; the ADRs in docs/ record them in full.

Decision Chosen Rejected alternative Price accepted
Source of truth where SDK and spec disagree The live wire — correct the spec to what production returns Make the SDK conform to the checked-in spec The spec is the side that changes; consumers who coded to the old spec text must re-read the contract
Posture where live evidence is thin Keep tolerating the spec's older shapes alongside the live one Commit hard to the single observed shape and delete the rest A few defensive branches that may never execute in production, carried on purpose
Compatibility Every change additive; no shipping default altered Align defaults to the spec (e.g. swap the auth scheme) The default (Bearer) is one the original spec didn't list verbatim — kept because it works and flipping it would break every existing caller

TL;DR

Area What it does Why this shape
Error envelope SDK parses { error: { type, code, message, request_id, details? } }; spec documents it That's the shape production returns for every observed error class
Rate-limit headers SDK reads x-ratelimit-*, exposes err.rateLimit; reset typed as epoch-seconds Production sends x--prefixed headers with an epoch reset, not RateLimit-* / delta
Auth Bearer default; authMode: 'x-api-key' opt-in; spec documents Bearer Both schemes work and share one rate bucket, so the choice is wire-format only
PATCH /v1/memories/{id} New memories.patch() for group-membership edits; spec documents it The endpoint is live; its contract is add_group_ids / remove_group_ids
Filter DSL Typed f builder for SearchRequest.filters A silently-wrong query (lost range bound, clobbered field) becomes impossible
searchAll() Cursor auto-pager — the search twin of list() Iterating a full result set shouldn't mean hand-threading the cursor
recall(… include) Forwards include: ['full_content'] to every pool Pull heavy artifact bodies in the same round-trip
Superseded helpers Resolve an ingest's superseded-by map to the replacement Memory The map gives you ids; you usually want the objects

Contract alignment (verified against production)

Four places where the SDK and spec are now pinned to the live wire. The evidence — raw 401/404/422 bodies, response headers, the PATCH probe — is recorded in docs/adr/ADR-003.

Error envelope

The constraint. A consumer is told to switch on error.code for stable, machine-readable handling. The deployed API returns errors as { error: { type, code, message, request_id, details? } }, but the spec documented a different shape ({ detail: { code, message } }, plus FastAPI's detail[] array for 422s). Coded to the spec, a consumer would read error.code as absent and fall back to a generic message — losing the exact field they're told to rely on.

What's now possible. error.code resolves to a real, stable code for every error the live API emits, and the spec no longer contradicts the wire. A 422 surfaces as Unprocessable with error.code === "validation_error" and the per-field array preserved under error.details.validation_errors, so a consumer can render field-level errors instead of a blob.

Why this design. The parser is legacy-first (it reads the live { error: … } shape) but still accepts the spec's { detail } / detail[] forms. The alternative was to delete those branches now that the live shape is known — they look like dead code. We kept them because the 429 error class was not captured (forcing one means exhausting a live bucket), so for the one class we haven't observed, tolerating the documented shape is cheap insurance against a wrong 1.0 bet. The price: a few parser branches that may never execute in production.

What it looks like.

import { Unprocessable } from "@xtraceai/memory";

try {
  await client.memories.search({ query: "" });
} catch (err) {
  if (err instanceof Unprocessable) {
    const fields = err.details?.validation_errors; // per-field errors, not a blob
  }
}

How we know it works. src/errors.test.ts drives each envelope shape ({ error }, { detail }, detail[], bare-string detail) through parseErrorBody and asserts the resolved code/message/subclass; the live 401/404/422 bodies are the fixtures the legacy-first path is pinned to.

Rate-limit headers

The constraint. The SDK exposes rate-limit state so callers can back off proactively. The deployed API sends that state as x-ratelimit-limit / x-ratelimit-remaining / x-ratelimit-reset, with reset as an epoch-seconds timestamp. The names are x--prefixed and the reset is an absolute time, neither of which matches the bare RateLimit-* / delta-seconds shape the spec described.

What's now possible. err.rateLimit is populated from live responses, and reset is typed and documented as epoch-seconds — so a backoff computation subtracts Date.now() from an absolute time rather than misreading a duration.

Why this design. We read x-ratelimit-* as canonical and corrected the spec to match. The alternative — also keep reading the bare RateLimit-* names in case some deployment emits them — we rejected because no observed response carried them, and a 1.0 spec shouldn't document headers production doesn't send. The price: if a future edge deployment ever emits the bare form, we won't read it until we've seen it.

What it looks like.

if (err instanceof RateLimited && err.rateLimit?.remaining === 0) {
  const waitMs = (err.rateLimit.reset * 1000) - Date.now(); // reset is epoch-seconds
}

How we know it works. src/http.test.ts feeds x-ratelimit-* headers through parseRateLimit and asserts the snapshot, with the epoch-vs-delta distinction in a dedicated case.

Auth

The constraint. Some deployments front the API with a gateway that expects an x-api-key header rather than Authorization. The SDK ships Bearer (which the live API accepts), so those deployments had no supported way to authenticate without forking the client.

What's now possible. authMode: 'x-api-key' is a one-line opt-in; everyone else is unaffected because Bearer stays the default. X-Org-Id is sent in both modes. The spec now documents Bearer as an accepted scheme, so it stops contradicting the shipping client.

Why this design. The scheme choice is wire-format only: both Bearer and x-api-key are throttled against the same (org_id, key_hash) rate bucket — confirmed live — so neither buys extra quota. The natural alternative, switching the default to x-api-key, was rejected because it would break every existing consumer for no functional gain; the README states the no-quota-advantage fact explicitly to head off the assumption. The price: the default is a scheme the original spec didn't list verbatim, kept because it demonstrably works.

What it looks like.

const client = new MemoryClient({
  apiKey: process.env.XTRACE_API_KEY!,
  orgId:  process.env.XTRACE_ORG_ID!,
  authMode: "x-api-key", // default is "bearer"
});

How we know it works. src/http.test.ts asserts header emission per authMode (Bearer vs x-api-key, X-Org-Id in both).

PATCH /v1/memories/{id}memories.patch()

The constraint. There was no supported way to re-tag a memory's group membership without re-ingesting: the SDK had no in-place mutation, and the spec annotated PATCH as removed (405).

What's now possible. memories.patch(id, { add_group_ids?, remove_group_ids? }) edits a memory's group membership in place and resolves to the updated Memory — the only in-place mutation the API offers. An empty patch throws synchronously rather than spending a round-trip, because the server rejects an empty body with 422 empty_patch.

Why this design. patch() models the live contract exactly — group-membership editing — rather than the spec's old { text, metadata } UpdateRequest. Reintroducing a generic update() against the stale body shape was the obvious move and was rejected: it would re-create the same spec-lags-wire drift this branch exists to remove. The price: patch() is deliberately narrow (group membership only, not arbitrary field edits), because that's the only contract the wire supports today.

What it looks like.

const reTagged = await client.memories.patch(memory.id, {
  add_group_ids: ["grp_tokyo2026"],
  remove_group_ids: ["grp_archive"],
});
console.log(reTagged.group_ids); // reflects the edit

await client.memories.patch("mem_123", {}); // throws synchronously

How we know it works. src/memories-patch.test.ts covers the happy path, the empty-body synchronous throw, and the 422 mapping; the live empty_patch capture is the fixture proving the endpoint and its accepted body.


Additive SDK surface

New capabilities on the aligned base, all purely additive.

Typed filter DSL — f

The constraint. SearchRequest.filters took a raw Record<string, unknown>. Hand-writing the wire JSON let a two-sided range ({ $gte, $lt }) silently collapse to one bound, or a typo'd operator no-op — a wrong-but-valid-looking query with no error.

What's now possible. f builds filters as typed, composable expressions where a silently-wrong query is structurally impossible: f.field(name, ops) is the only way to put multiple operators on one field (so a range can't lose a bound), and f.all(...) throws on a duplicate field rather than letting one clause clobber another. It's key-agnostic — your own indexed payload keys filter exactly like a built-in axis — and emits a plain object, so the raw Filter escape hatch still works.

Why this design. We chose an explicit builder over a flat merge helper because the failure mode being prevented is silent — the builder turns it into a thrown error or a type error at authoring time. The price: a small API to learn, mitigated by single-operator shorthands (f.eq, f.in, f.between, …).

What it looks like.

import { f } from "@xtraceai/memory";

const results = await client.memories.search({
  query: "recent activity",
  filters: f.all(
    f.eq("agent_id", "bot"),
    f.field("score", { $gte: 0.5, $lt: 0.9 }), // a range keeps BOTH operators
    f.in("plan", ["a", "b"]),
  ),
});

How we know it works. src/filter.test.ts asserts the wire JSON for every operator, the duplicate-field throw, and the same-field f.and composition path.

searchAll(), recall(… include), and superseded resolvers

The constraint. list() had an auto-paginating async-generator twin; search() didn't, so a full sweep meant hand-threading cursor/has_more. recall() couldn't pull heavy artifact bodies in the same round-trip. And an ingest that superseded a fact gave you an oldId → newId map but no easy way to fetch the replacement objects.

What's now possible. searchAll(req) sweeps every page (each page a fresh { ...body, cursor } spread, so the caller's request is never mutated). recall({ …, include: ['full_content'] }) forwards include to every pool, populating details.full_content in one trip. resolveAllSuperseded(result) returns a Map<oldId, Memory> of replacements.

Why this design. searchAll is a generator rather than a collect-into-array helper, so it streams instead of buffering an unbounded set — the trade-off being that a stable full sweep should pin mode: "retrieve" rather than the default compose selection pass (the method's doc comment calls this out). include is scoped to full_content rather than an open-ended list, since that's the only value the pool contract supports — an open list would invite forwarding values the server ignores.

What it looks like.

for await (const m of client.memories.searchAll({ query: "…", user_id: "alice" })) { /* … */ }

const { memories } = await client.memories.recall({
  query: "the trip itinerary",
  pools: [{ user_id: "alice" }, { group_ids: ["grp_tokyo2026"] }],
  include: ["full_content"],
});

const replacements = await client.memories.resolveAllSuperseded(done.result!);

How we know it works. src/search-all.test.ts asserts multi-page cursor threading and request-object immutability; src/recall.test.ts asserts include reaches every pool; src/superseded.test.ts covers single- and batch-resolution and the no-supersession case.


Validation

  • Full suite greentypecheck → test → build all pass. ~115 mocked-fetch tests, including the dedicated per-capability suites cited above. This is verified, not asserted.
  • Live probe — read-only / non-mutating calls against api.production.xtrace.ai (GET list, POST search with a bad body, GET missing id, PATCH probe body, bad-token 401). Raw bodies and headers are recorded in docs/adr/ADR-003.
  • Spec ↔ typessrc/generated/types.ts is regenerated from the corrected spec/memory.json (npm run gen:types).

Risks & limitations

Item Status
Error-envelope evidence is one body per class (401/404/422) The { error: … } shape is asserted canonical; the { detail } / detail[] fallback is kept rather than pruned.
The 429 envelope is not directly confirmed Forcing one needs an exhausted live bucket; the read-only probe didn't. Error-shape evidence covers three classes, not the full set.
PATCH success response not mutation-probed The endpoint and its accepted body are confirmed via 422 empty_patch; the success response shape reuses the existing Memory type and is not yet observed live.

Asks for the maintainer

  • src/generated/types.ts regeneration path. It's regenerated from the corrected spec via openapi-typescript. If your spec/memory.json is Stainless-generated rather than hand-maintained, say so and I'll rework the spec changes to fit that pipeline rather than hand-correcting the document.
  • Spec corrections are a contract change. The error/rate-limit/auth/PATCH edits change what spec/memory.json documents. They match production, but they need a maintainer's sign-off that production is the intended 1.0 contract, not the current spec text.

How to review

  1. docs/adr/ADR-003 — the live contract facts and the captured responses behind each claim. The spine.
  2. spec/memory.json — the corrections (error envelope, x-ratelimit-*, Bearer, PATCH). Diff against what ADR-003 says production returns.
  3. src/http.ts + src/errors.ts — error parsing, rate-limit headers, authMode.
  4. src/memories.tspatch(), searchAll(), recall(… include), superseded resolvers.
  5. src/filter.ts — the f builder (self-contained).
  6. src/generated/types.ts — regenerated; derived artifact, skim for surprises.
  7. README.md — the consumer-facing surface.

The five commits are split logically (docs → http → sdk → spec → readme), so commit-by-commit also works. Per-feature TDD commits and reviews live in the downstream fork PRs this came from: muunkky/memory-sdk-ts#3 (v0.3.0) and #7 (v0.4.0).

Opened from a downstream fork (muunkky/memory-sdk-ts). Thanks for considering it.


Planned and tracked with gitban.

Gitban details

Sprints

  • M2RECON (v0.3.0) — align the SDK and the lagging spec; add the filter/search/recall surface.
  • M3VERIFY (v0.4.0) — pin the contract corrections to captured live responses.

Roadmap

m2/s5 "spec-contract-alignment"m3/s6 "verification & corrections". This PR is the merged, upstream-facing landing of both.

Cards delivered

ID Type Title Key outcome
esxk0v doc spec reconciliation: auth/patch/metadata Spec corrected to the SDK's proven contract on auth, PATCH, metadata
m89x9w feat error-envelope + rate-limit hardening Parser tolerant of {error}/{detail}/detail[]; rate-limit snapshot surfaced
zc9r8x feat opt-in x-api-key auth mode authMode added; Bearer stays default
pktue3 feat typed filter DSL builder f builder; multi-operator field requires f.field, duplicate-field throws
lzwwfb feat recall include for full_content include forwarded per pool; scoped to full_content
77u8f3 feat searchAll cursor auto-pager Generator twin of list(); non-mutating per-page cursor spread
l3jhjg feat superseded-chain helper resolveAllSupersededMap<oldId, Memory>
f5ddyp chore type-surface source-of-truth + regen spec → gen:types path; regeneration guard documented
766plu feat fix parseRateLimitx-ratelimit-* + epoch Header parse corrected to live names; reset typed epoch-seconds
qkajsf feat memories.patch group-membership editing patch() for add/remove group ids; empty-body synchronous throw
26tyz6 chore A1/A4a spec corrections + regen + live 422 fixture Error envelope + PATCH documented to live wire; 422 fixture captured
aqxymt doc shared-bucket auth note + README README documents the no-quota-advantage shared rate bucket

Process cards (sprint plan, release, closeout) omitted; see metrics.

Risk-relevant evidence

gitban card 2syddu — the live-verification spike: raw 401/404/422 bodies, x-ratelimit-* headers, the auth re-probe, and the PATCH empty_patch capture. The evidence base every ADR-003 contract claim cites.

Sprint metrics

  • M2RECON: 11 cards (5 feature, 1 doc, 5 chore incl. plan/release/closeout) → v0.3.0.
  • M3VERIFY: 7 done cards (3 feature, 2 doc/chore corrections, 2 process) → v0.4.0.
  • Decision records: ADR-001 (SDK-as-canonical), ADR-002 (type-surface source of truth), ADR-003 (live-verified contract facts); design docs for each sprint.

Comment thread src/memories.ts
muunkky added 5 commits June 15, 2026 01:32
Engineering rationale behind this contribution: ADR-001 (SDK↔spec
reconciliation policy), ADR-002 (type-surface source of truth), ADR-003
(live-verified supersession after probing the deployed API), plus the two
implementation design docs. (They reference our downstream gitban tracking by
id; the decisions themselves stand alone.)
… auth

- parse the live {error:{type,code,message,request_id,details?}} envelope
  (tolerant of the prior {detail:…} / FastAPI shapes)
- surface x-ratelimit-limit/remaining/reset (reset = epoch seconds) on responses
  and errors, with RateLimit-* fallback
- opt-in authMode 'x-api-key' (Authorization: Bearer stays the default)
…, patch

- typed filter DSL (f) for SearchRequest.filters
- recall() forwards include:['full_content'] per pool
- searchAll() cursor auto-pager, symmetric with list()
- superseded-chain resolver helpers
- memories.patch(id, {add_group_ids?, remove_group_ids?}) -> Memory for the live
  PATCH group-membership endpoint
…ive wire

- error envelope documented as {error:{…}} (was {detail:{…}})
- rate-limit headers documented as x-ratelimit-* (was RateLimit-*)
- PATCH /v1/memories/{id} documented as group-membership (was marked removed/405)
- Authorization: Bearer documented as an accepted scheme
- generated/types.ts regenerated from the corrected spec
@muunkky muunkky force-pushed the cr/live-verified-reconciliation branch from 029af34 to 03565d7 Compare June 15, 2026 07:33

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 03565d7. Configure here.

Comment thread README.md
`npm run check:types-sync` (`gen:types` + `git diff --exit-code` on the
generated file) proves the committed reference is exactly what the current
`spec/memory.json` produces, catching reference drift. It is chained into
`prepublishOnly`, so a publish fails if the generated reference is stale.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Documented types-sync script missing

Low Severity

The README states npm run check:types-sync exists and is chained into prepublishOnly, and src/types.ts tells maintainers to keep the generated reference in sync via that command. package.json defines neither a check:types-sync script nor that step in prepublishOnly (only typecheck, test, and build), so the documented guard cannot run and publish is not protected as described.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03565d7. Configure here.

@muunkky muunkky changed the title feat(sdk): reconcile SDK + spec with the live API contract feat: reconcile the SDK + spec with what production actually returns (live-verified) Jun 15, 2026
@muunkky muunkky changed the title feat: reconcile the SDK + spec with what production actually returns (live-verified) feat: align the SDK + spec with the live API contract (verified against production) Jun 15, 2026
@muunkky muunkky marked this pull request as ready for review June 15, 2026 21:43
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.

1 participant