From b0ac6d7e3fcbfa0e038eea7836538b7fdc194d31 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Fri, 29 May 2026 14:58:47 -0500 Subject: [PATCH 1/5] docs(api): retire stale x-railway-request-id guidance on errors page (TRA-877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bb-2.3 hygiene item 2. The "Filing support tickets" section documented an x-railway-request-id edge header that the current deployment no longer emits — the path is GKE/Traefik/Cloudflare now, no Railway (confirmed with infra). It's a stale artifact from the old Railway deploy era, not an edge-conditional behavior, so drop the Railway paragraph and table row and keep only the x-request-id guidance. No edge caveat added: there is no edge-injected request-id header today (a Traefik requestid middleware would be separate, out of scope), so documenting one would be aspirational and a future staleness magnet. --- docs/api/errors.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/api/errors.md b/docs/api/errors.md index 85767bf..38ce50e 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -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 From e24a8eefab74ba280b22a2524cfb3556f2d3b609 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Fri, 29 May 2026 14:58:47 -0500 Subject: [PATCH 2/5] =?UTF-8?q?docs(bb):=20correct=20BB=5FPRE=5FKEY=20tags?= =?UTF-8?q?=20note=20=E2=80=94=20in=20v1=20scope,=20just=20not=20pre-seede?= =?UTF-8?q?d=20(TRA-877)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bb-2.3 hygiene item 5. The contract-track wrapper claimed "Tags are not in v1 public-API scope," which contradicts the spec (tags expose POST/DELETE .../tags subresources on assets and locations, gated by the parent resource's :write scope) and was disproven empirically — all three bb-2.3 sessions round-tripped session-created tags. The fixture genuinely ships no tag rows, so reframe from "not in scope" to "not pre-seeded," and tell the tester to exercise the surface by creating their own. --- tests/blackbox/BB_PRE_KEY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/blackbox/BB_PRE_KEY.md b/tests/blackbox/BB_PRE_KEY.md index 868cc22..4674981 100644 --- a/tests/blackbox/BB_PRE_KEY.md +++ b/tests/blackbox/BB_PRE_KEY.md @@ -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 From e8a15c9938e2f68adededf1e307ee60b35020b06 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Fri, 29 May 2026 14:58:47 -0500 Subject: [PATCH 3/5] docs(api): de-quote volatile asset-location detail string on resource-identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled with the TRA-877 docs PR per bb-2.3 cross-session consensus (unanimous bb-231/232/233). The page quoted an error `detail` string verbatim in two spots (the location-immutability prose and the location_id table row), and the live string had drifted. But `detail` is explicitly non-contractual ("do not branch on this value"; the auth page tells integrators detail wording may evolve), so re-pinning the new string — the original framing of the dropped TRA-877 item 6 — just resets the drift clock until the next backend wording tweak. De-quote instead of re-sync: state the boundary in the docs' own words (asset location is scan-derived, not externally writable in v1), reference Errors -> validation errors for the read_only behavior, and keep the two stable navigational pointers (GET /api/v1/reports/asset-locations, GET /api/v1/assets/{asset_id}/history). Eliminates the whole drift class rather than chasing one instance. Behavior verified: location_id / location_external_key on asset POST/PATCH return 400 / code: read_only. --- docs/api/resource-identifiers.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/api/resource-identifiers.md b/docs/api/resource-identifiers.md index 7439385..a49033f 100644 --- a/docs/api/resource-identifiers.md +++ b/docs/api/resource-identifiers.md @@ -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)). ::: @@ -236,16 +236,16 @@ Request and response field _names_ match for every writable field. Read response 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 `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` | 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. From 14ccc561d981e4c8bdd1bff8cd3e994ced039641 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Fri, 29 May 2026 15:03:11 -0500 Subject: [PATCH 4/5] docs(changelog): mark x-railway-request-id distinction superseded (bb-2.3 retirement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BB33 changelog entry announced the Errors-page x-request-id vs x-railway-request-id distinction and links to "Filing support tickets" — the section the bb-2.3 item-2 fix just retired. Left untouched it would contradict the live page (a dangling reference flagged during bb-2.3 review). Per the changelog's own convention, append a _(Superseded …)_ note rather than rewriting history: the x-railway-request-id reference is retired (deployment is GKE/Traefik/Cloudflare, no such header), the page now documents only x-request-id, and the x-request-id guidance in the entry still stands. --- docs/api/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/changelog.md b/docs/api/changelog.md index d10d77a..8ea8b55 100644 --- a/docs/api/changelog.md +++ b/docs/api/changelog.md @@ -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 From a5cec4073284cadbe60e9a700dce5104e9488950 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Fri, 29 May 2026 15:10:03 -0500 Subject: [PATCH 5/5] docs(api): de-quote remaining read-only field messages on resource-identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistency pass folding in the observation bb-231 flagged during #198 review: the read-only field table's id / created_at / updated_at / deleted_at rows still quoted verbatim service `message` strings — the same non-contractual drift-magnet class just de-quoted for location_*. Replace the verbatim quotes with behavior described in the docs' own words (server-managed/immutable; code: read_only; echo the current value or omit the field), reframe the column header from "Rejection message names" to "Rejection behavior," and note in the intro that the literal message/detail wording is non-contractual (branch on code, not detail). No verbatim service-message quote remains anywhere in the table. Behavior verified by bb-231 on BB1. --- docs/api/resource-identifiers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api/resource-identifiers.md b/docs/api/resource-identifiers.md index a49033f..aaea4db 100644 --- a/docs/api/resource-identifiers.md +++ b/docs/api/resource-identifiers.md @@ -232,16 +232,16 @@ 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 | +| Field | Surface | Rejection behavior | | ----------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | +| `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). |