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.

Asset current location removed from the asset resource

An asset's current location is fact data — derived from the scan-event stream — not a dimension attribute the asset resource carries. location_id and location_external_key are removed from the asset response shape and from the GET /api/v1/assets filter surface; current location is read from the scan-data endpoints, and POST / PATCH reject the two fields uniformly on presence. The reporting endpoints (/reports/asset-locations, /assets/{id}/history) are unchanged — they remain the system of record for asset location. Pre-launch contract change; no v1.0.0-or-later wire baseline to break.

  • location_id / location_external_key removed from the asset response. GET /api/v1/assets and GET /api/v1/assets/{asset_id} no longer return any location field — the asset resource carries master data only (name, external_key, is_active, effective dates, tags). Read an asset's current location from GET /api/v1/reports/asset-locations (latest location per asset, filterable by asset_id / asset_external_key / location_id / location_external_key) or from the latest row of GET /api/v1/assets/{asset_id}/history. Generated clients pick up an AssetView with two fewer fields; any client reading asset.location_id off the asset response must move to the reporting endpoints. See Data model and Resource identifiers → Foreign-key fields in responses.
  • ?location_id / ?location_external_key filters removed from GET /api/v1/assets. Filtering assets by current location is itself fact-shaped and moves off the dimension endpoint. To list assets at a location, use GET /api/v1/reports/asset-locations?location_id=... (or ?location_external_key=..., repeatable) and resolve the asset records from the returned asset_id set when full asset detail is needed. The location_id / location_external_key filter pair is unchanged on /reports/asset-locations. See Pagination, filtering, sorting → Filtering.
  • POST and PATCH reject location_id / location_external_key uniformly on presence. With location gone from the response shape there is nothing to round-trip, so the prior PATCH accept-if-matches / reject-if-differs handling on these fields is retired: both POST /api/v1/assets and PATCH /api/v1/assets/{asset_id} now return 400 validation_error / code: read_only whenever either field appears in the body, identical on both verbs. The error detail still names the scan-event ingestion paths. See Errors → read_only and Resource identifiers → Paired-key behavior per verb.

BB66 docs sweep — updated_at token semantics paragraph, openapi-fetch PATCH per-call recipe, Tag.tag_type read/write enum asymmetry called out

Three integrator-visible additions from the BB66 multi-org triage. No runtime behavior change; the platform-side companion (Tag schema description amendment) is a description-only spec edit. Each closes a gap a careful BB66 reader surfaced rather than fixing newly-broken behavior.

  • Design notes → updated_at is an optimistic-concurrency token on PATCH gains an explicit "what the token detects, exactly" paragraph. The section already documented that every accepted PATCH advances updated_at (per the BB64 follow-up below), but a careful reader could still derive a concern that PATCH {} health probes or writable-echo PATCHes "spuriously" advance the token. The new paragraph reframes the rule directly: updated_at tracks "a write request against this row succeeded" — not "the data on this row changed." Empty-body PATCH, writable-echo PATCH, and admin no-content touches all advance the field; the concurrency-token use case relies on that semantic, because any intervening successful write is information the would-be writer needs. Integrations wanting "data changed" detection rather than "a write request happened" detection should diff resource fields directly. Service behavior is unchanged.
  • Quickstart → TypeScript with openapi-fetch now shows the per-call PATCH helper alongside the existing middleware recipe. The mergePatchMiddleware helper still works and remains the lowest-overhead option once a project PATCHes from many sites, but it depends on copying a 30-line file from the platform repo. Integrators who only PATCH from one or two call sites — or who prefer not to pull a remote file into their tree — can now copy a self-contained patchAsset() helper from the page that sets Content-Type: application/merge-patch+json per call. openapi-fetch already calls JSON.stringify on the body by default, so no bodySerializer override is needed in the common path. Closes the BB41-follow-up gap where the existing recipe pointed at an external file.
  • Resource identifiers → Tag is a polymorphic resource names the read/write enum asymmetry on tag_type. The page previously described tag_type as an "open (extensible) enumeration" and pointed at the versioning policy on open enums, which is correct guidance on the read direction — pass through unknown tag_type values rather than rejecting them — but reads as contradicted by the runtime rejection on the write direction, where POST /tags and the natural-key tuple (tag_type, value) accept only the currently-defined variants (rfid, ble, barcode) and reject anything else with 400 validation_error / code: invalid_value / params.allowed_values: [...]. The prose now explicitly splits the directions and notes that the platform-side Tag / TagRequest schema descriptions mirror this disambiguation. The asymmetry is intentional pending the planned OpenAPI 3.1 x-extensible-enum migration; service behavior is unchanged.

BB64 follow-up — every accepted PATCH advances updated_at (drops wire-idempotency for no-op bodies)

Single behavior change from a BB64 docs-side probe verification. The prior model — shipped in the BB39 follow-up wave below — short-circuited the storage UPDATE when no settable field's value differed from the current state, leaving updated_at byte-equal across verbatim no-op PATCHes. Probe verification post-BB64 found the short-circuit broke for valid_from / valid_to whenever the stored value carried sub-millisecond precision the wire didn't see (server-defaulted timestamps, sub-millisecond client input): the IS DISTINCT FROM check ran in storage coordinates against bytes the wire-truncated body didn't fully express, so a verbatim wire-level echo could still advance updated_at. Rather than carry the edge cases, the model simplifies: every accepted PATCH advances updated_at, no exceptions.

  • Every accepted PATCH advances updated_at. Empty body ({}), verbatim writable echo of current values, partial mutation, and full mutation all advance updated_at to the current server time on success. Rejected PATCHes (400 from a read-only field mismatch, validation failure on a settable field, or any other 4xx) do not advance — the operation never reached storage. The model matches filesystem touch semantics: any successful write event advances the modification time regardless of whether content changed. The optimistic-concurrency-token contract on updated_at is unchanged — submitting a stale updated_at still returns 400 validation_error / code: read_only with the mismatch detail — but cached-body PATCH retries that include updated_at now need to refresh the token from a fresh GET before retrying (or omit updated_at from the body, letting the server advance it implicitly). Supersedes the BB39 follow-up R1 entry below ("PATCH idempotent on a verbatim no-op body"); the storage-level no-op short-circuit is gone. See Resource identifiers → Read shape vs. write shape for the round-trip framing and Errors → Idempotency for the cached-body retry-pattern consequence.

BB63 fix wave — readOnly annotations on Asset and Location read views, ErrorEnvelope named schema, read_only vs invalid_context semantic split

Three integrator-visible items from the BB63 multi-org parallel triage, all pre-launch. F1 closes a contract-class annotation gap on four read-view fields that the runtime already treated as read-only — strict-typed client generators now see the OpenAPI signal at the type layer. F2 hoists the ErrorResponse.error inline shape to a named ErrorEnvelope schema so generated client class names stop embedding another class's name. F4 splits the read_only code value into two — preserving read_only for truly server-managed fields and shifting fields mutable via a sub-resource verb onto the broader invalid_context code introduced in the BB62 fix wave below. F2 and F4 carry generated-client churn (a renamed schema class on F2; updated code-value branching on F4) — pre-launch is the right time to take both rather than after partners have pinned against the older shape.

  • readOnly: true added to four fields on AssetView and LocationView. AssetView.location_id, AssetView.location_external_key, AssetView.tags, and LocationView.tags now carry the OpenAPI 3.0 readOnly: true annotation, matching the runtime accept-if-matches / reject-if-differs rule that the other annotated fields (id, created_at, updated_at, deleted_at) already advertise. Strict-typed client generators (openapi-python-client emits Field(..., frozen=True) for readOnly properties; openapi-generator-cli python target marks them in the model layer; openapi-typescript@7.x carries the annotation through to the type alias) now mechanically signal that these fields shouldn't be sent back on PATCH bodies. Integrators diffing the read and write schemas to learn what's writable no longer have to reason about why location_id appears in the read shape but not in UpdateAssetRequest — the readOnly: true annotation closes the loop on the read side, and the field's absence from the write request shape closes it on the write side. Runtime behavior is unchanged: the same accept-if-matches / reject-if-differs rule fires on these fields as before. See Resource identifiers → Read shape vs. write shape.
  • ErrorEnvelope introduced as a named schema; ErrorResponse.error rewired to $ref. The error envelope shape (type, title, status, detail, instance, request_id, fields[]) now lives at #/components/schemas/ErrorEnvelope rather than as an inline anonymous object on ErrorResponse.error. The schema-level description "TrakRF error envelope, modeled on RFC 7807 but not 7807-compliant" moves with the hoisted schema. Wire shape is byte-identical — the error key on every non-2xx response still carries the same object. Generated clients pick up a class-name change: openapi-generator-cli --generator python emits class ErrorEnvelope(BaseModel) rather than the parent-derived class ErrorResponseError(BaseModel); openapi-typescript emits a components["schemas"]["ErrorEnvelope"] alias; openapi-python-client emits error_envelope.py as a module. Hand-written clients deserializing the response by field path (response.error.type, response.error.fields[0].code) are unaffected. Pre-launch breaking change against any generated client already pinned to the old class name — but no v1.0.0-or-later wire baseline exists yet, and post-launch the rename would carry materially more churn. See Errors → Envelope shape.
  • Error code semantic split: read_only (server-managed) vs invalid_context (sub-resource-mutable, not via this verb). code: read_only was previously emitted on two semantically distinct cases — truly server-managed fields (id, created_at, updated_at, deleted_at, plus the scan-derived location_id / location_external_key on assets) and fields mutable via a sub-resource verb that aren't writable on the PATCH surface (external_key mutable via POST /…/rename, tags mutable via POST/DELETE on the /tags sub-resource). The detail strings routed the integrator correctly in both cases, but a strict-typed client switching over FieldErrorCode saw the same value and couldn't distinguish "this is server-managed" from "use a different verb." The runtime emission now splits: external_key (assets, locations) and tags (assets, locations) on PATCH now emit code: invalid_context instead of code: read_only; truly server-managed fields continue to emit code: read_only. The invalid_context semantic was introduced in the BB62 fix wave below for the "known parameter, used in wrong context" case (?include_deleted=true on detail endpoints); the same semantic — known field on the surface, but wrong verb / sub-resource — fits the PATCH-mutable-via-sub-resource case naturally. Detail strings are unchanged on both code branches — clients displaying detail directly continue to work without modification. Programmatic handlers branching on code: read_only for external_key or tags should add an invalid_context arm (and route to the sub-resource verb described in detail); handlers that fall through unknown codes to a generic 400 handler are unaffected (FieldErrorCode is x-extensible-enum: true). See Errors → Validation errors and Resource identifiers → Read shape vs. write shape.

BB62 follow-up — Asset.name and Location.name validator tightened (control chars and surrounding whitespace rejected)

One pre-launch tightening from the BB62 follow-up. F1 splits the validator pattern on the public-API name fields so display strings have a stricter shape than free-form description text; the spec-side regex emission matches the server validator so generated clients honoring pattern: see the same boundary at decode time. A companion docs-narrow correction on the Location response header description is spec-emission-only with no behavior change — not called out here. Pre-launch behavioral / spec tightening; no v1.0.0-or-later wire baseline to break.

  • Asset.name and Location.name (create + update) now reject all C0 control characters (including \t, \n, \r), DEL (\x7F), and require non-whitespace at both the first and last character. The previous pattern (^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$) was multi-line-tolerant — reasonable for free-form description content but not for a single-line display name. Whitespace-only values (" "), embedded newlines ("line1\nline2"), embedded carriage returns or tab characters, and leading or trailing whitespace (" Asset 1" or "Asset 1 ") now return 400 validation_error / code: invalid_value on name. Internal whitespace ("Asset 1") and single-character names ("X") continue to succeed. Description fields keep the existing multi-line-tolerant pattern — multi-line content in Asset.description and Location.description remains accepted unchanged. Applies to CreateAssetWithTagsRequest.name, UpdateAssetRequest.name, CreateLocationWithTagsRequest.name, and UpdateLocationRequest.name. The spec emits pattern: ^\S(?:[^\x00-\x1F\x7F]*\S)?$ on these four fields so generated clients that honor pattern: see the same boundary as the server validator. See Errors → Validation errors.

BB62 fix wave — body-decode failures normalized to validation_error, new invalid_context code for known-field-wrong-context rejections, x-package-name spec extension, /ancestors ordering prose fix

Three integrator-visible service-side changes from the BB62 multi-org parallel triage plus one contract-class docs fix on the location-tree endpoints. F2 closes the fourth-time-surfaced envelope-split discoverability gap (BB52 / BB56 / BB61 / BB62) at the service layer rather than at the docs layer, retiring the workarounds shipped in those earlier waves. F3 and F4 are smaller hygiene items on the same triage. F1 corrects a prose-vs-spec drift on /ancestors ordering that no client-side test catches.

  • Body-decode validation errors now carry fields[]. Previously, a body-decode type-mismatch — sending a string where a boolean was expected, a float where an int was expected, or any JSON value the decoder couldn't coerce to the declared body-field type — returned error.type: bad_request with detail only and no fields[] array, distinct from value-level validation errors which returned validation_error with fields[]. The envelope is now unified: any field-attributable body validation failure returns validation_error with a populated fields[] (one entry per offending field). Type-mismatch entries carry code: invalid_value, params.expected_type set to the wire-facing JSON type name (boolean, number, string, array, object, null), and params.received_type set to the JSON kind the decoder actually saw — so a generated client can branch on the type names without parsing free-form detail text. detail mirrors the first field's message (e.g. is_active must be a boolean; received string). Multiple type-mismatches in one body yield multiple fields[] entries with the (and N more validation errors) suffix on detail, matching the validator's existing aggregation behavior for value-level failures. The bad_request envelope is now reserved for failures the API can't attribute to a field: invalid JSON syntax, a top-level type mismatch (the request body itself is the wrong JSON shape — for example an array where an object is expected), or a PATCH body that is the literal JSON token null. The behavior change is strictly additive on the validation_error path: clients branching on error.type continue to work without modification; clients that iterate fields[] for diagnostic detail now see useful data on previously-empty cases. The prior documentation workarounds at Errors → validation_error vs bad_request (the BB52 typed-client warning admonition and the BB61 side-by-side worked example) are simplified to match the unified post-refactor behavior — the warning shape is retained for the residual bad_request cases since field-attributable failures all now route to validation_error, the unconditional-fields[]-iteration footgun is much rarer in practice. The bad_request row in the type catalog is also updated to describe the narrower remaining scope. See Errors → bad_request and Errors → validation_error vs bad_request.
  • New code: invalid_context on FieldErrorCode for known-field-wrong-context rejections. GET /api/v1/locations/{location_id}?include_deleted=true previously returned 400 validation_error with fields[0].code: unknown_field on the include_deleted parameter — the same code the validator emits for truly-unknown parameters (?wat=1). The detail text was specific (include_deleted is a list-only filter; soft-deleted records are not retrievable by id (...)), but a strict-typed client switching over FieldErrorCode couldn't distinguish "field doesn't exist on this surface" from "field exists on the API but isn't allowed here." The detail-endpoint rejection now emits fields[0].code: invalid_context instead. The FieldErrorCode enum in the spec gains the new value. The shared validator helper applies the code uniformly across the surface: every list-endpoint filter parameter (external_key, is_active, q, the surrogate / natural-key reference pairs, include_deleted) now emits invalid_context when sent to a detail or write endpoint that doesn't declare it, with a message that names the list-endpoint sibling (e.g. GET /api/v1/assets) when one can be derived from the request path. include_deleted keeps its specialized natural-key-recovery wording so the soft-delete retrieval path stays discoverable. Truly unrecognised query keys (?wat=1) continue to emit unknown_field. Programmatic handlers branching on a switch over FieldErrorCode should add an invalid_context arm; handlers that fall through unknown codes to a generic 400 handler continue to work without modification (FieldErrorCode is declared x-extensible-enum: true in the spec). The catalog entry is at Errors → Validation errors (alongside unknown_field and the other FieldErrorCode values).
  • x-package-name: trakrf-api-client added to spec info. openapi-python-client@0.28.x's default snake-case derivation of the Python package name from info.title: "TrakRF API" produced the awkward trak_rf_api_client — the title's interior caps split into a separate token. Generators that honor x-package-name (including openapi-python-client) now derive the canonical package name trakrf_api_client automatically. Generators that ignore the extension can still use the --meta CLI override (openapi-python-client generate --path … --meta path/to/meta.toml, or the equivalent flag on other generators) to set the package name from outside the spec. No wire change; spec emission only.
  • Resource identifiers → Location tree endpoints ancestors prose corrected from "nearest-first" to "root-first". The endpoint description for GET /api/v1/locations/{location_id}/ancestors previously read "ordered nearest-first (immediate parent → … → root)." The OpenAPI spec and the live service both implement the opposite order: ancestors are returned root-first (walking up the parent_id chain to the tree root), with id ascending as a deterministic tiebreaker — which is what the Pagination, filtering, and sorting → Sub-resource list endpoints page documented correctly. A breadcrumb UI written from the prose (for a of response.data { render(a) }) renders reversed end-to-end — no 400 to surface it, just wrong visual output. The prose now matches the spec and the service, with an explicit "iterate in natural order and append the current location at the end" guidance for breadcrumb rendering. Service behavior is unchanged.

BB61 fix wave — tags PATCH echo compared as a set, worked example for validation_error vs bad_request

One service-side behavior change plus one docs-narrow item from the BB61 multi-org parallel triage. F1 is integrator-visible and strictly more permissive — every prior in-order echo continues to succeed. F2 closes a third-time-surfaced discoverability gap on the envelope split section by adding a concrete request / response pair next to the abstract description.

  • PATCH /api/v1/assets/{asset_id} and PATCH /api/v1/locations/{location_id} now compare the tags field on the PATCH echo as set-equality on full tag content rather than as sequence equality. When the request body echoes the tags collection (as part of a GET → edit → PATCH round-trip), the server compares the submitted array to the current state by tag id, then verifies that every other field on each Tag (tag_type, value, plus any other declared field) matches — array ordering is not significant. Differing set membership (a tag id present on one side but not the other), a length mismatch, or differing field values on a matching id still return 400 validation_error / code: read_only. Integrators using generated clients that deserialize tags into unordered collections — Python set or frozenset for tag-id deduplication, Go map[int64]Tag for keyed access, ORMs whose association-proxy / has_many defaults don't preserve collection ordering, or any "fetch tags by parent, format for PATCH" helper that goes through a hash-based intermediate — no longer need to manually sort by id before re-submission. The 400 read_only detail is tightened to read the tags field on PATCH must equal the current value as a set (idempotent echo only; ordering is not significant); use POST /api/v1/{resource}/{id}/tags and DELETE /api/v1/{resource}/{id}/tags/{tag_id} to mutate. The spec emission for both PATCH operation descriptions gains a parenthetical clarification that ordering is not significant on the tags collection — spec diff is description-only, no operationId or schema movement. Strictly more permissive: every prior in-order echo continues to succeed unchanged. See Errors → read_only and Resource identifiers → Tags use a composite natural key.
  • Errors → validation_error vs bad_request now includes a side-by-side worked example. The page already framed the parse-time vs validate-time split and (per the BB52 entry below) warned typed clients against unconditional fields[] iteration. A reader probing boolean coercion who hit is_active: "true" and saw a bad_request envelope still needed to translate the abstract prose into their concrete envelope shape; an in-cycle reproduction of the exact example surfaced the discoverability gap a third time. A new "worked example" block now shows the contrast directly — same POST /api/v1/assets endpoint, one body that fails at decode time and returns bad_request with no fields[], one body that fails at validate time and returns validation_error with a single fields[] entry. A reader probing either failure mode finds their specific envelope shape rendered, not just described. Service behavior is unchanged; the contract documented in Errors → Validation errors is load-bearing.

BB58 fix wave — location parent_id cycle rejection (1-hop + N-hop) with specific 409 detail, BearerAuth python-codegen warning

Two service-side fixes from the BB58 multi-org parallel triage. F1 is a contract-class fix on the location reparenting path; F2 (folded into F1) is a corollary hygiene fix on the existing 1-hop self-parent error; F3 is a one-line spec emission tweak.

  • PATCH /api/v1/locations/{location_id} now rejects any parent_id assignment that would create a cycle in the parent_location_id chain. Previously, the 1-hop self-parent case (parent_id = {location_id}) was rejected at the database CHECK-constraint layer with 409 conflict carrying a generic detail: "Request violates a domain invariant", and the N-hop transitive case (any depth ≥ 2 — e.g. X has child Y, then PATCH X with parent_id: Y) was silently accepted, leaving the service in a state where GET /api/v1/locations/{location_id}/ancestors and /descendants hung indefinitely and DELETE on either node returned 409 "has descendant locations" because the cycle counted itself as its own descendant. The service now walks the full ancestor chain from the proposed parent on every PATCH that changes parent_id to a non-null value; both 1-hop and N-hop cycles return 409 conflict with a specific actionable detail:
    • 1-hop: detail: "parent_id {location_id} would create a self-referential cycle"
    • N-hop: detail: "parent_id {Y} would create a cycle through location {X}" Wire shape (error.type, error.status) is unchanged on both paths — only the detail text is now specific. Valid non-descendant reparenting (PATCH .../{X} {"parent_id": Y} where Y is not in the subtree rooted at X) and root promotion (parent_id: null) continue to succeed unchanged. As defense in depth, the recursive parent_location_id walks used by /ancestors and /descendants now carry a PG14+ CYCLE clause so a corrupt-tree state surfaces as a 500 internal_error with diagnostic detail instead of hanging the walker. The SPA's location reparenting picker — which already excluded the current location and its descendants from selectable parents — adds visited-set hardening so a stale or cyclic in-memory cache cannot hang the dropdown. See Errors → conflict and Resource identifiers → Locations: parent_id and parent_external_key.
  • securitySchemes.BearerAuth.description now also names openapi-generator-cli python target as a generator that does not auto-attach the Authorization header. The warning previously listed only openapi-fetch; BB58 confirmed the same friction on the openapi-generator-cli python target — setting Configuration(access_token=…) alone is insufficient; integrators must additionally set ApiClient.default_headers['Authorization']. The updated wording reads: Some OpenAPI generators (e.g. openapi-fetch, openapi-generator-cli python target) do not auto-attach the Authorization header from this scheme — set it manually if your generated client does not. Spec emission only; no wire change. See Authentication → Request header.

BB57 fix wave — error UX hygiene (valid_from recommendation, bad_request envelope detail, PATCH rename hint, validator aggregation docs-narrow)

Four hygiene items from the BB57 multi-org parallel triage — three small service-side error-string changes plus a docs-narrow alignment on validator aggregation. Wire shape (error.type, fields[] structure, error.status) is unchanged on every item; programmatic handlers that branch on error.type and fields[].code continue to work without modification. detail text remains explanatory, not contractual — these changes improve the human-readable diagnostic value of detail strings at the points where the prior wording was contradictory, asymmetric, or missing information the server already had.

  • Sentinel-rejection recommendation on non-nullable timestamps no longer contradicts the null-rejection recommendation. POST /api/v1/assets and POST /api/v1/locations with valid_from: null already rejected with the omit-or-provide recommendation; the sentinel-value path (valid_from: "0001-01-01T00:00:00Z", the Go zero-time, or valid_from: "1970-01-01T00:00:00Z", the Unix epoch) previously emitted a detail recommending "use JSON null" — copy-pasted from the nullable-timestamp error path. An integrator following the second recommendation to fix the first error landed back on the first. The sentinel-rejection wording now mirrors the null-rejection wording on non-nullable fields: valid_from must not be a default-value sentinel ({sentinel}); omit the field to use the server default, or provide a real timestamp on POST, with the PATCH variant reading omit the field to leave unchanged. Nullable timestamps (valid_to) continue to recommend "use JSON null". See Errors → bad_request.
  • Body-decode bad_request detail now names the expected JSON type when the decoder knows it. Sending {"is_active": "true"} (string-as-boolean) to POST /api/v1/assets previously returned detail: Body field "is_active" could not be decoded as the expected type — the validation-stage envelope (validation_error with populated fields[]) surfaced the expected type via params, but the decode-stage envelope withheld it despite the decoder knowing it at the point of failure. The new wording is Body field "is_active" could not be decoded as the expected type (boolean). Applies across every decode-failure code path that knows the expected type; envelope type stays bad_request and fields[] is still not populated on this path (parse-time, no schema validator has run yet — see validation_error vs bad_request). Clients that branch on error.type are unaffected.
  • PATCH read-only external_key rejection now includes the rename body shape inline. PATCH /api/v1/assets/{asset_id} and PATCH /api/v1/locations/{location_id} with external_key set previously returned external_key is immutable via PATCH; use POST /api/v1/assets/{asset_id}/rename to change it — the hint pointed to the right verb and path but withheld the body shape. Integrators reading "to change it" mentally substituted the action and sent {"new_external_key": "..."} to the rename endpoint, which 400'd on the unknown field; the user self-corrected in one step, but the round-trip was avoidable. The new wording closes the loop: external_key is immutable via PATCH; use POST /api/v1/assets/{asset_id}/rename with body {"external_key": "<new value>"} to change it. Mirrored on the locations side. See Resource identifiers → Renaming an external_key for the canonical rename contract.
  • Errors → Envelope shape prose narrowed to match service behavior on validator aggregation. The page previously read as if (and N more validation errors) and a multi-entry fields[] array were the uniform shape for any body invalid on multiple fields. In practice, the body validator runs decode and validate as serial passes — a body invalid on valid_from (parse-time) short-circuits the rest of the validate pass entirely, so fields[] carries a single entry for the date-parse miss even though other body fields would also have failed validation. The reliable cross-field aggregation case is ambiguous_fields (paired natural-key conflict on parent_id + parent_external_key, or any of the paired surfaces in the ambiguous_fields catalog), which emits both per-field entries and the (and N more...) suffix. Clients should branch on fields[] array length to detect the multi-field case rather than assuming all violations on a body land in a single response. Service behavior is unchanged from BB57 — the prose, not the contract, is what's corrected.

BB53-54 docs — sort prose accuracy, fk_not_found vs ambiguous_fields precedence, /openapi.{yaml,json} alias

Three docs-discoverability items from the BB53/BB54 multipass triage. No spec or wire change.

  • Pagination, filtering, and sorting → Sorting no longer claims compile-time rejection of unknown sort fields. The page previously described "generated clients with strict typing reject unknown sort fields at compile time" as a primary defense, but the sort allowlist is encoded as a regex pattern: on a string-typed query parameter rather than as enum:, and the v1 verified-working codegen targets (openapi-typescript and openapi-generator-cli's python target) emit sort as a plain string without honoring pattern:. A careful integrator reading the prior wording could have skipped the runtime code: invalid_value branch in their error handler and been surprised by a 400 on a typo'd column name. The prose now names the actual pattern: mechanism and tells integrators their sort error handler must branch on the runtime 400. Service behavior — 400 validation_error with fields[].message: "unknown sort field: <name>" on a value outside the allowlist — is unchanged.
  • Errors → ambiguous_fields now documents precedence vs fk_not_found on paired natural-key conflicts. When parent_id and parent_external_key are both supplied with differing values, the validator resolves each form independently before comparing them — if one side fails FK resolution, fk_not_found fires on the invalid side and ambiguous_fields does not fire. ambiguous_fields only fires when both forms resolve to valid but distinct rows. The page previously read as "if the two values disagree, you get ambiguous_fields," which would have routed the one-side-invalid case to the wrong error branch in a validation UI built strictly from the docs. Clients should branch on the per-field code rather than the top-level error type. Applies to the POST /api/v1/locations and PATCH /api/v1/locations/{location_id} request-body surfaces. Service behavior is unchanged.
  • Quickstart §5 now documents the bare-form /openapi.{yaml,json} alias at the app origin. https://app.trakrf.id/openapi.yaml and .json resolve to the canonical /api/openapi.{yaml,json} paths via 301 Moved Permanently, matching the convention used by Stripe, GitHub, and other SaaS APIs. The bare form has worked since launch but was not referenced anywhere in the integrator-facing docs; an integrator who guessed it landed on the right bytes but had no way to confirm from the docs that the alias was supported. Either form is acceptable for codegen tooling.

BB50-52 recurring — POST /api/v1/locations accepts matching parent_id + parent_external_key pair

Service relaxation that closes a recurring contract-class finding surfaced three times across BB50, BB51, and BB52. Pre-launch, additive, non-breaking against any v1.0.0-or-later wire baseline. Also folds in BB51 F2 (error-message alignment) since shared validation logic resolves both items in one change.

  • POST /api/v1/locations now accepts a matching parent_id + parent_external_key pair. A payload that names the same parent through both forms is silently normalized to a single re-parent operation, symmetric with the long-standing PATCH behavior. A disagreement still returns 400 validation_error / code: ambiguous_fields using the PATCH-shaped "both supplied and disagree; supply exactly one or supply consistent values" wording (so POST and PATCH now emit identical error strings — that was the BB51 F2 side finding). The pre-fix POST rejected both-supplied outright regardless of value agreement; the asymmetry with PATCH was accidental spec evolution and surfaced three cycles in a row across BB50/51/52. The "POST-the-GET-response after editing one field" workflow now works on locations Create. CreateLocationWithTagsRequest drops the not: required: [parent_id, parent_external_key] constraint. The paired-key matrix and the filter-vs-write framing in pagination/filtering/sorting are updated to reflect the now-symmetric POST / PATCH body rule (and the still-strict GET filter rule that does not normalize matching pairs).

BB52 docs — validation_error vs bad_request envelope split, non-monotonic auto-mint wording

Two docs-discoverability items from the BB52 multipass triage. No spec or wire change.

  • Errors → validation_error vs bad_request now explicitly warns typed clients against unconditional fields[] iteration. The page already framed the parse-time vs validate-time split, but a handler shaped like for f of body.error.fields { surface(f.field, f.message) } silently swallows bad_request — the array is absent, the loop is a no-op, and the user sees an empty error toast for a real decoder-level failure (string-where-bool-expected, float-where-int-expected, JSON-integer-overflow). A new :::warning admonition spells out the failure mode and points to gating the iteration on error.type === "validation_error". The narrative example near the type-mismatch block now also contrasts the JSON-integer-overflow case (parent_id: 9999999999999999999999bad_request, no fields[]) against the in-range-but-too-large case (parent_id: 2147483649validation_error with code: too_large and params.max: 2147483647) so an integrator reading the page sees both envelope shapes side-by-side before generating their error-handling code. Service behavior is unchanged; the contract and per-field codes already documented in Errors → Validation errors are load-bearing.
  • Resource identifiers → external_key is optional on create replaces "per-organization sequence" with non-monotonic wording. The prior phrasing primed careful readers to expect Postgres-nextval-style monotonic allocation — once consumed, never reused. The observed behavior is "lowest unused per-organization slot in the ASSET-NNNN / LOC-NNNN namespace": deleting every live row holding ASSET-0006 makes that slot eligible for the next mint even when soft-deleted rows still exist under the same natural key. The auto-mint section, the upstream natural-key summary, and the resource table column header all now use namespace-shaped language, and a new paragraph adjacent to the existing "available for reuse" sentence calls out the recycle property explicitly so partner-side audit logs aren't built on a false never-recycled-slot assumption. The load-bearing system-of-record guidance ("supply the partner-side handle on create — don't rely on the auto-mint") is unchanged; it now cites the recycle property as a second reason alongside the original "won't join cleanly to a SKU." Service behavior is unchanged.

BB47-49 docs sweep — parent_external_key writability alignment, quickstart idempotence boundary, openapi-fetch PATCH CT surfaced on API clients page

Three docs-discoverability items from the BB47-49 multipass triage. No spec or wire change.

  • Quickstart §3 and earlier entries in this changelog now match Resource identifiers on parent_external_key writability. Both parent_id and parent_external_key are writable on PATCH /api/v1/locations/{location_id}, symmetric with CreateLocationRequest — either form re-parents and either accepts null to detach the location to root. Supplying both with matching values is accepted (silently normalized to a single re-parent); supplying both with differing values returns 400 validation_error / code: ambiguous_fields. Quickstart §3 previously listed parent_external_key in the read-only-echo set and named parent_id as the writable canonical for the parent relationship; earlier entries below characterized the natural-key form as read-only / silently-stripped on PATCH. Live service behavior is unchanged from what Resource identifiers documents — the docs drift, not the contract, is what's corrected. The typed-spec audience already gets this right (UpdateLocationRequest correctly lists both fields as writable in the spec); the affected audience is hand-rolled-client and curl-using integrators reading the prose. The matching 400 validation_error / code: fk_not_found envelope on a non-existent parent_external_key value (verified BB47) is documented at Errors → fk_not_found.
  • Quickstart §3 idempotence claim qualified for the create-then-echo case. A verbatim no-op body is wire-idempotent on every PATCH after the first; the first PATCH after POST on a fresh resource can advance updated_at once because storage holds microsecond precision and the wire is truncated to milliseconds, so a verbatim GET-echo body compares unequal at the sub-millisecond residue and a write fires. The PATCH rewrites storage at ms precision; subsequent echoes are wire-idempotent. The underlying µs→ms truncation is documented at Date fields → Inbound parsing and was unchanged — only the quickstart idempotence prose needed the qualification.
  • API clients (Postman, Insomnia, …) now covers the openapi-fetch PATCH content-type override. openapi-fetch@0.13.x does not auto-send Content-Type: application/merge-patch+json, despite the spec declaring this content type on every PATCH operation — every PATCH returns 415 unsupported_media_type unless you override per call or register the mergePatchMiddleware helper covered in Quickstart §5. The API-clients page now surfaces the same failure mode and cross-links to the existing middleware so integrators arriving from the GUI-client angle find the workaround without first reading the quickstart. The openapi-generator-cli typescript-fetch target and the Python openapi-generator handle merge-patch correctly out of the box; only the openapi-fetch runtime path needs the additional doc surface. Adjacent to the BB41 follow-up below, which surfaced the same workaround from Quickstart §3.

BB42 fix wave — error.detail URL substitution, tag_type strict-required, unknown query param code

Three small server-side fixes from BB42. F1 is a contract regression introduced by the earlier data-model-URL templating; F2 is a pre-launch tightening to align service behavior with the long-standing spec declaration; F8 closes a query-vs-body code-emission asymmetry surfaced on the BB42 retest. No OpenAPI spec change in any of the three — the spec already declared tag_type required on each *TagRequest subtype, the error.detail shape is wire-shape-neutral, and code: unknown_field was already enumerated in the error-code catalog.

  • Top-level error.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. (Superseded by the BB47-49 docs sweep aboveparent_external_key is itself a write path that re-parents, alongside parent_id; neither is the only one. The reject-if-differs hint on this entry no longer applies to parent_external_key, which is now fully writable.)
  • external_key-typed list filters now reject invalid characters at the boundary. Filter params on ?external_key=, ?location_external_key=, and ?parent_external_key= (on /api/v1/assets, /api/v1/locations, and /api/v1/reports/asset-locations) previously used a loose printable-string regex that admitted any non-control character; a slash, colon, comma, or space silently 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. (Superseded for parent_external_key by the BB47-49 docs sweep aboveparent_external_key is not in the read-only set on location PATCH; it is fully writable, symmetric with CreateLocationRequest. The accept-if-matches / reject-if-differs rule continues to govern the other four natural-key fields named here.) 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). (Superseded for parent_external_key by the BB47-49 docs sweep above — it is fully writable on location PATCH, not subject to the accept-if-matches rule. The other four fields named here are unchanged.) Sending a value that matches the current resource state is silently stripped from the update (200, other fields apply normally); sending a value that differs is rejected 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. (Superseded by the BB33 silent-echo collapse below and the BB47-49 docs sweep above — no explicit strip is needed on the round-trip in quickstart §3 today, and parent_external_key on locations is fully writable on PATCH rather than rejected.)
  • 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. (Superseded for the location side by the BB47-49 docs sweep aboveparent_external_key is fully writable on PATCH /api/v1/locations/{location_id}, symmetric with CreateLocationRequest. Asset-side location_external_key is unchanged — still PATCH-rejected because asset location is scan-data, not master-data; see the BB40 entry above.)
  • 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. (Superseded for parent_external_key by the BB47-49 docs sweep above — it is fully writable on location PATCH, not in the rename-managed category. The classification on external_key is unchanged.) 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.