Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ 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](./date-fields#inbound-rfc3339-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](./resource-identifiers#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](./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.
- **`x-request-id` is the in-band correlation id; `x-railway-request-id` is the hosting edge.** [Errors → Filing support tickets](./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. _(Superseded by the bb-2.3 hygiene pass — the `x-railway-request-id` reference has since been retired. The current deployment is not Railway-fronted (GKE / Traefik / Cloudflare) and emits no such header, so [Errors → Filing support tickets](./errors#filing-support-tickets) now documents only `x-request-id`. The `x-request-id` guidance in this entry still stands.)_

### BB33 fix wave — uniform read-only PATCH handling across every read-only field

Expand Down
9 changes: 3 additions & 6 deletions docs/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,9 @@ Per-call specifics (the offending field, the unparseable value, the resource id

When filing a support ticket, include the `error.request_id` value (or the equivalent **`x-request-id`** response header) — that ULID is the TrakRF service's in-band correlation id and is what support uses to find your call in service-side logs.

The hosting edge layer adds a separate **`x-railway-request-id`** response header on top of every response. It is **not** the service-side correlation id — it identifies the request to the Railway edge, not to the TrakRF service. Logging or quoting `x-railway-request-id` instead of `x-request-id` will send support triage to the wrong log surface.

| Header | Source | Use for |
| ---------------------- | -------------------- | ----------------------------------------------------------------------------------------------- |
| `x-request-id` | TrakRF service | Filing support tickets; matches `error.request_id` in the envelope. **This is the one to log.** |
| `x-railway-request-id` | Railway hosting edge | Hosting-level diagnostics only. Not used for TrakRF service-side correlation. |
| Header | Source | Use for |
| -------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| `x-request-id` | TrakRF service | Filing support tickets; matches `error.request_id` in the envelope. **This is the one to log.** |

## Error type catalog

Expand Down
24 changes: 12 additions & 12 deletions docs/api/resource-identifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Locations were not given an open `metadata` field because the practical "what wo
Asset and location updates use `PATCH /api/v1/{resource}/{id}` with `Content-Type: application/merge-patch+json` (JSON Merge Patch, [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396)).

:::important Scope of `POST` and `PATCH /api/v1/assets/{asset_id}`
Neither `POST` nor `PATCH` moves an asset. `location_id` and `location_external_key` are not part of the asset resource at all — not the read shape, not `CreateAssetWithTagsRequest`, not `UpdateAssetRequest`. Sending either field in a `POST` or `PATCH` body returns `400 validation_error` / `code: read_only` on presence; there is no accept-if-matches case, because the asset response carries no location value to match against. The error detail names the ingestion paths: "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." Read an asset's current location from `GET /api/v1/reports/asset-locations` or `GET /api/v1/assets/{asset_id}/history`. See [Data model](./data-model) for the master / scan bifurcation framing.
Neither `POST` nor `PATCH` moves an asset. `location_id` and `location_external_key` are not part of the asset resource at all — not the read shape, not `CreateAssetWithTagsRequest`, not `UpdateAssetRequest`. Sending either field in a `POST` or `PATCH` body returns `400 validation_error` / `code: read_only` on presence (see [Errors → validation errors](./errors#validation-errors)); there is no accept-if-matches case, because the asset response carries no location value to match against. Asset location is scan-derived — collected through scan event ingestion (fixed-reader MQTT pipeline or handheld UI submission), not externally writable in v1. Read an asset's current location from `GET /api/v1/reports/asset-locations` or `GET /api/v1/assets/{asset_id}/history`. See [Data model](./data-model) for the master / scan bifurcation framing.

The asset write surface is `name`, `description`, `is_active`, `metadata`, `valid_from`, `valid_to` (plus `external_key` and `tags` on `POST`). Mutate `external_key` post-create via `POST /api/v1/assets/{asset_id}/rename`; mutate `tags` via the tag subresource ([Tag CRUD](#tag-crud)).
:::
Expand All @@ -232,20 +232,20 @@ An empty body (`{}`) is a documented no-op against settable fields: the server a

Request and response field _names_ match for every writable field. Read responses carry fields that aren't part of the request schema — server-managed metadata (`id`, `created_at`, `updated_at`, `deleted_at`), the resource's own `external_key`, and the `tags` collection — and every one of those fields obeys a single uniform rule on `PATCH`:

**Accept-if-matches, reject-if-differs.** A body value that matches the current resource state is silently normalized out (so the update applies cleanly, ignoring the read-only field); a value that differs is rejected with `400 validation_error` and a `message` naming the proper write path. The rejection `code` splits along whether the field has a partner-mutable write path: truly server-managed fields (`id`, `created_at`, `updated_at`, `deleted_at`) return `code: read_only`; fields that are settable on the surface but via a different verb (`external_key` via `/rename`, `tags` via the `/tags` sub-resource) return `code: invalid_context`. Both codes share the per-field rejection message; client handlers that surface `detail` to humans need not branch, while handlers branching on `code` get the routing signal directly. The per-field rejection message is listed in the table immediately below.
**Accept-if-matches, reject-if-differs.** A body value that matches the current resource state is silently normalized out (so the update applies cleanly, ignoring the read-only field); a value that differs is rejected with `400 validation_error` and a `message` naming the proper write path. The rejection `code` splits along whether the field has a partner-mutable write path: truly server-managed fields (`id`, `created_at`, `updated_at`, `deleted_at`) return `code: read_only`; fields that are settable on the surface but via a different verb (`external_key` via `/rename`, `tags` via the `/tags` sub-resource) return `code: invalid_context`. Both codes share the per-field rejection message; client handlers that surface `detail` to humans need not branch, while handlers branching on `code` get the routing signal directly. The per-field behavior is summarized in the table immediately below — described in the docs' own words, since the literal `message`/`detail` wording is non-contractual and may evolve (branch on `code`, not on `detail`).

Asset `location_id` / `location_external_key` are a separate case — not on the asset read shape at all, so there is nothing to accept-if-matches. They are rejected with `code: read_only` whenever they appear in a `POST` or `PATCH` body; the last two table rows cover them.

| Field | Surface | Rejection `message` names |
| ----------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | `PATCH /assets`, `PATCH /locations` | `id` is server-assigned and immutable; submit the resource's current `id` or omit the field. |
| `created_at` | `PATCH /assets`, `PATCH /locations` | `created_at` is server-managed and immutable; submit the resource's current `created_at` or omit the field. |
| `updated_at` | `PATCH /assets`, `PATCH /locations` | `updated_at` is server-managed; `PATCH` advances it implicitly. Submit the resource's current `updated_at` or omit the field. |
| `deleted_at` | `PATCH /assets`, `PATCH /locations` | `deleted_at` is server-managed; use `DELETE /api/v1/{resource}/{id}` to soft-delete. Submit the resource's current `deleted_at` or omit the field. |
| `tags` | `PATCH /assets`, `PATCH /locations` | Tags are managed via `POST /api/v1/{resource}/{id}/tags` and `DELETE /api/v1/{resource}/{id}/tags/{tag_id}`. |
| `external_key` | `PATCH /assets`, `PATCH /locations` | The matching rename endpoint: `POST /api/v1/{resource}/{id}/rename`. |
| `location_id` | `POST /assets`, `PATCH /assets` | "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." See [Data model](./data-model). |
| `location_external_key` | `POST /assets`, `PATCH /assets` | Same as `location_id`. |
| Field | Surface | Rejection behavior |
| ----------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `PATCH /assets`, `PATCH /locations` | Server-assigned and immutable — `code: read_only`. Echo the current value or omit the field. |
| `created_at` | `PATCH /assets`, `PATCH /locations` | Server-managed and immutable — `code: read_only`. Echo the current value or omit the field. |
| `updated_at` | `PATCH /assets`, `PATCH /locations` | Server-managed — `code: read_only`; `PATCH` advances it implicitly. Echo the current value or omit the field. |
| `deleted_at` | `PATCH /assets`, `PATCH /locations` | Server-managed — `code: read_only`; use `DELETE /api/v1/{resource}/{id}` to soft-delete. Echo the current value or omit the field. |
| `tags` | `PATCH /assets`, `PATCH /locations` | Tags are managed via `POST /api/v1/{resource}/{id}/tags` and `DELETE /api/v1/{resource}/{id}/tags/{tag_id}`. |
| `external_key` | `PATCH /assets`, `PATCH /locations` | The matching rename endpoint: `POST /api/v1/{resource}/{id}/rename`. |
| `location_id` | `POST /assets`, `PATCH /assets` | Rejected `read_only` — asset location is scan-derived, not externally writable; read it from `GET /api/v1/reports/asset-locations` or `GET /api/v1/assets/{asset_id}/history`. See [Data model](./data-model). |
| `location_external_key` | `POST /assets`, `PATCH /assets` | Same as `location_id`. |

Both `parent_id` and `parent_external_key` on `PATCH /locations` are **not** in this rule — they are both fully writable (either form re-parents; supplying both with matching values is accepted, supplying both with differing values returns `ambiguous_fields`). The rename endpoint changes the row's own `external_key`, not its parentage.

Expand Down
2 changes: 1 addition & 1 deletion tests/blackbox/BB_PRE_KEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ BB1, BB2, and BB3 carry identical, deterministic data. You can assert against li

**Scans:** populated. Each org carries ~25 `asset_scans` per asset over a 90-day window (~675 per org), and the materialized `location_id` on every asset reflects the most-recent scan. `tracking:read` coverage is in-scope — exercise both `/api/v1/assets/{asset_id}/history` and `/api/v1/reports/asset-locations` with literal-value assertions.

**Tags:** not copied. Tags are not in v1 public-API scope.
**Tags:** not pre-seeded on fixture rows. Tags **are** part of the v1 public-API surface, though — the `POST`/`DELETE …/tags` subresources on both assets and locations, gated by the parent resource's `:write` scope. Don't skip the tags surface just because the fixture ships no tag rows: exercise it by creating your own (e.g. `POST /api/v1/assets/{asset_id}/tags`) and round-tripping.

## Key scope and lifecycle

Expand Down
Loading