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
8 changes: 8 additions & 0 deletions docs/api/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ This log records changes to the TrakRF public API under `/api/v1/` that affect i

Initial public API release. Stable contract for paths, field names, response shapes, and error envelopes per the [v1 stability commitment](./versioning).

### Location `external_key` auto-mint finalized to `LOC-NNN`

The location auto-mint format is `LOC-NNN` (3 digits, first mint `LOC-001` in a fresh organization), parallel to but intentionally narrower than `ASSET-NNNN`. The asymmetry reflects how the two resources are used in practice: assets accumulate ad-hoc creates without a pre-known partner-side handle (a quick scan-in from the SPA, a row pasted in from a CSV with no upstream SKU), while locations are typically named-and-known artifacts already documented in a facilities export, so auto-mint is the exception rather than the norm and the smaller slot suffices for the typical volume. Pre-launch correction; the [Resource identifiers](./resource-identifiers#external_key-is-optional-on-create) page previously described the location format as `LOC-NNNN` from the prior interim plumbing — no `v1.0.0`-or-later wire baseline to break.

- **Format and first mint.** Auto-mint produces `LOC-001` for the first omit-on-create in a fresh organization, then `LOC-002`, `LOC-003`, and so on, governed by the same "lowest unused slot among live rows" rule the [recycle paragraph on Resource identifiers](./resource-identifiers#external_key-is-optional-on-create) already documented for both resources. Soft-deleting the live row holding `LOC-005` makes that slot eligible for the next mint while soft-deleted rows can still hold the value; the partial unique index `(org_id, external_key) WHERE deleted_at IS NULL` is the system of record for "what slots are live."
- **Digit-count-agnostic past the natural width.** The `LOC-NNN` shape is the shape of the typical mint, not a width constraint on the value. Once the 3-digit space is exhausted in an organization the next mint is `LOC-1000`, then `LOC-1001`, with no migration or zero-pad reflow; the same property holds for `ASSET-NNNN` past `ASSET-9999`. Client-side parsers should match `^LOC-\d{3,}$` / `^ASSET-\d{4,}$` (or, more durably, treat the value as opaque) rather than anchoring on fixed-width digit groups. The new paragraph adjacent to the [auto-mint table](./resource-identifiers#external_key-is-optional-on-create) spells this out so a code generator emitting a strict-width validator doesn't reject a legitimately-minted `LOC-1042` after the 1000th create.
- **System-of-record guidance unchanged.** The load-bearing recommendation (supply the partner-side handle on create when integrating with an ERP / WMS / floor-plan tool — don't rely on the auto-mint) carries over verbatim; the auto-mint is the right call only for ad-hoc creates where no upstream handle yet exists. The recycle property and the namespace-shaped rather than counter-shaped wording from the [prior changelog entry on non-monotonic auto-mint](#bb52-docs--validation_error-vs-bad_request-envelope-split-non-monotonic-auto-mint-wording) both apply unchanged to the narrower location format.

### Tag rows cascade-soft-delete with their parent asset or location

Soft-deleting an asset or location now cascades to the attached tag rows in the same transaction with the same `deleted_at`, releasing the `(org_id, tag_type, value)` partial-unique slot the tag was holding. Brings runtime behavior in line with the existing [Tags use a composite natural key](./resource-identifiers#tags-use-a-composite-natural-key) contract — the page documented that soft-delete frees the natural-key handle for reuse, the platform was leaving orphan tag rows live and blocking reuse. Pre-launch fix; no `v1.0.0`-or-later wire baseline to break.
Expand Down
4 changes: 2 additions & 2 deletions docs/api/date-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ Every `valid_from` on the response is RFC 3339 in UTC. `valid_to` is either RFC
"data": [
{
"id": 7,
"external_key": "LOC-0001",
"external_key": "LOC-001",
"name": "Warehouse A",
"valid_from": "2026-01-15T00:00:00Z",
"valid_to": "2026-12-31T23:59:59Z"
},
{
"id": 8,
"external_key": "LOC-0002",
"external_key": "LOC-002",
"name": "Warehouse B",
"valid_from": "2026-02-01T00:00:00Z",
"valid_to": null
Expand Down
2 changes: 1 addition & 1 deletion docs/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ Use the retry column in the catalog above as the default, but these patterns app

The TrakRF v1 API does **not** support the `Idempotency-Key` header. Retry safety comes from HTTP semantics and natural-key constraints:

- **`POST /assets`, `POST /locations`** — retrying with the same `external_key` hits the partial unique index `(org_id, external_key) WHERE deleted_at IS NULL` and returns `409 conflict`. Detect the 409, then `GET /api/v1/{resource}?external_key=...` and read `.data[0].id` to recover the canonical `id`, then `PATCH` to reconcile. **If you omit `external_key` on a retry, you may create duplicates** — the server will mint a fresh value (`ASSET-NNNN` for assets, `LOC-NNNN` for locations) on each attempt. For retry-critical workflows, always supply an `external_key`.
- **`POST /assets`, `POST /locations`** — retrying with the same `external_key` hits the partial unique index `(org_id, external_key) WHERE deleted_at IS NULL` and returns `409 conflict`. Detect the 409, then `GET /api/v1/{resource}?external_key=...` and read `.data[0].id` to recover the canonical `id`, then `PATCH` to reconcile. **If you omit `external_key` on a retry, you may create duplicates** — the server will mint a fresh value (`ASSET-NNNN` for assets, `LOC-NNN` for locations) on each attempt. For retry-critical workflows, always supply an `external_key`.
- **`POST /assets/{asset_id}/rename`, `POST /locations/{location_id}/rename`** — fully idempotent in the value-matches sense: a same-value rename (new `external_key` equals the current one) returns `200` with the unchanged resource (and, for locations, `descendant_count_affected: 0`), and `updated_at` does not advance. A cached-body `PATCH` after a same-value rename retry is therefore safe — the cached `updated_at` still matches the live value. 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. A retry that hits a now-different value returns `409 conflict` against the per-org uniqueness index — recover by reading the current `external_key` via `GET` and deciding whether to abort or pick a different target. See [Resource identifiers → Renaming an `external_key`](./resource-identifiers#renaming-an-external_key).
- **`PATCH /assets/{asset_id}`, `PATCH /locations/{location_id}`** — JSON Merge Patch (RFC 7396) bodies are semantically idempotent on the resource's settable-field state: applying the same patch twice converges on the same final values. `updated_at` is not part of that idempotency guarantee — every accepted PATCH advances it to the current server time (see the [BB64 follow-up changelog entry](./changelog#bb64-follow-up--every-accepted-patch-advances-updated_at-drops-wire-idempotency-for-no-op-bodies) for the model and rationale). The retry-safety implication: a cached `PATCH` body that echoes `updated_at` from a successful first call will fail with `400 validation_error` / `code: read_only` on retry because the cached `updated_at` is now stale (the first call advanced it). Safe retry patterns are (a) omit `updated_at` from the body entirely — the server advances it regardless — or (b) re-`GET` immediately before the retry to refresh `updated_at`. Real-mutation retries face the same constraint, with the additional consideration that the retry may race a real intervening write by another caller, which the optimistic-concurrency token surfaces as the same stale-token rejection.
- **`DELETE /api/v1/assets/{asset_id}`, `DELETE /api/v1/locations/{location_id}`** — idempotent in the "ends up gone" sense. A second delete returns `404 not_found` (not `204`) so you can detect state drift; both outcomes are fine to treat as "deleted." On locations specifically, the first call may return `409 conflict` if the location has active descendant locations or active placed assets — see [Locations: delete semantics](./resource-identifiers#locations-delete-semantics). The retry-safety guarantee covers the `404` path-shape only; a 409 will keep returning 409 until the dependents are reassigned or removed.
Expand Down
12 changes: 7 additions & 5 deletions docs/api/resource-identifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Two string-handle concepts appear across these resources, and they're close enou
- **`external_key`** — the resource's own natural key (this page, top-to-bottom).
- **`*_external_key` foreign-key fields** — flat-scalar references _to_ another resource's `external_key` from a record that holds the relationship. Example: `parent_external_key` on locations. Covered under [Foreign-key fields in responses](#foreign-key-fields-in-responses-come-as-flat-scalar-pairs).

Both are write-routable: caller-supplied on create, or server-minted from a per-organization `ASSET-NNNN` / `LOC-NNNN` namespace when `external_key` is omitted — see [auto-mint behavior](#external_key-is-optional-on-create). For ancestor chains and breadcrumbs on locations, walk the `parent_id` chain via the [tree endpoints](#location-tree-endpoints); there is no derived display-path field on the response.
Both are write-routable: caller-supplied on create, or server-minted from a per-organization `ASSET-NNNN` / `LOC-NNN` namespace when `external_key` is omitted — see [auto-mint behavior](#external_key-is-optional-on-create). For ancestor chains and breadcrumbs on locations, walk the `parent_id` chain via the [tree endpoints](#location-tree-endpoints); there is no derived display-path field on the response.

### Natural keys per resource

Expand Down Expand Up @@ -482,12 +482,14 @@ Reassign or remove the dependents (move placed assets out via a scan event recor

## `external_key` is optional on create — both resources auto-mint {#external_key-is-optional-on-create}

`external_key` is **optional on `POST` for assets and locations alike**. Supply your own value to anchor the row to a partner-side handle (a SKU, an ERP code, an operator-typed location label, a row from a planned-layout export), or omit the field on the request body and the server assigns the lowest unused slot in the per-organization `ASSET-NNNN` / `LOC-NNNN` namespace. Each resource type has its own format and its own namespace:
`external_key` is **optional on `POST` for assets and locations alike**. Supply your own value to anchor the row to a partner-side handle (a SKU, an ERP code, an operator-typed location label, a row from a planned-layout export), or omit the field on the request body and the server assigns the lowest unused slot in the per-organization `ASSET-NNNN` / `LOC-NNN` namespace. Each resource type has its own format and its own namespace:

| Resource | Auto-minted format | Namespace scope |
| -------- | ------------------ | ---------------- |
| Asset | `ASSET-NNNN` | per-organization |
| Location | `LOC-NNNN` | per-organization |
| Location | `LOC-NNN` | per-organization |

The location format is intentionally narrower than the asset format — locations are typically named-and-known artifacts (warehouse rooms, dock doors, zones) for which the partner-side handle already exists in facilities documentation, so auto-mint is the exception rather than the norm and a 3-digit slot suffices for the typical "ad-hoc-from-the-SPA" volume. Both formats are digit-count-agnostic on the read side: the auto-mint contract is "fixed prefix, decimal slot," not "fixed total width." Once the 3-digit space is exhausted the next mint is `LOC-1000`, then `LOC-1001`, with no migration or zero-pad reflow; the same property holds for `ASSET-NNNN` past `ASSET-9999`. Don't anchor client-side parsing on `\d{3}` or `\d{4}` strictness — match `^LOC-\d{3,}$` / `^ASSET-\d{4,}$` (or, more durably, ignore the slot count and treat the value as opaque).

The pick is "lowest unused slot among live rows," not a monotonic counter: the namespace is governed by the same partial unique index that backs `external_key` uniqueness (`(org_id, external_key) WHERE deleted_at IS NULL`), so a slot freed by soft-deleting every live row that holds it becomes immediately eligible to be minted again. Don't model the namespace as Postgres-`nextval`-style append-only — auto-mint may pick a key whose namespace slot was previously occupied by one or more soft-deleted rows. The system-of-record guidance below (supply the partner-side handle on create) is the load-bearing rule for any caller that needs a stable, never-recycled identifier.

Expand All @@ -506,7 +508,7 @@ curl -X POST \
-d '{"name": "Pallet jack #14"}' \
"$BASE_URL/api/v1/assets"

# Same shape on locations — omit external_key to receive a LOC-NNNN value
# Same shape on locations — omit external_key to receive a LOC-NNN value
curl -X POST \
-H "Authorization: Bearer $TRAKRF_API_KEY" \
-H "Content-Type: application/json" \
Expand All @@ -518,7 +520,7 @@ A caller-supplied `external_key` that collides with an existing live row of the

**Optional means omit, not empty string.** The auto-mint path fires only when the request body has no `external_key` key at all. Sending `"external_key": ""` (or any whitespace-only value) returns `400 validation_error` with `code: too_short` — the same rejection `PATCH /api/v1/assets/{asset_id}` and `PATCH /api/v1/locations/{location_id}` produce, on the same envelope. CSV importers and form handlers that emit empty strings on blank inputs need to omit the key entirely, or they'll 400 on every blank row instead of receiving a server-minted value.

**When integrating with a system of record (an ERP, a WMS, a partner database, a layout / floor-plan tool), supply the partner-side handle on create** — don't rely on the auto-mint. Auto-minted `ASSET-NNNN` / `LOC-NNNN` values are per-organization-unique among live rows but they won't join cleanly to a SKU, a facility code, an ERP location, or any other handle a downstream system already uses, and they may recycle a slot vacated by a soft-deleted row (see the namespace note above) — neither property is what a partner-side audit log expects from an `id`-shaped identifier. The auto-mint is the right call for ad-hoc creates (a one-off entry from the SPA, a quick smoke test, a row pasted in from a CSV with no upstream key). In practice the supply-your-own pattern dominates on locations, which are more often planned-layout than ad-hoc, while assets see more auto-mint use when no upstream SKU yet exists.
**When integrating with a system of record (an ERP, a WMS, a partner database, a layout / floor-plan tool), supply the partner-side handle on create** — don't rely on the auto-mint. Auto-minted `ASSET-NNNN` / `LOC-NNN` values are per-organization-unique among live rows but they won't join cleanly to a SKU, a facility code, an ERP location, or any other handle a downstream system already uses, and they may recycle a slot vacated by a soft-deleted row (see the namespace note above) — neither property is what a partner-side audit log expects from an `id`-shaped identifier. The auto-mint is the right call for ad-hoc creates (a one-off entry from the SPA, a quick smoke test, a row pasted in from a CSV with no upstream key). In practice the supply-your-own pattern dominates on locations, which are more often planned-layout than ad-hoc, while assets see more auto-mint use when no upstream SKU yet exists.

## `external_key` value rules {#external_key-value-rules}

Expand Down
Loading
Loading