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.
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,[email protected]consumers 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 —[email protected](emitsstring | null),openapi-generator-clipython (Optional[StrictStr]) — and the known-broken case —[email protected]emitsnullable: 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.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. 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). 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. 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. 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. 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.