<!--
  AUTO-GENERATED from context/archive/specs/IAIS_v1_spec.md via
  apps/www/scripts/generate-ids-spec.mjs per ADR-032 §2.

  DO NOT EDIT THIS FILE BY HAND. Edits will be overwritten on the next
  generate run. To change the IDS contract:
    1. Edit context/archive/specs/IAIS_v1_spec.md, OR
    2. Add a transformation in the generator script.
    3. Re-run: node apps/www/scripts/generate-ids-spec.mjs

  CI verifies that the committed copy of this file matches the
  generator output. Drift fails the build.
-->

# IronScout Data Services (IDS) — v1.0.0

**Status:** Stable (v1.0.0, per ADR-032)  
**IDS Version:** 1.0.0  
**Last Updated:** 2026-02-20  

**Canonical URLs (recommended):**
- `/.well-known/ids.md` (this document)
- `/ids/changelog.md`
- `/ids/schemas/` (JSON Schemas; authoritative machine contracts)
- `/ids/openapi.json` (optional OpenAPI 3.1 referencing schemas)

IDS is a **public, machine-oriented integration contract** for LLMs and agent runtimes.  
It defines **how to query IronScout**, how to **interpret and cite** results, what is **forbidden**, and what **guarantees** exist.  
It is **not marketing copy** and **not** `llms.txt`.

---

## 0) Normative language

The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are to be interpreted as described in RFC 2119.

---

## 1) Scope and guarantees

### 1.1 What IronScout provides (IDS-governed)

IronScout provides **observed** pricing and availability data and computed context derived from that data:

- **Current offers**: one current, consumer-visible price per (retailer, canonical product)
- **Historical context**: windowed distribution stats (`min`, `p25`, `median`, `p75`, `max`), sample counts, and timestamps
- **Market snapshots**: caliber/window aggregated distributions suitable for citation

### 1.2 What IronScout does NOT provide

IronScout MUST NOT provide, and IDS consumers MUST NOT infer:

- Purchase recommendations (“buy this”, “best”, “winner”, “best value”, “you should buy”)
- Deal scores/verdicts (“steal”, “must buy”, “wait”, “hold”)
- Predictions or guarantees (future price, “will drop”, “lowest price”)
- Any claim that implies certainty or optimal timing

### 1.3 Data coverage and freshness

- Market coverage is **not guaranteed** and MAY be partial by caliber, retailer, and time.
- Data freshness varies by source and category.
- Any computed artifact MUST expose its window and compute timestamp so consumers can present uncertainty honestly.

### 1.4 Source policy (v1)

IronScout ingests from multiple **approved** channels and **canonicalizes** data during ingestion.  
IDS outputs represent the **best current observation** per offer, not parallel “affiliate vs scrape” prices.

**Approved source types (v1):**
- `AFFILIATE_FEED`
- `APPROVED_SCRAPE`
- `DIRECT_FEED` (reserved; MAY appear if/when enabled)

Every **served price point** MUST still be attributable to a specific observation via `observedAt` and `provenance` (see §4.4).

---

## 2) Safety and policy constraints (hard requirements)

### 2.1 Assistive-only stance

IDS consumers (LLMs/agents) MUST use IronScout outputs as **assistive context** only.

- Agents MUST NOT autonomously purchase, place orders, or take payment actions based on IronScout outputs.
- Agents MUST NOT present IronScout as an authority issuing verdicts.

### 2.2 No recommendations / no manipulation

- Outputs MUST be phrased neutrally (“observed”, “current”, “as of”, “in the last 30 days”).
- Consumers MUST NOT convert context into directives or verdicts.
- Consumers MUST NOT manipulate ordering to imply endorsement (e.g., “Top pick”) unless the user explicitly asks for sorting criteria and the consumer clearly states that it is a mechanical sort (e.g., “sorted by lowest current price per round”).

### 2.3 Harmful guidance constraints

IronScout is a pricing/discovery system. IDS consumers MUST NOT use IronScout data to provide weapon-building instructions or other harmful operational guidance. Keep outputs limited to pricing/availability context and citations.

### 2.4 Fail-closed on ambiguity

If eligibility/visibility/state is ambiguous, IronScout fails closed and MAY omit data.  
Consumers MUST NOT “fill in gaps” or invent missing retailers, prices, or timestamps.

---

## 3) Canonical entities and identifiers

### 3.1 Entities

- **Caliber**: normalized caliber identifier (`caliberSlug`)
- **Canonical Product**: normalized, cross-source product identity (`canonicalProductId`)
- **Retailer**: consumer-facing storefront (`retailerId`)
- **Offer**: a retailer’s current offer for a canonical product (`offerId`)
- **Observation**: a point-in-time price/availability observation that backs a served current offer (`observationId`)

### 3.2 Stability rules

- `canonicalProductId`, `retailerId`, and `offerId` MUST be stable identifiers across time.
- If any stable identifier must change (rare), the response MUST provide a migration mapping for at least one deprecation window (see §7).

---

## 4) Determinism, selection semantics, and citation fields

### 4.1 “Current” offer definition (normative)

For each (canonicalProductId, retailerId), IronScout returns **exactly one** `currentOffer` by default.

**Rule:** `currentOffer` = the visible observation with **max(observedAt)** after exclusions.

Selection steps:

1) Build candidate observations for (canonicalProductId, retailerId).  
2) Exclude observations that are not eligible for reads (corrections/ignored runs, visibility gating, invalid records).  
3) Select the observation with greatest `observedAt`.  
4) Tie-breaker (deterministic): if multiple observations share the same `observedAt`, select the one with greatest `ingestedAt`; if still tied, select lexicographically greatest `observationId`.  
5) If `observedAt` is missing/invalid, the observation MUST NOT be served as current (fail closed).

### 4.2 Query-time visibility enforcement

Retailer visibility is enforced at **query time**:

- A retailer’s inventory MUST appear only if the retailer is currently eligible/visible under platform policy and listing rules.
- If a retailer becomes ineligible, it MUST be removed from all IDS consumer surfaces (search, product views, alerts if applicable).

### 4.3 Required top-level citation fields (API responses)

All IDS-governed **API** JSON responses MUST include:

- `schemaVersion` (e.g., `caliber_market_snapshot_v1`)
- `computedAt` (RFC3339; when the underlying data was computed. For cached or precomputed responses this is typically older than response assembly time — the field tracks data freshness, not response timing.)
- `requestId` (opaque string for traceability)

API responses that include windowed computation MUST also include:

- `windowDays`
- `windowStart` / `windowEnd` (RFC3339)
- `methodology` (string; human-readable description for use in LLM citations)
- `methodologyMeta` object (structured fields for programmatic consumers):
  - `methodologyVersion`
  - `computationVersion`
  - `methodologyUrl` (stable URL)

**Static artifact exception (§5.1):** static snapshot artifacts at
`/market-snapshots/{windowDays}d/{caliber}.json` are precomputed files,
not request-time responses. They:

- omit `requestId` (no request context exists),
- MAY have `computedAt: null` when the artifact is a placeholder for a
  caliber with no qualifying data (`dataStatus: "UNAVAILABLE"`),
- emit `methodology` as a structured object (`{measurementTechnique, notes}`)
  rather than a string, and omit `methodologyMeta`.

See `market_snapshot_artifact_v1` for the artifact-specific schema.
The shape divergence between API and artifact is tracked in
`/ids/changelog.md` Known Divergences and may converge in v1.1.

### 4.4 Required offer-level attribution fields

This section applies to API endpoints that return offer-level data
(IDS-shaped `/search`, `/products`). The static artifact (§5.1)
returns caliber-level statistics, not individual offers, so the rules
here do not apply to it.

Every served offer MUST include:

- `observedAt` (RFC3339; when that price/availability was observed)
- `provenance` object minimally containing:
  - `sourceType` (`AFFILIATE_FEED` | `APPROVED_SCRAPE` | `DIRECT_FEED`)
  - `sourceId` (opaque)
  - `observationId` (opaque)
  - `ingestedAt` (RFC3339; optional but recommended)

This provides auditability **without** emitting duplicate prices.

---

## 5) Public endpoints (v1 minimum set)

**v1.0.0 implementation status.** The table below is the source of truth
for what an agent can rely on at v1.0.0; the per-section text describes
the contract in full. The IDS-shaped endpoints are served under
`/api/ids/*`. The legacy snake_case `/api/products/search` is retained
for the consumer web app and is NOT IDS-governed.

| Endpoint | Section | Schema | Status at v1.0.0 |
|---|---|---|---|
| `/market-snapshots/{windowDays}d/{caliber}.json` | §5.1 | `market_snapshot_artifact_v1` | **Shipped** |
| `/market-snapshots/{windowDays}d/index.json` | §5.1 (Snapshot index) | `market_snapshot_index_v1` | **Shipped** |
| `/api/market-snapshots/calibers` and `/api/market-snapshots/calibers/{caliber}` | §5.1 (Market snapshot API endpoint) | `caliber_market_snapshot_v1` / `caliber_market_snapshot_list_v1` | **Shipped** |
| `/api/ids/search` | §5.2 | `search_results_v1` | **Shipped.** Deterministic keyword/filter search; camelCase offers with `observedAt`, `provenance`, and signed `outUrl`. |
| `/api/ids/products/{canonicalProductId}` | §5.3 | `product_detail_v1` | **Shipped.** Canonical product + current offers per retailer. |
| `/api/ids/retailers` | §5.4 | `retailer_directory_v1` | **Shipped.** Currently eligible retailers only (ADR-005). |
| `/out` | §5.5 | n/a (opaque) | Live at `app.ironscout.ai/out` (HMAC-signed retailer redirects). IDS-shaped responses carry the signed `outUrl` field directly; use it verbatim (§5.5). |

Error responses from shipped IDS endpoints conform to `error_v1` (§9).

**Transports.** In addition to HTTP+JSON, the IDS read surface is exposed
over the Model Context Protocol (MCP) at `mcp.ironscout.ai` per ADR-033.
MCP tools map 1:1 to the endpoints above and inherit every exclusion and
citation rule in this document.


### 5.1 Market snapshot artifacts (citable, static)

**GET** `/market-snapshots/{windowDays}d/{caliber}.json`

Purpose: citable distribution context for a caliber across a fixed window.

**Response schema:** `market_snapshot_artifact_v1`

**Example response (abbreviated):**
```json
{
  "schemaVersion": "market-snapshot/v1",
  "caliberSlug": "9mm",
  "windowDays": 30,
  "windowStart": "2026-01-19T00:00:00Z",
  "windowEnd": "2026-02-18T23:59:59Z",
  "statBasis": "dailyBestObserved",
  "statLabel": "Observed daily-best price per round",
  "pricePerRound": {
    "median": 0.4195,
    "p25": 0.3290,
    "p75": 0.5590,
    "min": 0.1399,
    "max": 1.9950
  },
  "recentLow": 0.1399,
  "counts": {
    "sampleCount": 1435,
    "daysWithData": 30,
    "productCount": 87,
    "retailerCount": 4
  },
  "computedAt": "2026-02-18T18:00:01.955Z",
  "dataStatus": "SUFFICIENT",
  "methodology": {
    "measurementTechnique": "SQL PERCENTILE_CONT over daily-best per-product-per-day observed in-stock prices",
    "notes": [
      "Daily-best is MIN(price_per_round) per (caliber, product, UTC day).",
      "Only in-stock observations included.",
      "Coverage varies by retailer and source."
    ]
  }
}
```

**Static artifact field note:** `recentLow` is the lowest observed
price per round in a recent (7-day) sub-window. It is computed from a
shorter window than `pricePerRound.min` and is intended for "current
cheapest observed" displays. It is null when the recent window has no
qualifying data.

#### Market snapshot API endpoint

**GET** `/api/market-snapshots/calibers/{caliber}`
**GET** `/api/market-snapshots/calibers` (list; `?windowDays=7|30`, default 30)

Purpose: live (Redis-cached, 5-minute TTL) caliber snapshot data in a
flat, JSON-API-friendly shape. Prefer this over the static artifact
when calling from server-side code or when freshness matters more than
content-integrity hashing.

**Field naming note:** the API endpoint uses `caliber` (canonical
CaliberValue like `9mm`, `.45 ACP`), distinct from the static
artifact's `caliberSlug` (URL slug like `9mm`, `45-acp`). Both refer
to the same logical caliber; the values differ in format. Convergence
is tracked in `/ids/changelog.md`.

**Response schema (single):** `caliber_market_snapshot_v1`
**Response schema (list):** `caliber_market_snapshot_list_v1`

**Example response (single, abbreviated):**
```json
{
  "schemaVersion": "caliber_market_snapshot_v1",
  "requestId": "req_01JABC...",
  "caliber": "9mm",
  "windowDays": 30,
  "windowStart": "2026-01-19T00:00:00Z",
  "windowEnd": "2026-02-18T23:59:59Z",
  "statBasis": "dailyBestObserved",
  "statLabel": "Observed daily-best price per round",
  "methodology": "SQL PERCENTILE_CONT over daily-best per-product-per-day observed in-stock prices",
  "methodologyMeta": {
    "methodologyVersion": "msnap_v1",
    "computationVersion": "harvester_1.12.0",
    "methodologyUrl": "https://www.ironscout.ai/ids/methodology/caliber-market-snapshot-v1.md"
  },
  "median": 0.4195,
  "p25": 0.3290,
  "p75": 0.5590,
  "min": 0.1399,
  "max": 1.9950,
  "sampleCount": 1435,
  "daysWithData": 30,
  "productCount": 87,
  "retailerCount": 4,
  "computedAt": "2026-02-18T18:00:01.955Z",
  "computationVersion": "harvester_1.12.0",
  "dataStatus": "SUFFICIENT"
}
```

**Caching note:** responses use `Cache-Control: private, max-age=300,
stale-while-revalidate=600`. `private` prevents shared caches from
serving the same `requestId` to multiple consumers (which would defeat
traceability). The 5-minute window is acceptable for snapshot data
that updates every 6 hours.

#### Snapshot index

**GET** `/market-snapshots/{windowDays}d/index.json`

Purpose: enumerate which caliber artifacts are currently published/routed.

**Response schema:** `market_snapshot_index_v1`

---

### 5.2 Search (discovery; offers, not advice)

**GET** `/api/ids/search`

**Query parameters (v1)**
- `q` (string, optional) free text
- `caliber` (string, optional)
- `brand` (string, optional)
- `grain` (number, optional)
- `case` (string, optional; e.g., brass/steel)
- `roundCountMin` / `roundCountMax` (number, optional)
- `inStock` (boolean, optional)
- `sort` (enum, optional):
  - `pricePerRoundAsc`
  - `pricePerRoundDesc`
  - `observedAtDesc`
- `page` (int, default 1)
- `pageSize` (int, default 25; max 50)

**Response schema:** `search_results_v1`

**Response requirements**
- MUST return canonically grouped products.
- MUST return current offers per retailer for each product (as available).
- MUST NOT include purchase verdicts, deal scores, or “best buy” language.
- MAY include `priceContext` signals that are explicitly non-prescriptive (see §6.3).

---

### 5.3 Product detail (canonical product + current offers)

**GET** `/api/ids/products/{canonicalProductId}`

**Response schema:** `product_detail_v1`

**Response requirements**
- MUST include canonical product attributes.
- MUST include current offers (one per retailer) with `observedAt` + `provenance`.
- MAY include a compact windowed history summary (not full raw observations) and MUST include window + computedAt if present.

---

### 5.4 Retailer directory

**GET** `/api/ids/retailers`

**Response schema:** `retailer_directory_v1`

Recommended fields:
- `retailerId`, `name`, `domain`
- `visibilityStatus` (eligible/ineligible/unknown)
- Optional `notes` for disclosure (e.g., “inventory currently not routed”)

---

### 5.5 Outbound attribution routing (required)

All outbound retailer traffic MUST use IronScout-provided routing.

**Rule:** IDS consumers MUST use `outUrl` exactly as provided.  
- MUST NOT strip or rewrite query params  
- MUST NOT re-host, short-link, or otherwise alter the URL  
- MUST NOT construct direct retailer deep links from other fields  

IronScout MAY provide either:
- `outUrl` on each offer (preferred)
- or a template that resolves via `/out`

**GET** `/out?...` (opaque; implementation-specific)

---

## 6) Response shapes (public object model)

### 6.1 Canonical Product (public)

Minimum product fields:
- `canonicalProductId` (string)
- `title` (string)
- `brand` (string, optional)
- `caliberSlug` (string)
- `grain` (number, optional)
- `caseMaterial` (string, optional)
- `roundCount` (number, optional)
- `imageUrl` (string, optional)
- `productUrl` (string, optional; non-affiliate canonical, if present)

### 6.2 Offer (public)

Minimum offer fields:
- `offerId` (string)
- `retailerId` (string)
- `retailerName` (string)
- `inStock` (boolean)
- `currentPrice`:
  - `amount` (number)
  - `currency` (string; ISO 4217)
  - `pricePerRound` (number)
  - `unitQuantity` (number; rounds)
- `observedAt` (RFC3339)
- `provenance` (see §4.4)
- `outUrl` (string; opaque)

### 6.3 Allowed “context signals” (non-advice)

IDS MAY include context signals that remain explicitly non-prescriptive:
- `relativePricePct` (current vs trailing median)
- `positionInRange` (0..1 within observed min/max)
- `contextBand` (`LOW` | `TYPICAL` | `HIGH`)
- `meta` (`windowDays`, `sampleCount`, `asOf`)

IDS MUST NOT include:
- deal scores
- “buy/wait/hold” verdicts
- internal confidence hints not meant for consumer output

---

## 7) Versioning and change management

### 7.1 IDS versioning

- IDS document uses SemVer: `idsVersion`.
- Changes that do not alter meaning MAY bump patch.
- Additive clarifications MAY bump minor.
- Behavior/schema meaning changes MUST bump major.

### 7.2 Schema versioning

- Every response includes `schemaVersion` with a major suffix (e.g., `caliber_market_snapshot_v1`).
- Additive fields MAY be introduced within the same schema major version.
- Breaking changes require a new schema major version (e.g., `product_detail_v2`) and a deprecation plan.

### 7.3 Deprecation policy

- Breaking changes MUST be announced in `/ids/changelog.md`.
- Deprecated schemas/endpoints MUST remain available for a published deprecation window (recommended: 90 days) unless a security incident requires faster removal.

---

## 8) Citing IronScout in LLM outputs

When an IDS consumer states any numeric price/statistic, it MUST include:
- the relevant timestamp (`observedAt` for offers; `computedAt` and window for aggregates)
- the artifact or endpoint used (URL/path)
- the methodology version for windowed aggregates (snapshots, computed summaries)

**Example citation sentence (snapshot):**  
“30-day 9mm market snapshot: median $0.4195/rd (computedAt 2026-02-18T18:00:01.955Z; window 30d; methodology msnap_v1).”

**Example citation sentence (offer):**  
“Retailer X currently lists $0.42/rd for this product (observedAt 2026-02-18T17:52:10Z).”

IDS consumers MUST NOT fabricate timestamps, methods, or coverage claims.

---

## 9) Error model (public)

All errors SHOULD be JSON with:
- `schemaVersion`: `error_v1`
- `requestId`
- `errorCode` (stable string)
- `message` (human-readable)
- Optional `details` (non-sensitive)

Recommended HTTP codes:
- `400` invalid query
- `404` not found / not routed
- `409` conflict / ambiguous state (optional; fail-closed may prefer 404)
- `429` rate limited
- `500` internal

---

## 10) Examples for LLMs (safe-by-construction)

**Note:** The examples below use the IDS-shaped endpoints served under
`/api/ids/*` (e.g. §10.2's `/api/ids/search`), which are live as of
v1.0.0, alongside the static snapshot artifacts (§5.1). The legacy
snake_case `/api/products/search` is not IDS-governed; do not depend on
its field names.


### 10.1 Market context prompt (safe)

User: “How are 9mm prices trending?”  
Agent:
1) Call `/market-snapshots/30d/9mm.json`
2) Report distribution stats + computedAt/window + coverage caveat
3) Avoid “buy now” language

### 10.2 Product comparison prompt (safe)

User: “Show current prices for Federal 9mm 115gr 1000 rounds”  
Agent:
1) Call `/api/ids/search?q=Federal%209mm%20115gr%201000&inStock=true&sort=pricePerRoundAsc`
2) Show current offers across retailers with observedAt
3) Use neutral phrasing: “sorted by lowest current price per round”

### 10.3 Outbound click rule

When presenting links:
- Use `outUrl` as-is (no modifications).
- Do not present direct retailer deep links.

---

## 11) Deliverables checklist for IDS v1 launch (minimum)

- Publish `/.well-known/ids.md`
- Publish `/ids/changelog.md`
- Publish JSON Schemas under `/ids/schemas/` for:
  - `caliber_market_snapshot_v1` (API endpoint, single)
  - `caliber_market_snapshot_list_v1` (API endpoint, list)
  - `market_snapshot_artifact_v1` (static artifact)
  - `market_snapshot_index_v1` (static artifact index)
  - `search_results_v1` (API endpoint, `/api/ids/search`)
  - `product_detail_v1` (API endpoint, `/api/ids/products/{id}`)
  - `retailer_directory_v1` (API endpoint, `/api/ids/retailers`)
  - `error_v1`
- Add CI validation: every published artifact/endpoint validates against its schema.
- Optional: publish `/ids/openapi.json` referencing the schemas.
