[
  {
    "id": "mod_intake_evidence",
    "name": "Intake & evidence",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Validates capture submissions, stores artifacts write-once, issues receipts and schedules parse jobs.",
    "responsibilities": [
      "EST|One versioned capture interface for all collectors",
      "PROP|Content-addressed artifact storage (sha256) in the private evidence bucket",
      "PROP|Receipts with stage history, separate from resolution results"
    ],
    "ownedData": [
      "PROP|resolver.capture, resolver.artifact, resolver.receipt, resolver.receipt_stage, resolver.job (pending jobs)"
    ],
    "invariants": [
      "PROP|(tenant_id, collector_id, idempotency_key) is unique",
      "PROP|An artifact object is never overwritten; same checksum → same object",
      "EST|Claims keep capture + locator; the artifact they cite is retained while referenced"
    ],
    "allowedWrites": [
      "PROP|Its own tables; evidence bucket put-if-absent"
    ],
    "publicInterfaces": [
      "PROP|POST /v1/captures, GET /v1/receipts/{id} (contracts.html)"
    ],
    "internalInterfaces": [
      "PROP|EvidenceReader.slice(artifactRef, locator) for extraction, review and replay"
    ],
    "sync": [
      "PROP|Capture → receipt"
    ],
    "async": [
      "PROP|Enqueue parse job (reference only) after commit"
    ],
    "tx": [
      "PROP|Capture + receipt + job row in one transaction; artifact is written before the transaction; enqueue happens after commit"
    ],
    "failure": [
      "PROP|Artifact written but transaction failed → orphan object, removed by retention sweep (unreferenced)",
      "PROP|Commit succeeded but enqueue failed → job row stays pending; a sweeper re-enqueues pending jobs older than a threshold (OPEN)"
    ],
    "scaling": [
      "OPEN|Move intake to its own Worker deployment when capture bursts measurably delay admin API calls"
    ]
  },
  {
    "id": "mod_extraction_normalization",
    "name": "Extraction & normalization",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Selects a versioned deterministic adapter (pa_*), normalizes with profiles (np_*), falls back to the LLM extractor, and records claims with method and version.",
    "responsibilities": [
      "EST|Deterministic parsing first with versioned adapter selection",
      "EST|LLM extraction is the fallback, invoked through act_llm_extractor",
      "PROP|Every claim records method, adapter/profile version, capture and locator"
    ],
    "ownedData": [
      "PROP|resolver.claim, resolver.extraction_run, resolver.unmapped_field, resolver.fallback_group"
    ],
    "invariants": [
      "EST|A claim without a capture + locator is rejected",
      "PROP|LLM claims are marked proposed and cannot be accepted without the rules or a human in mod_identity_relationships / mod_review",
      "PROP|Unmapped source fields are preserved, not dropped"
    ],
    "allowedWrites": [
      "PROP|Its own tables"
    ],
    "publicInterfaces": [
      "PROP|None (internal only)"
    ],
    "internalInterfaces": [
      "PROP|ExtractionService.run(captureRef) → claims[]",
      "PROP|ModelProvider port (adapter-only SDKs)"
    ],
    "sync": [],
    "async": [
      "PROP|Consumes parse jobs; emits identity jobs",
      "PROP|Emits LLM fallback jobs"
    ],
    "tx": [
      "PROP|Bulk upsert of claims + unmapped fields + extraction run in one transaction per capture"
    ],
    "failure": [
      "PROP|Adapter error → LLM fallback job and a fallback-group entry for the improvement loop",
      "PROP|LLM attempts bounded per capture revision (llm_extractor: 2 in fixtures)"
    ],
    "scaling": [
      "OPEN|Separate consumer pool when fallback share measurably grows (measure LLM fallback rate per source)"
    ]
  },
  {
    "id": "mod_identity_relationships",
    "name": "Identity & relationships",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Canonical catalog items, families, brands, packaging, bundles, typed relationships, attribute values and listing mappings; candidate search and R-ID rules.",
    "responsibilities": [
      "EST|Owns canonical identity and listing mappings",
      "PROP|Applies R-ID rules; auto-accepts only deterministic validated matches (DEC-02)",
      "PROP|Owns attribute definitions and their versions (BR-04)",
      "PROP|Identity impact analysis when an identity-relevant definition changes"
    ],
    "ownedData": [
      "PROP|resolver.catalog_item, item_identity_key, attribute_definition, attribute_value, relationship, listing, listing_mapping, organization, organization role, brand, family, bundle_component"
    ],
    "invariants": [
      "PROP|INV-01, INV-02, INV-07, INV-08 (see State & recovery)",
      "EST|500 g ≠ 2 × 250 g: packaging is part of identity"
    ],
    "allowedWrites": [
      "PROP|Its own tables + outbox rows (through UnitOfWork)"
    ],
    "publicInterfaces": [
      "PROP|Admin reads: items, mappings, candidates"
    ],
    "internalInterfaces": [
      "PROP|IdentityService.resolve(claims, uow) → match | candidates | case",
      "PROP|MappingRepository, CatalogItemRepository, AttributeDefinitionRepository"
    ],
    "sync": [
      "PROP|Called in-process by mod_review when a decision is applied"
    ],
    "async": [
      "PROP|Consumes identity jobs; emits review-case jobs"
    ],
    "tx": [
      "EST|Mapping/fact change + outbox in one transaction"
    ],
    "failure": [
      "PROP|Ambiguous or LLM-dependent result → durable review case, never a guess",
      "PROP|Concurrent mapping update → optimistic revision check fails → job retries with fresh state"
    ],
    "scaling": [
      "OPEN|Candidate search moves to a dedicated index only if set-based SQL candidate search (Q-shape in Data evolution) misses its target"
    ]
  },
  {
    "id": "mod_source_merchant_policy",
    "name": "Sources, merchants & policies",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Organizations and roles, merchants, sources, source profiles, typed locations and versioned shipping policies.",
    "responsibilities": [
      "EST|Merchant, source, source operator, brand and manufacturer are distinct roles",
      "EST|Shipping policies are versioned and conditional; updates change summaries without a product scrape",
      "PROP|Policy versions carry effective windows per scope"
    ],
    "ownedData": [
      "PROP|resolver.merchant, source, source_profile, location, shipping_policy, shipping_policy_version, policy_clause"
    ],
    "invariants": [
      "PROP|INV-03 non-overlapping effective windows per policy scope",
      "EST|No shipping inferred from merchant or collector location"
    ],
    "allowedWrites": [
      "PROP|Its own tables + outbox rows"
    ],
    "publicInterfaces": [
      "PROP|Admin reads: sources, merchants, policies"
    ],
    "internalInterfaces": [
      "PROP|PolicyService.proposeVersion(claims, uow)"
    ],
    "sync": [],
    "async": [
      "PROP|Consumes policy parse results; opens policy review cases"
    ],
    "tx": [
      "PROP|Policy version + clauses + outbox in one transaction"
    ],
    "failure": [
      "EST|Conflicting or unparsed blocking conditions → unknown, never eligible"
    ],
    "scaling": [
      "OPEN|None expected before many merchants; measure policy revisions per week"
    ]
  },
  {
    "id": "mod_review",
    "name": "Review & decisions",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Durable review cases, LLM reviewer attachment, human decisions with optimistic concurrency and decision history.",
    "responsibilities": [
      "EST|Review cases live in the database; attempts per revision are bounded",
      "EST|LLM reviewer is separate and attached to the durable case; it recommends only",
      "EST|Human approval for LLM-dependent new identities and changed mappings",
      "PROP|Immutable decision history; corrections are new decisions that reference prior ones"
    ],
    "ownedData": [
      "PROP|resolver.review_case, review_case_revision, decision, llm_recommendation"
    ],
    "invariants": [
      "PROP|INV-04 decision references the case revision it was made against",
      "EST|Model agreement is not independent evidence"
    ],
    "allowedWrites": [
      "PROP|Its own tables; applies accepted effects through module facades inside one UnitOfWork"
    ],
    "publicInterfaces": [
      "PROP|GET /v1/review, GET /v1/review/{id}, POST /v1/decisions (admin API)"
    ],
    "internalInterfaces": [
      "PROP|ReviewService.decide(caseId, expectedRevision, action, correction)"
    ],
    "sync": [
      "PROP|Decisions from the operator UI"
    ],
    "async": [
      "PROP|LLM reviewer jobs"
    ],
    "tx": [
      "EST|Decision + facts + outbox rows in one resolver transaction (AW-01)"
    ],
    "failure": [
      "PROP|Stale revision → 409 conflict, nothing applied",
      "PROP|LLM reviewer timeout → case stays awaiting human with \"no recommendation\""
    ],
    "scaling": [
      "OPEN|Reviewer workload: measure review minutes per case and queue age before adding auto-accept rules"
    ]
  },
  {
    "id": "mod_worker_status",
    "name": "Worker status",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Registers collector workers and instances, records heartbeats, leases and run reports. Never starts machines.",
    "responsibilities": [
      "EST|Records collector status; never starts machines",
      "PROP|Five separate health dimensions (collectors.html)"
    ],
    "ownedData": [
      "PROP|resolver.collector_worker, collector_instance, heartbeat, run_report"
    ],
    "invariants": [
      "PROP|Liveness derives from lease expiry, not from schedule"
    ],
    "allowedWrites": [
      "PROP|Its own tables"
    ],
    "publicInterfaces": [
      "PROP|POST /v1/workers/register, POST /v1/workers/{id}/heartbeats, GET /v1/workers"
    ],
    "internalInterfaces": [
      "PROP|WorkerStatus.liveness(cw)"
    ],
    "sync": [
      "PROP|Heartbeats"
    ],
    "async": [],
    "tx": [
      "PROP|Single-row upserts"
    ],
    "failure": [
      "PROP|Lease expired → liveness unknown; captures still accepted if authenticated"
    ],
    "scaling": [
      "OPEN|None expected"
    ]
  },
  {
    "id": "mod_improvement_replay",
    "name": "Improvement & replay",
    "parent": "svc_resolver",
    "status": "PROP",
    "summary": "Groups fallbacks into improvement proposals, drafts issues, and replays preserved evidence through new adapter/profile versions and new attribute definitions (backfill).",
    "responsibilities": [
      "PROP|Deduplicated fallback groups and issue drafts",
      "PROP|Replay pins new versions and keeps original observed_at",
      "PROP|Backfill of new attribute definitions from preserved evidence (BR-04)"
    ],
    "ownedData": [
      "PROP|resolver.improvement, replay_run, replay_result, backfill_run"
    ],
    "invariants": [
      "PROP|Replay never overwrites accepted facts; differences become claims → rules or review cases",
      "PROP|Replay and backfill are idempotent per (run id, capture id)"
    ],
    "allowedWrites": [
      "PROP|Its own tables; new claims through ExtractionService"
    ],
    "publicInterfaces": [
      "PROP|GET /v1/improvements/{id}, POST /v1/improvements/{id}/replays"
    ],
    "internalInterfaces": [
      "PROP|ReplayService.run(selector, versions)"
    ],
    "sync": [],
    "async": [
      "PROP|Replay/backfill jobs on the internal job queue (low priority)"
    ],
    "tx": [
      "PROP|Per-capture transaction; a run is resumable from its last committed capture"
    ],
    "failure": [
      "PROP|Partial run → resume; results are compared with the recorded decision, never applied blindly"
    ],
    "scaling": [
      "OPEN|Throttle or separate queue when backfill measurably delays fresh captures (measure job-queue lag during backfill)"
    ]
  }
]