[
  {
    "id": "if_source_fetch",
    "name": "Source fetch",
    "boundary": "collector-source",
    "status": "EST",
    "proto": "HTTPS",
    "caller": {
      "status": "EST",
      "text": "Collector instance (ci_*) on Workers + Cron, Browser Run, a local process or a regional host"
    },
    "receiver": {
      "status": "EST",
      "text": "External source: merchant website, marketplace API or feed (src_*)"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS to the source, from the collector's own egress. The resolver never fetches sources."
    },
    "auth": {
      "status": "PROP",
      "text": "Whatever the source requires (public pages, a partner API key held by the collector deployment). Source credentials never go to the resolver."
    },
    "authz": {
      "status": "PROP",
      "text": "The collector fetches only the targets in its registration (targets[]), and these must match the source grants in its token."
    },
    "idempotency": {
      "status": "EST",
      "text": "GET-only. Re-fetching gives a new capture with a new captured_at, and identical bytes dedupe by sha256 at upload."
    },
    "retries": {
      "status": "PROP",
      "text": "Retry 429/5xx with backoff and Retry-After. Report repeated failures in the heartbeat run report (if_heartbeat). Never retry 4xx except 408 and 429."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. execution_region and egress are recorded on the capture and are never treated as a market."
    },
    "tenant": {
      "status": "PROP",
      "text": "The tenant is implicit from the collector's registration. The source sees no tenant information."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Connect 10 s, total 30 s per page (browser render 60 s)."
    },
    "example": {
      "method": "GET",
      "url": "https://lumenparts.example.com/catalog/ssd/strata-p5-1tb-hs",
      "headers": {
        "User-Agent": "PRW-collector/0.2.0 (+contact placeholder)"
      }
    },
    "response": {
      "status": 200,
      "media_type": "text/html",
      "bytes": 233904
    },
    "errors": [
      [
        "429",
        "RATE_LIMITED",
        true,
        "Honor Retry-After"
      ],
      [
        "503",
        "UPSTREAM_503",
        true,
        "Reported as run.error in the heartbeat"
      ],
      [
        "404",
        "NOT_FOUND",
        false,
        "Recorded as a run outcome; listing may be retracted after review"
      ]
    ],
    "schemaFiles": [],
    "openapi": [],
    "related": [
      "if_artifact_upload",
      "if_heartbeat"
    ]
  },
  {
    "id": "if_collector_bootstrap",
    "name": "Collector registration / bootstrap",
    "boundary": "collector-resolver",
    "status": "PROP",
    "proto": "OAuth2 + HTTPS/JSON",
    "caller": {
      "status": "PROP",
      "text": "An operator provisions a per-instance machine client. The collector instance then registers itself."
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver intake Worker: POST /v1/workers/register"
    },
    "protocol": {
      "status": "PROP",
      "text": "(1) The operator creates a client in the admin UI or IdP and gets client_id and secret (or private_key_jwt). (2) The collector gets a token from the IdP token endpoint (OAuth2 client-credentials). (3) HTTPS/JSON POST /v1/workers/register with that bearer token."
    },
    "auth": {
      "status": "PROP",
      "text": "Collector OAuth2 client credentials with workers:register. Operator provisioning establishes worker, stable instance, tenant and source grants before token issuance. This request uses no operator cookie or CSRF headers; cryptography is simulated."
    },
    "authz": {
      "status": "PROP",
      "text": "worker_id must match the provisioned worker; instance_id and addressed instance must equal token sub (the stable instance, not client_id). targets must be a nonempty subset of provisioned source grants. Registration records capabilities and a lease; it does not create privileges or start a machine."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Registration is an upsert keyed by instance_id. Re-registering with the same body returns 200 with the existing lease."
    },
    "retries": {
      "status": "PROP",
      "text": "Exponential backoff with full jitter (base 1 s, cap 5 min, max 8 attempts). 401 CREDENTIAL_REVOKED is not retried. Stop and alert the operator."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. Registration only records the instance. It never starts machines."
    },
    "tenant": {
      "status": "PROP",
      "text": "Tenant and source grants come from the provisioned authenticated binding. Registration cannot select another tenant or expand grants. A source outside grants is denied with SOURCE_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Token request 10 s; register 10 s."
    },
    "example": {
      "worker_id": "cw_halden_us_browser",
      "instance_id": "ci_halden_fra_01",
      "version": "0.4.2",
      "runtime": "local-process",
      "runtime_kind": "long-running",
      "execution_region": {
        "country": "DE",
        "label": "Frankfurt regional host",
        "declared_by": "operator"
      },
      "egress": {
        "country": "DE",
        "verified": false
      },
      "targets": [
        "src_halden_us"
      ],
      "capabilities": [
        "browser.render",
        "html",
        "screenshot",
        "policy-pages"
      ]
    },
    "response": {
      "status": 201,
      "body": {
        "instance_id": "ci_halden_fra_01",
        "lease_seconds": 90,
        "heartbeat_url": "/v1/workers/ci_halden_fra_01/heartbeat",
        "granted": {
          "tenant_id": "ten_primary",
          "sources": [
            "src_halden_us"
          ]
        }
      }
    },
    "errors": [
      [
        "401",
        "INVALID_TOKEN",
        false,
        "Unknown, expired or incorrectly bound token"
      ],
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        "Revoked or superseded client"
      ],
      [
        "403",
        "MISSING_SCOPE",
        false,
        "workers:register absent"
      ],
      [
        "403",
        "INSTANCE_SCOPE_DENIED",
        false,
        "Different stable instance"
      ],
      [
        "403",
        "ATTRIBUTION_DENIED",
        false,
        "Different worker"
      ],
      [
        "403",
        "SOURCE_SCOPE_DENIED",
        false,
        "Target outside provisioned grants"
      ],
      [
        "403",
        "PERMISSION_DENIED",
        false,
        "Operator session is not collector registration"
      ],
      [
        "404",
        "NOT_FOUND",
        false,
        "Unknown server-side instance"
      ],
      [
        "503",
        "UNAVAILABLE",
        true,
        ""
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/workers/register",
      "securitySchemes.collectorOAuth2"
    ],
    "related": [
      "if_heartbeat",
      "if_capture_submit"
    ]
  },
  {
    "id": "if_artifact_upload",
    "name": "Artifact upload",
    "boundary": "collector-resolver",
    "status": "PROP",
    "proto": "HTTPS/JSON + PUT",
    "caller": {
      "status": "PROP",
      "text": "Collector instance"
    },
    "receiver": {
      "status": "PROP",
      "text": "Resolver intake: POST /v1/artifacts/uploads, then the bytes go to the returned target (a presigned R2 URL, or PUT /v1/artifacts/uploads/{upload_id} streamed through the resolver)"
    },
    "protocol": {
      "status": "PROP",
      "text": "Two-step. Declare source_id, sha256, bytes and media_type over HTTPS/JSON. The resolver derives worker_id and instance_id from the authenticated collector, verifies source_id against its grants, and returns an immutable upload target. PUT bytes using the presigned capability or authenticated streamed endpoint; the latter resolves ownership from stored upload metadata. Verify the digest before marking stored; an existing tenant digest returns already_stored."
    },
    "auth": {
      "status": "PROP",
      "text": "Per Trust & tenancy recommendation: an OAuth2 client-credentials JWT (scope artifacts:write). The presigned target URL is a short-lived capability (PROP 300 s TTL), valid for one key and one content length."
    },
    "authz": {
      "status": "PROP",
      "text": "Require artifacts:write and a granted source_id. Derive tenant, worker and stable instance from authenticated context, never require client attribution fields. Conflicting supplied worker/instance fields are denied. A streamed PUT uses the stored upload owner and source; a rotated credential for the same stable instance retains access."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Natural key (tenant_id, sha256). A repeated declare returns the same ref. If the object exists it returns already_stored, and a repeated PUT of the same bytes is harmless."
    },
    "retries": {
      "status": "PROP",
      "text": "Exponential backoff with full jitter (base 1 s, cap 5 min, max 8 attempts). Retry the PUT with the same upload_id until expires_at, then declare again. A digest mismatch (422 DIGEST_MISMATCH) is not retryable without re-reading the source bytes."
    },
    "delivery": {
      "status": "PROP",
      "text": "Request/response. An artifact can be referenced by a capture only after it reaches status stored. A capture citing an unstored ref gets 422 ARTIFACT_NOT_STORED."
    },
    "tenant": {
      "status": "PROP",
      "text": "The source_id selector must name a source in the authenticated tenant and provisioned grants. Object keys are derived from authenticated tenant + digest. Source outside grants: 403 SOURCE_SCOPE_DENIED; another tenant: 403 TENANT_SCOPE_DENIED; missing or conflicting attribution: 403 ATTRIBUTION_DENIED. Identical bytes in different tenants remain different objects."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Declare 10 s. PUT 120 s. The presigned URL expires after 300 s, so a slow upload re-declares."
    },
    "example": {
      "sha256": "a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
      "bytes": 233904,
      "media_type": "text/html",
      "capture_hint": "cap_lum_0310",
      "mode": "presigned",
      "source_id": "src_lumen_web"
    },
    "response": {
      "status": 201,
      "body": {
        "upload_id": "upl_0310_html",
        "status": "pending",
        "ref": "r2://evidence-private/ten_primary/a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
        "sha256": "a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
        "target": {
          "method": "PUT",
          "url": "https://evidence-upload.example.com/presigned/upl_0310_html?sig=<placeholder>",
          "headers": {
            "Content-Type": "text/html",
            "Content-Length": "233904"
          }
        },
        "expires_at": "2026-10-10T03:36:55Z"
      }
    },
    "errors": [
      [
        "200",
        "already_stored",
        false,
        "Same tenant+sha256 already stored; skip PUT"
      ],
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        ""
      ],
      [
        "413",
        "ARTIFACT_TOO_LARGE",
        false,
        "Above PROP limit"
      ],
      [
        "422",
        "DIGEST_MISMATCH",
        false,
        "Uploaded bytes do not hash to declared sha256"
      ],
      [
        "503",
        "STORAGE_UNAVAILABLE",
        true,
        ""
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        "Source belongs to another tenant"
      ],
      [
        "403",
        "SOURCE_SCOPE_DENIED",
        false,
        "Source is not granted"
      ],
      [
        "403",
        "MISSING_SCOPE",
        false,
        "artifacts:write absent"
      ],
      [
        "403",
        "ATTRIBUTION_DENIED",
        false,
        "Missing source selector or conflicting attribution"
      ],
      [
        "403",
        "INSTANCE_SCOPE_DENIED",
        false,
        "Another instance owns the streamed upload"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/artifacts/uploads",
      "PUT /v1/artifacts/uploads/{upload_id}"
    ],
    "related": [
      "if_capture_submit",
      "if_object_storage"
    ]
  },
  {
    "id": "if_capture_submit",
    "name": "Capture submission",
    "boundary": "collector-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Collector instance"
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver intake: POST /v1/captures"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON. Returns a durable receipt, never a resolution result."
    },
    "auth": {
      "status": "PROP",
      "text": "Proposed: OAuth2 client credentials. Bound tenant, stable collector instance, worker and assigned sources; required resource:action scope checked on every request. Cryptography simulated in this workbench."
    },
    "authz": {
      "status": "PROP",
      "text": "Scope captures:write. source.source_id must be in the token's source grants. worker_id and instance_id must match the token binding."
    },
    "idempotency": {
      "status": "EST",
      "text": "Idempotency-Key (worker_id:native_record_id:captured_at). Same key and same body → 200 with the existing receipt. Same key and a different body → 409 IDEMPOTENCY_CONFLICT. Dedupe window PROP 7 days."
    },
    "retries": {
      "status": "EST",
      "text": "503 and network errors: resubmit with the same Idempotency-Key, honoring Retry-After. 409, 422, 401 and 403 are not retryable."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. On 202, intake commits the capture and enqueues resolution.job.v1 (refs only)."
    },
    "tenant": {
      "status": "PROP",
      "text": "Tenant and catalog come from the authenticated identity (token claims or operator membership). Any client-supplied tenant_id, catalog_id or source_id is only checked against those grants; it never grants access. Mismatch → 403 TENANT_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Client total 15 s. Server commit budget 5 s."
    },
    "example": "@capture",
    "response": {
      "status": 202,
      "body": {
        "receipt_id": "rcpt_0310",
        "capture_id": "cap_lum_0310",
        "status": "stored",
        "received_at": "2026-10-10T03:32:01Z",
        "links": {
          "receipt": "/v1/receipts/rcpt_0310"
        }
      }
    },
    "errors": [
      [
        "200",
        "idempotent_replay",
        false,
        "Same key and body"
      ],
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        "Token valid in form but client revoked"
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        "source_id not granted"
      ],
      [
        "409",
        "IDEMPOTENCY_CONFLICT",
        false,
        ""
      ],
      [
        "422",
        "VALIDATION_FAILED",
        false,
        "Nothing persisted"
      ],
      [
        "422",
        "ARTIFACT_NOT_STORED",
        false,
        "Ref not uploaded/verified"
      ],
      [
        "503",
        "STORAGE_UNAVAILABLE",
        true,
        "Retry-After"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/captures"
    ],
    "related": [
      "if_artifact_upload",
      "if_receipt_get",
      "if_resolution_job"
    ]
  },
  {
    "id": "if_receipt_get",
    "name": "Receipt lookup",
    "boundary": "collector-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Collector instance (its own receipts); operator dashboard (any receipt in the tenant)"
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver: GET /v1/receipts/{id}"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Collector: OAuth2 client-credentials JWT (scope receipts:read). Operator: OIDC session (if_operator_session)."
    },
    "authz": {
      "status": "PROP",
      "text": "Collectors read receipts owned by their stable instance_id, not the rotating OAuth client_id. Credential rotation preserves legitimate receipt access. Authenticated unknown receipt: 404 NOT_FOUND; another tenant: 403 TENANT_SCOPE_DENIED; same tenant/source but another instance: 403 INSTANCE_SCOPE_DENIED. Operators use their tenant-scoped viewer grant."
    },
    "idempotency": {
      "status": "EST",
      "text": "Safe GET"
    },
    "retries": {
      "status": "PROP",
      "text": "Exponential backoff with full jitter (base 1 s, cap 5 min, max 8 attempts). Poll interval ≥ 30 s; prefer heartbeat-time batch checks."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. A receipt status of accepted never means applied."
    },
    "tenant": {
      "status": "PROP",
      "text": "Tenant and catalog come from the authenticated identity (token claims or operator membership). Any client-supplied tenant_id, catalog_id or source_id is only checked against those grants; it never grants access. Mismatch → 403 TENANT_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "5 s"
    },
    "example": {
      "method": "GET",
      "path": "/v1/receipts/rcpt_0310"
    },
    "response": "@receipt:rcpt_0310",
    "errors": [
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        ""
      ],
      [
        "404",
        "NOT_FOUND",
        false,
        "Unknown receipt"
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        "Different tenant"
      ],
      [
        "403",
        "INSTANCE_SCOPE_DENIED",
        false,
        "Another stable collector instance"
      ],
      [
        "403",
        "MISSING_SCOPE",
        false,
        "receipts:read absent"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "GET /v1/receipts/{id}"
    ],
    "related": [
      "if_capture_submit"
    ]
  },
  {
    "id": "if_heartbeat",
    "name": "Heartbeat and run report",
    "boundary": "collector-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Collector instance. Long-running instances send liveness; scheduled instances send a report per invocation."
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver: POST /v1/workers/{id}/heartbeat"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Per Trust & tenancy recommendation: an OAuth2 client-credentials JWT (scope workers:heartbeat)."
    },
    "authz": {
      "status": "PROP",
      "text": "The path {id} must equal the token's bound instance_id."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Keyed by (instance_id, at). A duplicate is recorded once."
    },
    "retries": {
      "status": "PROP",
      "text": "Best effort: retry once after 5 s, then skip to the next interval. A missed heartbeat is visible as lease expiry, not data loss."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. Lease 90 s (DEC-05, OPEN thresholds)."
    },
    "tenant": {
      "status": "PROP",
      "text": "Tenant and catalog come from the authenticated identity (token claims or operator membership). Any client-supplied tenant_id, catalog_id or source_id is only checked against those grants; it never grants access. Mismatch → 403 TENANT_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "5 s"
    },
    "example": {
      "instance_id": "ci_bazaarline_cf",
      "at": "2026-10-11T08:00:04Z",
      "run": {
        "started_at": "2026-10-11T08:00:00Z",
        "outcome": "failed",
        "error": {
          "code": "UPSTREAM_503",
          "retryable": true
        },
        "captures_submitted": 0
      }
    },
    "response": {
      "status": 200,
      "body": {
        "instance_id": "ci_bazaarline_cf",
        "consecutive_failures": 25,
        "last_success": "2026-10-09T06:00:41Z",
        "lease_expires_at": null
      }
    },
    "errors": [
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        "Collector must stop"
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        "Path id ≠ token instance"
      ],
      [
        "404",
        "UNKNOWN_INSTANCE",
        false,
        "Register first"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/workers/{id}/heartbeat"
    ],
    "related": [
      "if_collector_bootstrap"
    ]
  },
  {
    "id": "if_operator_session",
    "name": "Operator login and session",
    "boundary": "operator-resolver",
    "status": "PROP",
    "proto": "OIDC + cookie",
    "caller": {
      "status": "PROP",
      "text": "Operator browser (operator dashboard static assets on the admin origin)"
    },
    "receiver": {
      "status": "PROP",
      "text": "Admin origin (resolver administrative API) and the OIDC identity provider"
    },
    "protocol": {
      "status": "PROP",
      "text": "OIDC authorization-code flow with PKCE, then an HttpOnly, Secure, SameSite=Strict session cookie on the admin origin. HTTPS only."
    },
    "auth": {
      "status": "PROP",
      "text": "Per Trust & tenancy recommendation: an operator OIDC session. A Cloudflare Access application in front of the admin origin is optional defense in depth. See trust.html for P3's final recommendation."
    },
    "authz": {
      "status": "PROP",
      "text": "Roles per tenant: viewer, reviewer, operator_admin (P3 owns the role list). State-changing requests also need a CSRF token."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Not applicable to login. Decisions use expected_revision (if_review_decision)."
    },
    "retries": {
      "status": "PROP",
      "text": "On 401 SESSION_EXPIRED the UI re-runs login, then retries GET requests. POSTs are not replayed automatically."
    },
    "delivery": {
      "status": "PROP",
      "text": "Browser redirect flow"
    },
    "tenant": {
      "status": "PROP",
      "text": "The session holds the operator's tenant memberships. The active tenant is chosen in the UI and must be one of them. Requests for other tenants get 403 TENANT_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Session idle 30 min, absolute 12 h (P3 to confirm)."
    },
    "example": {
      "method": "GET",
      "path": "/auth/login?return_to=/review",
      "note": "redirect to IdP authorize endpoint (placeholder)"
    },
    "response": {
      "status": 302,
      "headers": {
        "Location": "https://idp.example.com/authorize?client_id=prw-admin&code_challenge=<placeholder>"
      }
    },
    "errors": [
      [
        "401",
        "SESSION_EXPIRED",
        false,
        "Re-login"
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        "No membership in requested tenant"
      ],
      [
        "403",
        "FORBIDDEN",
        false,
        "Role insufficient"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "securitySchemes.operatorOidc"
    ],
    "related": [
      "if_review_read",
      "if_review_decision",
      "if_admin_read"
    ]
  },
  {
    "id": "if_review_read",
    "name": "Review queue and case read",
    "boundary": "operator-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Operator dashboard"
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver admin API: GET /v1/review, GET /v1/review/{case_id}"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Operator OIDC session (if_operator_session)"
    },
    "authz": {
      "status": "PROP",
      "text": "Role viewer or higher. Evidence artifact links are short-lived signed reads."
    },
    "idempotency": {
      "status": "EST",
      "text": "Safe GET. The case revision is returned for the optimistic check in decisions."
    },
    "retries": {
      "status": "PROP",
      "text": "GET is safe to retry with backoff."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. Cases are durable rows, not queue messages."
    },
    "tenant": {
      "status": "PROP",
      "text": "Only cases in the active tenant are returned. Shared-catalog (cat_primary) cases are visible to operators with membership in the catalog's owning tenant (P3 to confirm)."
    },
    "timeouts": {
      "status": "PROP",
      "text": "10 s"
    },
    "example": {
      "method": "GET",
      "path": "/v1/review?status=awaiting_human&kind=identity_mapping"
    },
    "response": "@review",
    "errors": [
      [
        "401",
        "SESSION_EXPIRED",
        false,
        ""
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        ""
      ],
      [
        "404",
        "NOT_FOUND",
        false,
        "Unknown case"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "GET /v1/review",
      "GET /v1/review/{case_id}"
    ],
    "related": [
      "if_review_decision"
    ]
  },
  {
    "id": "if_review_decision",
    "name": "Review decision",
    "boundary": "operator-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Operator dashboard (human reviewer)"
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver admin API: POST /v1/decisions"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Operator OIDC session plus a CSRF token"
    },
    "authz": {
      "status": "PROP",
      "text": "Role reviewer. Identity-creating decisions need reviewer and the case kind's permission. An LLM actor can never call this."
    },
    "idempotency": {
      "status": "PROP",
      "text": "expected_revision (optimistic concurrency) plus an Idempotency-Key header (PROP, 24 h window). Facts and outbox rows commit in one transaction."
    },
    "retries": {
      "status": "PROP",
      "text": "Retry only on network errors or 503, with the same Idempotency-Key. 409 STALE_CASE_REVISION means reload and re-check, never retry blindly."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response. A 201 means accepted by the resolver. The product service has not applied it yet."
    },
    "tenant": {
      "status": "PROP",
      "text": "The case must belong to the active tenant. The decision is recorded with the operator principal and tenant_id."
    },
    "timeouts": {
      "status": "PROP",
      "text": "10 s"
    },
    "example": {
      "case_id": "rc_0007",
      "expected_revision": 2,
      "action": "correct",
      "corrections": {
        "core:mpn": "CVN-SP5-1T0-HS",
        "storage:heatsink": true
      },
      "create_item": {
        "id": "itm_ssd_p5_1t_hs",
        "family": "fam_strata_p5"
      },
      "map_to": "itm_ssd_p5_1t_hs",
      "rationale": "Spec grid and title agree on integrated heatsink; JSON-LD sku is stale."
    },
    "response": {
      "status": 201,
      "body": {
        "decision_id": "dec_0141",
        "case_id": "rc_0007",
        "case_revision": 3,
        "outbox": [
          "evt_0141",
          "evt_0142"
        ]
      }
    },
    "errors": [
      [
        "409",
        "STALE_CASE_REVISION",
        false,
        "Reload"
      ],
      [
        "403",
        "FORBIDDEN",
        false,
        "Role"
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        ""
      ],
      [
        "503",
        "UNAVAILABLE",
        true,
        "Same Idempotency-Key"
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/decisions"
    ],
    "related": [
      "if_accepted_change",
      "if_db"
    ]
  },
  {
    "id": "if_admin_read",
    "name": "Admin operations (workers, decisions, improvements, dry run)",
    "boundary": "operator-resolver",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Operator dashboard"
    },
    "receiver": {
      "status": "EST",
      "text": "Resolver admin API: GET /v1/workers, GET /v1/decisions/{id}, GET /v1/improvements, POST /v1/resolve (dry run)"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Operator OIDC session"
    },
    "authz": {
      "status": "PROP",
      "text": "Role viewer for GET requests. Role operator_admin for POST /v1/resolve (it reads evidence but writes nothing)."
    },
    "idempotency": {
      "status": "EST",
      "text": "GET is safe. A dry run has no side effects (writes: 0)."
    },
    "retries": {
      "status": "PROP",
      "text": "Safe to retry with backoff. A dry run writes nothing."
    },
    "delivery": {
      "status": "EST",
      "text": "Request/response"
    },
    "tenant": {
      "status": "PROP",
      "text": "Results are filtered to the active tenant"
    },
    "timeouts": {
      "status": "PROP",
      "text": "10 s; dry run 30 s"
    },
    "example": {
      "method": "POST",
      "path": "/v1/resolve",
      "body": {
        "capture_id": "cap_bzl_0906",
        "record": "OFR-55120-NF",
        "adapter": "auto",
        "dry_run": true
      }
    },
    "response": {
      "status": 200,
      "body": {
        "dry_run": true,
        "adapter": "pa_bazaarline_offers@1.4.1",
        "writes": 0
      }
    },
    "errors": [
      [
        "401",
        "SESSION_EXPIRED",
        false,
        ""
      ],
      [
        "403",
        "FORBIDDEN",
        false,
        ""
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "GET /v1/workers",
      "GET /v1/decisions/{id}",
      "GET /v1/improvements",
      "POST /v1/resolve"
    ],
    "related": [
      "if_operator_session"
    ]
  },
  {
    "id": "if_replay_job",
    "name": "Improvement replay",
    "boundary": "operator-resolver",
    "status": "PROP",
    "proto": "HTTPS/JSON → queue",
    "caller": {
      "status": "PROP",
      "text": "Operator dashboard (improvement loop)"
    },
    "receiver": {
      "status": "PROP",
      "text": "Resolver admin API: POST /v1/improvements/{id}/replays. The API fans out one resolution.replay.v1 message per capture to the internal queue."
    },
    "protocol": {
      "status": "PROP",
      "text": "HTTPS/JSON in, then a queue message (Cloudflare Queues, PROP) to the replay consumer"
    },
    "auth": {
      "status": "PROP",
      "text": "HTTP: operator OIDC session. Queue: platform producer/consumer bindings. There is no bearer token inside messages."
    },
    "authz": {
      "status": "PROP",
      "text": "Role operator_admin. mode=apply needs a reviewer-approved improvement. Dry run is the default."
    },
    "idempotency": {
      "status": "PROP",
      "text": "job_id = replay_id:capture_id. The consumer inbox dedupes. Replays preserve the original observed_at and pin the adapter and profile versions."
    },
    "retries": {
      "status": "PROP",
      "text": "At-least-once. No ordering guarantee is assumed. Consumers dedupe by message key in a durable inbox table and ack only after their DB transaction commits. After 5 failed deliveries (PROP) the message moves to the dead-letter queue. Retryable: storage or DB unavailable, LLM 429/5xx. Not retryable: adapter version missing, or capture not in tenant (sent straight to the DLQ)."
    },
    "delivery": {
      "status": "PROP",
      "text": "At-least-once. No ordering guarantee is assumed. Consumers dedupe by message key in a durable inbox table and ack only after their DB transaction commits. After 5 failed deliveries (PROP) the message moves to the dead-letter queue."
    },
    "tenant": {
      "status": "PROP",
      "text": "Captures must belong to the active tenant. tenant_id and catalog_id are stamped by the API from the session, and the consumer re-checks them against the capture rows."
    },
    "timeouts": {
      "status": "PROP",
      "text": "API 10 s. Consumer per message 60 s (verify against provider limit for queue consumer wall time)."
    },
    "example": "@replay",
    "response": {
      "status": 202,
      "body": {
        "replay_id": "rpl_0003_1",
        "captures": 4,
        "enqueued": 4,
        "preserves_observed_at": true
      }
    },
    "errors": [
      [
        "202",
        "queued",
        false,
        ""
      ],
      [
        "403",
        "FORBIDDEN",
        false,
        "operator_admin required"
      ],
      [
        "409",
        "REPLAY_IN_PROGRESS",
        false,
        "Same improvement+pins already running"
      ],
      [
        "422",
        "UNKNOWN_ADAPTER_VERSION",
        false,
        ""
      ],
      [
        "DLQ",
        "resolution-replay-dlq",
        false,
        "After 5 attempts"
      ]
    ],
    "schemaFiles": [
      "contracts/queue-schemas/resolution.replay.v1.json"
    ],
    "openapi": [
      "POST /v1/improvements/{id}/replays",
      "webhooks.resolutionReplay"
    ],
    "related": [
      "if_resolution_job",
      "if_object_storage"
    ]
  },
  {
    "id": "if_resolution_job",
    "name": "Resolution job",
    "boundary": "resolver-internal",
    "status": "PROP",
    "proto": "Queue",
    "caller": {
      "status": "PROP",
      "text": "Resolver intake (producer, after the capture commits)"
    },
    "receiver": {
      "status": "PROP",
      "text": "Resolver queue consumers: extraction → normalization → identity. Each stage enqueues the next stage message."
    },
    "protocol": {
      "status": "PROP",
      "text": "Queue (Cloudflare Queues, PROP), JSON message body"
    },
    "auth": {
      "status": "PROP",
      "text": "Platform queue bindings. Only the resolver Worker can produce, and only the resolver consumer can consume. Messages carry no credentials."
    },
    "authz": {
      "status": "PROP",
      "text": "The consumer re-reads the capture row and checks that tenant_id and catalog_id in the message match it. A mismatch goes to the DLQ and raises a security alert."
    },
    "idempotency": {
      "status": "PROP",
      "text": "job_id = capture_id:stage:attempt_group. The consumer writes job_id to its inbox table in the same transaction as its results. A duplicate is acked without side effects."
    },
    "retries": {
      "status": "PROP",
      "text": "Retryable: R2 or DB unavailable, Hyperdrive timeouts, LLM 429/5xx (backoff with delay). Not retryable: schema invalid, artifact sha256 mismatch, tenant mismatch → DLQ immediately."
    },
    "delivery": {
      "status": "PROP",
      "text": "At-least-once. No ordering guarantee is assumed. Consumers dedupe by message key in a durable inbox table and ack only after their DB transaction commits. After 5 failed deliveries (PROP) the message moves to the dead-letter queue."
    },
    "tenant": {
      "status": "PROP",
      "text": "tenant_id and catalog_id are copied from the committed capture row, never from collector input."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Per message 60 s. Verify against provider limit for consumer wall-clock and CPU."
    },
    "example": "@job",
    "response": {
      "ack": "after consumer DB commit",
      "outcome": [
        "stage_completed",
        "review_case_opened",
        "ignored_duplicate"
      ]
    },
    "errors": [
      [
        "retry",
        "STORAGE_UNAVAILABLE",
        true,
        "Delayed retry"
      ],
      [
        "DLQ",
        "SCHEMA_INVALID",
        false,
        ""
      ],
      [
        "DLQ",
        "TENANT_MISMATCH",
        false,
        "Security alert"
      ],
      [
        "DLQ",
        "DIGEST_MISMATCH",
        false,
        "Artifact bytes ≠ sha256"
      ]
    ],
    "schemaFiles": [
      "contracts/queue-schemas/resolution.job.v1.json"
    ],
    "openapi": [
      "webhooks.resolutionJob"
    ],
    "related": [
      "if_capture_submit",
      "if_llm_extract",
      "if_object_storage",
      "if_db"
    ]
  },
  {
    "id": "if_llm_extract",
    "name": "LLM extraction",
    "boundary": "resolver-providers",
    "status": "PROP",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "PROP",
      "text": "Resolver extraction consumer, when no adapter supports the capture or the adapter failed"
    },
    "receiver": {
      "status": "PROP",
      "text": "Model provider API (OPEN which provider)"
    },
    "protocol": {
      "status": "PROP",
      "text": "HTTPS/JSON to the provider, with structured-output prompt extract-listing@5"
    },
    "auth": {
      "status": "PROP",
      "text": "A provider API key held as a resolver secret (Workers secret / secret store). It is never sent to collectors or the browser. P3 owns rotation."
    },
    "authz": {
      "status": "PROP",
      "text": "Outputs are proposals only. They can open or annotate review cases and can never accept facts. Inputs are untrusted (page text can carry prompt injection), so the model gets no tools and no credentials."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Keyed by (capture_id, artifact sha256, prompt version, model id). A cached result is reused on queue redelivery."
    },
    "retries": {
      "status": "PROP",
      "text": "Retry 429/5xx with backoff, up to 3 times. A schema-invalid output is retried once with a repair prompt, then recorded as an extraction failure."
    },
    "delivery": {
      "status": "PROP",
      "text": "Request/response inside a queue consumer"
    },
    "tenant": {
      "status": "PROP",
      "text": "One request per capture. No cross-tenant context in a prompt. The provider must not retain data or train on it (OPEN, contract term)."
    },
    "timeouts": {
      "status": "PROP",
      "text": "60 s per call"
    },
    "example": {
      "model": "<provider model id>",
      "prompt_version": "extract-listing@5",
      "input": {
        "capture_id": "cap_lum_0310",
        "artifact_sha256": "a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
        "text_excerpt": "<untrusted page text>"
      },
      "response_format": "json_schema:claims/1"
    },
    "response": {
      "claims": [
        {
          "attribute": "core:mpn",
          "value": "CVN-SP5-1T0-HS",
          "evidence_locator": "#specs tr:nth-child(3)"
        }
      ],
      "diagnostics": [
        "JSON-LD sku conflicts with spec grid"
      ]
    },
    "errors": [
      [
        "429",
        "RATE_LIMITED",
        true,
        ""
      ],
      [
        "5xx",
        "PROVIDER_ERROR",
        true,
        ""
      ],
      [
        "invalid",
        "OUTPUT_SCHEMA_INVALID",
        false,
        "Recorded; human review"
      ]
    ],
    "schemaFiles": [],
    "openapi": [],
    "related": [
      "if_resolution_job",
      "if_llm_review"
    ]
  },
  {
    "id": "if_llm_review",
    "name": "LLM review advice",
    "boundary": "resolver-providers",
    "status": "PROP",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "PROP",
      "text": "Resolver review consumer"
    },
    "receiver": {
      "status": "PROP",
      "text": "Model provider API (OPEN which provider)"
    },
    "protocol": {
      "status": "PROP",
      "text": "HTTPS/JSON, prompt review-case@3"
    },
    "auth": {
      "status": "PROP",
      "text": "Provider API key (resolver secret), as for if_llm_extract"
    },
    "authz": {
      "status": "EST",
      "text": "Advisory only. LLM-dependent new identities or mapping changes need a human decision (DEC-02)."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Keyed by (case_id, case revision, prompt version)"
    },
    "retries": {
      "status": "PROP",
      "text": "As for if_llm_extract"
    },
    "delivery": {
      "status": "PROP",
      "text": "Request/response inside a queue consumer"
    },
    "tenant": {
      "status": "PROP",
      "text": "Only the case's own evidence is sent"
    },
    "timeouts": {
      "status": "PROP",
      "text": "60 s"
    },
    "example": {
      "prompt_version": "review-case@3",
      "case_id": "rc_0007",
      "revision": 2,
      "evidence": [
        "<untrusted excerpts>"
      ]
    },
    "response": {
      "recommendation": {
        "action": "correct",
        "rationale": "<model text>",
        "cited_evidence": [
          "cap_lum_0310"
        ]
      },
      "advisory": true
    },
    "errors": [
      [
        "429",
        "RATE_LIMITED",
        true,
        ""
      ],
      [
        "invalid",
        "OUTPUT_SCHEMA_INVALID",
        false,
        "Case stays awaiting_human"
      ]
    ],
    "schemaFiles": [],
    "openapi": [],
    "related": [
      "if_llm_extract",
      "if_review_read"
    ]
  },
  {
    "id": "if_accepted_change",
    "name": "Accepted-change event",
    "boundary": "resolver-product",
    "status": "EST",
    "proto": "Queue",
    "caller": {
      "status": "EST",
      "text": "Resolver outbox relay (reads committed outbox rows)"
    },
    "receiver": {
      "status": "EST",
      "text": "Product service inbox consumer"
    },
    "protocol": {
      "status": "PROP",
      "text": "Queue product.accepted-change.v1 (Cloudflare Queues, PROP), JSON body"
    },
    "auth": {
      "status": "PROP",
      "text": "Platform queue bindings: resolver relay = producer, product Worker = consumer. No credentials in messages."
    },
    "authz": {
      "status": "PROP",
      "text": "The product service accepts events only for (tenant_id, catalog_id) pairs it is configured to project. Others go to the DLQ and raise an alert."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Dedupe on tenant/catalog/history_epoch/event_id. Committed sequence is a transactional per-stream cursor. Check epoch before inbox; old-history events are terminally ignored, never relabeled. Revision and dependencies decide application."
    },
    "retries": {
      "status": "PROP",
      "text": "The relay retries publishing until the queue acks, then marks the row published_at. The consumer retries on DB unavailable. A parked event (missing dependency) is stored in the inbox and acked."
    },
    "delivery": {
      "status": "PROP",
      "text": "Accepted-change committed positions are contiguous within (tenant_id, catalog_id, history_epoch). Writers allocate them with state and outbox under the transactional cursor lock; rollback consumes no position. Queue arrivals may be unordered, duplicated or temporarily missing, so consumers park dependencies and retrieve retained events to close gaps. Durable progress advances only through every processed position, including safely ignored stale revisions. A DLQ entry or an ACK of a parked event does not close a gap. Internal resolution/replay jobs use their own dedupe IDs and do not share this committed cursor. Commit projection changes, inbox outcomes, dependency parking and the greatest contiguous processed position together in the live product transaction. Persist/report progress monotonically to resolver retention control for the same tenant, catalog and history epoch only after that commit; an interrupted report conservatively retains extra events. The pruner may use only acknowledged durable progress. Shadow catch-up has a separate cursor and lease; it never advances the live cursor. Cutover atomically swaps serving generation and durable live cursor before releasing the rebuild lease."
    },
    "tenant": {
      "status": "PROP",
      "text": "tenant_id and catalog_id are stamped from the outbox row (the resolver's own data). The product service never trusts payload fields for scope."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Consumer per batch 30 s"
    },
    "example": "@event:evt_0142",
    "response": {
      "outcome": "applied",
      "projection": "listing_mapping:lst_lum_p5_1t_hs → rev 2"
    },
    "errors": [
      [
        "ignored",
        "ignored_duplicate",
        false,
        "ack"
      ],
      [
        "ignored",
        "ignored_stale_revision",
        false,
        "ack"
      ],
      [
        "parked",
        "awaiting dependency",
        false,
        "stored then ack"
      ],
      [
        "retry",
        "DB_UNAVAILABLE",
        true,
        ""
      ],
      [
        "DLQ",
        "SCOPE_NOT_PROJECTED",
        false,
        "Unknown tenant/catalog"
      ]
    ],
    "schemaFiles": [
      "contracts/queue-schemas/product.accepted-change.v1.json"
    ],
    "openapi": [
      "webhooks.acceptedChange"
    ],
    "related": [
      "if_review_decision",
      "if_projection_bootstrap"
    ]
  },
  {
    "id": "if_projection_bootstrap",
    "name": "Projection snapshot export (bootstrap)",
    "boundary": "resolver-product",
    "status": "PROP",
    "proto": "HTTPS/JSON + S3 GET",
    "caller": {
      "status": "PROP",
      "text": "Product service (new deployment, new catalog, or rebuild after projection loss)"
    },
    "receiver": {
      "status": "PROP",
      "text": "Resolver export API: POST /v1/exports/snapshots, GET /v1/exports/snapshots/{id}"
    },
    "protocol": {
      "status": "PROP",
      "text": "Request pinned repeatable-read snapshot over HTTP; renew lease; load immutable objects, read ordered retained tail, catch up to H, atomically cut over projection pointer and live cursor. Queue delivery alone is not a replay source."
    },
    "auth": {
      "status": "PROP",
      "text": "Per Trust & tenancy recommendation: an OAuth2 client-credentials JWT for the product service principal (scope exports:read)."
    },
    "authz": {
      "status": "PROP",
      "text": "The product principal may export only the tenant and catalog pairs it is granted. Signed object URLs are scoped to one export."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Idempotency-Key on POST. The same key returns the same export_id. Loading is idempotent: the product upserts by aggregate revision."
    },
    "retries": {
      "status": "PROP",
      "text": "Exponential backoff with full jitter (base 1 s, cap 5 min, max 8 attempts). An expired signed URL is fixed by re-GETting the manifest."
    },
    "delivery": {
      "status": "PROP",
      "text": "Serialize writers: Lock the cursor row for tenant_id + catalog_id + history_epoch BEFORE changing exported state. Update the transactional counter, accepted rows and outbox in one transaction. Rollback consumes no position. A delayed writer blocks later commits in this stream.\n\nPin retention: Under the retention-control lock shared by the pruner, create a renewable rebuild lease pinned after current pruned-through cursor P. Commit before opening the snapshot.\n\nRead one snapshot: REPEATABLE READ READ ONLY: read history_epoch, committed stream cursor W, and all accepted state including private/public releases and aggregate revisions in one snapshot. Never use max(outbox.sequence) or a PostgreSQL sequence.\n\nPublish export: Advance lease pin from P to W under retention coordination. Write checksummed export objects. Manifest becomes ready only after export completion and a valid lease check; abort on epoch change.\n\nLoad shadow: Load the manifest into an isolated projection generation, with aggregate revisions and W as its starting contiguous applied position. Renew the lease throughout export/load/catch-up.\n\nRetrieve retained events: GET /v1/exports/events?after=W&lease_id=…&history_epoch=… returns ordered authorized stream events. The pruner cannot remove events after an active pin. A stale cursor or expired lease returns 410; restart from a new export.\n\nCatch up and cut over: Choose committed target H. Apply every position through H, retaining dedupe, revisions and dependency parking. Require no unresolved dependencies at/below H. Pause the local applier, atomically swap the serving generation and durable consumer cursor, resume after H, then release the lease.\n\nAdvance live retention progress: Commit projection changes, inbox outcomes, dependency parking and the greatest contiguous processed position together in the live product transaction. Persist/report progress monotonically to resolver retention control for the same tenant, catalog and history epoch only after that commit; an interrupted report conservatively retains extra events. The pruner may use only acknowledged durable progress. Shadow catch-up has a separate cursor and lease; it never advances the live cursor. Cutover atomically swaps serving generation and durable live cursor before releasing the rebuild lease.\n\nFence restored history: Pause writers, relay, consumers and public serving; issue a NEW epoch in a trusted control plane outside the restored database. Fence old workers; abandon old events without relabeling; initialize a new cursor and rebuild from restored state. Audit and terminally ignore old-epoch deliveries."
    },
    "tenant": {
      "status": "PROP",
      "text": "Tenant and catalog come from the authenticated identity (token claims or operator membership). Any client-supplied tenant_id, catalog_id or source_id is only checked against those grants; it never grants access. Mismatch → 403 TENANT_SCOPE_DENIED."
    },
    "timeouts": {
      "status": "PROP",
      "text": "POST 10 s. Poll until ready (export runs async). Object GET 120 s each."
    },
    "example": {
      "tenant_id": "ten_primary",
      "catalog_id": "cat_primary",
      "aggregates": [
        "catalog_item",
        "listing_mapping",
        "merchant",
        "shipping_policy",
        "inventory_observation"
      ]
    },
    "response": "@manifest",
    "errors": [
      [
        "202",
        "pending",
        false,
        "Poll GET"
      ],
      [
        "401",
        "CREDENTIAL_REVOKED",
        false,
        ""
      ],
      [
        "403",
        "TENANT_SCOPE_DENIED",
        false,
        ""
      ],
      [
        "410",
        "EXPORT_EXPIRED",
        false,
        "Request a new export"
      ],
      [
        "503",
        "UNAVAILABLE",
        true,
        ""
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "POST /v1/exports/snapshots",
      "GET /v1/exports/snapshots/{id}"
    ],
    "related": [
      "if_accepted_change",
      "if_object_storage"
    ]
  },
  {
    "id": "if_product_query",
    "name": "Product API queries",
    "boundary": "consumer-product",
    "status": "EST",
    "proto": "HTTPS/JSON",
    "caller": {
      "status": "EST",
      "text": "Consumer frontend (product preview)"
    },
    "receiver": {
      "status": "EST",
      "text": "Product service API: GET /v1/items…, /v1/merchants…, /v1/sources/{id}, POST /v1/shipping/evaluations"
    },
    "protocol": {
      "status": "EST",
      "text": "HTTPS/JSON"
    },
    "auth": {
      "status": "PROP",
      "text": "Public read with no user identity. Rate limiting is at the edge. An optional publishable API key per frontend is OPEN (P3)."
    },
    "authz": {
      "status": "PROP",
      "text": "Use shared public serializers backed by an authorized, applied release snapshot. Item identity, published_revision and publication_revision are consistent across collection/detail/listings/inventory. Internal acceptance or projection application does not publish a record. Preserve allowed original URLs and observation times; never emit private evidence, capture/review/decision references or internal tags."
    },
    "idempotency": {
      "status": "EST",
      "text": "Safe. Shipping evaluation is computed per request and stores nothing."
    },
    "retries": {
      "status": "PROP",
      "text": "GET and shipping evaluation are side-effect-free. Retry 5xx once."
    },
    "delivery": {
      "status": "EST",
      "text": "Release authorization snapshots public fields and accepted listing mappings. Application waits for item and mapping dependencies, then changes the release pointer and affected public listing assignments in one product transaction. Each listing has one public item owner; a later internal remap remains private until an applied release. published_revision is the released item revision; publication_revision identifies the release. No public serializer reads live internal mappings."
    },
    "tenant": {
      "status": "PROP",
      "text": "Deployment binds ten_primary and tenant-private cat_primary. Shared catalogs plus overlays are a future alternative requiring a new decision."
    },
    "timeouts": {
      "status": "PROP",
      "text": "5 s; shipping evaluation 5 s"
    },
    "example": {
      "method": "GET",
      "path": "/v1/items?class=cls_storage_ssd&attr[storage:capacity]=1 TB"
    },
    "response": "@items",
    "errors": [
      [
        "404",
        "NOT_FOUND",
        false,
        ""
      ],
      [
        "422",
        "BASKET_SPANS_MERCHANTS",
        false,
        ""
      ],
      [
        "422",
        "INVALID_DESTINATION",
        false,
        ""
      ],
      [
        "429",
        "RATE_LIMITED",
        true,
        ""
      ]
    ],
    "schemaFiles": [],
    "openapi": [
      "GET /v1/items",
      "GET /v1/merchants",
      "POST /v1/shipping/evaluations"
    ],
    "related": [
      "if_db"
    ]
  },
  {
    "id": "if_object_storage",
    "name": "Evidence object storage",
    "boundary": "storage-db",
    "status": "EST",
    "proto": "R2 binding / S3",
    "caller": {
      "status": "EST",
      "text": "Resolver intake (write), resolver consumers and replay (read), export job (write export objects)"
    },
    "receiver": {
      "status": "EST",
      "text": "Private R2 buckets: evidence-private, exports-private (PROP name)"
    },
    "protocol": {
      "status": "PROP",
      "text": "R2 conditional put using etagDoesNotMatch:*; filesystem temp envelope + fsync + exclusive atomic link. Port get returns the entire object as Uint8Array; streaming/ranged reads need a separate future port."
    },
    "auth": {
      "status": "PROP",
      "text": "Workers binding (no key material in code). Presigned URLs are signed by the resolver with an R2 API token held as a secret (P3 owns scope and rotation)."
    },
    "authz": {
      "status": "PROP",
      "text": "The buckets are private. There is no public bucket access and no public r2.dev access. Only the resolver can write evidence; artifacts are immutable once stored."
    },
    "idempotency": {
      "status": "PROP",
      "text": "Atomic create-if-absent commits bytes and immutable metadata together. Retry succeeds only when digest, size and media type agree. Metadata mismatch is 409."
    },
    "retries": {
      "status": "PROP",
      "text": "Retry 5xx/network with backoff. A put is idempotent by content-addressed key."
    },
    "delivery": {
      "status": "EST",
      "text": "Strongly consistent read-after-write is assumed. Verify against provider documentation."
    },
    "tenant": {
      "status": "PROP",
      "text": "The tenant prefix is derived server-side. Reads check that the object's tenant prefix equals the caller's tenant."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Binding op 30 s"
    },
    "example": {
      "op": "putIfAbsent",
      "key": "evidence-private/ten_primary/a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
      "sha256": "a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192",
      "bytes": 233904,
      "media_type": "text/html"
    },
    "response": {
      "etag": "<etag>",
      "stored": true
    },
    "errors": [
      [
        "409",
        "IMMUTABLE_METADATA_CONFLICT",
        false,
        "Do not overwrite or reinterpret existing bytes"
      ],
      [
        "503",
        "STORAGE_UNAVAILABLE",
        true,
        "Retry same digest and metadata"
      ]
    ],
    "schemaFiles": [],
    "openapi": [],
    "related": [
      "if_artifact_upload",
      "if_resolution_job"
    ]
  },
  {
    "id": "if_db",
    "name": "Service databases",
    "boundary": "storage-db",
    "status": "EST",
    "proto": "SQL (Hyperdrive)",
    "caller": {
      "status": "EST",
      "text": "Resolver Workers and consumers; outbox relay; product Worker"
    },
    "receiver": {
      "status": "EST",
      "text": "Managed PostgreSQL via Hyperdrive (resolver DB). The product projection store is a separate database or schema (OPEN)."
    },
    "protocol": {
      "status": "EST",
      "text": "SQL (PostgreSQL wire protocol through Hyperdrive)"
    },
    "auth": {
      "status": "PROP",
      "text": "Per-service database roles: resolver_app, resolver_relay, product_app. Credentials live in the Hyperdrive config, never in code."
    },
    "authz": {
      "status": "PROP",
      "text": "Least privilege. The relay can read the outbox and update published_at only. product_app has no access to the resolver schema. Row-level security by tenant_id (P3 recommends)."
    },
    "idempotency": {
      "status": "EST",
      "text": "Facts and outbox are written in one transaction. Consumer inbox keys are unique."
    },
    "retries": {
      "status": "PROP",
      "text": "Retry serialization failures and connection resets up to 3 times. Never retry after an ambiguous commit without an idempotency check."
    },
    "delivery": {
      "status": "EST",
      "text": "Transactional"
    },
    "tenant": {
      "status": "PROP",
      "text": "Every tenant-scoped row has tenant_id. The session sets app.tenant_id from the authenticated context for RLS. Synthetic tenants ten_synth_a and ten_synth_b exist only to demonstrate isolation."
    },
    "timeouts": {
      "status": "PROP",
      "text": "Statement timeout 5 s (API), 30 s (consumers)"
    },
    "example": {
      "sql": "BEGIN; SELECT position FROM stream_cursor WHERE tenant_id=$1 AND catalog_id=$2 AND history_epoch=$3 FOR UPDATE; /* facts + outbox positions + cursor updated together */ COMMIT;",
      "role": "resolver_app"
    },
    "response": {
      "rows": 1
    },
    "errors": [
      [
        "40001",
        "serialization_failure",
        true,
        ""
      ],
      [
        "57014",
        "statement_timeout",
        true,
        "Once"
      ],
      [
        "42501",
        "insufficient_privilege",
        false,
        "Role misconfig"
      ]
    ],
    "schemaFiles": [],
    "openapi": [],
    "related": [
      "if_review_decision",
      "if_accepted_change"
    ]
  }
]