diff --git a/docs/api/changelog.md b/docs/api/changelog.md index b51b6a1..c009156 100644 --- a/docs/api/changelog.md +++ b/docs/api/changelog.md @@ -9,6 +9,17 @@ This log records changes to the TrakRF public API under `/api/v1/` that affect i Changes to the TrakRF app itself are logged separately in the [release notes](../release-notes). The two are deliberately decoupled: the API version tracks the contract documented here, while the app version moves on its own release cadence, so an app release that changes nothing for integrators produces no entry on this page. +## Asset history lists stays, not scans {#history-stays} + +Ships with app **v1.6.0**. No path, field name or response shape changes. What a history item means changes, and so do `total_count` and paging, so code that reads the endpoint may need attention. + +- **Each item on `GET /api/v1/assets/{asset_id}/history` is a stay: an unbroken run of observations of the asset at one location.** Consecutive observations at the same location come back as one item. An asset parked in front of a reader for a week is one item, not one per minute. Moving away and coming back starts a new stay, and an observation that resolved to no location is a stay of its own (`location_id: null`). +- **`event_observed_at` is when the stay began.** `duration_seconds` runs from there to the first observation of the asset anywhere else, and is `null` only while the stay is ongoing. +- **`total_count`, `limit` and `offset` count stays.** If your integration paged through history expecting one item per recorded minute, it now receives far fewer items for the same window. +- **`from` and `to` select stays without clipping them.** A stay is listed when at least one of its observations falls inside the window. A stay that began before `from` reports its real start, and a stay that continues past `to` reports its real duration. So a window ending in the past no longer reports its newest item as ongoing when the asset moved afterwards. +- **A gap in reads does not end a stay.** Two observations at the same location a week apart, with nothing in between, are one stay covering the week. A stay tells you where the asset was last seen and when it was next seen elsewhere. It does not mean a reader saw the asset throughout. +- **Docs correction: `duration_seconds` was described backwards.** These docs said it measured time at the *previous* location and was `null` on the *earliest* item. It has always measured time at the item's own location until the asset was next seen elsewhere, and it is `null` on the newest item. See [Date fields → `duration_seconds`](./date-fields#duration_seconds-on-asset-history-rows). + ## Location history and movement events gain one-minute resolution {#minute-resolution-history} Ships with app **v1.5.0**. No path, field name, response shape, or error envelope changes — this entry records a behavioral semantics change to data your code already consumes, the same reason webhook delivery semantics are logged here. diff --git a/docs/api/data-model.md b/docs/api/data-model.md index 0a18793..7df097a 100644 --- a/docs/api/data-model.md +++ b/docs/api/data-model.md @@ -37,10 +37,10 @@ The separation is intentional. Scan data carries provenance — which reader obs Consumption endpoints for scan data: -| Endpoint | Shape | Scope | -| --------------------------------------- | ------------------------------------------ | --------------- | -| `GET /api/v1/assets/{asset_id}/history` | Per-asset scan history; time-series rows | `tracking:read` | -| `GET /api/v1/reports/asset-locations` | Bulk current-location lookup across assets | `tracking:read` | +| Endpoint | Shape | Scope | +| --------------------------------------- | --------------------------------------------- | --------------- | +| `GET /api/v1/assets/{asset_id}/history` | Per-asset location history; one item per stay | `tracking:read` | +| `GET /api/v1/reports/asset-locations` | Bulk current-location lookup across assets | `tracking:read` | Both endpoints are projections of the scan-event stream — that's why the scope name reflects data lineage rather than the URL shape. See [Authentication → Scopes](./authentication#scopes) for the full mapping. @@ -63,11 +63,11 @@ curl -H "Authorization: Bearer $TRAKRF_ACCESS_TOKEN" \ The response is one row per scanned asset, with `asset_external_key`, `location_id`, `location_external_key`, and `asset_last_seen` for each. Pass repeated `asset_external_key` parameters for the assets you care about; the server returns rows in `-asset_last_seen` order by default. The `asset_external_key` query parameter (and its surrogate sibling `asset_id`) is the [repeatable filter](./pagination-filtering-sorting#filtering) that makes this batch-lookup expressible in one request rather than N — see [Pagination, filtering, sorting](./pagination-filtering-sorting) for the cursor and sort surface and the full filter param list on this endpoint. -If you only have surrogate ids in hand (you cached them from a previous create or list), use `asset_id` instead — both filter forms are supported, and the resolution rules match every other paired-key surface on the API (the two are [mutually exclusive in a single request](./pagination-filtering-sorting#paired-by-id-and-by-natural-key-filters-are-mutually-exclusive); the asset and location filter pairs are independent and intersect when combined). For a single asset, filter the report down to it — `GET /api/v1/reports/asset-locations?asset_id=4287` — or read the latest row of `GET /api/v1/assets/{asset_id}/history`; the asset resource itself carries no location field. +If you only have surrogate ids in hand (you cached them from a previous create or list), use `asset_id` instead — both filter forms are supported, and the resolution rules match every other paired-key surface on the API (the two are [mutually exclusive in a single request](./pagination-filtering-sorting#paired-by-id-and-by-natural-key-filters-are-mutually-exclusive); the asset and location filter pairs are independent and intersect when combined). For a single asset, filter the report down to it — `GET /api/v1/reports/asset-locations?asset_id=4287` — or read the newest item of `GET /api/v1/assets/{asset_id}/history`; the asset resource itself carries no location field. -For per-asset history rather than current-state — "where has this asset been over the last seven days" — use `GET /api/v1/assets/{asset_id}/history?from=...&to=...`. The endpoint is the projection of the same scan-event stream with a different shape (time-series rows rather than current-state pairs). See [Pagination, filtering, sorting → Time range (history)](./pagination-filtering-sorting#time-range-history) for the `from`/`to` semantics. +For per-asset history rather than current-state — "where has this asset been over the last seven days" — use `GET /api/v1/assets/{asset_id}/history?from=...&to=...`. The endpoint is the projection of the same scan-event stream with a different shape (a timeline of stays rather than current-state pairs). Each item is a **stay**: an unbroken run of observations at one location, with when it began (`event_observed_at`) and how long it lasted (`duration_seconds`, `null` while ongoing). See [Date fields → `duration_seconds`](./date-fields#duration_seconds-on-asset-history-rows) for how a stay is measured and [Pagination, filtering, sorting → Time range (history)](./pagination-filtering-sorting#time-range-history) for the `from`/`to` semantics. -History has **one-minute resolution**: at most one row per asset per minute, recording the first location observed in that minute (an operator save from the app overrides it). It is location history, not a per-read scan log — a fixed reader seeing a stationary tag hundreds of times an hour produces one row per minute, and the raw read stream is not exposed. The same resolution governs when [`asset.moved` webhooks](./webhooks#movement-detection-has-one-minute-resolution) fire. +History has **one-minute resolution**: the asset's location is recorded at most once per minute, taking the first location observed in that minute (an operator save from the app overrides it). It is location history, not a per-read scan log. A fixed reader seeing a stationary tag hundreds of times an hour for a week produces one stay, and the raw read stream is not exposed. The same resolution governs when [`asset.moved` webhooks](./webhooks#movement-detection-has-one-minute-resolution) fire. ## See also diff --git a/docs/api/date-fields.md b/docs/api/date-fields.md index cc5e118..7a1c87d 100644 --- a/docs/api/date-fields.md +++ b/docs/api/date-fields.md @@ -142,7 +142,7 @@ The two history-derived endpoints expose a per-row scan timestamp under differen | Field | Endpoint | Always present? | Meaning | | ------------------- | --------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `event_observed_at` | `GET /api/v1/assets/{asset_id}/history` | Yes — never `null`, never absent | When this scan event was observed for the asset. | +| `event_observed_at` | `GET /api/v1/assets/{asset_id}/history` | Yes — never `null`, never absent | When the stay began: the first observation of the asset at this item's location. | | `asset_last_seen` | `GET /api/v1/reports/asset-locations` | Yes — never `null`, never absent | When the asset's most recent scan was observed. Drives the `-asset_last_seen` default sort on this endpoint. | Both fields are declared `required` on their respective response schemas (`AssetHistoryItem.event_observed_at`, `AssetLocationItem.asset_last_seen`) and **not** marked `nullable`. `/reports/asset-locations` returns one row per scanned asset, so an asset that has never been scanned does not appear in the response — there is no "scanned but `asset_last_seen: null`" state. @@ -181,16 +181,18 @@ Inbound parsing on the `from` / `to` query parameters of `GET /api/v1/assets/{as ### `duration_seconds` on asset history rows -`AssetHistoryItem` carries a sibling field next to `event_observed_at` — `duration_seconds: integer | null` — that measures how long the asset stayed at the **previous** location before this row's scan moved it. The semantics: +Each history item is a **stay**: an unbroken run of observations of the asset at one location. `AssetHistoryItem` carries `duration_seconds: integer | null` next to `event_observed_at`, and it measures how long **this** stay lasted: -| Value | Meaning | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `null` | This is the earliest scan event in the asset's history; there is no previous location to measure dwell against. | -| Non-negative integer | Whole seconds elapsed between the previous scan-event timestamp and this row's `event_observed_at`, while at the previous location. | +| Value | Meaning | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Non-negative integer | Whole seconds from this item's `event_observed_at` to the first observation of the asset anywhere else. | +| `null` | The stay is ongoing: the asset has not been observed anywhere else since. Only the asset's newest stay can be `null`, whatever the date range. | -The field is declared `required` and `nullable: true` on `AssetHistoryItem` — always emitted, `null` only on the earliest row. Codegen-derived clients surface it as a non-optional nullable integer. Null-check the value, don't key-check. +The field is declared `required` and `nullable: true` on `AssetHistoryItem`, so it is always emitted. Codegen-derived clients surface it as a non-optional nullable integer. Null-check the value, don't key-check. -`duration_seconds` is computed against scan-event timestamps at the storage layer; it doesn't surface on `/reports/asset-locations` (that endpoint reports the current snapshot, not a per-event dwell). For per-location dwell across an entire history window, sum `duration_seconds` across the relevant subset of rows on the client side. +A stay runs from observation to observation. A stretch with no reads between two observations at the same location is inside the stay, not a gap in it, so `duration_seconds` is the time until the asset was next seen elsewhere, not the time a reader was seeing it. History is resolved to the minute, so a visit shorter than a minute may not appear as a stay of its own. + +`from` and `to` choose which stays are listed but do not clip them. A stay that began before `from` reports its real `event_observed_at`, and a stay that continues past `to` reports its full `duration_seconds`, or `null` if it is still ongoing. To total the time spent at a location, sum `duration_seconds` across that location's items. If only the time inside your window matters, clip the first and last items to the window on the client side. `duration_seconds` does not appear on `/reports/asset-locations`, which reports the current snapshot rather than stays. ### These fields are read-only diff --git a/docs/api/pagination-filtering-sorting.md b/docs/api/pagination-filtering-sorting.md index af3e6fa..d5c6f87 100644 --- a/docs/api/pagination-filtering-sorting.md +++ b/docs/api/pagination-filtering-sorting.md @@ -183,13 +183,21 @@ curl -H "Authorization: Bearer $TRAKRF_ACCESS_TOKEN" \ "$BASE_URL/api/v1/assets/4287/history?from=2026-04-01T00:00:00Z" ``` +History items are **stays**, not scans. The window selects which stays are listed but does not clip them: + +- A stay is listed when at least one of its observations falls between `from` and `to`. +- A stay that began before `from` reports its real start in `event_observed_at`. +- A stay that continues past `to` reports its real `duration_seconds`. It is `null` only if the asset has not been seen anywhere else since, so a window ending in the past does not report its newest item as ongoing when the asset moved afterwards. + +`total_count`, `limit` and `offset` count stays. See [Date fields → `duration_seconds`](./date-fields#duration_seconds-on-asset-history-rows). + :::tip Common confusion: `from`/`to` is not `valid_from`/`valid_to` `from` / `to` (history query parameters) and `valid_from` / `valid_to` (resource schema fields) share a similar shape but answer different questions. The two pairs visually collapse in URL paths and log lines (`from=2026-04-01T00:00:00Z` is one trim away from `valid_from=2026-04-01T00:00:00Z`), so it's worth knowing which is which: -| Pair | Where it lives | What it bounds | -| ------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `from` / `to` | Query params on `GET /api/v1/assets/{asset_id}/history` | The **observation window** — which scan events to include in the response. Scan timestamps, not effective dates. | -| `valid_from` / `valid_to` | Resource schema fields ([Date fields](./date-fields)) | The resource's **effective-dating bounds** — when the asset itself became and stops being effective. | +| Pair | Where it lives | What it bounds | +| ------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `from` / `to` | Query params on `GET /api/v1/assets/{asset_id}/history` | The **observation window** — which stays to include: those with an observation inside it. Scan timestamps, not effective dates. | +| `valid_from` / `valid_to` | Resource schema fields ([Date fields](./date-fields)) | The resource's **effective-dating bounds** — when the asset itself became and stops being effective. | The history endpoint applies the [currently-effective predicate](./resource-identifiers#effective-dating-and-is-active) to the joined location and embedded tags inside each event, but the `from` / `to` query params are independent of that. Renaming the history pair to something like `since` / `until` is a v2 consideration; in v1 the names are what they are. ::: @@ -289,7 +297,7 @@ This is the canonical [master-data / scan-data](./data-model) consumption flow: ### History -Asset movement history over a window, newest event first (path takes the canonical integer asset `id`): +Asset movement history over a window, newest stay first (path takes the canonical integer asset `id`): ```bash curl -H "Authorization: Bearer $TRAKRF_ACCESS_TOKEN" \ diff --git a/docs/api/quickstart.mdx b/docs/api/quickstart.mdx index 5f58aa9..54d5beb 100644 --- a/docs/api/quickstart.mdx +++ b/docs/api/quickstart.mdx @@ -269,7 +269,7 @@ await patchAsset(42, { name: "renamed" }); The walkthrough above covers the master-data surface — `assets:write` and `locations:write` give you CRUD over what's in your fleet. The TrakRF API also exposes a read-only **tracking surface** derived from scan events ingested through reader integrations. Two endpoints, both gated on `tracking:read`: -- **`GET /api/v1/assets/{asset_id}/history`** — Recent scan events for a single asset, in event order, each row carrying its observed location and the dwell duration at the previous location. ~90-day rolling window. Useful for per-asset timelines and dwell-time analysis. See [Resource identifiers → "Scan event" is a domain concept, not an API resource](./resource-identifiers#scan-event-vocabulary) for what's queryable and [Date fields → Scan-event date fields](./date-fields#scan-event-date-fields) for the response timestamps. +- **`GET /api/v1/assets/{asset_id}/history`** — Where a single asset has been, as a list of stays. Each item is an unbroken run of observations at one location, carrying when the stay began and how long it lasted. ~90-day rolling window. Useful for per-asset timelines and dwell-time analysis. See [Resource identifiers → "Scan event" is a domain concept, not an API resource](./resource-identifiers#scan-event-vocabulary) for what's queryable and [Date fields → Scan-event date fields](./date-fields#scan-event-date-fields) for the response timestamps. - **`GET /api/v1/reports/asset-locations`** — Current location of every asset in your organization, resolved from the most recent scan per asset. Useful for cross-fleet snapshots, nightly reconciliation, and "where is everything right now?" dashboards. See [Resource identifiers → `/reports/asset-locations` scopes to currently-effective assets](./resource-identifiers#asset-locations-effective-scope) for the default predicate and `asset_last_seen` semantics. If your key was minted without `tracking:read`, the calls return `403 forbidden`; scopes can't be edited after creation, so [mint a new key](./authentication#mint-your-first-api-key) to add the surface. The `scopes` array on `GET /api/v1/orgs/me` (covered in §2 above) confirms what your key carries. diff --git a/docs/api/resource-identifiers.md b/docs/api/resource-identifiers.md index 7763deb..8e662e2 100644 --- a/docs/api/resource-identifiers.md +++ b/docs/api/resource-identifiers.md @@ -600,7 +600,7 @@ If your business logic needs to surface an expired record (e.g., to render a "de `GET /api/v1/reports/asset-locations` applies the currently-effective predicate to the **asset side** of every row by default: a row appears only when the asset's `valid_from` is in the past AND its `valid_to` is `null` or in the future. The location-side join is also filtered through the same predicate per the bulleted list above. A row's `asset_deleted_at: null` is **not** a "currently effective" signal — it only means the asset isn't soft-deleted; the asset's `valid_to` may still have elapsed independently, in which case the row is dropped from the response. Soft-delete (`deleted_at` / `asset_deleted_at`) and temporal validity (`valid_from` / `valid_to`) are independent dimensions ([Three axes of liveness](./pagination-filtering-sorting#three-axes-of-liveness)). -To surface temporally inactive assets in the report — for retrospective "where did asset X end up before it was decommissioned" reporting — fall back to `GET /api/v1/assets/{asset_id}/history`, which carries scan-event rows without an effective-asset predicate on the asset itself. +To surface temporally inactive assets in the report — for retrospective "where did asset X end up before it was decommissioned" reporting — fall back to `GET /api/v1/assets/{asset_id}/history`, which lists the asset's stays without an effective-asset predicate on the asset itself. ## Tag is a polymorphic resource @@ -701,7 +701,7 @@ curl -X POST \ Scan-event-derived data is projected through two endpoints: -- `GET /api/v1/assets/{asset_id}/history` — the per-asset event timeline (timestamp, location, duration), authoritative for "what did this asset do over time?" +- `GET /api/v1/assets/{asset_id}/history` — the per-asset timeline of stays at locations (start, location, duration), authoritative for "what did this asset do over time?" - `GET /api/v1/reports/asset-locations` — the latest snapshot per asset, authoritative for "where is each asset right now?" Both are gated by the `tracking:read` scope (see [Authentication → Scopes](./authentication#scopes)) — the same scope, because both are projections of the same underlying event stream.