fix(agent-bff): order composite key segments by the record, not by the apimap - #1898
Conversation
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (3)
🛟 Help
|
nbouliol
left a comment
There was a problem hiding this comment.
Spec: no Linear ticket is linked, so the functional check was limited to the PR description, which the diff satisfies.
Applies to: packages/agent-bff/src/openapi/unfolded-paths.ts:463 (not in this diff)
Claude Fable 5.1 (claude-fable-5-1): Should fix: the OpenAPI document tells a client to build a composite parentId in apimap order, which the agent does not unpack.
Code
unfolded-paths.ts:460 describes the composite id as the read-model keys joined by | in that order, the apimap order the agent does not pack in.
A way out: drop the in that order claim and state the id is taken opaque from a record's id.
Reproduction
- Generate the unfolded document for a collection declared
tenant_id,seq, with a to-many relation. - Read the relation route's
parentIddescription, which says the values ofseq, tenant_idjoined by|in that order. - Send
42|acmeasparentId, and the agent unpackstenant_id=42,seq=acme, so the relation list fails.
|
On the second finding, the The description no longer claims an order a client can rely on. It names the keys, says the id is taken verbatim from a record's own Both findings are in. |
|
Claude Fable 5.1 (claude-fable-5-1): Closed: the composite parentId description no longer names an order a client could assemble. How it was checkedPredicate: the document states a key order for a composite parentId that differs from the order the agent packs in. |
|
Claude Fable 5.1 (claude-fable-5-1): Closed: the composite parentId description is now pinned to its new wording, so PRD-1221 is done. How it was checkedPredicate: the document states a key order for a composite parentId that differs from the order the agent packs in. |

Stacked on #1894 (
fix/agent-bff-4xx-body-leak), which merges first. This PR targets that branch, so the diff shown here is this change alone.What changed
__forest.primaryKeypaired the|-split segments of a record's packed id with the collection's primary keys by index. That index came from the apimap, which sorts its fields alphabetically (generator-collection.ts), while the agent packs in declaration order (agent/src/utils/id.ts). Neither order is contractual and they agree only by accident.In front of
forest-express-sequelize9.3.8, with atenant_id+seqtable: the apimap publishesseqfirst, the agent packsacme|42, and every list of that collection answeredThe record's own attributes now decide which segment belongs to which key. A segment equal to a key's attribute value belongs to that key, whatever order the schema published.
Why the record gives the order and not the value
The record decides the ordering and nothing else. Every emitted value still comes from the packed id, cast exactly as before. Reading the value from the record instead looks simpler and is wrong:
jsonapi-serializeroverwrites theidattribute with the resource id (deserializer-utils.js:90). A key namedidwould read backacme|42, and a singleNumberidkey would read'42'where the contract promises42.null, a boolean, a relation object written over the same key, or the JSON of a Buffer, and none of those fitRecord<string, string | number>.Datekey serializes as ISO in the attributes but asString(date)in the packed id, and only the packed form round-trips through the agent'sunpackId.A key that matches nothing,
idincluded, keeps its positional segment. That is the behavior that shipped before.Scope and safety
The matcher skips any key whose response key another field of the collection shares. The record then holds a single value under that key and nothing here can tell whose it is, so reading it could claim a sibling's segment.
ReadModelflags those keys withambiguousRecordKey, reusinggroupByRecordKey, the collision detector the context builder already relies on.What does not change: single-key collections, derived keys (#1895), the arity and numeric-cast errors, and every call that takes an id opaque (
parentId,recordIds). A request whose record cannot answer falls back to the positional split, errors included.Out of scope: a liana that publishes a single
isPrimaryKeyon a composite-key table.forest-railsflags onlytenant_idand its JSON:API id carries no second component, and no BFF code recovers a value the agent never sent.How to test
Against a v1 agent with a composite-key collection whose fields do not sort in declaration order:
Before:
500 mapping_error. After:200, with__forest.primaryKeynaming both columns.Measured on the conformance bench in front of
forest-express-sequelize9.3.8:pk-compositegoes fromfail 500topass 200, and that agent's column from 23 pass / 2 fail to 24 pass / 1 fail. The one failure left iscaps-route, which is structural, since no v1 liana serves the capabilities route.packages/agent-bff: 95 suites, 1888 tests, 0 failures.tsc --noEmitclean,eslint src testreports no error on the diff.Definition of Done
General
Security
https://claude.ai/code/session_01TrJS9jCkAfmyWQc1PdSEA2