03 · Services and boundaries
Who owns the record.
ADR-001 proposes three deployable roles. Collectors acquire. The resolution service is one modular process that accepts facts. The product service applies projections and never mints identity. Operator and reader interfaces own no records.
Proposed Established split
Figure
What does each part own, and where does its job stop?
Takeaway. The resolver writes object storage and its database separately. The object put stays outside the SQL transaction. A neighbor may read an event. It may not write the owner’s tables.
Text equivalent
- Collectors own local cursors only and stop when a receipt is durable.
- The resolver puts bytes in the evidence store and, separately, commits its own schema.
- Object storage is not inside the SQL transaction, and it does not write the database.
- The product service owns inbox rows and projections and stops at an applied release.
- Operator and consumer interfaces own no catalog records.
Six places people mix up
| Place | Responsibility | Stops when |
|---|---|---|
| Collector | Fetch a granted source. Upload bytes. Submit a capture. Heartbeat. | A receipt is stored, or the submission is refused. It does not parse for identity. |
| External source | The merchant page, feed, or API. It is not our software. | It never sees our tenant, and it never receives our decisions. |
| Resolution service | Evidence, claims, identity, policy, review, worker status, replay. | An accepted event is in the outbox. It does not serve readers. |
| Product service | Inbox, projections, shipping answers, authorized reads. | It does not create catalog identity and does not read the resolver tables. |
| Operator interface | Review, release, and improvement. Calls the admin API only. | It holds no product rows. A click is not durable until the API commits. |
| Reader interface | Query the product API. | It cannot see unreleased corrections or the evidence bucket. |
| External provider | Model, browser, or metered fetch. Untrusted output. | The provider key stays in the adapter. The actor prompt does not receive it. |
The resolver writes the evidence store and the resolver schema as two operations. The object put stays outside the SQL transaction. Nothing in the figure runs a transaction from the evidence store into the database.
Modules inside the resolution service
These seven modules are proposed parts of one process, not seven deployments. Status tags on each bullet come from the workbench inventory. EST means the rule is established in the design. PROP means the mechanism is proposed. OPEN means Jonathan has not decided.
svc_resolver · mod_intake_evidence
Intake & evidence
PROP
Validates capture submissions, stores artifacts write-once, issues receipts and schedules parse jobs.
Why it exists
- 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
What it owns and may modify
- PROP resolver.capture, resolver.artifact, resolver.receipt, resolver.receipt_stage, resolver.job (pending jobs)
Allowed writes: Its own tables; evidence bucket put-if-absent.
Where it stops
- 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
Callers, dependencies, and failure
- PROP Capture → receipt
- PROP Enqueue parse job (reference only) after commit
- PROP Capture + receipt + job row in one transaction; artifact is written before the transaction; enqueue happens after commit
- 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)
Public surface: PROP|POST /v1/captures, GET /v1/receipts/{id} (OpenAPI in the local reference).
Inside the process: PROP|EvidenceReader.slice(artifactRef, locator) for extraction, review and replay.
Scaling note, still open: OPEN|Move intake to its own Worker deployment when capture bursts measurably delay admin API calls.
svc_resolver · mod_extraction_normalization
Extraction & normalization
PROP
Selects a versioned deterministic adapter (pa_*), normalizes with profiles (np_*), falls back to the LLM extractor, and records claims with method and version.
Why it exists
- 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
What it owns and may modify
- PROP resolver.claim, resolver.extraction_run, resolver.unmapped_field, resolver.fallback_group
Allowed writes: Its own tables.
Where it stops
- 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
Callers, dependencies, and failure
- PROP Consumes parse jobs; emits identity jobs
- PROP Emits LLM fallback jobs
- PROP Bulk upsert of claims + unmapped fields + extraction run in one transaction per capture
- 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)
Public surface: PROP|None (internal only).
Inside the process: PROP|ExtractionService.run(captureRef) → claims[]; PROP|ModelProvider port (adapter-only SDKs).
Scaling note, still open: OPEN|Separate consumer pool when fallback share measurably grows (measure LLM fallback rate per source).
svc_resolver · mod_identity_relationships
Identity & relationships
PROP
Canonical catalog items, families, brands, packaging, bundles, typed relationships, attribute values and listing mappings; candidate search and R-ID rules.
Why it exists
- 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
What it owns and may modify
- PROP resolver.catalog_item, item_identity_key, attribute_definition, attribute_value, relationship, listing, listing_mapping, organization, organization role, brand, family, bundle_component
Allowed writes: Its own tables + outbox rows (through UnitOfWork).
Where it stops
- PROP INV-01, INV-02, INV-07, INV-08 (see recovery in the local reference)
- EST 500 g ≠ 2 × 250 g: packaging is part of identity
Callers, dependencies, and failure
- PROP Called in-process by mod_review when a decision is applied
- PROP Consumes identity jobs; emits review-case jobs
- EST Mapping/fact change + outbox in one transaction
- 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
Public surface: PROP|Admin reads: items, mappings, candidates.
Inside the process: PROP|IdentityService.resolve(claims, uow) → match | candidates | case; PROP|MappingRepository, CatalogItemRepository, AttributeDefinitionRepository.
Scaling note, still open: OPEN|Candidate search moves to a dedicated index only if set-based SQL candidate search (Q-shape in Data evolution) misses its target.
svc_resolver · mod_source_merchant_policy
Sources, merchants & policies
PROP
Organizations and roles, merchants, sources, source profiles, typed locations and versioned shipping policies.
Why it exists
- 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
What it owns and may modify
- PROP resolver.merchant, source, source_profile, location, shipping_policy, shipping_policy_version, policy_clause
Allowed writes: Its own tables + outbox rows.
Where it stops
- PROP INV-03 non-overlapping effective windows per policy scope
- EST No shipping inferred from merchant or collector location
Callers, dependencies, and failure
- PROP Consumes policy parse results; opens policy review cases
- PROP Policy version + clauses + outbox in one transaction
- EST Conflicting or unparsed blocking conditions → unknown, never eligible
Public surface: PROP|Admin reads: sources, merchants, policies.
Inside the process: PROP|PolicyService.proposeVersion(claims, uow).
Scaling note, still open: OPEN|None expected before many merchants; measure policy revisions per week.
svc_resolver · mod_review
Review & decisions
PROP
Durable review cases, LLM reviewer attachment, human decisions with optimistic concurrency and decision history.
Why it exists
- 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
What it owns and may modify
- PROP resolver.review_case, review_case_revision, decision, llm_recommendation
Allowed writes: Its own tables; applies accepted effects through module facades inside one UnitOfWork.
Where it stops
- PROP INV-04 decision references the case revision it was made against
- EST Model agreement is not independent evidence
Callers, dependencies, and failure
- PROP Decisions from the operator UI
- PROP LLM reviewer jobs
- EST Decision + facts + outbox rows in one resolver transaction (AW-01)
- PROP Stale revision → 409 conflict, nothing applied
- PROP LLM reviewer timeout → case stays awaiting human with "no recommendation"
Public surface: PROP|GET /v1/review, GET /v1/review/{id}, POST /v1/decisions (admin API).
Inside the process: PROP|ReviewService.decide(caseId, expectedRevision, action, correction).
Scaling note, still open: OPEN|Reviewer workload: measure review minutes per case and queue age before adding auto-accept rules.
svc_resolver · mod_worker_status
Worker status
PROP
Registers collector workers and instances, records heartbeats, leases and run reports. Never starts machines.
Why it exists
- EST Records collector status; never starts machines
- PROP Five separate health dimensions (proposed health dimensions)
What it owns and may modify
- PROP resolver.collector_worker, collector_instance, heartbeat, run_report
Allowed writes: Its own tables.
Where it stops
- PROP Liveness derives from lease expiry, not from schedule
Callers, dependencies, and failure
- PROP Heartbeats
- PROP Single-row upserts
- PROP Lease expired → liveness unknown; captures still accepted if authenticated
Public surface: PROP|POST /v1/workers/register, POST /v1/workers/{id}/heartbeats, GET /v1/workers.
Inside the process: PROP|WorkerStatus.liveness(cw).
Scaling note, still open: OPEN|None expected.
svc_resolver · mod_improvement_replay
Improvement & replay
PROP
Groups fallbacks into improvement proposals, drafts issues, and replays preserved evidence through new adapter/profile versions and new attribute definitions (backfill).
Why it exists
- 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)
What it owns and may modify
- PROP resolver.improvement, replay_run, replay_result, backfill_run
Allowed writes: Its own tables; new claims through ExtractionService.
Where it stops
- PROP Replay never overwrites accepted facts; differences become claims → rules or review cases
- PROP Replay and backfill are idempotent per (run id, capture id)
Callers, dependencies, and failure
- PROP Replay/backfill jobs on the internal job queue (low priority)
- PROP Per-capture transaction; a run is resumable from its last committed capture
- PROP Partial run → resume; results are compared with the recorded decision, never applied blindly
Public surface: PROP|GET /v1/improvements/{id}, POST /v1/improvements/{id}/replays.
Inside the process: PROP|ReplayService.run(selector, versions).
Scaling note, still open: OPEN|Throttle or separate queue when backfill measurably delays fresh captures (measure job-queue lag during backfill).
Models are actors
ADR-006 keeps the extractor and the reviewer out of the service list. They return a value to the module that called them. They cannot accept, decide, publish, read secrets, fetch a URL, or touch the database. Any added model cost still has to fit the proposed USD 40 operating envelope and leave the last USD 10 untouched.
| Permission | act_llm_extractor | act_llm_reviewer |
|---|---|---|
| Read supplied artifact slice | allow | limited |
| Read case evidence, claims, candidates | deny | allow |
| Write proposed claims + diagnostics | allow | deny |
| Write recommendation | deny | allow |
| Accept facts / auto-accept | deny | deny |
| Decide review cases | deny | deny |
| Publish events | deny | deny |
| Read secrets / credentials | deny | deny |
| Fetch URLs / network | deny | deny |
| Direct database access | deny | deny |
- Neither actor can accept, decide, publish, read secrets or fetch URLs EST
- "limited": the reviewer sees the slices already attached to the case, not arbitrary artifacts PROP
- Enforced structurally: actors receive a prepared input bundle and return a value; they hold no credentials and no database or network handles. The adapter calling the model provider holds the provider key, not the actor prompt PROP
- Output schema validation + citation check happen in the calling module before anything is stored PROP
Actor, not a service · fixture llm_extractor
LLM extractor
EST
Fallback extraction. Reads one artifact slice; writes proposed claims and diagnostics only. Prompt extract-listing@5 in fixtures.
- EST|Fallback when no deterministic adapter supports the capture or it fails
- PROP|Every proposed claim cites a locator inside the supplied slice
Owns: PROP|None; outputs are stored by mod_extraction_normalization
May write: PROP|Proposed claims + diagnostics (via mod_extraction_normalization)
Failure: PROP|Invalid or uncited output → discarded, diagnostic recorded, attempt counted
Actor, not a service · fixture llm_reviewer
LLM reviewer
EST
Separate advisory actor attached to a durable review case. Reads case evidence, claims and candidates; writes a recommendation only. Prompt review-case@3 in fixtures.
- EST|Separate from the extractor; attached to the durable review case
- EST|Recommends; never decides
Owns: PROP|None; recommendation stored by mod_review
May write: PROP|One recommendation + rationale per case revision attempt
Failure: PROP|Timeout or invalid output → case stays awaiting human
Four atomic writes
These are the transaction boundaries the design cares about. SPK-01 and SPK-02 have not been run, so the database behavior is unverified even where the shape is established.
AW-01 · Resolver decision (review or automatic rule)
PROP Owner: mod_review / mod_identity_relationships.
- BEGIN — resolver tx (READ COMMITTED + row locks; isolation choice validated in SPK-01) (PROP)
- Coordinate stream writer — 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. (PROP)
- Check case revision — UPDATE review_case SET revision = revision + 1 WHERE id AND revision = expected (INV-04) (EST)
- Insert decision — Immutable row: action, actor, rationale, evidence refs, prior decision refs (PROP)
- Apply facts — Item / mapping / attribute rows with revision + 1; check INV-01/02/07 (PROP)
- Insert outbox rows — Allocate consecutive committed positions with the transactional cursor; one event per affected aggregate revision. Include tenant, catalog, history_epoch, event id, revision, dependencies and payload in the same transaction as decision and facts. (EST)
- COMMIT — All or nothing (EST)
- Relay (after commit) — Publishes outbox rows to the product-ingestion queue; duplicates possible (EST)
AW-02 · Capture intake
PROP Owner: mod_intake_evidence.
- Authenticate + validate — Capture schema version, idempotency key (credential model: see Trust & tenancy) (PROP)
- Put artifact — Content-addressed, put-if-absent in the evidence bucket (before the tx) (PROP)
- BEGIN — resolver tx (PROP)
- Insert capture + receipt + job row — Duplicate idempotency key → return the existing receipt (PROP)
- COMMIT — Receipt is now durable (PROP)
- Enqueue job (ref) — After commit; on failure the pending-job sweeper re-enqueues (PROP)
AW-03 · Outbox relay
PROP Owner: svc_resolver (relay).
- Read batch — SELECT unpublished rows ORDER BY seq LIMIT batch (FOR UPDATE SKIP LOCKED if several relays) (PROP)
- Publish — Send batch to the product-ingestion queue; message carries event id + small payload or ref (PROP)
- Mark published — UPDATE published_at; crash between publish and mark → re-publish (duplicate) (PROP)
- Prune — Prune only a contiguous prefix of published events whose retention age has elapsed, through the minimum of every registered live consumer’s durable contiguous progress and every unexpired rebuild lease pin. Serialize lease creation/advancement, consumer registration and pruning with the retention-control lock. Delivery or acknowledgement alone is not application progress; a missing position or parked dependency protects the tail. Monitor blocked retention and lease expiry; never silently remove a lagging live consumer. (PROP)
AW-04 · Product consumer apply
EST Owner: svc_product.
- Receive — At-least-once delivery (Queues, verified 2026-10-12) (EST)
- BEGIN — product tx (EST)
- Inbox insert — INSERT inbox(event_id) ON CONFLICT DO NOTHING; conflict → duplicate → COMMIT + ack (EST)
- Revision check — Event rev ≤ applied rev for the aggregate → record as ignored (EST)
- Dependency check — Missing dependency revision → park the event (parked row) and ack; released when the dependency applies (EST)
- Apply projection — Upsert projection rows; set applied rev (EST)
- Commit contiguous 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. (PROP)
- COMMIT, then ack — Ack only after commit; crash before ack → redelivery → inbox dedupe (EST)
Contracts for the edges between these owners are the next chapter. Schemas and ADRs are linked from each connection and from the local reference.