diff --git a/docs/api/changelog.md b/docs/api/changelog.md index 8ea8b55..024ca5d 100644 --- a/docs/api/changelog.md +++ b/docs/api/changelog.md @@ -11,6 +11,13 @@ 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). +### `id` is globally unique and opaque; `external_key` auto-mint clarified to `MAX(live key) + 1` {#id-global-uniqueness-external-key-max-plus-one} + +Two identifier-model clarifications that sharpen the line between the surrogate `id` and the `external_key` handle. Pre-launch; no `v1.0.0`-or-later wire baseline to break. The `external_key` item is a docs correction only (service behavior unchanged); the `id` item reflects the move to a single shared id sequence landing under the hood. + +- **The surrogate `id` is globally unique across every resource type, opaque, and permanent.** No two rows — of any type — share an `id`; an `id` never changes and is never reused, because there is no hard delete and the shared id sequence is never reseeded. The prior docs described ids as unique only within an entity type (so the same integer could be both an asset id and a tag id). That cross-type collision is eliminated: ids are now minted from one shared sequence, so global uniqueness holds by construction. Treat `id` as opaque (don't parse it, order by it, or infer a count or creation time) and use it as your durable foreign key. See [ID format](./id-format) and [Resource identifiers → Numeric `id` is a surrogate key](./resource-identifiers#numeric-id-is-a-surrogate-key). +- **`external_key` auto-mint is `MAX(live key) + 1`, not a lowest-unused-slot allocator.** Earlier wording ("lowest unused slot among live rows," "gap-filling") was incorrect: the server takes the highest live `ASSET-NNNN` / `LOC-NNN` in your org and adds one, zero-padded (`ASSET-%04d` / `LOC-%03d`). A middle-of-range delete therefore leaves a *permanent* gap that is never backfilled; only deleting the current highest live key frees its number for re-issue. A separate, caller-initiated path still lets you re-supply a soft-deleted key string directly (the partial unique index is `WHERE deleted_at IS NULL`, so deleted keys sit outside it). The [auto-mint section on Resource identifiers](./resource-identifiers#external_key-is-optional-on-create) and the `LOC-NNN` changelog entry below are corrected to match. Service behavior is unchanged; only the docs were wrong. + ### Surrogate ids are int64 end-to-end; the int32 ceiling and id `too_large` are retired {#surrogate-ids-int64} Every surrogate id — primary keys (`id`), foreign keys (`parent_id`, `location_id`, `asset_id`, `tag_id`, `org_id`), the `_id` query filters, and the numeric path parameters — now declares `format: int64` with `maximum: 9007199254740991` (JavaScript's `Number.MAX_SAFE_INTEGER`, 2⁵³−1) uniformly across response bodies, request bodies, query strings, and path parameters. The previous split — `format: int64` on the wire but an int32 (`2147483647`) runtime ceiling backed by an `int4` column — is gone: the storage column is now `int8`, and the spec is internally coherent at one id type everywhere. The id `too_large` rejection (`params.max: 2147483647`) is retired with it. Pre-launch alignment; no `v1.0.0`-or-later wire baseline to break. @@ -39,9 +46,9 @@ Integrator authentication changes from "use your API-key JWT directly as a Beare 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." +- **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 `MAX(live key) + 1` rule the [auto-mint paragraph on Resource identifiers](./resource-identifiers#external_key-is-optional-on-create) documents for both resources. Because the next mint is one past the highest live key, soft-deleting a middle value like `LOC-002` leaves a permanent gap that is never backfilled, while soft-deleting the current highest `LOC-NNN` frees that number for re-issue; the partial unique index `(org_id, external_key) WHERE deleted_at IS NULL` is the system of record for "what keys 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. +- **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 `MAX(live key) + 1` high-water-mark behavior and the non-monotonic, namespace-shaped (not counter-shaped) framing from the [prior changelog entry on non-monotonic auto-mint](#bb52-docs--validation_error-vs-bad_request-envelope-split-non-monotonic-auto-mint-wording) apply unchanged to the narrower location format. ### Tag rows cascade-soft-delete with their parent asset or location @@ -149,7 +156,7 @@ Service relaxation that closes a recurring contract-class finding surfaced three Two docs-discoverability items from the BB52 multipass triage. No spec or wire change. - **[Errors → `validation_error` vs `bad_request`](./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: 9999999999999999999999` → `bad_request`, no `fields[]`) against the in-range-but-too-large case (`parent_id: 2147483649` → `validation_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](./errors#validation-errors) are load-bearing. -- **[Resource identifiers → `external_key` is optional on create](./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. +- **[Resource identifiers → `external_key` is optional on create](./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 actual behavior is `MAX(live key) + 1` over the per-organization `ASSET-NNNN` / `LOC-NNN` namespace: deleting the current highest live `ASSET-0006` frees that number for re-issue on the next mint, while deleting a lower value leaves a permanent gap that is never backfilled. The auto-mint section, the upstream natural-key summary, and the resource table column header all use namespace-shaped (not counter-shaped) language, and a paragraph adjacent to the existing "available for reuse" sentence calls out that an auto-minted number is not a stable reference, so partner-side audit logs aren't built on a false never-re-issued 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 re-issue 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 diff --git a/docs/api/id-format.md b/docs/api/id-format.md index c390d64..4d612be 100644 --- a/docs/api/id-format.md +++ b/docs/api/id-format.md @@ -23,7 +23,7 @@ The declared `maximum` is the **JS-safe-integer cap** (2⁵³−1), not a storag ## Why int64, and why the 2⁵³−1 cap -The TrakRF id namespace is randomly distributed across a wide range, not monotonically assigned from `1`. Declaring `int64` from v1 launch means a future widening of the id space is a non-event for typed clients — they are already typed wide enough: +The surrogate `id` is a high-entropy, server-assigned integer — **globally unique across every resource type** (no two rows, of any type, share an `id`), **opaque** (don't parse it, order by it, or infer a count or creation time from it), and **permanent** (it never changes and is never reused — there is no hard delete, and the shared id sequence is never reseeded). It is scattered across a wide range rather than monotonically assigned from `1`. Declaring `int64` from v1 launch means a future widening of the id space is a non-event for typed clients — they are already typed wide enough: - Java / Kotlin clients surface ids as `Long`. - C# clients surface `long`. @@ -31,6 +31,10 @@ The TrakRF id namespace is randomly distributed across a wide range, not monoton The `maximum: 9007199254740991` ceiling is the load-bearing half of that promise on the JS side: it guarantees the spec never admits an id a `number`-typed client would round. A client that synthesizes ids on its own side (uncommon — ids are server-assigned) should stay within the same bound. +:::note `id` is not `external_key` +This page is about the surrogate `id`. The string `external_key` (`ASSET-NNNN` / `LOC-NNN`) is a separate, per-organization handle with its own allocation — dense and low-valued, minted as `MAX(live key) + 1`. A dense or low-numbered `external_key` implies nothing about `id`; the two are allocated independently. See [Resource identifiers → `external_key` is optional on create](./resource-identifiers#external_key-is-optional-on-create). +::: + ## Error envelopes at the id boundaries Surrogate ids no longer carry a dedicated "too large" rejection. A syntactically valid id that doesn't resolve takes the ordinary lookup path for its surface: diff --git a/docs/api/resource-identifiers.md b/docs/api/resource-identifiers.md index aaea4db..9bd92ae 100644 --- a/docs/api/resource-identifiers.md +++ b/docs/api/resource-identifiers.md @@ -55,7 +55,7 @@ This is the conventional REST shape and the URL stays valid even if the asset's ### Numeric `id` is a surrogate key -Numeric `id` values are surrogate keys — unique within their entity type, not across types. The same integer can exist as both an asset id and a tag id; that's expected behavior. The API disambiguates by URL position (`/assets/{asset_id}`, `/locations/{location_id}/tags/{tag_id}`) or query-parameter name (`location_id`, `parent_id`), so an id is never passed without its entity context at the API boundary. Client code matches ids to entity type — standard surrogate-key discipline. +Numeric `id` values are surrogate keys — server-assigned, high-entropy, and **globally unique across every resource type**: no two rows, whether two assets or an asset and a tag, ever share an `id`. They are **opaque** (don't parse an `id`, order by it, or infer a count or creation time from it) and **permanent** (an `id` never changes and is never reused — there is no hard delete, and ids are minted from a single shared sequence that is never reseeded). The API still carries each id with its entity context — by URL position (`/assets/{asset_id}`, `/locations/{location_id}/tags/{tag_id}`) or query-parameter name (`location_id`, `parent_id`) — and client code matches ids to their entity type as standard surrogate-key discipline. Use `id` as your durable foreign key when you mirror TrakRF data. Every surrogate id is declared **int64** (`format: int64`, `maximum: 9007199254740991`) on the spec — the same type and ceiling on response bodies, request bodies, `_id` query filters, and path parameters. The `maximum` is JavaScript's `Number.MAX_SAFE_INTEGER` (2⁵³−1), so every admissible id is exactly representable in a `number`-typed client. See [ID format: int64 wire, int64 runtime](./id-format) for the rationale, the id-boundary error envelopes, and what it means for typed-client codegen. @@ -482,7 +482,7 @@ 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-NNN` 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 auto-assigns the next value in the per-organization `ASSET-NNNN` / `LOC-NNN` namespace — `MAX(live key) + 1`. Each resource type has its own format and its own namespace: | Resource | Auto-minted format | Namespace scope | | -------- | ------------------ | ---------------- | @@ -491,7 +491,12 @@ Reassign or remove the dependents (move placed assets out via a scan event recor 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. +Auto-mint computes **`MAX(live key) + 1`**: it takes the highest `ASSET-NNNN` / `LOC-NNN` value currently held by a **live** (non-soft-deleted) row in your organization and adds one, zero-padded to the natural width (`ASSET-%04d`, `LOC-%03d`). That high-water mark is read over live rows only — through the same partial unique index that backs `external_key` uniqueness, `(org_id, external_key) WHERE deleted_at IS NULL`. It is **not** a Postgres-`nextval`-style monotonic counter, and — equally — **not** a lowest-unused-slot allocator that backfills gaps: + +- **Middle-of-range deletes leave permanent gaps.** Soft-delete `ASSET-0002` while `ASSET-0003` is still live and the next auto-mint is `MAX(0003) + 1 = ASSET-0004`, never `ASSET-0002` — the gap is not backfilled. +- **Only deleting the current highest live key frees its number.** Soft-delete the top `ASSET-0003` with nothing higher live and the next auto-mint re-issues `ASSET-0003`. + +Because the number can be re-issued this way, a server-assigned `external_key` you saw before can, after a delete, later refer to a *different* resource. Don't cache an auto-minted key as a long-lived reference or infer a count from it — supply your own handle (below) when you need a stable, never-recycled identifier. (The surrogate `id`, by contrast, is globally unique and never reused — see [Numeric `id` is a surrogate key](#numeric-id-is-a-surrogate-key).) ```bash # Caller-supplied external_key @@ -520,7 +525,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-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. +**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 an auto-minted number is not a stable reference — the `MAX(live key) + 1` mechanism can re-issue the number of a deleted top-of-range 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} diff --git a/superpowers/plans/2026-05-30-tra-885-id-vs-external-key-distribution.md b/superpowers/plans/2026-05-30-tra-885-id-vs-external-key-distribution.md new file mode 100644 index 0000000..92400b2 --- /dev/null +++ b/superpowers/plans/2026-05-30-tra-885-id-vs-external-key-distribution.md @@ -0,0 +1,60 @@ +# TRA-885 — `id` vs `external_key` Implementation Plan + +> **For agentic workers:** Consumer-docs prose change across three files. No unit-test harness for +> docs; verification is `pnpm build` (Redocusaurus + broken-link check) plus a read-through +> against the spec. Source of truth: bb-253 round-2.5 §0 convergence + Mike's in-session +> confirmation (the TRA-885 ticket is stale). + +**Goal:** State the identifier contract precisely — surrogate `id` is globally unique / opaque / +permanent (never reused); `external_key` auto-mint is `MAX(live key) + 1` (permanent middle-delete +gaps, only highest-freed reused) — and remove the stale "randomly distributed" and "lowest unused +slot / gap-filling" wording. + +**Architecture:** Three files. id-format.md (field-name the distribution claim + a distinction +note); resource-identifiers.md (globally-unique `id` framing + MAX+1 auto-mint mechanism); +changelog.md (new entry + fix two stale "lowest unused slot" assertions). No structural change. + +**Tech Stack:** Docusaurus, Markdown, Redocusaurus, pnpm. + +--- + +## Constraints + +- `id`: globally unique across all resource types, opaque, permanent, **never reused** (no hard + delete; single shared id sequence never reseeded). Gated: PR does not merge until TRA-886 is + prod-live + Mike's diff approval. +- `external_key`: `MAX(live key) + 1`, zero-padded `ASSET-%04d` / `LOC-%03d`; permanent + middle-delete gaps; only the current highest live key's number is freed for re-issue; separate + caller-resupply path for soft-deleted key strings. Keep "non-monotonic / not a Postgres + sequence"; drop "lowest unused slot," "gap-filling," "recyclable-implies-refill." +- Docs-only. + +## Task 1 — `docs/api/id-format.md` ✅ + +- Replaced the standalone "randomly distributed" sentence (`:26`) with the field-named `id` + framing (high-entropy, globally unique, opaque, permanent/never-reused). +- Added a `:::note` pinning `id` ≠ `external_key`, cross-linking the auto-mint section. + +## Task 2 — `docs/api/resource-identifiers.md` ✅ + +- "Numeric `id` is a surrogate key" para: per-type-scoping → globally unique / opaque / permanent; + use `id` as durable FK. +- Auto-mint section: replaced the "lowest unused slot among live rows" paragraph with the `MAX(live + key) + 1` mechanism (permanent gaps, highest-freed reused, zero-padding), plus the contrast to + the never-reused `id`. +- System-of-record note: "may recycle a slot" → "not a stable reference; `MAX+1` can re-issue a + deleted top-of-range number." + +## Task 3 — `docs/api/changelog.md` ✅ + +- New top entry: `id` globally unique/opaque/permanent (tied to single shared sequence) + + `external_key` `MAX+1` correction. +- Fixed the LOC-NNN-finalize entry and the BB52 non-monotonic-wording entry: "lowest unused slot" + → `MAX(live key) + 1`. + +## Task 4 — Verify & commit + +- [ ] `pnpm build` passes (Redocusaurus + broken-link check; new anchors resolve). +- [ ] Read-through: no "randomly distributed" standalone; no "lowest unused slot / gap-filling" + except where quoted-as-corrected in the new changelog entry; every claim names its field. +- [ ] Commit; open PR; **hold for Mike's diff review** (no merge until TRA-886 prod-live). diff --git a/superpowers/specs/2026-05-30-tra-885-id-vs-external-key-distribution-design.md b/superpowers/specs/2026-05-30-tra-885-id-vs-external-key-distribution-design.md new file mode 100644 index 0000000..6eb1800 --- /dev/null +++ b/superpowers/specs/2026-05-30-tra-885-id-vs-external-key-distribution-design.md @@ -0,0 +1,93 @@ +--- +title: TRA-885 — Distinguish the globally-unique surrogate id from the MAX+1 external_key +date: 2026-05-30 +ticket: TRA-885 +status: converged +source: bb-253 round-2.5 convergence (§0 / locked copy); operator-confirmed by Mike in-session +--- + +# TRA-885 — `id` vs `external_key`: state the contract precisely + +## Source of truth + +**The TRA-885 ticket text is stale.** Mike directed (in dispatch and in-session) that the +authoritative wording comes from the **bb-253 round-2.5 convergence (§0 locked copy)** — that +session did the post-ticket investigation and wording-refinement. Where the ticket and §0 +disagree, §0 + Mike's in-session confirmation win. Mike okayed updating the ticket itself. + +Two places the ticket is stale: + +1. Ticket says "Make NO claim about cross-type id uniqueness either way." → **Superseded.** Mike + confirmed directly: **state global uniqueness explicitly** ("better to be explicit and head + off potential confusion"). TRA-886 (single shared sequence, PR #443, Done) makes it true by + construction. +2. Ticket / existing docs frame `external_key` auto-mint as "lowest unused slot / gap-filling." + → **Wrong.** Platform code-confirmed (`assets.go:60` `GetNextAssetSequence`, `locations.go:57` + `GetNextLocationSequence`): it is **`MAX(live key)+1`**. Independently code-read by docs, + triple-confirmed by platform via bb-253. + +## The two identifiers (locked §0 framing) + +- **`id`** — high-entropy, server-assigned integer. **Globally unique across all resources** + (no two rows of any type share an `id`) and **opaque** (don't parse it, order by it, or infer + count or time from it). **Permanent: never changes, never reused — even after delete.** Use it + as the durable foreign key when mirroring TrakRF data; address a resource via its typed + endpoint (`GET /assets/{id}`). +- **`external_key`** — human-readable handle, unique within your org for a given resource type, + among **live** (non-deleted) rows. Supply your own, or the server auto-assigns. **Auto-assigned + values are not a stable sequence:** they are `MAX(live key)+1`, so deletes leave **permanent + gaps** and a freed value can be reused — a server-assigned `external_key` you saw before can + later refer to a *different* resource. Don't cache it as a long-lived reference or infer counts + from it; use `id` for that. + +## external_key auto-mint mechanism (platform-confirmed) + +- Auto-assigned key = `MAX(existing live ASSET-NNNN / LOC-NNN for your org) + 1`, zero-padded + `ASSET-%04d` / `LOC-%03d`. +- **Middle-delete gaps are permanent.** Delete `ASSET-0002` while `0003` lives → next mint = + `MAX(0003)+1` = `0004`, never `0002`. Not backfilled. +- **Only deleting the current highest live key frees its number.** Delete top `0003` → next mint + re-issues `0003`. +- **Separate, caller-initiated reuse path:** a caller may re-supply a soft-deleted row's key + string (the partial unique index is `WHERE deleted_at IS NULL`, so deleted keys sit outside + it). Distinct from auto-mint. +- Keep the "non-monotonic / not a Postgres sequence" framing (accurate, consistent with + changelog.md:152). Drop "lowest unused slot," "gap-filling," and any + "backfilled / recyclable-implies-refill" wording. + +## Build scope + +1. **`docs/api/id-format.md`** — replace the standalone "randomly distributed" sentence (:26) + with the field-named `id` framing (high-entropy, **globally unique**, opaque, permanent). + Add the reinforcement line: a dense / low-valued `external_key` implies nothing about `id` — + the two are allocated independently. +2. **`docs/api/resource-identifiers.md`** — two fixes: + - "Numeric `id` is a surrogate key" section: replace "unique within their entity type, not + across types / the same integer can be both an asset id and a tag id" → **globally unique, + opaque, permanent**. + - auto-mint section + system-of-record note: replace "lowest unused slot / gap-filling / + recyclable" with the `MAX+1` mechanism above. +3. **`docs/api/changelog.md`** — fix the stale "lowest unused slot among live rows" assertions in + the existing LOC-NNN-finalize and non-monotonic-wording entries; add a new entry recording the + `id` global-uniqueness framing (tied to TRA-886) and the `external_key` `MAX+1` correction. + +## Scope boundary + +- Docs-only, consumer-facing. +- `id` global-uniqueness claim is **gated**: the PR does not merge until TRA-886 is live (prod) + and Mike approves the diff. Timing handled by the gate, not by omitting the claim. + +## Verification + +- `pnpm build` passes (Redocusaurus + broken-link check). +- Read-through: no "randomly distributed" standalone line; no "lowest unused slot / gap-filling" + anywhere; every distribution/allocation claim names its field; global uniqueness stated in + id-format and resource-identifiers, consistent. + +## Evidence + +- Platform code: `assets.go:60` / `locations.go:57` → `SELECT MAX(...) WHERE deleted_at IS NULL` + then `+1`. +- TRA-886 (PR #443, Done): single shared sequence → globally unique `id` by construction. +- Live BB evidence (from ticket): BB2 `LOC-004/005` + distributed `id`; BB3 `ASSET-0021` + + 36-sample scatter, spread 4.1e15, 0 collisions.