Skip to main content

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.detail now carries the substituted https://docs.trakrf.id/api/data-model URL on the asset location_* read-only rejection. Prior to this fix, POST /api/v1/assets and PATCH /api/v1/assets/{asset_id} with location_id or location_external_key set returned a per-field fields[0].message with the URL fully substituted but a top-level error.detail carrying an unsubstituted https://[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 surface detail to humans would have displayed https://[internal] in their UI; integrators branching on fields[].code and rendering message were already correct. Both paths now render the substituted URL. Programmatic handlers should continue to branch on error.type (validation_error) and fields[].code (read_only) — the detail text remains explanatory, not contractual. See Errors → read_only.
  • POST /api/v1/assets/{asset_id}/tags and POST /api/v1/locations/{location_id}/tags now reject an omitted or null tag_type. The spec has marked tag_type required on every *TagRequest subtype since the BB33 spec-level restructure below, but the service retained a silent default to rfid for hand-written raw-HTTP callers. Sending {"value": "..."} or {"tag_type": null, "value": "..."} now returns 400 validation_error / code: required / field: tag_type instead of silently creating an RFID tag. Generated SDKs that already required tag_type per the discriminated-union types are unaffected; hand-written callers that omitted the field need to send it explicitly (one of rfid, 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 on rfid. Pre-launch tightening; no v1.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 — ParseListParams for unknown filter keys on list endpoints, and the unknown-key validator for single-resource and write endpoints — had been emitting code: invalid_value instead, so generated clients branching on code: unknown_field for query-validation handling silently missed the query-side case (e.g. a typo'd ?location_id_=42 returned the value-shaped code rather than the field-shaped one). Both paths now return code: unknown_field with fields[].field naming 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) remain code: invalid_value. The BB32 entry below now describes shipped behavior on every surface, not just the body side. See Errors → validation_error vs bad_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-fetch readers to the merge-patch middleware in §5 before the first PATCH. The substantive fix — a copyable mergePatchMiddleware plus a createTrakrfClient wrapper — has been at §5 — TypeScript with openapi-fetch since the TRA-718 cycle. A reader following the curl walkthrough in §3, then translating to openapi-fetch before reaching §5, would still hit 415 unsupported_media_type on their first PATCH. A short :::tip admonition 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/assets now rejects location_id and location_external_key. Both fields are removed from CreateAssetWithTagsRequest (they previously lived on the schema alongside a not: required: [location_id, location_external_key] mutual-exclusion constraint). Sending either field now returns 400 validation_error / code: read_only with 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 accepted location_id on 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 both POST and PATCH. 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 on error.type (validation_error) and fields[].code (read_only) — the detail text 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-locations as the canonical batch-lookup form. Linked from Resource identifiers and from the read_only envelope text on the asset write surface.
  • GET /api/v1/reports/asset-locations now filters by asset, not just by location. Two new repeatable query parameters land alongside the existing location pair: asset_id (canonical surrogate) and asset_external_key (natural key, validated by the same ^[A-Za-z0-9-]+$ regex enforced on POST/PATCH bodies and the other external_key-typed filters). Within each pair the two forms are mutually exclusive — supplying both returns 400 validation_error / code: ambiguous_fields, symmetric with the location_id / location_external_key rule. The asset and location filter pairs are independent and intersect when combined (?asset_external_key=AST-01&location_external_key=DOCK-1 returns 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 the ambiguous_fields surfaces 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} and PATCH /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's IS DISTINCT FROM short-circuit matches zero rows and skips the UPDATE — updated_at stays byte-equal across the call. An EXISTS disambiguation step keeps 404 not_found honest for missing-row cases. The previous behavior advanced updated_at on 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 on updated_at on 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 advance updated_at as normal — only the no-op path stays byte-stable. Non-breaking against any v1.0.0-or-later wire baseline. See Errors → Idempotency and the verbatim round-trip admonition in Resource identifiers → Read shape vs. write shape.
  • Explicit null on a non-nullable field now uniformly emits code: invalid_value across POST and PATCH. Previously, the asset / location POST endpoints emitted code: required for {"name": null} (and similar for external_key) because the field was on the request's required list. The PATCH side already emitted invalid_value for the same shape (and valid_from, metadata returned invalid_value on both verbs throughout). The POST asymmetry surfaced as an integrator inconsistency: branching on code per the docs guidance gave you required on POST but invalid_value on PATCH for the same logical error. POST handlers now run the explicit-null pre-check on every non-nullable field, so code: required is reserved for the absent-key case across both verbs. The matching /docs/api/errors definitions split cleanly (required → key absent; invalid_value (null variant) → key present with explicit null on a non-nullable field) — see Errors → Validation errors. Non-breaking against any v1.0.0-or-later wire baseline (pre-launch alignment).
  • AssetLocationItem.asset_id and .asset_external_key are now non-nullable in the spec. Both fields originate from NOT NULL storage columns and the view constructor always emitted a value; the nullable: true annotation was vestigial. /api/v1/reports/asset-locations returns 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 no v1.0.0-or-later wire baseline exists yet, so the tightening lands cleanly. The other fields on AssetLocationItem keep their declarations: location_id and location_external_key remain required + nullable because a soft-deleted current location is intentionally projected as null on this report (the cross-resource null projection — current-state-of-the-world by design), and asset_deleted_at remains required + nullable per 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-nullable asset_last_seen already 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}/rename and POST /api/v1/locations/{location_id}/rename are now fully idempotent on a same-value rename. When the new external_key value equals the current one, the handler short-circuits before the row UPDATE — no audit-log row, no updated_at bump, no observable mutation. Locations already had this short-circuit; assets did not, and the asset path advanced updated_at by ~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-body PATCH would fail the accept-if-matches check on updated_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) advances updated_at like any other write; re-GET before a cached-body PATCH in that path. Non-breaking against any v1.0.0-or-later wire baseline (pre-launch behavioral alignment; the prior asset-side updated_at advance was a missing short-circuit, not a stable contract). See Resource identifiers → Renaming an external_key and 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 on asset_id, location_id, tag_id path params and on the ?parent_id= / ?location_id= list filters (including the location_id[] array-item shape) was previously only visible after a request reached the server and bounced back as 400 validation_error / code: too_large. The spec now encodes that ceiling alongside the existing minimum: 1, so generators that honor maximum (most openapi-generator-cli targets, openapi-typescript@7.x 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_id on POST /api/v1/assets, parent_id on POST /api/v1/locations) keep the unconstrained format: int64 declaration — the runtime cap applies there too, but the spec stays descriptive of the long-horizon wire contract on body shapes. The runtime 400 too_large envelope is unchanged on every surface (path / body / query). ID format → What this means for clients is updated. Non-breaking against any v1.0.0-or-later wire baseline.
  • Codegen note added to the spec's interactive reference: nullable: true is interpreted differently across generators. A new paragraph in info.description (rendered at /api) names the verified-working targets — openapi-typescript@7.x (emits string | null), openapi-generator-cli python (Optional[StrictStr]) — and the known-broken case — datamodel-codegen@0.57.0 emits nullable: true fields as non-Optional required types, so Pydantic raises ValidationError on every null response field. The Quickstart → Raw spec for codegen section surfaces the same recommendation alongside its existing generator-target notes. The OpenAPI 3.0 nullable keyword is ambiguous on required + nullable shapes; 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.detail wording for identical 401 conditions. Two distinct auth middleware paths previously emitted their own literals: GET /api/v1/orgs/me returned "Authorization header is required" for the missing-header case, while /assets, /locations, and /reports/asset-locations returned "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" (no Authorization header), "Invalid authorization header format" (wrong scheme such as Basic or a non-Bearer prefix), "Invalid or expired token" (malformed or expired JWT), "API key has been revoked", "API key has expired", and "Use Authorization: Bearer <token>" (the X-API-Key mistake hint). The Quickstart 401 envelope example and the X-API-Key callout in Authentication already showed the canonical wording; the /orgs/me deviation is what changed. Programmatic handlers should keep branching on error.type (unauthorized) — detail remains explanatory text, not a contract — see Errors → unauthorized. Non-breaking against any v1.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}/history rows now expose event_observed_at (was timestamp); GET /api/v1/reports/asset-locations rows now expose asset_last_seen (was last_seen). Both names follow the qualifier-prefix pattern already used by asset_deleted_at on 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_at on history, and ?sort=asset_last_seen / -asset_last_seen on the asset-locations report (alongside the unchanged asset_external_key, location_external_key). The -asset_last_seen default sort on /reports/asset-locations is unchanged in meaning. Storage column names are unchanged — this is a wire-shape rename only. Non-breaking against any v1.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 .NNNZ shape uniformly — 2026-04-29T12:34:56.000Z, …56.123Z — across valid_from / valid_to, created_at / updated_at / deleted_at, and the scan-event fields event_observed_at / asset_last_seen. No trailing-zero trimming, no nanosecond suffix. A regex match like \.\d{3}Z$ is safe on outbound. Postgres timestamptz storage 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 the from / to query parameters on GET /api/v1/assets/{asset_id}/history continue to accept any RFC 3339 fractional precision (0–9 digits), so a client may copy an emitted event_observed_at value 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-library Instant.parse() / equivalent see no change. Spec example: values for every date-time field are bumped from …56Z to …56.000Z to 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, and reports.location_external_key. Generated clients that validate input against the spec now reject abc/def at 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.

  • Tag and TagRequest are now oneOf discriminated unions over RfidTag / BleTag / BarcodeTag (and the matching *Request subtypes). The discriminator is tag_type (propertyName: tag_type; mapping entries rfid / ble / barcode). Generated SDKs now surface three named subtypes — TypeScript clients get a discriminated union usable with switch (tag.tag_type), Python clients get three concrete model classes plus a Tag union alias. The single anonymous Tag type with a free-form tag_type string is replaced. Server-side, the row shape, the storage layer, and the /assets/{asset_id}/tags / /locations/{location_id}/tags endpoints are unchanged — the polymorphism is a spec-and-codegen concern, not a wire shape. Pre-launch reasoning: the partner-breakage cost of converting Tag from 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_type is required on each subtype, no default: rfid in the spec. OpenAPI discriminator semantics require the discriminator property to be required on every member of the union, so each *TagRequest subtype declares tag_type required and the prior default: rfid on the parent TagRequest is gone. At this wave the server still defaulted an omitted or null tag_type to rfid on POST /api/v1/{resource}/{id}/tags — the BB42 wave above tightens that to a hard 400 validation_error / code=required so spec and service agree. No per-kind narrowing of value in 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 on rfid, MAC/UUID on ble, barcode charset on barcode) 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 /rename to re-parent. The reject-if-differs hint returned for parent_external_key previously named POST /api/v1/locations/{id}/rename as an alternative re-parent path. RenameLocationRequest only carries external_key — the rename endpoint cannot change parentage. The hint now names parent_id as the only write path that re-parents (null clears 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 returned 200 with 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 returns 400 validation_error / code: invalid_value with one fields[] entry per offending parameter. Behavior change for callers that probed these filters with reserved characters: the response status moves from 200 (empty data) to 400 (validation error). Non-breaking against any v1.0.0-or-later wire baseline (pre-launch tightening; no real row could match the rejected inputs). See Resource identifiers → external_key value 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-scopes extension 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 carries x-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] on POST /api/v1/assets).
  • The extension is the canonical machine-readable scope source. Authentication → x-required-scopes on 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_from across the boundary cases shows the underlying timestamp with time zone column truncates the sub-microsecond tail toward zero — …0.0000015Z stores as …0.000001, …0.9999999Z stores as …0.999999, and the prior worked example 2026-04-24T15:30:00.123456789Z stores as 2026-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 for valid_to. Server-side behavior is unchanged; this supersedes the BB32 docs correction below. If your test fixtures asserted the rounded-up tail (.123457Z for 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_id is not writable — it does not appear in UpdateAssetRequest, and PATCHing it returns 400 read_only with "record a scan event to update asset location". location.parent_id is directly writable via PATCH (it appears in UpdateLocationRequest; null clears 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-id is the in-band correlation id; x-railway-request-id is the hosting edge. Errors → Filing support tickets now distinguishes the two response headers explicitly. The TrakRF service logs and surfaces x-request-id (matches error.request_id in the envelope); x-railway-request-id is 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 PATCH now obey one accept-if-matches / reject-if-differs rule. PATCH /api/v1/assets/{id} and PATCH /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_only if differs); and tags was presence-rejected with invalid_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 with 400 validation_error / code: read_only. The accompanying message names the proper write path — POST /{resource}/{id}/rename for *_external_key, the /tags subresource for tags, "record a scan event" for asset location_*, and a "server-managed; use DELETE to soft-delete / submit the current value or omit" wording for the four server-managed fields. Practical effect: a verbatim GET → mutate-other-fields → PATCH round-trip works without any client-side scrubbing, including for tags. The pre-TRA-710 "pop tags before sending" pattern is no longer needed and is removed from the Quickstart and Resource Identifiers worked examples. The tags rejection code switches from invalid_value (presence-only) to read_only (value-aware) — a client that was branching on invalid_value to detect a tags-on-PATCH misuse should switch to read_only and inspect fields[].field for "tags". Non-breaking against any v1.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 the read_only envelope.

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_error vs bad_request — that unknown query keys land in validation_error with one fields[] entry per offending key — was true only for list endpoints (which run through ParseListParams). Single-resource GETs 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=42 returns 400 validation_error with fields[].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.
  • POST decode-error detail no longer leaks the Go struct prefix. A type-mismatch on a POST body (e.g. name sent as a number on POST /api/v1/assets) previously returned Body field "CreateAssetRequest.name" could not be decoded as the expected type, exposing the server-side struct name. The detail now strips the struct-qualified prefix and returns Body field "name" could not be decoded ..., matching the snake_case JSON-key wording PATCH already emitted (e.g. Body field "is_active" could not be decoded ...). The Errors → Type mismatches example envelope was already accurate; the prior POST-side leak is now eliminated at the source.
  • POST /api/v1/{resource}/{id}/tags returns a Location header. Sub-resource tag-create endpoints now set Location: /api/v1/{resource}/{id}/tags/{tag_id} on the 201 response, matching the canonical subresource URL that DELETE /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 a Location header" guidance on HTTP method coverage → Location header on 201 Created; that section is updated to reflect the new behavior. RFC 7231 §7.1.2.
  • PATCH with body literal null returns RFC 7396 wording, not a parse-error message. Sending null as the entire PATCH body previously returned 400 bad_request with detail: "Request body is not valid JSON" — but null IS valid JSON, and RFC 7396 defines a top-level null merge-patch as a directive that empties the target. TrakRF does not honor the directive; the rejection itself is correct, only the wording misdiagnosed. New detail: "Request body must be a JSON object (RFC 7396)". See Errors → validation_error vs bad_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.123456789Z2026-04-24T15:30:00.123456Z. Empirical reality: the underlying timestamp with time zone column rounds half-to-even at the microsecond boundary, so .123456789Z rounds 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 (.123456Z for input .123456789Z), refresh them against the rounded value.

BB32 fix wave — Create/Update nullability tightened to symmetric rejection

  • valid_from, is_active, and metadata reject null on both POST and PATCH. 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 explicit null instead 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. Sending null for any of the three on POST or PATCH now returns 400 validation_error / code: invalid_value. Reverses the earlier valid_from: null Create-only acceptance documented under "FK and validator consistency" below; the entry there is retired. Non-breaking against any v1.0.0-or-later wire baseline (pre-launch tightening). See Date fields → valid_from: null is rejected on both Create and Update and Resource identifiers → metadata is 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-field 400 validation_error / code: invalid_value. Sentinel rejections carry a distinct message that echoes the offending value and names JSON null as 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: every valid_from / valid_to on POST and PATCH against /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 any v1.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-Type enforcement tightened on every write method. Three shapes that were silently accepted now return 415 unsupported_media_type: requests with the Content-Type header missing entirely; POST sending application/merge-patch+json (the merge-patch media type is PATCH-only); and multipart/form-data on 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 and text/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 accepts multipart/form-data. The error-envelope unsupported_media_type row now reflects the full method × Content-Type matrix. Non-breaking against any v1.0.0-or-later wire baseline (pre-launch tightening; the looser behavior was a silent drift from the spec, which already declared 415 on every write).
  • X-RateLimit-* headers now present on 415 unsupported_media_type responses. The Rate limits → Response headers page commits to X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset on every public-surface response — explicitly including 415 — and the platform middleware ordering now satisfies that contract. Budget-tracking dashboards and observability metrics no longer have a 415-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 every format: date-time property. The strict regex pattern: previously paired with format: date-time is gone — format: date-time already implies RFC 3339, and the redundant pattern broke openapi-generator-cli -g python deserialization on every response carrying a timestamp (the generated @field_validator runs after Pydantic parses the string, then stringifies the datetime with a space separator before matching, which never satisfies a T-separator-anchored regex). The server's RFC 3339 input validation is unchanged and still returns 400 validation_error for bad input; clients that relied on spec-level pattern matching should switch to validating against format: date-time instead. Reverses the Phase 3.5 entry below that announced the pattern addition. Non-breaking against any v1.0.0-or-later wire baseline.
  • Natural-key reference fields on PATCH now follow a uniform accept-if-matches / reject-if-differs rule. Five fields are in scope — external_key (assets and locations), parent_external_key (locations), and location_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 with 400 validation_error / code: read_only and a message naming the proper write path. The *_external_key cases point at POST /api/v1/{resource}/{id}/rename; the asset location_* 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; tags is 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 verbatim GET → mutate-other-fields → PATCH round-trip works without an explicit strip step for the natural-key fields — they echo silently through; the only field still requiring an explicit strip is tags. (2) PATCH /api/v1/assets/{asset_id} no longer accepts location_id or location_external_key as a way to move an asset — record a scan event instead. The pre-existing pattern of mutating asset location via PATCH is retired. Non-breaking against any v1.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 the read_only envelope. Quickstart §3's round-trip example is updated to reflect the simplification, and (BB31 Finding 4 bundled) shows a portable python3 -c form alongside jq so integrators on Windows or air-gapped environments without jq aren'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_type removed from DELETE operations in the spec. The service does not enforce Content-Type on DELETE (there's no body to interpret), and the previous declaration encouraged typed clients to add defensive Content-Type handling that never fires. Removing the response from every DELETE operation tightens generated client shapes. See HTTP method coverage.
  • Tag.is_active removed from the spec, Go struct, storage layer, and frontend types. The field had no transition surface in v1 — every live tag was is_active: true — so it was dead weight on the response payload. Tag responses no longer carry is_active; codegen-derived clients regenerated against the updated spec see the field disappear from the Tag schema. No integrator-visible behavior change (no caller had a value other than true to 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 respond 301 Moved Permanently to 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.yaml is 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's external_key as parent_external_key, and an asset whose location is soft-deleted carries the location's external_key as location_external_key, when accessed via GET /api/v1/locations?include_deleted=true and GET /api/v1/assets?include_deleted=true respectively. Previously the natural-key form was projected as null when the referenced row had been soft-deleted, breaking the FK-pair invariant (surrogate populated, natural-key null). The cross-resource report GET /api/v1/reports/asset-locations intentionally keeps the null projection because its rows are current-state snapshots; the spec description on that endpoint now calls out the divergence and points readers at ?include_deleted=true for 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) vs parent_external_key (locations) split — silent-drop on the asset side, 400 read_only on 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.
  • metadata is opaque storage — explicit doc. PATCH replaces the entire metadata object 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 the metadata value. Behavior is unchanged; the prose is new. See Resource identifiers → metadata is stored opaquely.
  • bad_request vs validation_error framed as parse-time vs validate-time. Error handlers should branch on error.type first — bad_request means the request couldn't be parsed (no fields[]); validation_error means 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_error vs bad_request.
  • Quickstart gains a round-trip GET → strip → PATCH worked example. Section 3 of the Quickstart now includes a curl pipeline that pops the rejected keys (tags, external_key, plus parent_external_key on locations) before sending the body back on PATCH. The minimal-form PATCH is still the recommended shape, but integrations whose in-memory model mirrors the read shape one-to-one have an authoritative pattern to follow.
  • Tag.value vs external_key character 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_key is stricter — [A-Za-z0-9-] only — because it flows into URL paths and log lines. A value that happens to match the external_key pattern is a coincidence, not a guarantee. See Resource identifiers → external_key and tags[].value are not symmetric.
  • metadata absent on locations is a v1 commitment, not an oversight. Reintroduction is a v1.1 consideration; generated clients should not assume the field will appear on LocationView and should branch on its presence if a future spec adds it. See Resource identifiers → Asset metadata vs. location tags.
  • tag_type: null accepted as "use the rfid default" 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 with tag_type: rfid. The BB42 tightening removes the silent default: tag_type is now required on every attach and an omitted or null value returns 400 validation_error / code=required / field=tag_type. See Resource identifiers → Tags use a composite natural key.
  • No new Tag.is_active references 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 (was v1). URL versioning under /api/v1/ is unchanged; the spec-document version and the URL version evolve independently.
  • Integer path-param maximum: 2147483647 (was 9007199254740991). Path-param ids are now bounded by the underlying int4 column. Values in the previously-accepted range (2^31 - 1, 2^53 - 1] return 400 validation_error with params.max=2147483647 instead of falling through to a 500 internal_error from the database driver.
  • Every integer field declares format: int32. Code generators emit width-bounded int32 types — number in TS, int in Python, int32 in Go/Java — rather than a permissive integer. No runtime behavior change.
  • Every operation declares a default response pointing at the ErrorResponse envelope. 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 PATCH round-trip is now supported at the wire. Sending external_key, tags, or any other read-only field (id, created_at, updated_at, *_deleted_at, tree_path, depth) in a PATCH body returns 200 with the field silently ignored. Mutate external_key via POST /api/v1/{resource}/{id}/rename and tags via POST /api/v1/{resource}/{id}/tags (and the DELETE counterparts). Reverses earlier guidance that called these fields "rejected with 400 immutable_field" / "rejected with 400 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_field validation code retired. No remaining emitter; previously only fired for external_key on PATCH. 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 (or parent_id) returns the same 400 validation_error envelope as a non-existent location_external_key (or parent_external_key); previously the surrogate-key form fell through to 500 internal_error. (The specific FieldError.code was reshaped again ahead of v1.0 — see below.)
  • description: "" is rejected with too_short. Sending an empty string on POST or PATCH for description returns 400 validation_error / code: too_short / params.min_length=1, matching every other length-bearing string field. Send explicit null to clear the field.
  • required, invalid_value, and too_short split on length-bearing required fields. Omission (POST /api/v1/assets {}) emits code: required; explicit null on a non-nullable field (POST /api/v1/assets {"name": null}) emits code: invalid_value; sending a value below the documented minimum (POST /api/v1/assets {"name": ""} on a min_length: 1 field) emits code: too_short with params.min_length. Reverses an earlier pre-launch decision that folded omission and empty-string into a single too_short code — 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 as 400 bad_request with no fields[] because it fails at decode time before the schema validator runs. See Errors → Validation errors. Internally guarded by a contract-test enum-coverage gate (FieldErrorCode enum values must each be observed at least once during the Schemathesis run) so silent regressions of the split fail CI.

Errors and conflict messages

  • 5xx responses no longer leak database driver strings in error.detail. error.detail on 500 is a fixed generic string; the underlying cause is logged server-side and correlatable through the request_id in the envelope.
  • Tag conflict error strings use "tag," not "identifier." A duplicate (tag_type, value) on POST /api/v1/{resource}/{id}/tags returns detail: "tag rfid:E2-… already exists". String-matching on the literal word tag is 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 sort query 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 declares sort as type: string with a CSV-shaped pattern: regex instead of type: array with style: form, explode: false. Generated clients that previously typed sort as string[] will now type it as string. Hand-rolled clients building the query string directly are unaffected.
  • Write request bodies declare additionalProperties: false. Unknown top-level keys in POST and PATCH bodies are rejected at the schema boundary with 400 validation_error rather than silently accepted. The existing silent-accept rule for read-only fields is unchanged — those are not "unknown" keys, they're declared readOnly: true on the read shape.
  • Printable-string validation on body strings and q filters. name, description, tag value, and the q substring-search query param reject NUL bytes and other ASCII control characters at the validator with 400 validation_error. Previously these could reach the storage layer and surface as 500 internal_error from a downstream invalid_text_representation (SQLSTATE 22021).
  • RFC 3339 pattern: on every format: date-time property. Date-time fields now carry a strict regex pattern: in addition to the format: keyword, so codegen tools that honor pattern reject malformed timestamps client-side; the server already validated the RFC 3339 profile and continues to return 400 validation_error for bad input.
  • Surrogate-id query filters and offset are int4-bounded. *_id query filter items declare minimum: 1 / maximum: 2147483647; offset declares minimum: 0 / maximum: 2147483647. Out-of-range values return 400 validation_error rather 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_found returns 400 validation_error. A non-existent location_id (or parent_id) and a non-existent location_external_key (or parent_external_key) both return the same 400 validation_error / code: fk_not_found envelope, on POST and PATCH. The 409 conflict envelope is reserved for true state-conflict cases (POST collisions on external_key, the non-leaf-location delete check). fk_not_found is a new FieldError.code value; clients integrating against a Phase 3.5 pre-release that briefly routed FK-not-found through 409 conflict should branch on the new typed code.
  • Natural-key FK form is read-only on PATCH. location_external_key (on assets) and parent_external_key (on locations) are silently stripped from PATCH request bodies regardless of whether they agree with the surrogate *_id form. 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 on PATCH, matching the read-only-strip pattern that already covers id, created_at, updated_at, *_deleted_at, tree_path, depth, external_key, and tags. See Resource identifiers → Read shape vs. write shape.
  • POST body and GET list filter reject both-supplied with ambiguous_fields. The surrogate / natural-key forms are mutually exclusive on POST /api/v1/assets, POST /api/v1/locations, and the GET list filters on /assets and /locations. Sending both returns 400 validation_error / code: ambiguous_fields with one fields[] entry per offending parameter. The POST rule is encoded directly in the OpenAPI spec (not: required: [location_id, location_external_key] on CreateAssetWithTagsRequest and the location equivalent); the GET-filter rule is enforced handler-side because OpenAPI 3 cannot express mutual exclusion on query parameters. ambiguous_fields is a new FieldError.code value. 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.

  • OPTIONS now returns 405 on the public surface. Production and preview deploys ship with CORS disabled and now treat OPTIONS as an unsupported verb — 405 Method Not Allowed with an Allow header listing the supported methods, matching every other unsupported-verb response. The earlier "204 No Content with 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-scopes extension on scope-gated operations. Every scope-gated operation in the public spec now carries an x-required-scopes array listing the scope strings the endpoint requires (e.g. x-required-scopes: [assets:write] on POST /api/v1/assets). The OpenAPI BearerAuth scheme (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-scopes on operations.
  • error.detail bubbles the first field message on validation_error. The detail string is now the first offending field's message verbatim (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 iterating fields[] to surface anything readable. fields[] is unchanged; programmatic handling should still branch on fields[].code. See Errors → Validation errors.
  • Singular grammar in length-validator messages. too_short and too_long now 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}} and baseUrl=https://app.preview.trakrf.id (bare host). The Postman page and quickstart §4 previously instructed {{apiKey}} and baseUrl=…/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.mdx and openapi.yaml already use bare host (https://app.trakrf.id); postman.mdx and the Postman section of quickstart.mdx are updated to match. The OpenAPI servers[].url is authoritative. See Authentication → Base URL.
  • PATCH round-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 *View declares a superset of fields with most marked required and Update*Request declares a smaller, all-optional shape. See Resource identifiers → Read shape vs. write shape. (Superseded by the three-category PATCH split below — the wire guarantee no longer covers tags / external_key / parent_external_key.)
  • PATCH validator splits read-shape-only fields into three categories. Previously every read-only field on a PATCH body 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_at on both resources, plus location_external_key on assets. Managed via subresource (400 validation_error / code: invalid_value): tags on assets and locations — mutate via POST /api/v1/{resource}/{id}/tags and the DELETE counterpart. Managed via rename (400 validation_error / code: read_only): external_key on both resources and parent_external_key on locations — mutate via POST /api/v1/{resource}/{id}/rename (and for parent_external_key, via the rename endpoint on the parent row). The asymmetry with the asset side's location_external_key is deliberate: that field has no rename counterpart and is silent-stripped; parent_external_key shares 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 getting 200 with their tags / external_key / parent_external_key writes ignored, which was a bug masking pattern. A loose-typed client that echoed a full GET response back through PATCH now needs to pop the rejected keys client-side or switch to the minimal-body form. FieldError.code gains read_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's ValidScopes set has always included keys: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:admin so 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_path and depth are removed from LocationView. Neither field appears in the response shape, the OpenAPI schema, the required list, or the read-only-strip allow list on PATCH /api/v1/locations/{location_id}. The materialized path on locations was a denormalization derived from external_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 surrogate parent_id chain via the tree endpoints; indent-rendering of flat lists walks parent_id client-side. See Resource identifiers → Locations: parent_id and parent_external_key.
  • Breaking change for clients sorting locations by tree_path. ?sort=tree_path and ?sort=-tree_path are dropped from the locations sort enum; sending either returns 400 validation_error. Valid sort fields on GET /api/v1/locations are now external_key, name, and created_at (each with - prefix for descending). Default sort moves from the implicit tree_path ordering to external_key ascending, with id ascending as a deterministic tiebreaker. See Pagination, filtering, sorting → Sortable fields per endpoint.
  • GET /api/v1/locations/{location_id}/ancestors fixed-sort description rewritten. The endpoint's natural order is unchanged at the wire (root first, walking up the parent chain), but the OpenAPI description now describes the order as "root first (walking up the parent_id chain)" rather than "depth ascending (root first)" since the depth field is gone. GET /api/v1/locations/{location_id}/descendants also has its description updated: depth-first tree order is preserved, with each level now described as sorted by lowercased external_key (was: "ordered by ltree path ascending"). The fixed-sort contract on these three endpoints is unchanged — no sort query parameter is exposed, and the visible row order at the wire is the same.
  • POST /api/v1/locations/{location_id}/rename is no longer cascading. The endpoint mutates only the renamed row's external_key; descendants are not modified server-side. The response still includes descendant_count_affected, but the field now reports the live count of descendants reachable through the parent_id chain (was: "count of rows whose tree_path was 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 "cascade tree_path" qualifier. See Resource identifiers → Location rename.
  • Case-distinct location external_keys coexist as distinct rows. WAREHOUSE-WEST and warehouse-west are 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 same tree_path segment 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_key value 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:read renamed to tracking:read. The scope gates both GET /api/v1/assets/{asset_id}/history (time-series) and GET /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, and tracking:read is permission to read where things are and have been. Re-mint any API key that previously carried history:read with the new scope string; JWTs minted under the old literal will fail the scope check. See Authentication → Scopes.
  • Breaking change for generated clients: PATCH operation IDs renamed from patchAsset / patchLocation to updateAsset / updateLocation. Generated clients previously produced client.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 PATCH callers: Content-Type: application/merge-patch+json is now required exclusively. PATCH /api/v1/assets/{asset_id} and PATCH /api/v1/locations/{location_id} previously silently accepted application/json as well; both now return 415 unsupported_media_type with detail: "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 require application/json and now also reject the merge-patch media type with detail: "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.code gains unknown_field; drops immutable_field. Unknown top-level keys in a write request body now emit code: unknown_field instead of invalid_value, so integrators can branch on "typo'd field name" vs. "wrong value." invalid_value continues to cover value-validation failures (enum mismatch, format check). immutable_field was already retired from the emitter earlier in the pre-launch hardening cycle (see the PATCH round-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 / BBNN references from Go swag annotations into generated SDK docstrings (CreateLocationWithTagsRequest.external_key; the ancestors / children / descendants location 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 every PATCH operation declares only application/merge-patch+json in requestBody.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_at is the per-resource soft-delete field name. AssetView and LocationView now carry deleted_at directly — the asset_/location_ prefix has been dropped on the per-resource views. The cross-resource report row AssetLocationItem (on /reports/asset-locations) keeps asset_deleted_at because it merges fields from multiple resources and needs the disambiguation. Codegen-derived clients regenerated against the updated spec see the field name change on AssetView / LocationView only; 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 /descendants now declare their natural sort order via OpenAPI descriptiondepth ascending (ancestors), name ascending (children), depth-first tree order (descendants), each with id ascending as a deterministic tiebreaker. No sort query parameter is exposed on these three; sending one returns 400 validation_error against the spec. See Pagination, filtering, sorting → Sub-resource list endpoints use a fixed sort order.
  • Location header policy on 201 Created is documented. Top-level POST creates (/assets, /locations) return a Location header pointing at the canonical resource URL. Sub-resource POST creates (/tags on 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 rule trakrf-location-header-on-201-top-level-create on the spec. See HTTP method coverage → Location header on 201 Created.
  • Body-decoder date error message rewritten. The validator message on a malformed format: date-time field 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 on GET /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, send T00:00:00Z explicitly.
  • OPTIONS preflight clarification. The API is server-to-server only — no third-party origins are permitted. Preflights always return 204 No Content with no Access-Control-Allow-Origin (and no other Access-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 — OPTIONS now returns 405 on CORS-disabled deploys.)
  • duration_seconds semantics 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's timestamp. Always present, null only on the earliest scan event in the asset's history (no previous location to measure against). See Date fields → duration_seconds.
  • /reports/asset-locations scope rationale. The endpoint is gated by tracking:read (renamed from history:read — see BB28 entry above), not locations:read or assets: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 Tag natural key — the polymorphic (tag_type, value) pair, scoped per organization — is now summarized in Resource identifiers → Natural keys per resource alongside the asset / location external_key form. The detailed Tags use a composite natural key section is unchanged.