feat: align the SDK + spec with the live API contract (verified against production)#9
feat: align the SDK + spec with the live API contract (verified against production)#9muunkky wants to merge 5 commits into
Conversation
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
029af34 to
03565d7
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
| `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. |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 03565d7. Configure here.


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/memoryandspec/memory.jsonnow define a single contract that matches what the deployed API atapi.production.xtrace.aiactually returns — the auth scheme, the error envelope, the rate-limit headers, and thePATCH /v1/memories/{id}shape. On top of that aligned base, the SDK gains a typed filter DSL, search auto-pagination,includeon 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, andpackage.json/CHANGELOG.mdare left to your release flow.Background — why this matters now
@xtraceai/memoryis 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 thanspec/memory.jsondescribes. 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
PATCHendpoint 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 at0.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.Bearer) is one the original spec didn't list verbatim — kept because it works and flipping it would break every existing callerTL;DR
{ error: { type, code, message, request_id, details? } }; spec documents itx-ratelimit-*, exposeserr.rateLimit;resettyped as epoch-secondsx--prefixed headers with an epochreset, notRateLimit-*/ deltaBearerdefault;authMode: 'x-api-key'opt-in; spec documentsBearerPATCH /v1/memories/{id}memories.patch()for group-membership edits; spec documents itadd_group_ids/remove_group_idsfbuilder forSearchRequest.filterssearchAll()searchtwin oflist()recall(… include)include: ['full_content']to every poolMemoryContract 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.codefor 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'sdetail[]array for 422s). Coded to the spec, a consumer would readerror.codeas absent and fall back to a generic message — losing the exact field they're told to rely on.What's now possible.
error.coderesolves to a real, stable code for every error the live API emits, and the spec no longer contradicts the wire. A422surfaces asUnprocessablewitherror.code === "validation_error"and the per-field array preserved undererror.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 the429error 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.
How we know it works.
src/errors.test.tsdrives each envelope shape ({ error },{ detail },detail[], bare-stringdetail) throughparseErrorBodyand asserts the resolvedcode/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, withresetas an epoch-seconds timestamp. The names arex--prefixed and theresetis an absolute time, neither of which matches the bareRateLimit-*/ delta-seconds shape the spec described.What's now possible.
err.rateLimitis populated from live responses, andresetis typed and documented as epoch-seconds — so a backoff computation subtractsDate.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 bareRateLimit-*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.
How we know it works.
src/http.test.tsfeedsx-ratelimit-*headers throughparseRateLimitand 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-keyheader rather thanAuthorization. The SDK shipsBearer(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 becauseBearerstays the default.X-Org-Idis sent in both modes. The spec now documentsBeareras an accepted scheme, so it stops contradicting the shipping client.Why this design. The scheme choice is wire-format only: both
Bearerandx-api-keyare throttled against the same(org_id, key_hash)rate bucket — confirmed live — so neither buys extra quota. The natural alternative, switching the default tox-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.
How we know it works.
src/http.test.tsasserts header emission perauthMode(Bearer vsx-api-key,X-Org-Idin 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
PATCHas 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 updatedMemory— 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 with422 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 genericupdate()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.
How we know it works.
src/memories-patch.test.tscovers the happy path, the empty-body synchronous throw, and the422mapping; the liveempty_patchcapture 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 —
fThe constraint.
SearchRequest.filterstook a rawRecord<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.
fbuilds 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), andf.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 rawFilterescape 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.
How we know it works.
src/filter.test.tsasserts the wire JSON for every operator, the duplicate-field throw, and the same-fieldf.andcomposition path.searchAll(),recall(… include), and superseded resolversThe constraint.
list()had an auto-paginating async-generator twin;search()didn't, so a full sweep meant hand-threadingcursor/has_more.recall()couldn't pull heavy artifact bodies in the same round-trip. And an ingest that superseded a fact gave you anoldId → newIdmap 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'] })forwardsincludeto every pool, populatingdetails.full_contentin one trip.resolveAllSuperseded(result)returns aMap<oldId, Memory>of replacements.Why this design.
searchAllis 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 pinmode: "retrieve"rather than the defaultcomposeselection pass (the method's doc comment calls this out).includeis scoped tofull_contentrather 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.
How we know it works.
src/search-all.test.tsasserts multi-page cursor threading and request-object immutability;src/recall.test.tsassertsincludereaches every pool;src/superseded.test.tscovers single- and batch-resolution and the no-supersession case.Validation
typecheck → test → buildall pass. ~115 mocked-fetchtests, including the dedicated per-capability suites cited above. This is verified, not asserted.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 indocs/adr/ADR-003.src/generated/types.tsis regenerated from the correctedspec/memory.json(npm run gen:types).Risks & limitations
{ error: … }shape is asserted canonical; the{ detail }/detail[]fallback is kept rather than pruned.429envelope is not directly confirmed422 empty_patch; the success response shape reuses the existingMemorytype and is not yet observed live.Asks for the maintainer
src/generated/types.tsregeneration path. It's regenerated from the corrected spec viaopenapi-typescript. If yourspec/memory.jsonis 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/memory.jsondocuments. 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
docs/adr/ADR-003— the live contract facts and the captured responses behind each claim. The spine.spec/memory.json— the corrections (error envelope,x-ratelimit-*,Bearer, PATCH). Diff against what ADR-003 says production returns.src/http.ts+src/errors.ts— error parsing, rate-limit headers,authMode.src/memories.ts—patch(),searchAll(),recall(… include), superseded resolvers.src/filter.ts— thefbuilder (self-contained).src/generated/types.ts— regenerated; derived artifact, skim for surprises.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
v0.3.0) — align the SDK and the lagging spec; add the filter/search/recall surface.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
esxk0vmetadatam89x9w{error}/{detail}/detail[]; rate-limit snapshot surfacedzc9r8xx-api-keyauth modeauthModeadded;Bearerstays defaultpktue3fbuilder; multi-operator field requiresf.field, duplicate-field throwslzwwfbincludefor full_contentincludeforwarded per pool; scoped tofull_content77u8f3searchAllcursor auto-pagerlist(); non-mutating per-page cursor spreadl3jhjgresolveAllSuperseded→Map<oldId, Memory>f5ddypspec → gen:typespath; regeneration guard documented766pluparseRateLimit→x-ratelimit-*+ epochresettyped epoch-secondsqkajsfmemories.patchgroup-membership editingpatch()for add/remove group ids; empty-body synchronous throw26tyz6aqxymtProcess 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 PATCHempty_patchcapture. The evidence base every ADR-003 contract claim cites.Sprint metrics
v0.3.0.v0.4.0.