Changelog
This log records changes to the TrakRF public API under /api/v1/ that affect integrators. Entries follow the Keep a Changelog convention with the v1 stability commitment in Versioning: within v1, changes are additive only — no silent breaking changes. Deprecations are flagged at least six months before sunset via RFC 8594 headers.
v1.0 — Launch (TBD)
Initial public API release. Stable contract for paths, field names, response shapes, and error envelopes per the v1 stability commitment.
Asset current location removed from the asset resource
An asset's current location is fact data — derived from the scan-event stream — not a dimension attribute the asset resource carries. location_id and location_external_key are removed from the asset response shape and from the GET /api/v1/assets filter surface; current location is read from the scan-data endpoints, and POST / PATCH reject the two fields uniformly on presence. The reporting endpoints (/reports/asset-locations, /assets/{id}/history) are unchanged — they remain the system of record for asset location. Pre-launch contract change; no v1.0.0-or-later wire baseline to break.
location_id/location_external_keyremoved from the asset response.GET /api/v1/assetsandGET /api/v1/assets/{asset_id}no longer return any location field — the asset resource carries master data only (name,external_key,is_active, effective dates, tags). Read an asset's current location fromGET /api/v1/reports/asset-locations(latest location per asset, filterable byasset_id/asset_external_key/location_id/location_external_key) or from the latest row ofGET /api/v1/assets/{asset_id}/history. Generated clients pick up anAssetViewwith two fewer fields; any client readingasset.location_idoff the asset response must move to the reporting endpoints. See Data model and Resource identifiers → Foreign-key fields in responses.?location_id/?location_external_keyfilters removed fromGET /api/v1/assets. Filtering assets by current location is itself fact-shaped and moves off the dimension endpoint. To list assets at a location, useGET /api/v1/reports/asset-locations?location_id=...(or?location_external_key=..., repeatable) and resolve the asset records from the returnedasset_idset when full asset detail is needed. Thelocation_id/location_external_keyfilter pair is unchanged on/reports/asset-locations. See Pagination, filtering, sorting → Filtering.POSTandPATCHrejectlocation_id/location_external_keyuniformly on presence. With location gone from the response shape there is nothing to round-trip, so the priorPATCHaccept-if-matches / reject-if-differs handling on these fields is retired: bothPOST /api/v1/assetsandPATCH /api/v1/assets/{asset_id}now return400 validation_error/code: read_onlywhenever either field appears in the body, identical on both verbs. The error detail still names the scan-event ingestion paths. See Errors →read_onlyand Resource identifiers → Paired-key behavior per verb.
BB66 docs sweep — updated_at token semantics paragraph, openapi-fetch PATCH per-call recipe, Tag.tag_type read/write enum asymmetry called out
Three integrator-visible additions from the BB66 multi-org triage. No runtime behavior change; the platform-side companion (Tag schema description amendment) is a description-only spec edit. Each closes a gap a careful BB66 reader surfaced rather than fixing newly-broken behavior.
- Design notes →
updated_atis an optimistic-concurrency token on PATCH gains an explicit "what the token detects, exactly" paragraph. The section already documented that every accepted PATCH advancesupdated_at(per the BB64 follow-up below), but a careful reader could still derive a concern thatPATCH {}health probes or writable-echo PATCHes "spuriously" advance the token. The new paragraph reframes the rule directly:updated_attracks "a write request against this row succeeded" — not "the data on this row changed." Empty-body PATCH, writable-echo PATCH, and admin no-content touches all advance the field; the concurrency-token use case relies on that semantic, because any intervening successful write is information the would-be writer needs. Integrations wanting "data changed" detection rather than "a write request happened" detection should diff resource fields directly. Service behavior is unchanged. - Quickstart → TypeScript with
openapi-fetchnow shows the per-call PATCH helper alongside the existing middleware recipe. ThemergePatchMiddlewarehelper still works and remains the lowest-overhead option once a project PATCHes from many sites, but it depends on copying a 30-line file from the platform repo. Integrators who only PATCH from one or two call sites — or who prefer not to pull a remote file into their tree — can now copy a self-containedpatchAsset()helper from the page that setsContent-Type: application/merge-patch+jsonper call.openapi-fetchalready callsJSON.stringifyon the body by default, so nobodySerializeroverride is needed in the common path. Closes the BB41-follow-up gap where the existing recipe pointed at an external file. - Resource identifiers → Tag is a polymorphic resource names the read/write enum asymmetry on
tag_type. The page previously describedtag_typeas an "open (extensible) enumeration" and pointed at the versioning policy on open enums, which is correct guidance on the read direction — pass through unknowntag_typevalues rather than rejecting them — but reads as contradicted by the runtime rejection on the write direction, wherePOST /tagsand the natural-key tuple(tag_type, value)accept only the currently-defined variants (rfid,ble,barcode) and reject anything else with400 validation_error/code: invalid_value/params.allowed_values: [...]. The prose now explicitly splits the directions and notes that the platform-sideTag/TagRequestschema descriptions mirror this disambiguation. The asymmetry is intentional pending the planned OpenAPI 3.1x-extensible-enummigration; service behavior is unchanged.
BB64 follow-up — every accepted PATCH advances updated_at (drops wire-idempotency for no-op bodies)
Single behavior change from a BB64 docs-side probe verification. The prior model — shipped in the BB39 follow-up wave below — short-circuited the storage UPDATE when no settable field's value differed from the current state, leaving updated_at byte-equal across verbatim no-op PATCHes. Probe verification post-BB64 found the short-circuit broke for valid_from / valid_to whenever the stored value carried sub-millisecond precision the wire didn't see (server-defaulted timestamps, sub-millisecond client input): the IS DISTINCT FROM check ran in storage coordinates against bytes the wire-truncated body didn't fully express, so a verbatim wire-level echo could still advance updated_at. Rather than carry the edge cases, the model simplifies: every accepted PATCH advances updated_at, no exceptions.
- Every accepted PATCH advances
updated_at. Empty body ({}), verbatim writable echo of current values, partial mutation, and full mutation all advanceupdated_atto the current server time on success. Rejected PATCHes (400 from a read-only field mismatch, validation failure on a settable field, or any other 4xx) do not advance — the operation never reached storage. The model matches filesystemtouchsemantics: any successful write event advances the modification time regardless of whether content changed. The optimistic-concurrency-token contract onupdated_atis unchanged — submitting a staleupdated_atstill returns400 validation_error/code: read_onlywith the mismatch detail — but cached-bodyPATCHretries that includeupdated_atnow need to refresh the token from a freshGETbefore retrying (or omitupdated_atfrom the body, letting the server advance it implicitly). Supersedes the BB39 follow-up R1 entry below ("PATCH idempotent on a verbatim no-op body"); the storage-level no-op short-circuit is gone. See Resource identifiers → Read shape vs. write shape for the round-trip framing and Errors → Idempotency for the cached-body retry-pattern consequence.
BB63 fix wave — readOnly annotations on Asset and Location read views, ErrorEnvelope named schema, read_only vs invalid_context semantic split
Three integrator-visible items from the BB63 multi-org parallel triage, all pre-launch. F1 closes a contract-class annotation gap on four read-view fields that the runtime already treated as read-only — strict-typed client generators now see the OpenAPI signal at the type layer. F2 hoists the ErrorResponse.error inline shape to a named ErrorEnvelope schema so generated client class names stop embedding another class's name. F4 splits the read_only code value into two — preserving read_only for truly server-managed fields and shifting fields mutable via a sub-resource verb onto the broader invalid_context code introduced in the BB62 fix wave below. F2 and F4 carry generated-client churn (a renamed schema class on F2; updated code-value branching on F4) — pre-launch is the right time to take both rather than after partners have pinned against the older shape.
readOnly: trueadded to four fields onAssetViewandLocationView.AssetView.location_id,AssetView.location_external_key,AssetView.tags, andLocationView.tagsnow carry the OpenAPI 3.0readOnly: trueannotation, matching the runtime accept-if-matches / reject-if-differs rule that the other annotated fields (id,created_at,updated_at,deleted_at) already advertise. Strict-typed client generators (openapi-python-clientemitsField(..., frozen=True)forreadOnlyproperties;openapi-generator-clipython target marks them in the model layer;openapi-typescript@7.xcarries the annotation through to the type alias) now mechanically signal that these fields shouldn't be sent back on PATCH bodies. Integrators diffing the read and write schemas to learn what's writable no longer have to reason about whylocation_idappears in the read shape but not inUpdateAssetRequest— thereadOnly: trueannotation closes the loop on the read side, and the field's absence from the write request shape closes it on the write side. Runtime behavior is unchanged: the same accept-if-matches / reject-if-differs rule fires on these fields as before. See Resource identifiers → Read shape vs. write shape.ErrorEnvelopeintroduced as a named schema;ErrorResponse.errorrewired to$ref. The error envelope shape (type,title,status,detail,instance,request_id,fields[]) now lives at#/components/schemas/ErrorEnveloperather than as an inline anonymous object onErrorResponse.error. The schema-level description "TrakRF error envelope, modeled on RFC 7807 but not 7807-compliant" moves with the hoisted schema. Wire shape is byte-identical — theerrorkey on every non-2xx response still carries the same object. Generated clients pick up a class-name change:openapi-generator-cli --generator pythonemitsclass ErrorEnvelope(BaseModel)rather than the parent-derivedclass ErrorResponseError(BaseModel);openapi-typescriptemits acomponents["schemas"]["ErrorEnvelope"]alias;openapi-python-clientemitserror_envelope.pyas a module. Hand-written clients deserializing the response by field path (response.error.type,response.error.fields[0].code) are unaffected. Pre-launch breaking change against any generated client already pinned to the old class name — but nov1.0.0-or-later wire baseline exists yet, and post-launch the rename would carry materially more churn. See Errors → Envelope shape.- Error code semantic split:
read_only(server-managed) vsinvalid_context(sub-resource-mutable, not via this verb).code: read_onlywas previously emitted on two semantically distinct cases — truly server-managed fields (id,created_at,updated_at,deleted_at, plus the scan-derivedlocation_id/location_external_keyon assets) and fields mutable via a sub-resource verb that aren't writable on the PATCH surface (external_keymutable viaPOST /…/rename,tagsmutable via POST/DELETE on the/tagssub-resource). The detail strings routed the integrator correctly in both cases, but a strict-typed client switching overFieldErrorCodesaw the same value and couldn't distinguish "this is server-managed" from "use a different verb." The runtime emission now splits:external_key(assets, locations) andtags(assets, locations) on PATCH now emitcode: invalid_contextinstead ofcode: read_only; truly server-managed fields continue to emitcode: read_only. Theinvalid_contextsemantic was introduced in the BB62 fix wave below for the "known parameter, used in wrong context" case (?include_deleted=trueon detail endpoints); the same semantic — known field on the surface, but wrong verb / sub-resource — fits the PATCH-mutable-via-sub-resource case naturally. Detail strings are unchanged on bothcodebranches — clients displayingdetaildirectly continue to work without modification. Programmatic handlers branching oncode: read_onlyforexternal_keyortagsshould add aninvalid_contextarm (and route to the sub-resource verb described indetail); handlers that fall through unknown codes to a generic 400 handler are unaffected (FieldErrorCodeisx-extensible-enum: true). See Errors → Validation errors and Resource identifiers → Read shape vs. write shape.
BB62 follow-up — Asset.name and Location.name validator tightened (control chars and surrounding whitespace rejected)
One pre-launch tightening from the BB62 follow-up. F1 splits the validator pattern on the public-API name fields so display strings have a stricter shape than free-form description text; the spec-side regex emission matches the server validator so generated clients honoring pattern: see the same boundary at decode time. A companion docs-narrow correction on the Location response header description is spec-emission-only with no behavior change — not called out here. Pre-launch behavioral / spec tightening; no v1.0.0-or-later wire baseline to break.
Asset.nameandLocation.name(create + update) now reject all C0 control characters (including\t,\n,\r), DEL (\x7F), and require non-whitespace at both the first and last character. The previous pattern (^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$) was multi-line-tolerant — reasonable for free-formdescriptioncontent but not for a single-line displayname. Whitespace-only values (" "), embedded newlines ("line1\nline2"), embedded carriage returns or tab characters, and leading or trailing whitespace (" Asset 1"or"Asset 1 ") now return400 validation_error/code: invalid_valueonname. Internal whitespace ("Asset 1") and single-character names ("X") continue to succeed. Description fields keep the existing multi-line-tolerant pattern — multi-line content inAsset.descriptionandLocation.descriptionremains accepted unchanged. Applies toCreateAssetWithTagsRequest.name,UpdateAssetRequest.name,CreateLocationWithTagsRequest.name, andUpdateLocationRequest.name. The spec emitspattern: ^\S(?:[^\x00-\x1F\x7F]*\S)?$on these four fields so generated clients that honorpattern:see the same boundary as the server validator. See Errors → Validation errors.
BB62 fix wave — body-decode failures normalized to validation_error, new invalid_context code for known-field-wrong-context rejections, x-package-name spec extension, /ancestors ordering prose fix
Three integrator-visible service-side changes from the BB62 multi-org parallel triage plus one contract-class docs fix on the location-tree endpoints. F2 closes the fourth-time-surfaced envelope-split discoverability gap (BB52 / BB56 / BB61 / BB62) at the service layer rather than at the docs layer, retiring the workarounds shipped in those earlier waves. F3 and F4 are smaller hygiene items on the same triage. F1 corrects a prose-vs-spec drift on /ancestors ordering that no client-side test catches.
- Body-decode validation errors now carry
fields[]. Previously, a body-decode type-mismatch — sending a string where a boolean was expected, a float where an int was expected, or any JSON value the decoder couldn't coerce to the declared body-field type — returnederror.type: bad_requestwithdetailonly and nofields[]array, distinct from value-level validation errors which returnedvalidation_errorwithfields[]. The envelope is now unified: any field-attributable body validation failure returnsvalidation_errorwith a populatedfields[](one entry per offending field). Type-mismatch entries carrycode: invalid_value,params.expected_typeset to the wire-facing JSON type name (boolean,number,string,array,object,null), andparams.received_typeset to the JSON kind the decoder actually saw — so a generated client can branch on the type names without parsing free-formdetailtext.detailmirrors the first field'smessage(e.g.is_active must be a boolean; received string). Multiple type-mismatches in one body yield multiplefields[]entries with the(and N more validation errors)suffix ondetail, matching the validator's existing aggregation behavior for value-level failures. Thebad_requestenvelope is now reserved for failures the API can't attribute to a field: invalid JSON syntax, a top-level type mismatch (the request body itself is the wrong JSON shape — for example an array where an object is expected), or aPATCHbody that is the literal JSON tokennull. The behavior change is strictly additive on thevalidation_errorpath: clients branching onerror.typecontinue to work without modification; clients that iteratefields[]for diagnostic detail now see useful data on previously-empty cases. The prior documentation workarounds at Errors →validation_errorvsbad_request(the BB52 typed-client warning admonition and the BB61 side-by-side worked example) are simplified to match the unified post-refactor behavior — the warning shape is retained for the residualbad_requestcases since field-attributable failures all now route tovalidation_error, the unconditional-fields[]-iteration footgun is much rarer in practice. Thebad_requestrow in the type catalog is also updated to describe the narrower remaining scope. See Errors →bad_requestand Errors →validation_errorvsbad_request. - New
code: invalid_contextonFieldErrorCodefor known-field-wrong-context rejections.GET /api/v1/locations/{location_id}?include_deleted=truepreviously returned400 validation_errorwithfields[0].code: unknown_fieldon theinclude_deletedparameter — the same code the validator emits for truly-unknown parameters (?wat=1). Thedetailtext was specific (include_deleted is a list-only filter; soft-deleted records are not retrievable by id (...)), but a strict-typed client switching overFieldErrorCodecouldn't distinguish "field doesn't exist on this surface" from "field exists on the API but isn't allowed here." The detail-endpoint rejection now emitsfields[0].code: invalid_contextinstead. TheFieldErrorCodeenum in the spec gains the new value. The shared validator helper applies the code uniformly across the surface: every list-endpoint filter parameter (external_key,is_active,q, the surrogate / natural-key reference pairs,include_deleted) now emitsinvalid_contextwhen sent to a detail or write endpoint that doesn't declare it, with amessagethat names the list-endpoint sibling (e.g.GET /api/v1/assets) when one can be derived from the request path.include_deletedkeeps its specialized natural-key-recovery wording so the soft-delete retrieval path stays discoverable. Truly unrecognised query keys (?wat=1) continue to emitunknown_field. Programmatic handlers branching on a switch overFieldErrorCodeshould add aninvalid_contextarm; handlers that fall through unknown codes to a generic 400 handler continue to work without modification (FieldErrorCodeis declaredx-extensible-enum: truein the spec). The catalog entry is at Errors → Validation errors (alongsideunknown_fieldand the otherFieldErrorCodevalues). x-package-name: trakrf-api-clientadded to specinfo.openapi-python-client@0.28.x's default snake-case derivation of the Python package name frominfo.title: "TrakRF API"produced the awkwardtrak_rf_api_client— the title's interior caps split into a separate token. Generators that honorx-package-name(includingopenapi-python-client) now derive the canonical package nametrakrf_api_clientautomatically. Generators that ignore the extension can still use the--metaCLI override (openapi-python-client generate --path … --meta path/to/meta.toml, or the equivalent flag on other generators) to set the package name from outside the spec. No wire change; spec emission only.- Resource identifiers → Location tree endpoints ancestors prose corrected from "nearest-first" to "root-first". The endpoint description for
GET /api/v1/locations/{location_id}/ancestorspreviously read "ordered nearest-first (immediate parent → … → root)." The OpenAPI spec and the live service both implement the opposite order: ancestors are returned root-first (walking up theparent_idchain to the tree root), withidascending as a deterministic tiebreaker — which is what the Pagination, filtering, and sorting → Sub-resource list endpoints page documented correctly. A breadcrumb UI written from the prose (for a of response.data { render(a) }) renders reversed end-to-end — no 400 to surface it, just wrong visual output. The prose now matches the spec and the service, with an explicit "iterate in natural order and append the current location at the end" guidance for breadcrumb rendering. Service behavior is unchanged.
BB61 fix wave — tags PATCH echo compared as a set, worked example for validation_error vs bad_request
One service-side behavior change plus one docs-narrow item from the BB61 multi-org parallel triage. F1 is integrator-visible and strictly more permissive — every prior in-order echo continues to succeed. F2 closes a third-time-surfaced discoverability gap on the envelope split section by adding a concrete request / response pair next to the abstract description.
PATCH /api/v1/assets/{asset_id}andPATCH /api/v1/locations/{location_id}now compare thetagsfield on the PATCH echo as set-equality on full tag content rather than as sequence equality. When the request body echoes thetagscollection (as part of a GET → edit → PATCH round-trip), the server compares the submitted array to the current state by tag id, then verifies that every other field on each Tag (tag_type,value, plus any other declared field) matches — array ordering is not significant. Differing set membership (a tag id present on one side but not the other), a length mismatch, or differing field values on a matching id still return400 validation_error/code: read_only. Integrators using generated clients that deserialize tags into unordered collections — Pythonsetorfrozensetfor tag-id deduplication, Gomap[int64]Tagfor keyed access, ORMs whose association-proxy /has_manydefaults don't preserve collection ordering, or any "fetch tags by parent, format for PATCH" helper that goes through a hash-based intermediate — no longer need to manually sort by id before re-submission. The 400read_onlydetailis tightened to readthe tags field on PATCH must equal the current value as a set (idempotent echo only; ordering is not significant); use POST /api/v1/{resource}/{id}/tags and DELETE /api/v1/{resource}/{id}/tags/{tag_id} to mutate. The spec emission for bothPATCHoperation descriptions gains a parenthetical clarification that ordering is not significant on thetagscollection — spec diff is description-only, no operationId or schema movement. Strictly more permissive: every prior in-order echo continues to succeed unchanged. See Errors →read_onlyand Resource identifiers → Tags use a composite natural key.- Errors →
validation_errorvsbad_requestnow includes a side-by-side worked example. The page already framed the parse-time vs validate-time split and (per the BB52 entry below) warned typed clients against unconditionalfields[]iteration. A reader probing boolean coercion who hitis_active: "true"and saw abad_requestenvelope still needed to translate the abstract prose into their concrete envelope shape; an in-cycle reproduction of the exact example surfaced the discoverability gap a third time. A new "worked example" block now shows the contrast directly — samePOST /api/v1/assetsendpoint, one body that fails at decode time and returnsbad_requestwith nofields[], one body that fails at validate time and returnsvalidation_errorwith a singlefields[]entry. A reader probing either failure mode finds their specific envelope shape rendered, not just described. Service behavior is unchanged; the contract documented in Errors → Validation errors is load-bearing.
BB58 fix wave — location parent_id cycle rejection (1-hop + N-hop) with specific 409 detail, BearerAuth python-codegen warning
Two service-side fixes from the BB58 multi-org parallel triage. F1 is a contract-class fix on the location reparenting path; F2 (folded into F1) is a corollary hygiene fix on the existing 1-hop self-parent error; F3 is a one-line spec emission tweak.
PATCH /api/v1/locations/{location_id}now rejects anyparent_idassignment that would create a cycle in theparent_location_idchain. Previously, the 1-hop self-parent case (parent_id={location_id}) was rejected at the database CHECK-constraint layer with409 conflictcarrying a genericdetail: "Request violates a domain invariant", and the N-hop transitive case (any depth ≥ 2 — e.g. X has child Y, then PATCH X withparent_id: Y) was silently accepted, leaving the service in a state whereGET /api/v1/locations/{location_id}/ancestorsand/descendantshung indefinitely andDELETEon either node returned409 "has descendant locations"because the cycle counted itself as its own descendant. The service now walks the full ancestor chain from the proposed parent on every PATCH that changesparent_idto a non-null value; both 1-hop and N-hop cycles return409 conflictwith a specific actionabledetail:- 1-hop:
detail: "parent_id {location_id} would create a self-referential cycle" - N-hop:
detail: "parent_id {Y} would create a cycle through location {X}"Wire shape (error.type,error.status) is unchanged on both paths — only thedetailtext is now specific. Valid non-descendant reparenting (PATCH .../{X} {"parent_id": Y}where Y is not in the subtree rooted at X) and root promotion (parent_id: null) continue to succeed unchanged. As defense in depth, the recursiveparent_location_idwalks used by/ancestorsand/descendantsnow carry a PG14+CYCLEclause so a corrupt-tree state surfaces as a500 internal_errorwith diagnostic detail instead of hanging the walker. The SPA's location reparenting picker — which already excluded the current location and its descendants from selectable parents — adds visited-set hardening so a stale or cyclic in-memory cache cannot hang the dropdown. See Errors →conflictand Resource identifiers → Locations:parent_idandparent_external_key.
- 1-hop:
securitySchemes.BearerAuth.descriptionnow also namesopenapi-generator-clipython target as a generator that does not auto-attach theAuthorizationheader. The warning previously listed onlyopenapi-fetch; BB58 confirmed the same friction on theopenapi-generator-clipython target — settingConfiguration(access_token=…)alone is insufficient; integrators must additionally setApiClient.default_headers['Authorization']. The updated wording reads: Some OpenAPI generators (e.g. openapi-fetch, openapi-generator-cli python target) do not auto-attach the Authorization header from this scheme — set it manually if your generated client does not. Spec emission only; no wire change. See Authentication → Request header.
BB57 fix wave — error UX hygiene (valid_from recommendation, bad_request envelope detail, PATCH rename hint, validator aggregation docs-narrow)
Four hygiene items from the BB57 multi-org parallel triage — three small service-side error-string changes plus a docs-narrow alignment on validator aggregation. Wire shape (error.type, fields[] structure, error.status) is unchanged on every item; programmatic handlers that branch on error.type and fields[].code continue to work without modification. detail text remains explanatory, not contractual — these changes improve the human-readable diagnostic value of detail strings at the points where the prior wording was contradictory, asymmetric, or missing information the server already had.
- Sentinel-rejection recommendation on non-nullable timestamps no longer contradicts the null-rejection recommendation.
POST /api/v1/assetsandPOST /api/v1/locationswithvalid_from: nullalready rejected with the omit-or-provide recommendation; the sentinel-value path (valid_from: "0001-01-01T00:00:00Z", the Go zero-time, orvalid_from: "1970-01-01T00:00:00Z", the Unix epoch) previously emitted adetailrecommending"use JSON null"— copy-pasted from the nullable-timestamp error path. An integrator following the second recommendation to fix the first error landed back on the first. The sentinel-rejection wording now mirrors the null-rejection wording on non-nullable fields:valid_from must not be a default-value sentinel ({sentinel}); omit the field to use the server default, or provide a real timestampon POST, with the PATCH variant readingomit the field to leave unchanged. Nullable timestamps (valid_to) continue to recommend"use JSON null". See Errors →bad_request. - Body-decode
bad_requestdetailnow names the expected JSON type when the decoder knows it. Sending{"is_active": "true"}(string-as-boolean) toPOST /api/v1/assetspreviously returneddetail: Body field "is_active" could not be decoded as the expected type— the validation-stage envelope (validation_errorwith populatedfields[]) surfaced the expected type viaparams, but the decode-stage envelope withheld it despite the decoder knowing it at the point of failure. The new wording isBody field "is_active" could not be decoded as the expected type (boolean). Applies across every decode-failure code path that knows the expected type; envelopetypestaysbad_requestandfields[]is still not populated on this path (parse-time, no schema validator has run yet — seevalidation_errorvsbad_request). Clients that branch onerror.typeare unaffected. - PATCH read-only
external_keyrejection now includes the rename body shape inline.PATCH /api/v1/assets/{asset_id}andPATCH /api/v1/locations/{location_id}withexternal_keyset previously returnedexternal_key is immutable via PATCH; use POST /api/v1/assets/{asset_id}/rename to change it— the hint pointed to the right verb and path but withheld the body shape. Integrators reading "to change it" mentally substituted the action and sent{"new_external_key": "..."}to the rename endpoint, which 400'd on the unknown field; the user self-corrected in one step, but the round-trip was avoidable. The new wording closes the loop:external_key is immutable via PATCH; use POST /api/v1/assets/{asset_id}/rename with body {"external_key": "<new value>"} to change it. Mirrored on the locations side. See Resource identifiers → Renaming anexternal_keyfor the canonical rename contract. - Errors → Envelope shape prose narrowed to match service behavior on validator aggregation. The page previously read as if
(and N more validation errors)and a multi-entryfields[]array were the uniform shape for any body invalid on multiple fields. In practice, the body validator runs decode and validate as serial passes — a body invalid onvalid_from(parse-time) short-circuits the rest of the validate pass entirely, sofields[]carries a single entry for the date-parse miss even though other body fields would also have failed validation. The reliable cross-field aggregation case isambiguous_fields(paired natural-key conflict onparent_id+parent_external_key, or any of the paired surfaces in theambiguous_fieldscatalog), which emits both per-field entries and the(and N more...)suffix. Clients should branch onfields[]array length to detect the multi-field case rather than assuming all violations on a body land in a single response. Service behavior is unchanged from BB57 — the prose, not the contract, is what's corrected.
BB53-54 docs — sort prose accuracy, fk_not_found vs ambiguous_fields precedence, /openapi.{yaml,json} alias
Three docs-discoverability items from the BB53/BB54 multipass triage. No spec or wire change.
- Pagination, filtering, and sorting → Sorting no longer claims compile-time rejection of unknown sort fields. The page previously described "generated clients with strict typing reject unknown sort fields at compile time" as a primary defense, but the sort allowlist is encoded as a regex
pattern:on astring-typed query parameter rather than asenum:, and the v1 verified-working codegen targets (openapi-typescriptandopenapi-generator-cli's python target) emitsortas a plain string without honoringpattern:. A careful integrator reading the prior wording could have skipped the runtimecode: invalid_valuebranch in their error handler and been surprised by a 400 on a typo'd column name. The prose now names the actualpattern:mechanism and tells integrators theirsorterror handler must branch on the runtime 400. Service behavior —400 validation_errorwithfields[].message: "unknown sort field: <name>"on a value outside the allowlist — is unchanged. - Errors →
ambiguous_fieldsnow documents precedence vsfk_not_foundon paired natural-key conflicts. Whenparent_idandparent_external_keyare both supplied with differing values, the validator resolves each form independently before comparing them — if one side fails FK resolution,fk_not_foundfires on the invalid side andambiguous_fieldsdoes not fire.ambiguous_fieldsonly fires when both forms resolve to valid but distinct rows. The page previously read as "if the two values disagree, you getambiguous_fields," which would have routed the one-side-invalid case to the wrong error branch in a validation UI built strictly from the docs. Clients should branch on the per-fieldcoderather than the top-level error type. Applies to thePOST /api/v1/locationsandPATCH /api/v1/locations/{location_id}request-body surfaces. Service behavior is unchanged. - Quickstart §5 now documents the bare-form
/openapi.{yaml,json}alias at the app origin.https://app.trakrf.id/openapi.yamland.jsonresolve to the canonical/api/openapi.{yaml,json}paths via301 Moved Permanently, matching the convention used by Stripe, GitHub, and other SaaS APIs. The bare form has worked since launch but was not referenced anywhere in the integrator-facing docs; an integrator who guessed it landed on the right bytes but had no way to confirm from the docs that the alias was supported. Either form is acceptable for codegen tooling.
BB50-52 recurring — POST /api/v1/locations accepts matching parent_id + parent_external_key pair
Service relaxation that closes a recurring contract-class finding surfaced three times across BB50, BB51, and BB52. Pre-launch, additive, non-breaking against any v1.0.0-or-later wire baseline. Also folds in BB51 F2 (error-message alignment) since shared validation logic resolves both items in one change.
POST /api/v1/locationsnow accepts a matchingparent_id+parent_external_keypair. A payload that names the same parent through both forms is silently normalized to a single re-parent operation, symmetric with the long-standing PATCH behavior. A disagreement still returns400 validation_error/code: ambiguous_fieldsusing the PATCH-shaped "both supplied and disagree; supply exactly one or supply consistent values" wording (so POST and PATCH now emit identical error strings — that was the BB51 F2 side finding). The pre-fix POST rejected both-supplied outright regardless of value agreement; the asymmetry with PATCH was accidental spec evolution and surfaced three cycles in a row across BB50/51/52. The "POST-the-GET-response after editing one field" workflow now works on locations Create.CreateLocationWithTagsRequestdrops thenot: required: [parent_id, parent_external_key]constraint. The paired-key matrix and the filter-vs-write framing in pagination/filtering/sorting are updated to reflect the now-symmetric POST / PATCH body rule (and the still-strictGETfilter rule that does not normalize matching pairs).
BB52 docs — validation_error vs bad_request envelope split, non-monotonic auto-mint wording
Two docs-discoverability items from the BB52 multipass triage. No spec or wire change.
- Errors →
validation_errorvsbad_requestnow explicitly warns typed clients against unconditionalfields[]iteration. The page already framed the parse-time vs validate-time split, but a handler shaped likefor f of body.error.fields { surface(f.field, f.message) }silently swallowsbad_request— the array is absent, the loop is a no-op, and the user sees an empty error toast for a real decoder-level failure (string-where-bool-expected, float-where-int-expected, JSON-integer-overflow). A new:::warningadmonition spells out the failure mode and points to gating the iteration onerror.type === "validation_error". The narrative example near the type-mismatch block now also contrasts the JSON-integer-overflow case (parent_id: 9999999999999999999999→bad_request, nofields[]) against the in-range-but-too-large case (parent_id: 2147483649→validation_errorwithcode: too_largeandparams.max: 2147483647) so an integrator reading the page sees both envelope shapes side-by-side before generating their error-handling code. Service behavior is unchanged; the contract and per-field codes already documented in Errors → Validation errors are load-bearing. - Resource identifiers →
external_keyis optional on create replaces "per-organization sequence" with non-monotonic wording. The prior phrasing primed careful readers to expect Postgres-nextval-style monotonic allocation — once consumed, never reused. The observed behavior is "lowest unused per-organization slot in theASSET-NNNN/LOC-NNNNnamespace": deleting every live row holdingASSET-0006makes that slot eligible for the next mint even when soft-deleted rows still exist under the same natural key. The auto-mint section, the upstream natural-key summary, and the resource table column header all now use namespace-shaped language, and a new paragraph adjacent to the existing "available for reuse" sentence calls out the recycle property explicitly so partner-side audit logs aren't built on a false never-recycled-slot assumption. The load-bearing system-of-record guidance ("supply the partner-side handle on create — don't rely on the auto-mint") is unchanged; it now cites the recycle property as a second reason alongside the original "won't join cleanly to a SKU." Service behavior is unchanged.
BB47-49 docs sweep — parent_external_key writability alignment, quickstart idempotence boundary, openapi-fetch PATCH CT surfaced on API clients page
Three docs-discoverability items from the BB47-49 multipass triage. No spec or wire change.
- Quickstart §3 and earlier entries in this changelog now match Resource identifiers on
parent_external_keywritability. Bothparent_idandparent_external_keyare writable onPATCH /api/v1/locations/{location_id}, symmetric withCreateLocationRequest— either form re-parents and either acceptsnullto detach the location to root. Supplying both with matching values is accepted (silently normalized to a single re-parent); supplying both with differing values returns400 validation_error / code: ambiguous_fields. Quickstart §3 previously listedparent_external_keyin the read-only-echo set and namedparent_idas the writable canonical for the parent relationship; earlier entries below characterized the natural-key form as read-only / silently-stripped on PATCH. Live service behavior is unchanged from what Resource identifiers documents — the docs drift, not the contract, is what's corrected. The typed-spec audience already gets this right (UpdateLocationRequestcorrectly lists both fields as writable in the spec); the affected audience is hand-rolled-client and curl-using integrators reading the prose. The matching400 validation_error / code: fk_not_foundenvelope on a non-existentparent_external_keyvalue (verified BB47) is documented at Errors →fk_not_found. - Quickstart §3 idempotence claim qualified for the create-then-echo case. A verbatim no-op body is wire-idempotent on every PATCH after the first; the first PATCH after POST on a fresh resource can advance
updated_atonce because storage holds microsecond precision and the wire is truncated to milliseconds, so a verbatim GET-echo body compares unequal at the sub-millisecond residue and a write fires. The PATCH rewrites storage at ms precision; subsequent echoes are wire-idempotent. The underlying µs→ms truncation is documented at Date fields → Inbound parsing and was unchanged — only the quickstart idempotence prose needed the qualification. - API clients (Postman, Insomnia, …) now covers the
openapi-fetchPATCH content-type override.openapi-fetch@0.13.xdoes not auto-sendContent-Type: application/merge-patch+json, despite the spec declaring this content type on every PATCH operation — every PATCH returns415 unsupported_media_typeunless you override per call or register themergePatchMiddlewarehelper covered in Quickstart §5. The API-clients page now surfaces the same failure mode and cross-links to the existing middleware so integrators arriving from the GUI-client angle find the workaround without first reading the quickstart. Theopenapi-generator-clitypescript-fetchtarget and the Pythonopenapi-generatorhandle merge-patch correctly out of the box; only theopenapi-fetchruntime path needs the additional doc surface. Adjacent to the BB41 follow-up below, which surfaced the same workaround from Quickstart §3.
BB42 fix wave — error.detail URL substitution, tag_type strict-required, unknown query param code
Three small server-side fixes from BB42. F1 is a contract regression introduced by the earlier data-model-URL templating; F2 is a pre-launch tightening to align service behavior with the long-standing spec declaration; F8 closes a query-vs-body code-emission asymmetry surfaced on the BB42 retest. No OpenAPI spec change in any of the three — the spec already declared tag_type required on each *TagRequest subtype, the error.detail shape is wire-shape-neutral, and code: unknown_field was already enumerated in the error-code catalog.
- Top-level
error.detailnow carries the substitutedhttps://docs.trakrf.id/api/data-modelURL on the assetlocation_*read-only rejection. Prior to this fix,POST /api/v1/assetsandPATCH /api/v1/assets/{asset_id}withlocation_idorlocation_external_keyset returned a per-fieldfields[0].messagewith the URL fully substituted but a top-levelerror.detailcarrying an unsubstitutedhttps://[internal]placeholder — the template substitution introduced in the earlier data-model-URL wave landed on the field-level path and missed the top-level detail. An integrator following the docs guidance to surfacedetailto humans would have displayedhttps://[internal]in their UI; integrators branching onfields[].codeand renderingmessagewere already correct. Both paths now render the substituted URL. Programmatic handlers should continue to branch onerror.type(validation_error) andfields[].code(read_only) — thedetailtext remains explanatory, not contractual. See Errors →read_only. POST /api/v1/assets/{asset_id}/tagsandPOST /api/v1/locations/{location_id}/tagsnow reject an omitted ornulltag_type. The spec has markedtag_typerequiredon every*TagRequestsubtype since the BB33 spec-level restructure below, but the service retained a silent default torfidfor hand-written raw-HTTP callers. Sending{"value": "..."}or{"tag_type": null, "value": "..."}now returns400 validation_error/code: required/field: tag_typeinstead of silently creating an RFID tag. Generated SDKs that already requiredtag_typeper the discriminated-union types are unaffected; hand-written callers that omitted the field need to send it explicitly (one ofrfid,ble,barcode). Closes the strict-typed-vs-loose-client divergence — the same body Pydantic / Jackson rejected pre-wire is now rejected service-side too. Closes the future-footgun on the open-extensible discriminator: a request that meant to target a not-yet-implemented variant no longer lands silently onrfid. Pre-launch tightening; nov1.0.0-or-later wire baseline to break. See Resource identifiers → Tags use a composite natural key.- Unknown query parameters on every endpoint now emit
code: unknown_field(matching the body-side strict decoder). The BB32 fix wave below committed to a unified field-error code for unknown keys across both surfaces, and the body-side strict decoder honored the contract from day one. Both query-side emission paths —ParseListParamsfor unknown filter keys on list endpoints, and the unknown-key validator for single-resource and write endpoints — had been emittingcode: invalid_valueinstead, so generated clients branching oncode: unknown_fieldfor query-validation handling silently missed the query-side case (e.g. a typo'd?location_id_=42returned the value-shaped code rather than the field-shaped one). Both paths now returncode: unknown_fieldwithfields[].fieldnaming the offending parameter. Value-shaped failures on known keys (bad sort field name, non-boolean value for a bool filter, regex-format violation on a filter value) remaincode: invalid_value. The BB32 entry below now describes shipped behavior on every surface, not just the body side. See Errors →validation_errorvsbad_request.
BB41 follow-up — TypeScript openapi-fetch PATCH 415 surfaced from §3
Single docs-discoverability item from BB41. No spec or wire change.
- Quickstart §3 now points TypeScript
openapi-fetchreaders to the merge-patch middleware in §5 before the first PATCH. The substantive fix — a copyablemergePatchMiddlewareplus acreateTrakrfClientwrapper — has been at §5 — TypeScript withopenapi-fetchsince the TRA-718 cycle. A reader following the curl walkthrough in §3, then translating toopenapi-fetchbefore reaching §5, would still hit415 unsupported_media_typeon their firstPATCH. A short:::tipadmonition now lands next to the §3 PATCH curl example, naming the failure mode and cross-linking to the existing middleware. Pure docs polish; no wire change. See Quickstart §3.
BB40 — Master-data / scan-data API bifurcation
The asset-create surface and the asset PATCH surface now treat asset location identically: scan-data, not master-data. Both reject location_id and location_external_key. The error detail names the ingestion paths (fixed-reader MQTT and handheld UI submission) so an integrator who tried to set initial location on POST lands on the right consumption surface. Pre-launch behavioral / spec tightening; no v1.0.0-or-later wire baseline to break.
POST /api/v1/assetsnow rejectslocation_idandlocation_external_key. Both fields are removed fromCreateAssetWithTagsRequest(they previously lived on the schema alongside anot: required: [location_id, location_external_key]mutual-exclusion constraint). Sending either field now returns400 validation_error/code: read_onlywith the same detail string the PATCH side emits: "asset location is collected through scan event ingestion (fixed-reader MQTT pipeline or handheld UI submission) and is not directly settable through the public API." The previous POST behavior acceptedlocation_idon create as a way to seed an initial location; that path is closed because the line between "what the API accepts" and "what the API surfaces" needed to align with the master-data / scan-data product positioning. Create the asset, then let the scan-event stream populate location. See Data model for the framing and Resource identifiers → Paired-key behavior per verb for the updated POST row in the per-surface matrix.- Error detail for asset
location_*read-only rejection rewritten on bothPOSTandPATCH. The previous wording was "asset location is derived from scan events and not directly settable; record a scan event to update asset location." The new wording names the actual ingestion paths and points integrators at the consumption surface: "asset location is collected through scan event ingestion (fixed-reader MQTT pipeline or handheld UI submission) and is not directly settable through the public API." Programmatic handlers should keep branching onerror.type(validation_error) andfields[].code(read_only) — thedetailtext remains explanatory, not contractual. See Errors →read_only. - New page: Data model — master sync and scan data. Articulates the bifurcation that drives the asset-write surface: master data (assets, locations, tag-asset associations) is read-write and synced from upstream systems of record; scan / operational data (asset locations, scan history) is read-only and collected through the reader fleet's MQTT pipeline and handheld UI submission. The page also gathers the consumption-pattern guidance for "I have a list of asset external keys; where are they?" with
GET /api/v1/reports/asset-locationsas the canonical batch-lookup form. Linked from Resource identifiers and from theread_onlyenvelope text on the asset write surface. GET /api/v1/reports/asset-locationsnow filters by asset, not just by location. Two new repeatable query parameters land alongside the existing location pair:asset_id(canonical surrogate) andasset_external_key(natural key, validated by the same^[A-Za-z0-9-]+$regex enforced on POST/PATCH bodies and the otherexternal_key-typed filters). Within each pair the two forms are mutually exclusive — supplying both returns400 validation_error/code: ambiguous_fields, symmetric with thelocation_id/location_external_keyrule. The asset and location filter pairs are independent and intersect when combined (?asset_external_key=AST-01&location_external_key=DOCK-1returns rows matching both). This makes the canonical batch-lookup integrator flow expressible in a single request — read a list of asset external_keys from an ERP master system, resolve current scan-derived locations in one round-trip — rather than N round-trips or a full-report scan. The endpoint description, the filter param table and theambiguous_fieldssurfaces enumeration are updated. Non-breaking; pure addition.
BB39 follow-up wave — PATCH no-op short-circuit, null-on-non-nullable uniformity, AssetLocationItem nullability
Three follow-up items surfaced during the BB39 retest, shipped together pre-launch. R1 and R2 are wire-shape-neutral; R4 is a pre-launch spec tightening (no v1 baseline to break).
PATCH /api/v1/assets/{asset_id}andPATCH /api/v1/locations/{location_id}are now fully idempotent on a verbatim no-op body. When every settable field in the request matches the current resource state, the storage layer'sIS DISTINCT FROMshort-circuit matches zero rows and skips the UPDATE —updated_atstays byte-equal across the call. AnEXISTSdisambiguation step keeps404 not_foundhonest for missing-row cases. The previous behavior advancedupdated_aton every PATCH regardless of whether any column actually changed; an integrator caching a GET response and re-PATCHing with the same body would fail the accept-if-matches check onupdated_aton the second call. Pairs symmetrically with the rename short-circuit shipped earlier in this v1.0 wave: rename was idempotent for same-value, PATCH was not, and now both are. Real changes still advanceupdated_atas normal — only the no-op path stays byte-stable. Non-breaking against anyv1.0.0-or-later wire baseline. See Errors → Idempotency and the verbatim round-trip admonition in Resource identifiers → Read shape vs. write shape.- Explicit
nullon a non-nullable field now uniformly emitscode: invalid_valueacrossPOSTandPATCH. Previously, the asset / location POST endpoints emittedcode: requiredfor{"name": null}(and similar forexternal_key) because the field was on the request's required list. The PATCH side already emittedinvalid_valuefor the same shape (andvalid_from,metadatareturnedinvalid_valueon both verbs throughout). The POST asymmetry surfaced as an integrator inconsistency: branching oncodeper the docs guidance gave yourequiredon POST butinvalid_valueon PATCH for the same logical error. POST handlers now run the explicit-null pre-check on every non-nullable field, socode: requiredis reserved for the absent-key case across both verbs. The matching/docs/api/errorsdefinitions split cleanly (required→ key absent;invalid_value(null variant) → key present with explicitnullon a non-nullable field) — see Errors → Validation errors. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch alignment). AssetLocationItem.asset_idand.asset_external_keyare now non-nullable in the spec. Both fields originate fromNOT NULLstorage columns and the view constructor always emitted a value; thenullable: trueannotation was vestigial./api/v1/reports/asset-locationsreturns one row per scanned asset, so the asset side of the join is structurally non-null by construction. Pre-launch breaking change against clients that have already taught themselves to null-check these specific fields — but nov1.0.0-or-later wire baseline exists yet, so the tightening lands cleanly. The other fields onAssetLocationItemkeep their declarations:location_idandlocation_external_keyremainrequired + nullablebecause a soft-deleted current location is intentionally projected asnullon this report (the cross-resource null projection — current-state-of-the-world by design), andasset_deleted_atremainsrequired + nullableper the soft-delete contract. See Resource identifiers → Foreign-key fields in responses for the narrower null surface and Date fields → Scan-event date fields for the per-row required-non-nullableasset_last_seenalready covered separately.
The PATCH /assets vs /locations write-schema asymmetry (asset location FK is scan-derived and absent from UpdateAssetRequest; location parent FK pair is partner-managed and present on UpdateLocationRequest) was also investigated as part of this follow-up wave. It's a load-bearing product decision; the schema split is the right surface for typed clients and the 400 read_only "record a scan event" hint is the safety net for hand-rolled callers. The asymmetry-by-design framing now lives in Resource identifiers → Read shape vs. write shape.
BB39 fix wave — same-value rename no longer advances updated_at
Single behavioral fix from BB39. No spec surface — wire shape is unchanged.
POST /api/v1/assets/{asset_id}/renameandPOST /api/v1/locations/{location_id}/renameare now fully idempotent on a same-value rename. When the newexternal_keyvalue equals the current one, the handler short-circuits before the row UPDATE — no audit-log row, noupdated_atbump, no observable mutation. Locations already had this short-circuit; assets did not, and the asset path advancedupdated_atby ~370ms on every value-match retry. The asymmetry surfaced as an optimistic-concurrency trap: an integrator who cached an asset's response body and issued a defensive same-value rename followed by a cached-bodyPATCHwould fail the accept-if-matches check onupdated_at. Both rename endpoints now mirror — same-value rename is safe to retry, and the cached body remains valid through that retry. A real rename (new value differs from current) advancesupdated_atlike any other write; re-GETbefore a cached-bodyPATCHin that path. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch behavioral alignment; the prior asset-sideupdated_atadvance was a missing short-circuit, not a stable contract). See Resource identifiers → Renaming anexternal_keyand Errors → Idempotency.
BB37 fix wave — path/query id maximum declared in the spec, nullable: true codegen interpretation noted
Two pre-launch polish items from BB37. Wire format is unchanged.
- Path-param and query-param id schemas now declare
maximum: 2147483647. The runtime int32 ceiling onasset_id,location_id,tag_idpath params and on the?parent_id=/?location_id=list filters (including thelocation_id[]array-item shape) was previously only visible after a request reached the server and bounced back as400 validation_error/code: too_large. The spec now encodes that ceiling alongside the existingminimum: 1, so generators that honormaximum(mostopenapi-generator-clitargets,openapi-typescript@7.xconsumers that lean on the schema for runtime validation) catch an out-of-range value at the client-validation layer. Request-body id fields (location_idonPOST /api/v1/assets,parent_idonPOST /api/v1/locations) keep the unconstrainedformat: int64declaration — the runtime cap applies there too, but the spec stays descriptive of the long-horizon wire contract on body shapes. The runtime400 too_largeenvelope is unchanged on every surface (path / body / query). ID format → What this means for clients is updated. Non-breaking against anyv1.0.0-or-later wire baseline. - Codegen note added to the spec's interactive reference:
nullable: trueis interpreted differently across generators. A new paragraph ininfo.description(rendered at /api) names the verified-working targets —openapi-typescript@7.x(emitsstring | null),openapi-generator-clipython (Optional[StrictStr]) — and the known-broken case —datamodel-codegen@0.57.0emitsnullable: truefields as non-Optional required types, so Pydantic raisesValidationErroron every null response field. The Quickstart → Raw spec for codegen section surfaces the same recommendation alongside its existing generator-target notes. The OpenAPI 3.0nullablekeyword is ambiguous onrequired + nullableshapes; the 3.1 type-union syntax (type: ["string", "null"]) is a post-v1 consideration.
BB36 fix wave — 401 unauthorized detail strings harmonized across endpoints
- Every endpoint now emits identical
error.detailwording for identical 401 conditions. Two distinct auth middleware paths previously emitted their own literals:GET /api/v1/orgs/mereturned"Authorization header is required"for the missing-header case, while/assets,/locations, and/reports/asset-locationsreturned"Missing authorization header". The split was service-vs-service drift within a single API. All paths now route through one set of canonical constants —"Missing authorization header"(noAuthorizationheader),"Invalid authorization header format"(wrong scheme such asBasicor a non-Bearerprefix),"Invalid or expired token"(malformed or expired JWT),"API key has been revoked","API key has expired", and"Use Authorization: Bearer <token>"(theX-API-Keymistake hint). The Quickstart401envelope example and theX-API-Keycallout in Authentication already showed the canonical wording; the/orgs/medeviation is what changed. Programmatic handlers should keep branching onerror.type(unauthorized) —detailremains explanatory text, not a contract — see Errors → unauthorized. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch harmonization; the pre-fix state was inconsistent rather than load-bearing).
BB34 polish batch — scan-event field renames, outbound millisecond precision, filter pattern spec residual
Pre-launch polish from BB34 (F2, F3, F5 carry-over). Wire-format renames and precision pinning have no breakage cost before launch — no partner integrations yet.
- Scan-event timestamp fields renamed for cross-endpoint cohesion.
GET /api/v1/assets/{asset_id}/historyrows now exposeevent_observed_at(wastimestamp);GET /api/v1/reports/asset-locationsrows now exposeasset_last_seen(waslast_seen). Both names follow the qualifier-prefix pattern already used byasset_deleted_aton the same report row, preserving the event-row vs asset-most-recent semantic split. Sort allowlists move with the fields:?sort=event_observed_at/-event_observed_aton history, and?sort=asset_last_seen/-asset_last_seenon the asset-locations report (alongside the unchangedasset_external_key,location_external_key). The-asset_last_seendefault sort on/reports/asset-locationsis unchanged in meaning. Storage column names are unchanged — this is a wire-shape rename only. Non-breaking against anyv1.0.0-or-later wire baseline. See Date fields → Scan-event date fields and Pagination, filtering, sorting → Sortable fields. - Outbound RFC 3339 timestamps pinned to fixed three-digit millisecond fractional precision. Every date-time field on the public API now emits the
.NNNZshape uniformly —2026-04-29T12:34:56.000Z,…56.123Z— acrossvalid_from/valid_to,created_at/updated_at/deleted_at, and the scan-event fieldsevent_observed_at/asset_last_seen. No trailing-zero trimming, no nanosecond suffix. A regex match like\.\d{3}Z$is safe on outbound. Postgrestimestamptzstorage is unchanged at microsecond; the wire is truncated to millisecond because server-receipt-time on the reader path carries millisecond-scale network jitter, so the bottom three digits would be false precision relative to what reader clients can act on. Inbound parsing is unchanged — request bodies and thefrom/toquery parameters onGET /api/v1/assets/{asset_id}/historycontinue to accept any RFC 3339 fractional precision (0–9 digits), so a client may copy an emittedevent_observed_atvalue verbatim into a filter without parse rejection. Practical effect on client code: hand-rolled regex parsers that hard-coded\.\d{6}Z$against the prior Go-stdlib trimmed shape now match the pinned\.\d{3}Z$shape; clients using a date-libraryInstant.parse()/ equivalent see no change. Specexample:values for every date-time field are bumped from…56Zto…56.000Zto match. See Date fields → Wire format. - Spec residual —
external_key-typed filter param patterns tightened in the spec. Server-side filter validation was tightened in the BB33 fix wave above; the spec YAML declarations still carried the loose tag-value pattern^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$. Five parameter declarations now carry the strict field pattern^[A-Za-z0-9-]+$:assets.external_key,assets.location_external_key,locations.external_key,locations.parent_external_key, andreports.location_external_key. Generated clients that validate input against the spec now rejectabc/defat the client-validation layer instead of letting it through and surprising the caller with a server-side 400. No service-side behavior change.
Spec-level restructure from BB33 (C1). Wire format is byte-identical; this is a client-side type-discoverability change.
TagandTagRequestare nowoneOfdiscriminated unions overRfidTag/BleTag/BarcodeTag(and the matching*Requestsubtypes). The discriminator istag_type(propertyName: tag_type; mapping entriesrfid/ble/barcode). Generated SDKs now surface three named subtypes — TypeScript clients get a discriminated union usable withswitch (tag.tag_type), Python clients get three concrete model classes plus aTagunion alias. The single anonymousTagtype with a free-formtag_typestring is replaced. Server-side, the row shape, the storage layer, and the/assets/{asset_id}/tags//locations/{location_id}/tagsendpoints are unchanged — the polymorphism is a spec-and-codegen concern, not a wire shape. Pre-launch reasoning: the partner-breakage cost of convertingTagfrom a single type to a discriminated union grows monotonically once TeamCentral / Wesco / Camcode have generated SDKs against the v1 spec, so the restructure ships before launch. See Resource identifiers → Tag is a polymorphic resource.tag_typeisrequiredon each subtype, nodefault: rfidin the spec. OpenAPI discriminator semantics require the discriminator property to berequiredon every member of the union, so each*TagRequestsubtype declarestag_typerequiredand the priordefault: rfidon the parentTagRequestis gone. At this wave the server still defaulted an omitted ornulltag_typetorfidonPOST /api/v1/{resource}/{id}/tags— the BB42 wave above tightens that to a hard400 validation_error / code=requiredso spec and service agree. No per-kind narrowing ofvaluein this wave — all three subtypes carry the same^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$pattern the server enforces today. Per-kind regex (EPC hex onrfid, MAC/UUID onble, barcode charset onbarcode) is a post-launch consideration that would need a matching server-side validator change to avoid SDK-rejected payloads the server would have accepted.
BB33 fix wave — parent_external_key hint corrected, external_key-typed filters tightened
Two small server-side fixes from BB33 (F3, F5, C2).
PATCH /api/v1/locations/{id}no longer points at/renameto re-parent. The reject-if-differs hint returned forparent_external_keypreviously namedPOST /api/v1/locations/{id}/renameas an alternative re-parent path.RenameLocationRequestonly carriesexternal_key— the rename endpoint cannot change parentage. The hint now namesparent_idas the only write path that re-parents (nullclears the FK). The matching Read shape vs. write shape admonition and the per-field rejection table on Resource identifiers are corrected. Adjacent sweep on every other PATCH reject-if-differs message —external_key → /rename,deleted_at → DELETE,tags → /tags— confirmed each names an endpoint that actually does what the message claims. (Superseded by the BB47-49 docs sweep above —parent_external_keyis itself a write path that re-parents, alongsideparent_id; neither is the only one. The reject-if-differs hint on this entry no longer applies toparent_external_key, which is now fully writable.)external_key-typed list filters now reject invalid characters at the boundary. Filter params on?external_key=,?location_external_key=, and?parent_external_key=(on/api/v1/assets,/api/v1/locations, and/api/v1/reports/asset-locations) previously used a loose printable-string regex that admitted any non-control character; a slash, colon, comma, or space silently returned200with empty data because no row can ever carry such a value (the write side rejects the same input). The filter regex is tightened to match the field regex^[A-Za-z0-9-]+$, and invalid input now returns400 validation_error/code: invalid_valuewith onefields[]entry per offending parameter. Behavior change for callers that probed these filters with reserved characters: the response status moves from200(empty data) to400(validation error). Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch tightening; no real row could match the rejected inputs). See Resource identifiers →external_keyvalue rules and Pagination, filtering, sorting → Repeatable filters.
BB33 spec hygiene — x-required-scopes canonicalized as the machine-readable scope source
Spec-level fix from BB33 (F6, F7). The allOf-with-siblings restructure on TagRequest.tag_type is internal to the spec and does not affect prose; the F7 extension change does.
x-required-scopesextension now appears on every operation, not just scope-gated ones.GET /api/v1/orgs/me— previously the only public operation without a scope requirement — now carriesx-required-scopes: []. The empty array is the explicit "any authenticated key works" signal, intended for codegen ingestors and policy tooling minting minimal-scope keys: presence of the extension with an empty value is a positive signal, not a missing-field ambiguity. Every other operation carries the same one-element array shape introduced earlier in this v1.0 wave (e.g.x-required-scopes: [assets:write]onPOST /api/v1/assets).- The extension is the canonical machine-readable scope source. Authentication →
x-required-scopeson operations is rewritten to say so explicitly: scope-aware partners and codegen consumers should read the extension rather than parsing the Required scope: prose marker. Both views are auto-derived from the same server-side annotations and must stay in sync — drift is a spec-generation bug, not a documentation choice. The prose remains the canonical reference for human readers.
BB33 docs reconciliation — truncation policy, PATCH verb-scope callouts, x-request-id distinction
Three docs-only fixes from BB33; no service-side change.
- Sub-microsecond timestamps are truncated toward zero, not rounded. Date fields → Inbound: RFC 3339 only previously claimed sub-microsecond input was rounded half-to-even ("banker's rounding"), as documented in the BB32 entry below. Empirical verification on
valid_fromacross the boundary cases shows the underlyingtimestamp with time zonecolumn truncates the sub-microsecond tail toward zero —…0.0000015Zstores as…0.000001,…0.9999999Zstores as…0.999999, and the prior worked example2026-04-24T15:30:00.123456789Zstores as2026-04-24T15:30:00.123456(not.123457). The BB34 entry above pins the outbound wire shape to millisecond, so the on-the-wire read after this write is…0.123Z— the storage policy described here is what determines which microseconds survive to the wire-truncation step. Truncation is the documented policy going forward — it's reasonable for ETL/sensor input where sub-microsecond precision is noise from the upstream date library, and it matches the wording already used forvalid_to. Server-side behavior is unchanged; this supersedes the BB32 docs correction below. If your test fixtures asserted the rounded-up tail (.123457Zfor input.123456789Z), refresh them against the truncated value. - PATCH operation scope is now called out per resource on Read shape vs. write shape. Two paired-FK shapes look symmetric on the read side but diverge on
PATCH:asset.location_idis not writable — it does not appear inUpdateAssetRequest, and PATCHing it returns400 read_onlywith"record a scan event to update asset location".location.parent_idis directly writable via PATCH (it appears inUpdateLocationRequest;nullclears the FK). The new admonitions on the resource-identifiers page name the writable surface per resource so an integrator who patterned one resource's PATCH code on the other doesn't hit the wrong wall. x-request-idis the in-band correlation id;x-railway-request-idis the hosting edge. Errors → Filing support tickets now distinguishes the two response headers explicitly. The TrakRF service logs and surfacesx-request-id(matcheserror.request_idin the envelope);x-railway-request-idis added by the Railway edge layer and is not used for service-side correlation. Include the former when filing a support ticket.
BB33 fix wave — uniform read-only PATCH handling across every read-only field
- All read-only fields on
PATCHnow obey one accept-if-matches / reject-if-differs rule.PATCH /api/v1/assets/{id}andPATCH /api/v1/locations/{id}previously split read-only fields into three classes: server-managed metadata (id,created_at,updated_at,deleted_at) was silent-stripped regardless of value; natural-key reference fields (external_key,parent_external_key,location_id,location_external_key) were value-aware (accept-if-matches /400 read_onlyif differs); andtagswas presence-rejected withinvalid_value, including against an empty list. The classes collapse into one: every read-only field above accepts a verbatim echo of the current value (silent strip) and rejects a differing value with400 validation_error/code: read_only. (Superseded forparent_external_keyby the BB47-49 docs sweep above —parent_external_keyis not in the read-only set on location PATCH; it is fully writable, symmetric withCreateLocationRequest. The accept-if-matches / reject-if-differs rule continues to govern the other four natural-key fields named here.) The accompanyingmessagenames the proper write path —POST /{resource}/{id}/renamefor*_external_key, the/tagssubresource fortags, "record a scan event" for assetlocation_*, and a "server-managed; useDELETEto soft-delete / submit the current value or omit" wording for the four server-managed fields. Practical effect: a verbatimGET→ mutate-other-fields →PATCHround-trip works without any client-side scrubbing, including fortags. The pre-TRA-710 "pop tags before sending" pattern is no longer needed and is removed from the Quickstart and Resource Identifiers worked examples. Thetagsrejection code switches frominvalid_value(presence-only) toread_only(value-aware) — a client that was branching oninvalid_valueto detect a tags-on-PATCH misuse should switch toread_onlyand inspectfields[].fieldfor"tags". Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch tightening; the prior shape was a bug-masking convenience rather than a stable contract). See Resource identifiers → Read shape vs. write shape for the per-field hint table and Errors → Validation errors for theread_onlyenvelope.
BB32 fix wave — minor fixes batch (unknown query params, decode-error wording, Location on tag POSTs, PATCH null body, microsecond rounding)
Five small fixes batched into one wave; each was roughly a one-line wording or header change, unrelated to the larger BB32 sweeps above. Four shipped in platform; the fifth is a docs-only correction.
- Unknown query parameters are now rejected on every endpoint (not just list endpoints). The cross-cutting claim under Errors →
validation_errorvsbad_request— that unknown query keys land invalidation_errorwith onefields[]entry per offending key — was true only for list endpoints (which run throughParseListParams). Single-resourceGETs and write endpoints silently accepted unknown queries. Every non-list public route now enforces the same allow-list-empty rule:GET /api/v1/assets/{id}?bogus=42returns400 validation_errorwithfields[].field: "bogus"/code: unknown_field. List endpoints are unchanged (the same code path runs through the existing list-param validator). Internal-only surfaces (/bulk, frontend session endpoints) are out of scope of the public-API claim and out of scope of the new middleware. POSTdecode-error detail no longer leaks the Go struct prefix. A type-mismatch on aPOSTbody (e.g.namesent as a number onPOST /api/v1/assets) previously returnedBody field "CreateAssetRequest.name" could not be decoded as the expected type, exposing the server-side struct name. Thedetailnow strips the struct-qualified prefix and returnsBody field "name" could not be decoded ..., matching the snake_case JSON-key wordingPATCHalready emitted (e.g.Body field "is_active" could not be decoded ...). The Errors → Type mismatches example envelope was already accurate; the priorPOST-side leak is now eliminated at the source.POST /api/v1/{resource}/{id}/tagsreturns aLocationheader. Sub-resource tag-create endpoints now setLocation: /api/v1/{resource}/{id}/tags/{tag_id}on the201response, matching the canonical subresource URL thatDELETE /api/v1/{resource}/{id}/tags/{tag_id}accepts. The spec declares the header on both endpoints. Inverts the prior "sub-resource POSTs do not set aLocationheader" guidance on HTTP method coverage →Locationheader on201 Created; that section is updated to reflect the new behavior. RFC 7231 §7.1.2.PATCHwith body literalnullreturns RFC 7396 wording, not a parse-error message. Sendingnullas the entirePATCHbody previously returned400 bad_requestwithdetail: "Request body is not valid JSON"— butnullIS valid JSON, and RFC 7396 defines a top-levelnullmerge-patch as a directive that empties the target. TrakRF does not honor the directive; the rejection itself is correct, only the wording misdiagnosed. Newdetail:"Request body must be a JSON object (RFC 7396)". See Errors →validation_errorvsbad_request.- Date Fields page corrected — microsecond precision is rounded, not truncated. Date fields → Inbound: RFC 3339 only previously claimed sub-microsecond input was "truncated at write" with worked example
2026-04-24T15:30:00.123456789Z→2026-04-24T15:30:00.123456Z. Empirical reality: the underlyingtimestamp with time zonecolumn rounds half-to-even at the microsecond boundary, so.123456789Zrounds up to.123457Z. The page is corrected on both the inbound and outbound sites (the per-row scan-event timestamps under Scan-event date fields → Wire format followed the same wording). Server-side rounding behavior is unchanged — this is a docs-only correction. If your test fixtures asserted the truncated tail (.123456Zfor input.123456789Z), refresh them against the rounded value.
BB32 fix wave — Create/Update nullability tightened to symmetric rejection
valid_from,is_active, andmetadatarejectnullon bothPOSTandPATCH. These three fields were previously nullable on the create request schemas and non-nullable on the update request schemas. The asymmetry was a pre-launch carve-out for ETL pipelines whose JSON serializers emit explicitnullinstead of omitting keys; the carve-out is removed because omission already serves the "use server default on create / leave unchanged on update" semantic without needing a second wire shape. Sendingnullfor any of the three onPOSTorPATCHnow returns400 validation_error/code: invalid_value. Reverses the earliervalid_from: nullCreate-only acceptance documented under "FK and validator consistency" below; the entry there is retired. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch tightening). See Date fields →valid_from: nullis rejected on both Create and Update and Resource identifiers →metadatais stored opaquely for the integrator-side details.
BB32 fix wave — Unix epoch rejected alongside Go zero-time on timestamp fields
1970-01-01T00:00:00Z(Unix epoch) is now rejected on every request timestamp field. The Go zero time (0001-01-01T00:00:00Z) was already rejected; the Unix epoch joins it as the second documented default-value sentinel. Both reach the same chokepoint —FlexibleDate.UnmarshalJSON— and both produce a per-field400 validation_error/code: invalid_value. Sentinel rejections carry a distinctmessagethat echoes the offending value and names JSONnullas the unset signal ("valid_to must not be a default-value sentinel (1970-01-01T00:00:00Z); use JSON null to leave the field unset"); format failures (date-only, slashes, empty string) continue to return the existing"{field} must be an RFC 3339 timestamp"wording. Rejection is by exact instant, so non-UTC offsets that resolve to the same moment (e.g.1970-01-01T05:00:00+05:00) are rejected too. Scope: everyvalid_from/valid_toonPOSTandPATCHagainst/api/v1/assets,/api/v1/locations, and the with-tags create variants — the only public-API surfaces that accept a timestamp in the request body. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch tightening). See Date fields → Default-value sentinels are rejected for the integrator-side details.
BB32 fix wave — strict Content-Type enforcement and rate-limit headers on 415
Content-Typeenforcement tightened on every write method. Three shapes that were silently accepted now return415 unsupported_media_type: requests with theContent-Typeheader missing entirely;POSTsendingapplication/merge-patch+json(the merge-patch media type isPATCH-only); andmultipart/form-dataon any public-surface path. The "any other media type returns 415 regardless of method" clause already in HTTP method coverage → Request body Content-Type per method covered typo'd subtypes andtext/plain; this wave closes the gap on the missing-header and POST-side merge-patch shapes. The bulk-CSV upload endpoint (/api/v1/assets/bulk, internal) is unaffected — it still acceptsmultipart/form-data. The error-envelopeunsupported_media_typerow now reflects the full method × Content-Type matrix. Non-breaking against anyv1.0.0-or-later wire baseline (pre-launch tightening; the looser behavior was a silent drift from the spec, which already declared415on every write).X-RateLimit-*headers now present on415 unsupported_media_typeresponses. The Rate limits → Response headers page commits toX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reseton every public-surface response — explicitly including415— and the platform middleware ordering now satisfies that contract. Budget-tracking dashboards and observability metrics no longer have a415-shaped blind spot. No prose change to the Rate limits page; the existing "every API response on the public surface" wording covers this entry.
BB31 fix wave — date-time pattern removal and uniform natural-key PATCH semantics
- RFC 3339
pattern:removed from everyformat: date-timeproperty. The strict regexpattern:previously paired withformat: date-timeis gone —format: date-timealready implies RFC 3339, and the redundant pattern brokeopenapi-generator-cli -g pythondeserialization on every response carrying a timestamp (the generated@field_validatorruns after Pydantic parses the string, then stringifies thedatetimewith a space separator before matching, which never satisfies aT-separator-anchored regex). The server's RFC 3339 input validation is unchanged and still returns400 validation_errorfor bad input; clients that relied on spec-level pattern matching should switch to validating againstformat: date-timeinstead. Reverses the Phase 3.5 entry below that announced the pattern addition. Non-breaking against anyv1.0.0-or-later wire baseline. - Natural-key reference fields on
PATCHnow follow a uniform accept-if-matches / reject-if-differs rule. Five fields are in scope —external_key(assets and locations),parent_external_key(locations), andlocation_id/location_external_key(assets). (Superseded forparent_external_keyby the BB47-49 docs sweep above — it is fully writable on location PATCH, not subject to the accept-if-matches rule. The other four fields named here are unchanged.) Sending a value that matches the current resource state is silently stripped from the update (200, other fields apply normally); sending a value that differs is rejected with400 validation_error/code: read_onlyand amessagenaming the proper write path. The*_external_keycases point atPOST /api/v1/{resource}/{id}/rename; the assetlocation_*cases name "record a scan event," reflecting that current asset location is derived from the scan-event stream and not directly settable. Replaces the prior three-category PATCH split announced in the BB29 catch-all entry below: round-trip-safe metadata still silent-drops;tagsis still rejected as managed-via-subresource (invalid_value); the natural-key reference fields move from "silently strip on assets, presence-reject on locations" to one rule across both resources. Two practical effects for integrators: (1) a verbatimGET→ mutate-other-fields →PATCHround-trip works without an explicit strip step for the natural-key fields — they echo silently through; the only field still requiring an explicit strip istags. (2)PATCH /api/v1/assets/{asset_id}no longer acceptslocation_idorlocation_external_keyas a way to move an asset — record a scan event instead. The pre-existing pattern of mutating asset location viaPATCHis retired. Non-breaking against anyv1.0.0-or-later wire baseline (this is pre-launch behavior consolidation; no integrator has a 400-handler keyed on the prior asymmetry to special-case). See Resource identifiers → Read shape vs. write shape for the per-field table and Errors → Validation errors for theread_onlyenvelope. Quickstart §3's round-trip example is updated to reflect the simplification, and (BB31 Finding 4 bundled) shows a portablepython3 -cform alongsidejqso integrators on Windows or air-gapped environments withoutjqaren't blocked.
BB30 fix wave — spec + docs cleanup
A consolidated BB30 fix wave covering five spec/server-side changes and eight docs-side clarifications. None of these break the wire contract against a v1.0.0-or-later baseline.
Spec and server-side
415 unsupported_media_typeremoved fromDELETEoperations in the spec. The service does not enforceContent-TypeonDELETE(there's no body to interpret), and the previous declaration encouraged typed clients to add defensiveContent-Typehandling that never fires. Removing the response from everyDELETEoperation tightens generated client shapes. See HTTP method coverage.Tag.is_activeremoved from the spec, Go struct, storage layer, and frontend types. The field had no transition surface in v1 — every live tag wasis_active: true— so it was dead weight on the response payload. Tag responses no longer carryis_active; codegen-derived clients regenerated against the updated spec see the field disappear from theTagschema. No integrator-visible behavior change (no caller had a value other thantrueto branch on).- Canonical OpenAPI spec URLs unified on
/api/openapi.{json,yaml}. The previous variants/api/v1/openapi.{json,yaml}and the root-path aliases/openapi.{json,yaml}now respond301 Moved Permanentlyto the canonical URLs. Update any hardcoded references; the canonical path is also what the docs and Postman pages link to. The redocusaurus copy at/redocusaurus/trakrf-api.yamlis unchanged in behavior (it's been redirecting to the canonical URL since launch). - Sub-resource sort rejection now reads "sort parameter not supported on this endpoint." Endpoints with no sort allowlist —
/api/v1/locations/{location_id}/ancestors,/children,/descendants— previously returned the field-shaped"unknown sort field: <name>"wording, which misled callers into "fix the field name" debugging. The new wording is distinct from the wording that still fires on endpoints with a sort allowlist, so a validation UI can branch on the cause. See Pagination, filtering, sorting → Sub-resource list endpoints use a fixed sort order. - Ancestor identifiers preserved across tombstones on
?include_deleted=true. A child location whose parent is soft-deleted now carries the parent'sexternal_keyasparent_external_key, and an asset whose location is soft-deleted carries the location'sexternal_keyaslocation_external_key, when accessed viaGET /api/v1/locations?include_deleted=trueandGET /api/v1/assets?include_deleted=truerespectively. Previously the natural-key form was projected asnullwhen the referenced row had been soft-deleted, breaking the FK-pair invariant (surrogate populated, natural-key null). The cross-resource reportGET /api/v1/reports/asset-locationsintentionally keeps thenullprojection because its rows are current-state snapshots; the spec description on that endpoint now calls out the divergence and points readers at?include_deleted=truefor the raw identifier. See Resource identifiers → Ancestor identifiers are preserved across tombstones.
Docs-side clarifications
- PATCH asymmetry on paired natural-key forms documented as an intent matrix. The
location_external_key(assets) vsparent_external_key(locations) split — silent-drop on the asset side,400 read_onlyon the location side — is now summarized as an action-oriented "what do I send to do X" table alongside the longer rationale. See Resource identifiers → Read shape vs. write shape. metadatais opaque storage — explicit doc.PATCHreplaces the entiremetadataobject with the value sent; the server performs no merging within the field, even inside a JSON Merge Patch request. Clients that need to preserve existing keys must fetch, compute the result client-side using whatever merge strategy is appropriate, and send the resulting object back as themetadatavalue. Behavior is unchanged; the prose is new. See Resource identifiers →metadatais stored opaquely.bad_requestvsvalidation_errorframed as parse-time vs validate-time. Error handlers should branch onerror.typefirst —bad_requestmeans the request couldn't be parsed (nofields[]);validation_errormeans it parsed cleanly but a value failed validation (fields[]populated). The split has always been there; the framing makes the integrator side of the branch easier to reason about. See Errors →validation_errorvsbad_request.- Quickstart gains a round-trip
GET→ strip →PATCHworked example. Section 3 of the Quickstart now includes a curl pipeline that pops the rejected keys (tags,external_key, plusparent_external_keyon locations) before sending the body back onPATCH. The minimal-formPATCHis still the recommended shape, but integrations whose in-memory model mirrors the read shape one-to-one have an authoritative pattern to follow. (Superseded by the BB33 silent-echo collapse below and the BB47-49 docs sweep above — no explicit strip is needed on the round-trip in quickstart §3 today, andparent_external_keyon locations is fully writable on PATCH rather than rejected.) Tag.valuevsexternal_keycharacter allowance spelled out. The Tag pattern allows tab, newline, and carriage return (and every printable character except other C0 controls), reflecting real-world barcode/RFID payload diversity.external_keyis stricter —[A-Za-z0-9-]only — because it flows into URL paths and log lines. A value that happens to match theexternal_keypattern is a coincidence, not a guarantee. See Resource identifiers →external_keyandtags[].valueare not symmetric.metadataabsent on locations is a v1 commitment, not an oversight. Reintroduction is a v1.1 consideration; generated clients should not assume the field will appear onLocationViewand should branch on its presence if a future spec adds it. See Resource identifiers → Assetmetadatavs. locationtags.tag_type: nullaccepted as "use therfiddefault" on POST (retired in the BB42 wave above). At this wave, a body of{"value": "..."},{"tag_type": null, "value": "..."}, and{"tag_type": "rfid", "value": "..."}were all equivalent — the row was stored withtag_type: rfid. The BB42 tightening removes the silent default:tag_typeis now required on every attach and an omitted ornullvalue returns400 validation_error / code=required / field=tag_type. See Resource identifiers → Tags use a composite natural key.- No new
Tag.is_activereferences in docs. The field was already absent from the public examples and reference prose; verified during this fix wave. - Spec-URL references in docs converged on
/api/openapi.{json,yaml}. No remaining references to/api/v1/openapi.*or root-path/openapi.*in customer-facing pages. The platform-side 301 redirects cover any external caller still hitting the legacy paths.
These changes flowed into the v1.0.0 spec and into shipped behavior ahead of launch. Listed here for partners tracking the docs / spec mirror; none are breaking against a v1.0.0-or-later baseline.
Spec hygiene
info.version: 1.0.0(wasv1). URL versioning under/api/v1/is unchanged; the spec-document version and the URL version evolve independently.- Integer path-param
maximum: 2147483647(was9007199254740991). Path-paramids are now bounded by the underlyingint4column. Values in the previously-accepted range(2^31 - 1, 2^53 - 1]return400 validation_errorwithparams.max=2147483647instead of falling through to a500 internal_errorfrom the database driver. - Every integer field declares
format: int32. Code generators emit width-boundedint32types —numberin TS,intin Python,int32in Go/Java — rather than a permissiveinteger. No runtime behavior change. - Every operation declares a
defaultresponse pointing at theErrorResponseenvelope. Code generators that emit discriminated-union response types now get a real catch-all branch. - Date / date-time properties carry RFC 3339
example:values. Visible in Redoc's example panel.
PATCH round-trip and read-only handling
- Full-object
PATCHround-trip is now supported at the wire. Sendingexternal_key,tags, or any other read-only field (id,created_at,updated_at,*_deleted_at,tree_path,depth) in aPATCHbody returns200with the field silently ignored. Mutateexternal_keyviaPOST /api/v1/{resource}/{id}/renameand tags viaPOST /api/v1/{resource}/{id}/tags(and theDELETEcounterparts). Reverses earlier guidance that called these fields "rejected with400 immutable_field" / "rejected with400 invalid_value." This wire-level tolerance is not a type-level guarantee — strict-typed codegen (Pydantic, Java, Go with generated structs) still requires reshaping the read object into the write schema before sending, because the read and write schemas declare different field sets. Loose-typed clients can echo the response straight back. See Resource identifiers → Read shape vs. write shape for the per-resource read-only set and the typed-client caveat. immutable_fieldvalidation code retired. No remaining emitter; previously only fired forexternal_keyonPATCH. Clients that branched on the code can drop the case.
FK and validator consistency
- FK envelope is consistent across surrogate and natural-key forms. A non-existent
location_id(orparent_id) returns the same400 validation_errorenvelope as a non-existentlocation_external_key(orparent_external_key); previously the surrogate-key form fell through to500 internal_error. (The specificFieldError.codewas reshaped again ahead of v1.0 — see below.) description: ""is rejected withtoo_short. Sending an empty string onPOSTorPATCHfordescriptionreturns400 validation_error/code: too_short/params.min_length=1, matching every other length-bearing string field. Send explicitnullto clear the field.required,invalid_value, andtoo_shortsplit on length-bearing required fields. Omission (POST /api/v1/assets {}) emitscode: required; explicitnullon a non-nullable field (POST /api/v1/assets {"name": null}) emitscode: invalid_value; sending a value below the documented minimum (POST /api/v1/assets {"name": ""}on amin_length: 1field) emitscode: too_shortwithparams.min_length. Reverses an earlier pre-launch decision that folded omission and empty-string into a singletoo_shortcode — that decision was too lossy for integrators branching on "you forgot the field" vs. "you sent an empty value." A wrong-typed value ({"name": 42}) continues to surface as400 bad_requestwith nofields[]because it fails at decode time before the schema validator runs. See Errors → Validation errors. Internally guarded by a contract-test enum-coverage gate (FieldErrorCodeenum values must each be observed at least once during the Schemathesis run) so silent regressions of the split fail CI.
Errors and conflict messages
5xxresponses no longer leak database driver strings inerror.detail.error.detailon500is a fixed generic string; the underlying cause is logged server-side and correlatable through therequest_idin the envelope.- Tag conflict error strings use "tag," not "identifier." A duplicate
(tag_type, value)onPOST /api/v1/{resource}/{id}/tagsreturnsdetail: "tag rfid:E2-… already exists". String-matching on the literal wordtagis now correct everywhere caller-visible.
Spec hygiene — Phase 3.5 (Schemathesis gate flip)
Final spec + validator changes flowing into v1.0.0 ahead of flipping the Schemathesis contract-test gate to blocking.
- Breaking change for generated clients: the
sortquery parameter shape changed from array to comma-separated string. Regenerate clients from the updated spec. The wire form integrators send is unchanged (?sort=-created_at,external_key); the spec now declaressortastype: stringwith a CSV-shapedpattern:regex instead oftype: arraywithstyle: form, explode: false. Generated clients that previously typedsortasstring[]will now type it asstring. Hand-rolled clients building the query string directly are unaffected. - Write request bodies declare
additionalProperties: false. Unknown top-level keys inPOSTandPATCHbodies are rejected at the schema boundary with400 validation_errorrather than silently accepted. The existing silent-accept rule for read-only fields is unchanged — those are not "unknown" keys, they're declaredreadOnly: trueon the read shape. - Printable-string validation on body strings and
qfilters.name,description, tagvalue, and theqsubstring-search query param reject NUL bytes and other ASCII control characters at the validator with400 validation_error. Previously these could reach the storage layer and surface as500 internal_errorfrom a downstreaminvalid_text_representation(SQLSTATE 22021). - RFC 3339
pattern:on everyformat: date-timeproperty. Date-time fields now carry a strict regexpattern:in addition to theformat:keyword, so codegen tools that honorpatternreject malformed timestamps client-side; the server already validated the RFC 3339 profile and continues to return400 validation_errorfor bad input. - Surrogate-id query filters and
offsetare int4-bounded.*_idquery filter items declareminimum: 1/maximum: 2147483647;offsetdeclaresminimum: 0/maximum: 2147483647. Out-of-range values return400 validation_errorrather than overflowing into the database driver.
FK error codes and paired-key contract reshape
Three contract-shaped decisions from the Phase 3.5 fix-wave were reshaped per design review to align with BB27 framing and the existing immutable-external_key pattern. None of these are breaking against a v1.0.0-or-later baseline.
fk_not_foundreturns400 validation_error. A non-existentlocation_id(orparent_id) and a non-existentlocation_external_key(orparent_external_key) both return the same400 validation_error/code: fk_not_foundenvelope, onPOSTandPATCH. The409 conflictenvelope is reserved for true state-conflict cases (POSTcollisions onexternal_key, the non-leaf-location delete check).fk_not_foundis a newFieldError.codevalue; clients integrating against a Phase 3.5 pre-release that briefly routed FK-not-found through409 conflictshould branch on the new typed code.- Natural-key FK form is read-only on
PATCH.location_external_key(on assets) andparent_external_key(on locations) are silently stripped fromPATCHrequest bodies regardless of whether they agree with the surrogate*_idform. Mutate the relationship via the surrogate form (location_id,parent_id); the natural-key form is recomputed by the server on read. The previous "send both if they agree, 400 on disagree" contract is retired — disagreement is now silently ignored onPATCH, matching the read-only-strip pattern that already coversid,created_at,updated_at,*_deleted_at,tree_path,depth,external_key, andtags. See Resource identifiers → Read shape vs. write shape. (Superseded for the location side by the BB47-49 docs sweep above —parent_external_keyis fully writable onPATCH /api/v1/locations/{location_id}, symmetric withCreateLocationRequest. Asset-sidelocation_external_keyis unchanged — still PATCH-rejected because asset location is scan-data, not master-data; see the BB40 entry above.) POSTbody andGETlist filter reject both-supplied withambiguous_fields. The surrogate / natural-key forms are mutually exclusive onPOST /api/v1/assets,POST /api/v1/locations, and theGETlist filters on/assetsand/locations. Sending both returns400 validation_error/code: ambiguous_fieldswith onefields[]entry per offending parameter. ThePOSTrule is encoded directly in the OpenAPI spec (not: required: [location_id, location_external_key]onCreateAssetWithTagsRequestand the location equivalent); theGET-filter rule is enforced handler-side because OpenAPI 3 cannot express mutual exclusion on query parameters.ambiguous_fieldsis a newFieldError.codevalue. See Resource identifiers → Paired-key behavior per verb for the full matrix.
BB29 catch-all — contract polish
Polish, docs alignment, and contract-honesty fixes rolled into one wave alongside the tree_path / depth removal below. Non-breaking on the wire.
OPTIONSnow returns405on the public surface. Production and preview deploys ship with CORS disabled and now treatOPTIONSas an unsupported verb —405 Method Not Allowedwith anAllowheader listing the supported methods, matching every other unsupported-verb response. The earlier "204 No Contentwith no CORS headers" shape was the worst-of-both for a CORS-disabled deploy: it told a browser "preflight succeeded" without authorizing the actual cross-origin call. Server-to-server clients aren't affected. See HTTP method coverage →OPTIONS.x-required-scopesextension on scope-gated operations. Every scope-gated operation in the public spec now carries anx-required-scopesarray listing the scope strings the endpoint requires (e.g.x-required-scopes: [assets:write]onPOST /api/v1/assets). The OpenAPIBearerAuthscheme (HTTP Bearer, JWT format) can't express scope-per-operation by itself; the extension is metadata for scope-aware partners and policy tooling, and is auto-derived from the existing@Security BearerAuth[scope]annotations at spec-publish time. Standard codegen ignores it (matching the previous behavior). See Authentication →x-required-scopeson operations.error.detailbubbles the first field message onvalidation_error. The detail string is now the first offending field'smessageverbatim (e.g."external_key must be at most 255 characters") on single-field cases, with(and N more validation errors)appended on multi-field cases. The earlier generic"Request did not pass validation"left integrators iteratingfields[]to surface anything readable.fields[]is unchanged; programmatic handling should still branch onfields[].code. See Errors → Validation errors.- Singular grammar in length-validator messages.
too_shortandtoo_longnow render "1 character" / "2 characters" and "1 item" / "2 items" correctly (was: "1 characters"). Wire envelope is otherwise identical. - Postman collection variables documented correctly. The shipped collection has always used
{{bearerToken}}andbaseUrl=https://app.preview.trakrf.id(bare host). The Postman page and quickstart §4 previously instructed{{apiKey}}andbaseUrl=…/api/v1, which produced 401 and 404 against a freshly imported collection. Docs aligned to the collection. See Postman collection. - Docs base-URL convention unified on bare host.
quickstart.mdxandopenapi.yamlalready use bare host (https://app.trakrf.id);postman.mdxand the Postman section ofquickstart.mdxare updated to match. The OpenAPIservers[].urlis authoritative. See Authentication → Base URL. PATCHround-trip note clarified for typed codegen. The "Full-object PATCH round-trip" wire guarantee is unchanged — the server silently ignores every read-only field — but the docs now call out that strict-typed clients (Pydantic, Java, Go with generated structs) still need to reshape the read object into the write schema, because*Viewdeclares a superset of fields with most markedrequiredandUpdate*Requestdeclares a smaller, all-optional shape. See Resource identifiers → Read shape vs. write shape. (Superseded by the three-categoryPATCHsplit below — the wire guarantee no longer coverstags/external_key/parent_external_key.)PATCHvalidator splits read-shape-only fields into three categories. Previously every read-only field on aPATCHbody was silently dropped; the validator now routes the body through three rules so misuse surfaces at the wire instead of vanishing into a silent strip. Round-trip safe (silent drop,200):id,created_at,updated_at,deleted_aton both resources, pluslocation_external_keyon assets. Managed via subresource (400 validation_error/code: invalid_value):tagson assets and locations — mutate viaPOST /api/v1/{resource}/{id}/tagsand theDELETEcounterpart. Managed via rename (400 validation_error/code: read_only):external_keyon both resources andparent_external_keyon locations — mutate viaPOST /api/v1/{resource}/{id}/rename(and forparent_external_key, via the rename endpoint on the parent row). The asymmetry with the asset side'slocation_external_keyis deliberate: that field has no rename counterpart and is silent-stripped;parent_external_keyshares its value with a renameable field on the parent and is presence-rejected so a write on the wrong row doesn't get silently lost. (Superseded forparent_external_keyby the BB47-49 docs sweep above — it is fully writable on location PATCH, not in the rename-managed category. The classification onexternal_keyis unchanged.) Not a wire-breaking change against any caller that was using the silent-strip correctly — those callers were getting200with theirtags/external_key/parent_external_keywrites ignored, which was a bug masking pattern. A loose-typed client that echoed a fullGETresponse back throughPATCHnow needs to pop the rejected keys client-side or switch to the minimal-body form.FieldError.codegainsread_only; the enum is extensible. See Resource identifiers → Read shape vs. write shape and Errors → Validation errors.- Sixth scope (
keys:admin) documented as internal-only. The platform'sValidScopesset has always includedkeys:admin, which gates SPA-side key administration (mint / list / revoke) and is not selectable in the New Key picker. Integrators do not need, hold, or branch on this scope; it is documented in Authentication → Internal scope:keys:adminso the public scope table isn't read as the platform's complete list.
BB29 — Location tree_path / depth removal
A single BB29 fix wave addressing two related findings on LocationView. Breaking against generated clients — regenerate from the updated spec — and breaking against any caller relying on ?sort=tree_path or default-path-order on GET /api/v1/locations.
tree_pathanddepthare removed fromLocationView. Neither field appears in the response shape, the OpenAPI schema, therequiredlist, or the read-only-strip allow list onPATCH /api/v1/locations/{location_id}. The materialized path on locations was a denormalization derived fromexternal_key(lowercased, hyphens to underscores) and silently folded case-distinct external keys into the same segment, inviting use as a partner-side join key it wasn't built to be. Hierarchy walks now go through the surrogateparent_idchain via the tree endpoints; indent-rendering of flat lists walksparent_idclient-side. See Resource identifiers → Locations:parent_idandparent_external_key.- Breaking change for clients sorting locations by
tree_path.?sort=tree_pathand?sort=-tree_pathare dropped from the locations sort enum; sending either returns400 validation_error. Valid sort fields onGET /api/v1/locationsare nowexternal_key,name, andcreated_at(each with-prefix for descending). Default sort moves from the implicittree_pathordering toexternal_keyascending, withidascending as a deterministic tiebreaker. See Pagination, filtering, sorting → Sortable fields per endpoint. GET /api/v1/locations/{location_id}/ancestorsfixed-sort description rewritten. The endpoint's natural order is unchanged at the wire (root first, walking up the parent chain), but the OpenAPIdescriptionnow describes the order as "root first (walking up theparent_idchain)" rather than "depthascending (root first)" since thedepthfield is gone.GET /api/v1/locations/{location_id}/descendantsalso has its description updated: depth-first tree order is preserved, with each level now described as sorted by lowercasedexternal_key(was: "ordered by ltree path ascending"). The fixed-sort contract on these three endpoints is unchanged — nosortquery parameter is exposed, and the visible row order at the wire is the same.POST /api/v1/locations/{location_id}/renameis no longer cascading. The endpoint mutates only the renamed row'sexternal_key; descendants are not modified server-side. The response still includesdescendant_count_affected, but the field now reports the live count of descendants reachable through theparent_idchain (was: "count of rows whosetree_pathwas rewritten"). An integrator who maintains derived natural-key joins on their own side still uses this as the signal for how many subtree rows may need refreshing. The operation summary loses the "cascadetree_path" qualifier. See Resource identifiers → Location rename.- Case-distinct location
external_keys coexist as distinct rows.WAREHOUSE-WESTandwarehouse-westare now sibling rows under the per-org partial unique index on(org_id, external_key) WHERE deleted_at IS NULL— previously they folded into the sametree_pathsegment and behaved as silent duplicates at the path level. Pick a casing convention for your own integration to keep partner-side joins predictable; the platform doesn't enforce one. See Resource identifiers →external_keyvalue rules.
BB28 consolidated cleanup
Six BB28 findings rolled into a single consolidated fix wave. The three items below are flagged breaking against generated clients — regenerate from the updated spec — but none break a v1.0.0-or-later wire baseline once clients are regenerated.
- Breaking change for generated clients: scope
history:readrenamed totracking:read. The scope gates bothGET /api/v1/assets/{asset_id}/history(time-series) andGET /api/v1/reports/asset-locations(current-state snapshot). The previous name read as "permission to see historical data only" and misdirected integrators trying to scope-minimize. The new name reflects the data lineage — both endpoints are derived from the scan-event stream, andtracking:readis permission to read where things are and have been. Re-mint any API key that previously carriedhistory:readwith the new scope string; JWTs minted under the old literal will fail the scope check. See Authentication → Scopes. - Breaking change for generated clients:
PATCHoperation IDs renamed frompatchAsset/patchLocationtoupdateAsset/updateLocation. Generated clients previously producedclient.patch_asset(...)/client.patchAsset(...)— HTTP-verb-named instead of business-verb-named, inconsistent with the rest of the operation surface (createAsset,renameAsset,addAssetTag). Regenerate clients from the updated spec; hand-rolled clients calling the HTTP method directly are unaffected. - Breaking change for
PATCHcallers:Content-Type: application/merge-patch+jsonis now required exclusively.PATCH /api/v1/assets/{asset_id}andPATCH /api/v1/locations/{location_id}previously silently acceptedapplication/jsonas well; both now return415 unsupported_media_typewithdetail: "Content-Type must be application/merge-patch+json on PATCH operations". The merge-patch media type is the surface signal of the merge-patch semantics the body already had to satisfy; the wider-accept was a silent drift from the spec. POST endpoints continue to requireapplication/jsonand now also reject the merge-patch media type withdetail: "Content-Type must be application/json"(POST side does not carry the method suffix). See HTTP method coverage → Request body Content-Type per method. FieldError.codegainsunknown_field; dropsimmutable_field. Unknown top-level keys in a write request body now emitcode: unknown_fieldinstead ofinvalid_value, so integrators can branch on "typo'd field name" vs. "wrong value."invalid_valuecontinues to cover value-validation failures (enum mismatch, format check).immutable_fieldwas already retired from the emitter earlier in the pre-launch hardening cycle (see thePATCHround-trip note above) and is now also dropped from the enum. See Errors → Validation errors.- Internal ticket references stripped from spec descriptions. Four operation / schema descriptions previously leaked
TRA-NNN/BBNNreferences from Go swag annotations into generated SDK docstrings (CreateLocationWithTagsRequest.external_key; theancestors/children/descendantslocation sub-resource operations). All clean post-regen. A new Spectral rule (trakrf-no-internal-references-in-descriptions) gates future leaks. - New Spectral rule:
trakrf-patch-merge-patch-ct-only. Asserts everyPATCHoperation declares onlyapplication/merge-patch+jsoninrequestBody.content, preventing future drift back toward dual-CT acceptance.
BB27 consolidated cleanup
Eight BB27 post-launch findings rolled into a single consolidated fix wave. None are breaking against a v1.0.0-or-later baseline.
deleted_atis the per-resource soft-delete field name.AssetViewandLocationViewnow carrydeleted_atdirectly — theasset_/location_prefix has been dropped on the per-resource views. The cross-resource report rowAssetLocationItem(on/reports/asset-locations) keepsasset_deleted_atbecause it merges fields from multiple resources and needs the disambiguation. Codegen-derived clients regenerated against the updated spec see the field name change onAssetView/LocationViewonly; the report shape is unchanged. See Resource identifiers → Soft-delete visibility on lists for the naming asymmetry rule.- Sub-resource list endpoints declare a fixed sort order.
/api/v1/locations/{location_id}/ancestors,/children, and/descendantsnow declare their natural sort order via OpenAPIdescription—depthascending (ancestors),nameascending (children), depth-first tree order (descendants), each withidascending as a deterministic tiebreaker. Nosortquery parameter is exposed on these three; sending one returns400 validation_erroragainst the spec. See Pagination, filtering, sorting → Sub-resource list endpoints use a fixed sort order. Locationheader policy on201 Createdis documented. Top-levelPOSTcreates (/assets,/locations) return aLocationheader pointing at the canonical resource URL. Sub-resourcePOSTcreates (/tagson assets and locations) omit the header by design — tags have no top-level canonical URL, and the parent URL is already known to the caller. The policy is enforced by the Spectral ruletrakrf-location-header-on-201-top-level-createon the spec. See HTTP method coverage →Locationheader on201 Created.- Body-decoder date error message rewritten. The validator message on a malformed
format: date-timefield is now"{field} must be an RFC 3339 timestamp"(was:"RFC3339 date or datetime string"). Date-only input (2026-05-10) is still rejected — the previous "date or datetime" wording was inaccurate. The per-query message onGET /api/v1/assets/{asset_id}/history?from=...is unchanged ("Invalid 'from' timestamp; expected RFC 3339, e.g. 2026-04-21T00:00:00.000Z"). Date-only support is not on v1; if you need it, sendT00:00:00Zexplicitly. OPTIONSpreflight clarification. The API is server-to-server only — no third-party origins are permitted. Preflights always return204 No Contentwith noAccess-Control-Allow-Origin(and no otherAccess-Control-Allow-*headers); there is no allowlist that produces a populated CORS envelope. The "204 with CORS headers when allowed" wording was misleading and has been removed. See HTTP method coverage →OPTIONS. (Superseded by the BB29 entry below —OPTIONSnow returns405on CORS-disabled deploys.)duration_secondssemantics documented.AssetHistoryItem.duration_seconds(already in the spec) is the whole-second dwell at the previous location, measured from the previous scan-event timestamp to this row'stimestamp. Always present,nullonly on the earliest scan event in the asset's history (no previous location to measure against). See Date fields →duration_seconds./reports/asset-locationsscope rationale. The endpoint is gated bytracking:read(renamed fromhistory:read— see BB28 entry above), notlocations:readorassets:read, because every field on every row is derived from the scan-event stream (last_seen,location_id,location_external_key). The endpoint URL says "reports" and the rows are asset-at-location pairs, but the scope follows the data lineage. See Authentication → Scopes.- Composite natural keys covered in the resource-identifiers overview. The
Tagnatural key — the polymorphic(tag_type, value)pair, scoped per organization — is now summarized in Resource identifiers → Natural keys per resource alongside the asset / locationexternal_keyform. The detailed Tags use a composite natural key section is unchanged.