diff --git a/backend/app/corporate_entity_ingestion.py b/backend/app/corporate_entity_ingestion.py
index f0f395dc9..689175e9e 100644
--- a/backend/app/corporate_entity_ingestion.py
+++ b/backend/app/corporate_entity_ingestion.py
@@ -16,6 +16,7 @@
import asyncio
import hashlib
+from dataclasses import dataclass
import asyncpg
@@ -40,6 +41,17 @@
_CREATION_LOCK_KEY = "lineageweave:corporate_entity_creation"
+@dataclass(frozen=True)
+class PreparedCorporateEntityResolution:
+ """Provider-complete corporate resolution plan with no database writes."""
+
+ normalized_name: str
+ catalog_id: str | None
+ unresolved_reason: str | None
+ proposal: HierarchyProposal | None
+ parent: PreparedCorporateEntityResolution | None
+
+
def _auto_entity_code(organization_name: str) -> str:
"""Return a deterministic, namespace-separated code."""
digest = hashlib.sha256(organization_name.encode("utf-8")).hexdigest()[:16]
@@ -103,8 +115,7 @@ def _remember_candidate(
)
-async def get_or_create_corporate_entity(
- conn: asyncpg.Connection,
+async def prepare_corporate_entity_resolution(
organization_name: str,
context_text: str,
inference_client: CorporateHierarchyInferenceClient,
@@ -113,42 +124,38 @@ async def get_or_create_corporate_entity(
*,
_depth: int = 0,
_visited_names: frozenset[str] = frozenset(),
-) -> tuple[str | None, str | None]:
- """Return ``(catalog id, unresolved reason code)``.
-
- The catalog id is ``None`` exactly when the reason code is one of
- ADR 0141's closed values (``reason_tied_candidates``,
- ``reason_no_live_client``, ``reason_not_corroborated``) -- the caller
- persists that reason instead of a flat "unresolved" fact. It is
- ``None, None`` only for the pure input-validation edge cases (an empty
- name, or a name already in the recursion path) that never represent a
- real actor mention.
-
- A unique similarity match is reused. A tied top score stays unbound
- and does not create a third same-named row (ADR 0026). Only a genuine
- miss -- no candidate at or above ``min_similarity`` -- may enter ADR
- 0010 inference. A proposed parent must independently corroborate and
- resolve before the child can be inserted. Repeated names in the
- recursion path are cycles, including multi-node cycles such as
- A -> B -> A.
+) -> PreparedCorporateEntityResolution:
+ """Run scoring and provider checks without mutating the catalog.
+
+ The frozen result can be applied only after a caller's own current-input
+ fence. A tie remains terminal and parent chains remain bounded exactly as
+ in ADR 0010/0026.
"""
normalized_name = organization_name.strip()
if not normalized_name:
- return None, None
+ return PreparedCorporateEntityResolution("", None, None, None, None)
visit_key = normalized_name.casefold()
if visit_key in _visited_names:
- return None, None
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, None, None, None
+ )
existing = score_corporate_entity(normalized_name, candidates)
if existing.kind == RESOLUTION_UNIQUE and existing.catalog_id is not None:
- return existing.catalog_id, None
+ return PreparedCorporateEntityResolution(
+ normalized_name, existing.catalog_id, None, None, None
+ )
if existing.kind == RESOLUTION_TIE:
- return None, "reason_tied_candidates"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_tied_candidates", None, None
+ )
if _depth >= _MAX_HIERARCHY_DEPTH or not inference_client.available:
# Excessive recursion depth is folded into the same "not attempted"
# bucket as an unconfigured client: from the reader's perspective
# both mean enrichment never ran for this specific mention.
- return None, "reason_no_live_client"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_no_live_client", None, None
+ )
try:
proposal = await asyncio.to_thread(
@@ -160,11 +167,17 @@ async def get_or_create_corporate_entity(
# A provider timeout is an unavailable enrichment channel, not a
# reason to discard the source-grounded summary. Keep the actor
# unbound and let an explicit retry attempt catalog enrichment later.
- return None, "reason_no_live_client"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_no_live_client", None, None
+ )
if proposal is None:
- return None, "reason_not_corroborated"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_not_corroborated", None, None
+ )
if not verification_client.available:
- return None, "reason_no_live_client"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_no_live_client", None, None
+ )
try:
placement_result = await asyncio.to_thread(
@@ -178,16 +191,22 @@ async def get_or_create_corporate_entity(
# ingestion) -- treat it the same as "not corroborated this run":
# the entity simply isn't auto-created, same conservative outcome
# as a real search that found nothing.
- return None, "reason_no_live_client"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_no_live_client", None, None
+ )
if placement_result.status_code != STATUS_CORROBORATED:
- return None, "reason_not_corroborated"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_not_corroborated", None, None
+ )
visited_names = _visited_names | {visit_key}
- parent_entity_id: str | None = None
+ parent_plan: PreparedCorporateEntityResolution | None = None
if proposal.parent_name is not None:
normalized_parent = proposal.parent_name.strip()
if not normalized_parent or normalized_parent.casefold() in visited_names:
- return None, "reason_not_corroborated"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_not_corroborated", None, None
+ )
try:
parent_result = await asyncio.to_thread(
verification_client.verify,
@@ -197,11 +216,14 @@ async def get_or_create_corporate_entity(
except (HttpClientError, OSError):
# Same fail-closed-without-crashing behavior as the placement
# verification above.
- return None, "reason_no_live_client"
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_no_live_client", None, None
+ )
if parent_result.status_code != STATUS_CORROBORATED:
- return None, "reason_not_corroborated"
- parent_entity_id, _parent_reason = await get_or_create_corporate_entity(
- conn,
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_not_corroborated", None, None
+ )
+ parent_plan = await prepare_corporate_entity_resolution(
normalized_parent,
context_text,
inference_client,
@@ -210,10 +232,65 @@ async def get_or_create_corporate_entity(
_depth=_depth + 1,
_visited_names=visited_names,
)
- if parent_entity_id is None:
+ if parent_plan.catalog_id is None and parent_plan.proposal is None:
# The child's own corroboration succeeded; it's the hierarchy
# placement (ADR 0010 requires the whole chain) that didn't.
+ return PreparedCorporateEntityResolution(
+ normalized_name, None, "reason_not_corroborated", None, None
+ )
+
+ return PreparedCorporateEntityResolution(
+ normalized_name,
+ None,
+ None,
+ proposal,
+ parent_plan,
+ )
+
+
+async def apply_prepared_corporate_entity_resolution(
+ conn: asyncpg.Connection,
+ prepared: PreparedCorporateEntityResolution,
+ candidates: list[CorporateEntityCandidate],
+ *,
+ _resolved_parent_ids: set[str] | None = None,
+) -> tuple[str | None, str | None]:
+ """Apply a provider-complete plan using database work only."""
+ resolved_parent_ids = (
+ _resolved_parent_ids if _resolved_parent_ids is not None else set()
+ )
+ if prepared.catalog_id is not None or prepared.proposal is None:
+ if prepared.catalog_id is not None:
+ resolved_parent_ids.add(prepared.catalog_id)
+ return prepared.catalog_id, prepared.unresolved_reason
+
+ parent_entity_id: str | None = None
+ if prepared.parent is not None:
+ parent_entity_id, _parent_reason = (
+ await apply_prepared_corporate_entity_resolution(
+ conn,
+ prepared.parent,
+ candidates,
+ _resolved_parent_ids=resolved_parent_ids,
+ )
+ )
+ if parent_entity_id is None:
return None, "reason_not_corroborated"
+ resolved_parent_ids.add(parent_entity_id)
+
+ evolving = score_corporate_entity(
+ prepared.normalized_name,
+ [
+ candidate
+ for candidate in candidates
+ if candidate.corporate_entity_id not in resolved_parent_ids
+ ],
+ )
+ if evolving.kind == RESOLUTION_UNIQUE and evolving.catalog_id is not None:
+ resolved_parent_ids.add(evolving.catalog_id)
+ return evolving.catalog_id, None
+ if evolving.kind == RESOLUTION_TIE:
+ return None, "reason_tied_candidates"
async with conn.transaction():
await conn.execute(
@@ -225,20 +302,46 @@ async def get_or_create_corporate_entity(
# initial lookup remains fuzzy, while this check only prevents a
# concurrent insert of the same normalized name.
fresh = score_corporate_entity(
- normalized_name,
+ prepared.normalized_name,
await _reload_candidates(conn),
min_similarity=1.0,
)
if fresh.kind == RESOLUTION_UNIQUE and fresh.catalog_id is not None:
- _remember_candidate(candidates, fresh.catalog_id, normalized_name)
+ _remember_candidate(candidates, fresh.catalog_id, prepared.normalized_name)
+ resolved_parent_ids.add(fresh.catalog_id)
return fresh.catalog_id, None
if fresh.kind == RESOLUTION_TIE:
return None, "reason_tied_candidates"
new_id = await _create_entity(
conn,
- normalized_name,
- proposal.level_code,
+ prepared.normalized_name,
+ prepared.proposal.level_code,
parent_entity_id,
)
- _remember_candidate(candidates, new_id, normalized_name)
+ _remember_candidate(candidates, new_id, prepared.normalized_name)
+ resolved_parent_ids.add(new_id)
return new_id, None
+
+
+async def get_or_create_corporate_entity(
+ conn: asyncpg.Connection,
+ organization_name: str,
+ context_text: str,
+ inference_client: CorporateHierarchyInferenceClient,
+ verification_client: RelationVerificationClient,
+ candidates: list[CorporateEntityCandidate],
+ *,
+ _depth: int = 0,
+ _visited_names: frozenset[str] = frozenset(),
+) -> tuple[str | None, str | None]:
+ """Prepare provider evidence, then resolve or create the catalog row."""
+ prepared = await prepare_corporate_entity_resolution(
+ organization_name,
+ context_text,
+ inference_client,
+ verification_client,
+ candidates,
+ _depth=_depth,
+ _visited_names=_visited_names,
+ )
+ return await apply_prepared_corporate_entity_resolution(conn, prepared, candidates)
diff --git a/backend/app/five_w1h_ingestion.py b/backend/app/five_w1h_ingestion.py
index 8bc90024d..c9f65666d 100644
--- a/backend/app/five_w1h_ingestion.py
+++ b/backend/app/five_w1h_ingestion.py
@@ -19,7 +19,7 @@ async def load_five_w1h_slots(
can_see_post: Callable[[asyncpg.Record], bool],
) -> dict[str, Any]:
"""Build 5W1H from stored projections and visible lineage only."""
- summary = await fetch_persisted_summary(conn, post_id) or {}
+ summary = await fetch_persisted_summary(conn, post_id, allow_stale=True) or {}
evidence_claims = await conn.fetch(
"""
select slot_code, value_text, evidence_text
diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py
index e0804bdd9..06187f546 100644
--- a/backend/app/keyman_ingestion.py
+++ b/backend/app/keyman_ingestion.py
@@ -49,7 +49,7 @@
from __future__ import annotations
import asyncio
-from dataclasses import replace
+from dataclasses import dataclass, replace
import asyncpg
@@ -67,11 +67,22 @@
NullOrganizationNameResolutionClient,
OrganizationNameResolutionClient,
)
-from lineageweave.relation_verification import NullRelationVerificationClient, RelationVerificationClient
+from lineageweave.relation_verification import (
+ NullRelationVerificationClient,
+ RelationVerificationClient,
+)
-from .corporate_entity_ingestion import get_or_create_corporate_entity
+from .corporate_entity_ingestion import (
+ PreparedCorporateEntityResolution,
+ apply_prepared_corporate_entity_resolution,
+ prepare_corporate_entity_resolution,
+)
from .knowledge_graph import persist_edges_for_post
-from .organization_name_resolution_ingestion import resolve_organization_name
+from .organization_name_resolution_ingestion import (
+ PreparedOrganizationNameResolution,
+ apply_prepared_organization_name_resolution,
+ prepare_organization_name_resolution,
+)
async def _load_corporate_entity_candidates(conn: asyncpg.Connection) -> list[CorporateEntityCandidate]:
@@ -174,7 +185,18 @@ async def _upsert_affiliation(
)
-async def _resolve_affiliated_organization(
+@dataclass(frozen=True)
+class PreparedAffiliatedOrganization:
+ """No-write name and hierarchy plan for one affiliation mention."""
+
+ raw_name: str
+ resolved_name: str
+ name_resolution: PreparedOrganizationNameResolution | None
+ entity_resolution: PreparedCorporateEntityResolution | None
+ unresolved_reason: str | None
+
+
+async def prepare_affiliated_organization(
conn: asyncpg.Connection,
organization_name: str,
context_text: str,
@@ -182,36 +204,92 @@ async def _resolve_affiliated_organization(
verification_client: RelationVerificationClient,
hierarchy_inference_client: CorporateHierarchyInferenceClient,
candidates: list[CorporateEntityCandidate],
-) -> tuple[str, str, str | None, str | None]:
- """Resolve one affiliation without rewriting a known raw-name tie.
-
- The fourth element is ADR 0141's unresolved-reason code (``None`` when
- ``corporate_entity_id`` is set). The Keyman ingestion caller below does
- not yet surface it -- only the R&R post-summary path
- (``post_summary_ingestion.py``) does -- but the reason is real and
- computed either way, so callers that need it later don't have to
- re-derive it.
+) -> PreparedAffiliatedOrganization:
+ """Prepare one affiliation without rewriting a known raw-name tie.
+
+ Provider work completes here, while cache/catalog writes are deferred to
+ :func:`apply_prepared_affiliated_organization`.
"""
raw_outcome = score_corporate_entity(organization_name, candidates)
if raw_outcome.kind == RESOLUTION_TIE:
- return organization_name, organization_name, None, "reason_tied_candidates"
+ return PreparedAffiliatedOrganization(
+ organization_name,
+ organization_name,
+ None,
+ None,
+ "reason_tied_candidates",
+ )
- resolved_name = await resolve_organization_name(
+ name_resolution = await prepare_organization_name_resolution(
conn,
resolution_client,
verification_client,
organization_name,
context_text,
)
- corporate_entity_id, unresolved_reason = await get_or_create_corporate_entity(
- conn,
- resolved_name,
+ entity_resolution = await prepare_corporate_entity_resolution(
+ name_resolution.resolved_name,
context_text,
hierarchy_inference_client,
verification_client,
candidates,
)
- return organization_name, resolved_name, corporate_entity_id, unresolved_reason
+ return PreparedAffiliatedOrganization(
+ organization_name,
+ name_resolution.resolved_name,
+ name_resolution,
+ entity_resolution,
+ None,
+ )
+
+
+async def apply_prepared_affiliated_organization(
+ conn: asyncpg.Connection,
+ prepared: PreparedAffiliatedOrganization,
+ candidates: list[CorporateEntityCandidate],
+) -> tuple[str, str, str | None, str | None]:
+ """Apply prepared cache/catalog writes without calling providers."""
+ if prepared.name_resolution is None or prepared.entity_resolution is None:
+ return (
+ prepared.raw_name,
+ prepared.resolved_name,
+ None,
+ prepared.unresolved_reason,
+ )
+ resolved_name = await apply_prepared_organization_name_resolution(
+ conn,
+ prepared.name_resolution,
+ )
+ corporate_entity_id, unresolved_reason = (
+ await apply_prepared_corporate_entity_resolution(
+ conn,
+ prepared.entity_resolution,
+ candidates,
+ )
+ )
+ return prepared.raw_name, resolved_name, corporate_entity_id, unresolved_reason
+
+
+async def _resolve_affiliated_organization(
+ conn: asyncpg.Connection,
+ organization_name: str,
+ context_text: str,
+ resolution_client: OrganizationNameResolutionClient,
+ verification_client: RelationVerificationClient,
+ hierarchy_inference_client: CorporateHierarchyInferenceClient,
+ candidates: list[CorporateEntityCandidate],
+) -> tuple[str, str, str | None, str | None]:
+ """Prepare provider evidence, then persist affiliation catalog changes."""
+ prepared = await prepare_affiliated_organization(
+ conn,
+ organization_name,
+ context_text,
+ resolution_client,
+ verification_client,
+ hierarchy_inference_client,
+ candidates,
+ )
+ return await apply_prepared_affiliated_organization(conn, prepared, candidates)
async def ingest_post_keymen(
diff --git a/backend/app/main.py b/backend/app/main.py
index 765cac35a..fbd6e56ce 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -115,13 +115,16 @@
)
from backend.app.analysis_run_worker import run_analysis_run_worker
from backend.app.post_content_queue import (
+ SUCCEEDED,
ensure_post_content_job,
fetch_post_summary_source,
post_content_api_status,
post_content_is_complete,
post_content_summary_is_ready,
+ post_content_summary_status_message,
post_body_has_images,
publish_post_content_event,
+ source_body_sha256,
)
from backend.app.post_content_worker import run_post_content_worker_supervised
from backend.app.source_research_ingestion import (
@@ -1971,97 +1974,135 @@ async def read_post_content(
"""Return persisted content evidence; never derive or invent reader-facing copy."""
await _load_visible_post(post_id, account, pool)
queue_event: tuple[str, str] | None = None
+ derived_content_is_current = False
async with pool.acquire() as conn:
- unit_rows = await conn.fetch(
- """
- select unit.unit_index, unit.unit_kind_code, unit.unit_label, unit.unit_text,
- coalesce(structure.indent_level, 0) as indent_level,
- structure.decision_source_code, structure.structure_confidence,
- structure.evidence_text
- from post_content_unit unit
- left join post_content_unit_structure structure
- on structure.post_content_unit_id = unit.post_content_unit_id
- where unit.post_id = $1
- order by unit.unit_index
- """,
- post_id,
- )
- content_status = post_content_api_status(
- None,
- content_present=bool(unit_rows),
- )
- body_row = await conn.fetchrow(
- "select post_body from source_post where post_id = $1", post_id
- )
- raw_body = None if body_row is None else body_row["post_body"]
- if isinstance(raw_body, str) and raw_body.strip():
- content_present = bool(unit_rows)
- content_complete = await post_content_is_complete(
- conn,
+ unit_rows: list[asyncpg.Record] = []
+ rows: list[asyncpg.Record] = []
+ region_rows: list[asyncpg.Record] = []
+ content_status = post_content_api_status(None, content_present=False)
+ job = None
+ async with conn.transaction():
+ body_row = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1 for update",
post_id,
- embedding_model_code=load_settings().embedding_model,
- require_structure=bool(
- load_settings().orchestrator_base_url
- and load_settings().orchestrator_api_key
- ),
)
- async with conn.transaction():
+ raw_body = None if body_row is None else body_row["post_body"]
+ if isinstance(raw_body, str) and raw_body.strip():
+ content_complete = await post_content_is_complete(
+ conn,
+ post_id,
+ embedding_model_code=load_settings().embedding_model,
+ require_structure=bool(
+ load_settings().orchestrator_base_url
+ and load_settings().orchestrator_api_key
+ ),
+ )
job = await ensure_post_content_job(
conn,
post_id,
raw_body,
content_complete=content_complete,
)
+ if job is not None:
content_status = post_content_api_status(
job.status_code,
- content_present=content_present,
+ content_present=False,
)
if job.should_publish:
queue_event = (job.post_id, job.source_body_sha256)
- rows = await conn.fetch(
- """
- select image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code,
- image.extracted_text, image.image_caption,
- coalesce(
- array_agg(tag.tag_text order by tag.tag_text)
- filter (where tag.tag_text is not null),
- '{}'::text[]
- ) as tags
- from post_content_unit unit
- join post_content_image image
- on image.post_content_unit_id = unit.post_content_unit_id
- left join post_content_image_tag tag
- on tag.post_content_image_id = image.post_content_image_id
- where unit.post_id = $1
- group by image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code,
- image.extracted_text, image.image_caption
- order by unit.unit_index
- """,
- post_id,
- )
- region_rows = await conn.fetch(
- """
- select image.post_content_image_id, region.region_index,
- region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio,
- region.description_status_code, region.extracted_text, region.image_caption,
- coalesce(
- array_agg(tag.tag_text order by tag.tag_text)
- filter (where tag.tag_text is not null),
- '{}'::text[]
- ) as tags
- from post_content_image image
- join post_content_image_region region
- on region.post_content_image_id = image.post_content_image_id
- left join post_content_image_region_tag tag
- on tag.post_content_image_region_id = region.post_content_image_region_id
- where image.post_content_image_id = any($1::uuid[])
- group by image.post_content_image_id, region.region_index,
- region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio,
- region.description_status_code, region.extracted_text, region.image_caption
- order by image.post_content_image_id, region.region_index
- """,
- [row["post_content_image_id"] for row in rows],
- ) if rows else []
+ async with conn.transaction():
+ source_binding = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1 for share",
+ post_id,
+ )
+ binding = await conn.fetchrow(
+ """
+ select source_body_sha256, status_code
+ from post_content_ingestion_job
+ where post_id = $1
+ for share
+ """,
+ post_id,
+ ) if source_binding is not None else None
+ bound_body = (
+ None if source_binding is None else source_binding["post_body"]
+ )
+ derived_content_is_current = bool(
+ isinstance(bound_body, str)
+ and binding is not None
+ and binding["status_code"] == SUCCEEDED
+ and binding["source_body_sha256"] == source_body_sha256(bound_body)
+ )
+ if derived_content_is_current:
+ unit_rows = await conn.fetch(
+ """
+ select unit.unit_index, unit.unit_kind_code, unit.unit_label,
+ unit.unit_text,
+ coalesce(structure.indent_level, 0) as indent_level,
+ structure.decision_source_code,
+ structure.structure_confidence, structure.evidence_text
+ from post_content_unit unit
+ left join post_content_unit_structure structure
+ on structure.post_content_unit_id = unit.post_content_unit_id
+ where unit.post_id = $1
+ order by unit.unit_index
+ """,
+ post_id,
+ )
+ content_status = post_content_api_status(
+ str(binding["status_code"]),
+ content_present=bool(unit_rows),
+ )
+ elif job.status_code == SUCCEEDED:
+ content_status = "processing"
+ rows = await conn.fetch(
+ """
+ select image.post_content_image_id, unit.unit_index, image.mime_type,
+ image.description_status_code, image.extracted_text,
+ image.image_caption,
+ coalesce(
+ array_agg(tag.tag_text order by tag.tag_text)
+ filter (where tag.tag_text is not null),
+ '{}'::text[]
+ ) as tags
+ from post_content_unit unit
+ join post_content_image image
+ on image.post_content_unit_id = unit.post_content_unit_id
+ left join post_content_image_tag tag
+ on tag.post_content_image_id = image.post_content_image_id
+ where unit.post_id = $1
+ group by image.post_content_image_id, unit.unit_index,
+ image.mime_type, image.description_status_code,
+ image.extracted_text, image.image_caption
+ order by unit.unit_index
+ """,
+ post_id,
+ ) if derived_content_is_current else []
+ region_rows = await conn.fetch(
+ """
+ select image.post_content_image_id, region.region_index,
+ region.x_ratio, region.y_ratio, region.width_ratio,
+ region.height_ratio, region.description_status_code,
+ region.extracted_text, region.image_caption,
+ coalesce(
+ array_agg(tag.tag_text order by tag.tag_text)
+ filter (where tag.tag_text is not null),
+ '{}'::text[]
+ ) as tags
+ from post_content_image image
+ join post_content_image_region region
+ on region.post_content_image_id = image.post_content_image_id
+ left join post_content_image_region_tag tag
+ on tag.post_content_image_region_id = region.post_content_image_region_id
+ where image.post_content_image_id = any($1::uuid[])
+ group by image.post_content_image_id, region.region_index,
+ region.x_ratio, region.y_ratio, region.width_ratio,
+ region.height_ratio, region.description_status_code,
+ region.extracted_text, region.image_caption
+ order by image.post_content_image_id, region.region_index
+ """,
+ [row["post_content_image_id"] for row in rows],
+ ) if rows else []
if queue_event is not None:
await publish_post_content_event(
valkey,
@@ -3144,13 +3185,7 @@ async def read_post_summary(
)
except ValueError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
- stored = await fetch_persisted_summary(conn, post_id)
- if stored is not None:
- return stored
- stale = await fetch_persisted_summary(conn, post_id, allow_stale=True)
image_body = post_body_has_images(raw_body)
- if stale is not None and not image_body:
- return stale
if image_body:
content_complete = await post_content_is_complete(
conn,
@@ -3170,7 +3205,11 @@ async def read_post_summary(
)
if job.should_publish:
queue_event = (job.post_id, job.source_body_sha256)
- summary_waiting_for_images = not await post_content_summary_is_ready(conn, post_id)
+ summary_waiting_for_images = not await post_content_summary_is_ready(
+ conn,
+ post_id,
+ job.source_body_sha256,
+ )
if summary_waiting_for_images and queue_event is not None:
await publish_post_content_event(
valkey,
@@ -3181,8 +3220,46 @@ async def read_post_summary(
if summary_waiting_for_images:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- "Post summary is unavailable: image evidence is still being processed",
+ post_content_summary_status_message(job.status_code),
+ )
+ normalized_body = await fetch_post_summary_source(conn, post_id)
+ if not normalized_body:
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Post summary is unavailable: persisted post content is not available",
)
+ stored = await fetch_persisted_summary(
+ conn,
+ post_id,
+ summary_input=normalized_body,
+ )
+ if stored is not None:
+ if queue_event is not None:
+ await publish_post_content_event(
+ valkey,
+ post_id=queue_event[0],
+ source_body_digest=queue_event[1],
+ )
+ return stored
+ else:
+ normalized_body = (
+ await asyncio.to_thread(normalize_post_body, raw_body)
+ ).text
+ stored = await fetch_persisted_summary(
+ conn,
+ post_id,
+ summary_input=normalized_body,
+ )
+ if stored is not None:
+ return stored
+ stale = await fetch_persisted_summary(
+ conn,
+ post_id,
+ summary_input=normalized_body,
+ allow_stale=True,
+ )
+ if stale is not None:
+ return stale
with use_llm_metadata(post_metadata):
client = _post_summary_client()
if not client.available:
@@ -3193,14 +3270,6 @@ async def read_post_summary(
context_hints = await _load_post_semantic_hints(conn, post_id)
summarize_with_hints = getattr(client, "summarize_with_hints", None)
try:
- if image_body:
- normalized_body = await fetch_post_summary_source(conn, post_id)
- if not normalized_body:
- raise ValueError("persisted post content is not available")
- else:
- normalized_body = (
- await asyncio.to_thread(normalize_post_body, raw_body)
- ).text
if callable(summarize_with_hints):
summary = await asyncio.to_thread(
summarize_with_hints, post["post_title"], normalized_body, context_hints
@@ -3224,6 +3293,8 @@ async def read_post_summary(
post_id,
summary,
post_body=normalized_body,
+ expected_source_body_sha256=source_body_sha256(raw_body),
+ require_image_evidence=image_body,
resolution_client=_organization_name_resolution_client(),
hierarchy_inference_client=_corporate_hierarchy_inference_client(),
verification_client=_relation_verification_client(),
diff --git a/backend/app/organization_name_resolution_ingestion.py b/backend/app/organization_name_resolution_ingestion.py
index 9300586c4..7745fbfa5 100644
--- a/backend/app/organization_name_resolution_ingestion.py
+++ b/backend/app/organization_name_resolution_ingestion.py
@@ -4,10 +4,12 @@
from __future__ import annotations
import asyncio
+from dataclasses import dataclass
import asyncpg
from lineageweave.organization_name_resolution import (
+ OrganizationNameResolution,
OrganizationNameResolutionClient,
resolve_and_verify_organization_name,
)
@@ -17,14 +19,22 @@
)
-async def resolve_organization_name(
+@dataclass(frozen=True)
+class PreparedOrganizationNameResolution:
+ """Effective name plus an optional verified cache row awaiting apply."""
+
+ resolved_name: str
+ resolution: OrganizationNameResolution | None
+
+
+async def prepare_organization_name_resolution(
conn: asyncpg.Connection,
resolution_client: OrganizationNameResolutionClient,
verification_client: RelationVerificationClient,
raw_name: str,
context_text: str,
-) -> str:
- """Return the corroborated canonical name, otherwise ``raw_name``.
+) -> PreparedOrganizationNameResolution:
+ """Resolve and verify a name without mutating the shared cache.
Synchronous network adapters run in a worker thread so this async
ingestion path does not block unrelated requests.
@@ -36,10 +46,13 @@ async def resolve_organization_name(
)
if cached is not None:
if cached["verification_status_code"] == STATUS_CORROBORATED:
- return cached["resolved_organization_name"]
- return raw_name
+ return PreparedOrganizationNameResolution(
+ cached["resolved_organization_name"],
+ None,
+ )
+ return PreparedOrganizationNameResolution(raw_name, None)
if not resolution_client.available:
- return raw_name
+ return PreparedOrganizationNameResolution(raw_name, None)
resolution = await asyncio.to_thread(
resolve_and_verify_organization_name,
@@ -49,7 +62,26 @@ async def resolve_organization_name(
verification_client,
)
if resolution is None:
- return raw_name
+ return PreparedOrganizationNameResolution(raw_name, None)
+
+ return PreparedOrganizationNameResolution(
+ (
+ resolution.resolved_organization_name
+ if resolution.verification_status_code == STATUS_CORROBORATED
+ else raw_name
+ ),
+ resolution,
+ )
+
+
+async def apply_prepared_organization_name_resolution(
+ conn: asyncpg.Connection,
+ prepared: PreparedOrganizationNameResolution,
+) -> str:
+ """Persist a prepared cache row without making provider calls."""
+ resolution = prepared.resolution
+ if resolution is None:
+ return prepared.resolved_name
await conn.execute(
"""
@@ -68,6 +100,22 @@ async def resolve_organization_name(
resolution.verification_status_code,
resolution.verification_evidence_url,
)
- if resolution.verification_status_code == STATUS_CORROBORATED:
- return resolution.resolved_organization_name
- return raw_name
+ return prepared.resolved_name
+
+
+async def resolve_organization_name(
+ conn: asyncpg.Connection,
+ resolution_client: OrganizationNameResolutionClient,
+ verification_client: RelationVerificationClient,
+ raw_name: str,
+ context_text: str,
+) -> str:
+ """Prepare provider evidence, then persist and return the effective name."""
+ prepared = await prepare_organization_name_resolution(
+ conn,
+ resolution_client,
+ verification_client,
+ raw_name,
+ context_text,
+ )
+ return await apply_prepared_organization_name_resolution(conn, prepared)
diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py
index 731e97579..ed15458a5 100644
--- a/backend/app/post_content_queue.py
+++ b/backend/app/post_content_queue.py
@@ -149,12 +149,20 @@ async def post_content_is_complete(
async def post_content_summary_is_ready(
conn: asyncpg.Connection,
post_id: str,
+ source_body_digest: str,
) -> bool:
- """Require every embedded image and visual region to have VISION evidence."""
+ """Require current-body success and complete persisted VISION evidence."""
return bool(
await conn.fetchval(
"""
select exists(
+ select 1
+ from post_content_ingestion_job job
+ where job.post_id = $1
+ and job.source_body_sha256 = $2
+ and job.status_code = $3
+ )
+ and exists(
select 1
from post_content_unit unit
where unit.post_id = $1
@@ -179,6 +187,8 @@ async def post_content_summary_is_ready(
)
""",
post_id,
+ source_body_digest,
+ SUCCEEDED,
)
)
@@ -187,21 +197,43 @@ async def fetch_post_summary_source(
conn: asyncpg.Connection,
post_id: str,
) -> str | None:
- """Return persisted semantic units, including completed image evidence."""
+ """Return ordered semantic units plus persisted region VISION evidence."""
rows = await conn.fetch(
"""
- select unit_text
- from post_content_unit
- where post_id = $1
- order by unit_index
+ select unit.unit_index, unit.unit_text,
+ region.region_index, region.extracted_text, region.image_caption
+ from post_content_unit unit
+ left join post_content_image image
+ on image.post_content_unit_id = unit.post_content_unit_id
+ left join post_content_image_region region
+ on region.post_content_image_id = image.post_content_image_id
+ where unit.post_id = $1
+ order by unit.unit_index, region.region_index
""",
post_id,
)
- source = "\n\n".join(
- str(row["unit_text"]).strip()
- for row in rows
- if isinstance(row["unit_text"], str) and row["unit_text"].strip()
- )
+ source_parts: list[str] = []
+ previous_unit_index: int | None = None
+ for row in rows:
+ unit_index = int(row["unit_index"])
+ if unit_index != previous_unit_index:
+ unit_text = row["unit_text"]
+ if isinstance(unit_text, str) and unit_text.strip():
+ source_parts.append(unit_text.strip())
+ previous_unit_index = unit_index
+ region_index = row["region_index"]
+ if region_index is None:
+ continue
+ region_evidence = [
+ value.strip()
+ for value in (row["image_caption"], row["extracted_text"])
+ if isinstance(value, str) and value.strip()
+ ]
+ if region_evidence:
+ source_parts.append(
+ f"[image region {int(region_index)}]\n" + "\n".join(region_evidence)
+ )
+ source = "\n\n".join(source_parts)
return source or None
@@ -277,12 +309,14 @@ async def transition_post_content_job(
failure_code: str | None = None,
detail_text: str | None = None,
expected_attempt_count: int | None = None,
+ expected_source_body_sha256: str | None = None,
+ expected_status_code: str | None = None,
) -> bool:
"""Update one job attempt and append its lifecycle event atomically.
- ``expected_attempt_count`` fences stale workers after lease recovery. A
- late completion from an older attempt must not overwrite the newer
- attempt's status or append a misleading lifecycle event.
+ The optional claim fields fence stale workers after lease recovery. A late
+ completion from an older attempt must not overwrite the newer attempt's
+ status or append a misleading lifecycle event.
"""
updated = await conn.execute(
"""
@@ -300,6 +334,8 @@ async def transition_post_content_job(
last_error_detail = $8
where post_id = $1
and ($9::integer is null or attempt_count = $9)
+ and ($10::text is null or source_body_sha256 = $10)
+ and ($11::text is null or status_code = $11)
""",
post_id,
status_code,
@@ -310,6 +346,8 @@ async def transition_post_content_job(
failure_code,
detail_text,
expected_attempt_count,
+ expected_source_body_sha256,
+ expected_status_code,
)
if not updated.endswith(" 1"):
return False
@@ -342,7 +380,7 @@ async def ensure_post_content_job(
post_id,
)
if row is None:
- initial_status = SUCCEEDED if content_complete else QUEUED
+ initial_status = QUEUED
await conn.execute(
"""
insert into post_content_ingestion_job
@@ -372,7 +410,6 @@ async def ensure_post_content_job(
update post_content_ingestion_job
set source_body_sha256 = $2,
status_code = $3,
- attempt_count = 0,
queued_at = now(),
started_at = null,
completed_at = null,
@@ -420,7 +457,6 @@ async def requeue_failed_post_content_job(
update post_content_ingestion_job
set source_body_sha256 = $2,
status_code = $3,
- attempt_count = 0,
queued_at = now(),
started_at = null,
completed_at = null,
@@ -451,6 +487,13 @@ async def record_post_content_backfill_success(
) -> PostContentJobRequest:
"""Synchronize a completed operator backfill with the durable job ledger."""
digest = source_body_sha256(body)
+ source_row = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1 for update",
+ post_id,
+ )
+ current_body = None if source_row is None else source_row["post_body"]
+ if not isinstance(current_body, str) or source_body_sha256(current_body) != digest:
+ raise ValueError("source body changed during post-content backfill")
row = await conn.fetchrow(
"""
select status_code
@@ -518,7 +561,7 @@ async def republish_queued_post_content_jobs(
(
post_content_ingestion_job.status_code = $1
and (
- post_content_ingestion_job.attempt_count = 0
+ post_content_ingestion_job.last_error_code is null
or post_content_ingestion_job.queued_at <= now() - $2::interval
)
)
diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py
index f4c64fd7f..1e70c5da1 100644
--- a/backend/app/post_content_worker.py
+++ b/backend/app/post_content_worker.py
@@ -11,13 +11,6 @@
import asyncpg
import redis.asyncio as redis
-from lineageweave.embedding_client import EmbeddingClient
-from lineageweave.image_content import ImageContentClient
-from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
-from lineageweave.post_content_normalization import normalize_post_body
-from lineageweave.post_content_persistence import persist_post_content
-from lineageweave.post_structure import PostStructureClient
-
from backend.app.config import load_settings
from backend.app.post_content_queue import (
FAILED,
@@ -29,9 +22,16 @@
STALE_RUNNING_INTERVAL,
SUCCEEDED,
post_content_is_complete,
- transition_post_content_job,
republish_queued_post_content_jobs,
+ source_body_sha256,
+ transition_post_content_job,
)
+from lineageweave.embedding_client import EmbeddingClient
+from lineageweave.image_content import ImageContentClient
+from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata
+from lineageweave.post_content_normalization import normalize_post_body
+from lineageweave.post_content_persistence import persist_post_content
+from lineageweave.post_structure import PostStructureClient
_logger = logging.getLogger(__name__)
_RECOVERY_INTERVAL_SECONDS = 30.0
@@ -54,35 +54,78 @@ async def _claim_job(
*,
embedding_model_code: str,
require_structure: bool = False,
-) -> asyncpg.Record | None:
+) -> dict[str, object] | None:
async with pool.acquire() as conn:
async with conn.transaction():
- row = await conn.fetchrow(
+ source_row = await conn.fetchrow(
+ """
+ select p.*
+ from source_post p
+ where p.post_id = $1::uuid
+ and coalesce(upper(btrim(p.source_detail_state_code)), '') <> 'W'
+ for update of p
+ """,
+ post_id,
+ )
+ if source_row is None:
+ return None
+ if str(source_row.get("source_detail_state_code") or "").strip().upper() == "W":
+ return None
+ raw_body = source_row["post_body"]
+ if not isinstance(raw_body, str):
+ return None
+ if source_body_sha256(raw_body) != source_body_digest:
+ return None
+ job_row = await conn.fetchrow(
"""
- select p.*, j.source_body_sha256 as job_source_body_sha256,
+ select j.source_body_sha256 as job_source_body_sha256,
j.status_code as job_status_code,
j.attempt_count as job_attempt_count,
+ (
+ select count(*)
+ from post_content_ingestion_job_status_event event
+ where event.post_id = j.post_id
+ and event.status_code = $3
+ and event.status_ordinal > coalesce(
+ (
+ select max(boundary.status_ordinal)
+ from post_content_ingestion_job_status_event boundary
+ where boundary.post_id = j.post_id
+ and boundary.status_code = $4
+ and boundary.failure_code is null
+ ),
+ -1
+ )
+ ) as job_cycle_attempt_count,
j.started_at as job_started_at,
j.queued_at as job_queued_at
from post_content_ingestion_job j
- join source_post p on p.post_id = j.post_id
where j.post_id = $1::uuid
and j.source_body_sha256 = $2
- and coalesce(upper(btrim(p.source_detail_state_code)), '') <> 'W'
- for update of j, p
+ for update
""",
post_id,
source_body_digest,
+ RUNNING,
+ QUEUED,
)
- if row is None:
- return None
- if str(row.get("source_detail_state_code") or "").strip().upper() == "W":
+ if job_row is None:
return None
+ row = dict(source_row)
+ row.update(dict(job_row))
status_code = str(row["job_status_code"])
- attempt_count = int(row["job_attempt_count"])
+ cycle_attempt_count = int(row["job_cycle_attempt_count"])
if status_code == FAILED:
return None
- if status_code == RUNNING and attempt_count >= POST_CONTENT_MAX_ATTEMPTS:
+ if status_code == RUNNING and row["job_started_at"] is not None:
+ stale = await conn.fetchval(
+ "select now() - $1::timestamptz > $2::interval",
+ row["job_started_at"],
+ STALE_RUNNING_INTERVAL,
+ )
+ if not stale:
+ return None
+ if status_code == RUNNING and cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS:
await transition_post_content_job(
conn,
post_id,
@@ -91,7 +134,7 @@ async def _claim_job(
detail_text="post-content ingestion attempt limit was already reached",
)
return None
- if status_code == QUEUED and attempt_count >= POST_CONTENT_MAX_ATTEMPTS:
+ if status_code == QUEUED and cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS:
await transition_post_content_job(
conn,
post_id,
@@ -100,7 +143,7 @@ async def _claim_job(
detail_text="post-content ingestion attempt limit was already reached",
)
return None
- if status_code == QUEUED and attempt_count > 0:
+ if status_code == QUEUED and cycle_attempt_count > 0:
retry_ready = await conn.fetchval(
"select now() >= $1::timestamptz + $2::interval",
row["job_queued_at"],
@@ -117,24 +160,67 @@ async def _claim_job(
)
if content_complete:
return None
- if status_code == RUNNING and row["job_started_at"] is not None:
- stale = await conn.fetchval(
- "select now() - $1::timestamptz > $2::interval",
- row["job_started_at"],
- STALE_RUNNING_INTERVAL,
+ claimed_attempt_count = int(
+ await conn.fetchval(
+ """
+ update post_content_ingestion_job
+ set attempt_count = attempt_count + 1
+ where post_id = $1
+ and source_body_sha256 = $2
+ returning attempt_count
+ """,
+ post_id,
+ source_body_digest,
)
- if not stale:
- return None
- await conn.execute(
- """
- update post_content_ingestion_job
- set attempt_count = attempt_count + 1
- where post_id = $1
- """,
+ )
+ await transition_post_content_job(
+ conn,
post_id,
+ RUNNING,
+ expected_attempt_count=claimed_attempt_count,
+ expected_source_body_sha256=source_body_digest,
)
- await transition_post_content_job(conn, post_id, RUNNING)
- return row
+ claimed = dict(row)
+ claimed["job_attempt_count"] = claimed_attempt_count
+ claimed["job_cycle_attempt_count"] = cycle_attempt_count + 1
+ return claimed
+
+
+async def _lock_current_claim(
+ conn: asyncpg.Connection,
+ post_id: str,
+ *,
+ expected_source_body_sha256: str,
+ expected_attempt_count: int,
+) -> bool:
+ """Lock and validate the immutable worker claim against current source."""
+ source_row = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1::uuid for update",
+ post_id,
+ )
+ if source_row is None:
+ return False
+ raw_body = source_row["post_body"]
+ if not isinstance(raw_body, str) or (
+ source_body_sha256(raw_body) != expected_source_body_sha256
+ ):
+ return False
+ job_row = await conn.fetchrow(
+ """
+ select status_code
+ from post_content_ingestion_job
+ where post_id = $1::uuid
+ and source_body_sha256 = $2
+ and attempt_count = $3
+ and status_code = $4
+ for update
+ """,
+ post_id,
+ expected_source_body_sha256,
+ expected_attempt_count,
+ RUNNING,
+ )
+ return job_row is not None
async def _finish_job(
@@ -142,21 +228,30 @@ async def _finish_job(
post_id: str,
status_code: str,
*,
+ expected_source_body_sha256: str,
expected_attempt_count: int,
failure_code: str | None = None,
detail_text: str | None = None,
) -> None:
"""Finish only the attempt that actually owns the running lease."""
- async with pool.acquire() as conn:
- async with conn.transaction():
- await transition_post_content_job(
- conn,
- post_id,
- status_code,
- expected_attempt_count=expected_attempt_count,
- failure_code=failure_code,
- detail_text=detail_text,
- )
+ async with pool.acquire() as conn, conn.transaction():
+ if not await _lock_current_claim(
+ conn,
+ post_id,
+ expected_source_body_sha256=expected_source_body_sha256,
+ expected_attempt_count=expected_attempt_count,
+ ):
+ return
+ await transition_post_content_job(
+ conn,
+ post_id,
+ status_code,
+ expected_attempt_count=expected_attempt_count,
+ expected_source_body_sha256=expected_source_body_sha256,
+ expected_status_code=RUNNING,
+ failure_code=failure_code,
+ detail_text=detail_text,
+ )
async def _finish_failed_job(
@@ -165,7 +260,9 @@ async def _finish_failed_job(
*,
failure_code: str,
detail_text: str,
+ expected_source_body_sha256: str,
expected_attempt_count: int,
+ cycle_attempt_count: int,
) -> None:
"""Schedule one retry, or persist a terminal failure for this attempt.
@@ -173,37 +270,29 @@ async def _finish_failed_job(
a worker whose lease was reclaimed cannot retry or terminally fail a newer
attempt.
"""
- async with pool.acquire() as conn:
- async with conn.transaction():
- attempt_count = int(
- await conn.fetchval(
- """
- select attempt_count
- from post_content_ingestion_job
- where post_id = $1
- and status_code = $2
- for update
- """,
- post_id,
- RUNNING,
- )
- or -1
- )
- if attempt_count != expected_attempt_count:
- return
- terminal = attempt_count >= POST_CONTENT_MAX_ATTEMPTS
- await transition_post_content_job(
- conn,
- post_id,
- FAILED if terminal else QUEUED,
- failure_code=_ATTEMPT_LIMIT_FAILURE_CODE if terminal else failure_code,
- detail_text=(
- "post-content ingestion reached its bounded retry limit"
- if terminal
- else detail_text
- ),
- expected_attempt_count=expected_attempt_count,
- )
+ async with pool.acquire() as conn, conn.transaction():
+ if not await _lock_current_claim(
+ conn,
+ post_id,
+ expected_source_body_sha256=expected_source_body_sha256,
+ expected_attempt_count=expected_attempt_count,
+ ):
+ return
+ terminal = cycle_attempt_count >= POST_CONTENT_MAX_ATTEMPTS
+ await transition_post_content_job(
+ conn,
+ post_id,
+ FAILED if terminal else QUEUED,
+ failure_code=_ATTEMPT_LIMIT_FAILURE_CODE if terminal else failure_code,
+ detail_text=(
+ "post-content ingestion reached its bounded retry limit"
+ if terminal
+ else detail_text
+ ),
+ expected_attempt_count=expected_attempt_count,
+ expected_source_body_sha256=expected_source_body_sha256,
+ expected_status_code=RUNNING,
+ )
async def process_post_content_job(
@@ -226,7 +315,8 @@ async def process_post_content_job(
)
if row is None:
return
- attempt_count = int(row["job_attempt_count"]) + 1
+ attempt_count = int(row["job_attempt_count"])
+ cycle_attempt_count = int(row["job_cycle_attempt_count"])
try:
raw_body = row["post_body"]
if not isinstance(raw_body, str) or not raw_body.strip():
@@ -238,7 +328,7 @@ async def process_post_content_job(
vision_client = vision_factory()
normalized = await asyncio.to_thread(normalize_post_body, raw_body, vision_client)
async with pool.acquire() as conn:
- await persist_post_content(
+ persisted_count = await persist_post_content(
conn,
post_id,
raw_body,
@@ -248,7 +338,11 @@ async def process_post_content_job(
normalized_result=normalized,
structure_client=structure_client,
post_title=str(row["post_title"]),
+ expected_source_body_sha256=source_body_digest,
+ expected_attempt_count=attempt_count,
)
+ if persisted_count is None:
+ return
async with pool.acquire() as conn:
complete = await post_content_is_complete(
conn,
@@ -264,20 +358,30 @@ async def process_post_content_job(
post_id,
failure_code=_INCOMPLETE_FAILURE_CODE,
detail_text="post-content providers did not produce complete persisted evidence",
+ expected_source_body_sha256=source_body_digest,
expected_attempt_count=attempt_count,
+ cycle_attempt_count=cycle_attempt_count,
)
return
- except Exception: # noqa: BLE001 - durable failure is recorded for retry.
+ except Exception:
_logger.exception("post content ingestion failed for post_id=%s", post_id)
await _finish_failed_job(
pool,
post_id,
failure_code="post_content_ingestion_failed",
detail_text=_UNEXPECTED_FAILURE_DETAIL,
+ expected_source_body_sha256=source_body_digest,
expected_attempt_count=attempt_count,
+ cycle_attempt_count=cycle_attempt_count,
)
return
- await _finish_job(pool, post_id, SUCCEEDED, expected_attempt_count=attempt_count)
+ await _finish_job(
+ pool,
+ post_id,
+ SUCCEEDED,
+ expected_source_body_sha256=source_body_digest,
+ expected_attempt_count=attempt_count,
+ )
async def consume_post_content_stream_once(
diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py
index eb8e4b638..4dda3d31f 100644
--- a/backend/app/post_summary_ingestion.py
+++ b/backend/app/post_summary_ingestion.py
@@ -28,6 +28,7 @@
from __future__ import annotations
+import hashlib
from typing import Any
import asyncpg
@@ -68,12 +69,19 @@
RelationVerificationClient,
)
-from .corporate_entity_ingestion import get_or_create_corporate_entity
+from .corporate_entity_ingestion import (
+ PreparedCorporateEntityResolution,
+ apply_prepared_corporate_entity_resolution,
+ prepare_corporate_entity_resolution,
+)
from .keyman_ingestion import (
+ PreparedAffiliatedOrganization,
_load_corporate_entity_candidates,
- _resolve_affiliated_organization,
+ apply_prepared_affiliated_organization,
+ prepare_affiliated_organization,
)
from .knowledge_graph import persist_edges_for_post
+from .post_content_queue import SUCCEEDED, fetch_post_summary_source, source_body_sha256
from .post_eligibility import normalize_source_detail_state_code
from .team_ingestion import upsert_team
@@ -91,6 +99,11 @@ def require_summary_source_body(body: str | None) -> str:
return body
+def summary_input_sha256(summary_input: str) -> str:
+ """Bind a summary row to its exact normalized source/evidence text."""
+ return hashlib.sha256(summary_input.encode("utf-8")).hexdigest()
+
+
async def require_summary_target(conn: asyncpg.Connection, post_id: str) -> None:
"""Keep W out of both persisted and on-demand summary generation."""
state_code = await conn.fetchval(
@@ -101,10 +114,51 @@ async def require_summary_target(conn: asyncpg.Connection, post_id: str) -> None
raise ValueError(SUMMARY_TARGET_UNAVAILABLE)
+async def _lock_current_summary_input(
+ conn: asyncpg.Connection,
+ post_id: str,
+ *,
+ expected_source_body_sha256: str,
+ expected_summary_input: str,
+ require_image_evidence: bool,
+) -> bool:
+ """Lock and recheck the exact source and persisted summary evidence."""
+ row = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1 for update",
+ post_id,
+ )
+ if row is None:
+ return False
+ current_body = row["post_body"]
+ if not isinstance(current_body, str):
+ return False
+ if source_body_sha256(current_body) != expected_source_body_sha256:
+ return False
+ if not require_image_evidence:
+ return True
+ job = await conn.fetchrow(
+ """
+ select status_code
+ from post_content_ingestion_job
+ where post_id = $1
+ and source_body_sha256 = $2
+ and status_code = $3
+ for update
+ """,
+ post_id,
+ expected_source_body_sha256,
+ SUCCEEDED,
+ )
+ if job is None:
+ return False
+ return await fetch_post_summary_source(conn, post_id) == expected_summary_input
+
+
async def fetch_persisted_summary(
conn: asyncpg.Connection,
post_id: str,
*,
+ summary_input: str | None = None,
allow_stale: bool = False,
) -> dict[str, Any] | None:
"""Return the stored summary payload, or None when none is usable.
@@ -114,16 +168,24 @@ async def fetch_persisted_summary(
by ``entity_name``. Person chips read ``cataloged_person_id``. A stale
row is returned only when ``allow_stale`` is explicit so a caller can
preserve reader continuity without presenting old semantics as current.
+ A current row additionally requires an exact normalized-input binding.
"""
header = await conn.fetchrow(
- "select korean_summary, summary_contract_version "
+ "select korean_summary, summary_contract_version, summary_input_sha256 "
"from post_summary_result where post_id = $1",
post_id,
)
if header is None:
return None
summary_contract_version = header["summary_contract_version"]
- if summary_contract_version != POST_SUMMARY_CONTRACT_VERSION and not allow_stale:
+ input_matches = bool(
+ summary_input is not None
+ and header.get("summary_input_sha256") == summary_input_sha256(summary_input)
+ )
+ summary_is_current = (
+ summary_contract_version == POST_SUMMARY_CONTRACT_VERSION and input_matches
+ )
+ if not summary_is_current and not allow_stale:
return None
events = await conn.fetch(
"""
@@ -280,9 +342,7 @@ async def fetch_persisted_summary(
"post_id": post_id,
"korean_summary": header["korean_summary"],
"summary_status": (
- "current"
- if summary_contract_version == POST_SUMMARY_CONTRACT_VERSION
- else "stale"
+ "current" if summary_is_current else "stale"
),
"summary_contract_version": summary_contract_version,
"key_events": [row["event_text"] for row in events],
@@ -400,29 +460,30 @@ async def persist_post_summary(
post_id: str,
summary: PostSummary,
*,
- post_body: str | None = None,
+ post_body: str,
+ expected_source_body_sha256: str,
+ require_image_evidence: bool = False,
resolution_client: OrganizationNameResolutionClient | None = None,
hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,
verification_client: RelationVerificationClient | None = None,
) -> dict[str, Any]:
"""Replace the stored summary for ``post_id`` and return the public payload.
- ``post_body`` is the context an organization-actor hierarchy proposal
- is inferred from (ADR 0010); it falls back to the summary's own Korean
- text when not given. The pluggable clients default to unavailable Null
+ ``post_body`` is the exact normalized summary input and the context an
+ organization-actor hierarchy proposal is inferred from (ADR 0010). The
+ pluggable clients default to unavailable Null
clients, so an organization actor then only resolves against an existing
``corporate_entity``.
- Organization inference, verification, and any lock-protected catalog
- creation complete before the atomic summary transaction. The catalog is
- an idempotent shared identity registry; keeping that enrichment separate
- prevents network latency and ``pg_advisory_xact_lock`` from extending the
- summary replacement transaction while all post-owned rows still commit or
- roll back together.
+ Provider-only organization proposals are prepared before the
+ exact-current source/evidence transaction. Catalog writes apply only after
+ that recheck and share its transaction with summary replacement, so neither
+ network latency nor stale provider output holds or mutates locked state
+ (ADR 0114).
"""
await require_summary_target(conn, post_id)
- if post_body is not None:
- require_summary_source_body(post_body)
+ normalized_summary_input = require_summary_source_body(post_body)
+ summary_input_digest = summary_input_sha256(normalized_summary_input)
hierarchy_inference_client = (
hierarchy_inference_client or NullCorporateHierarchyInferenceClient()
@@ -430,62 +491,109 @@ async def persist_post_summary(
resolution_client = resolution_client or NullOrganizationNameResolutionClient()
verification_client = verification_client or NullRelationVerificationClient()
- context_text = post_body if post_body is not None else summary.korean_summary
+ context_text = normalized_summary_input
candidates = (
await _load_corporate_entity_candidates(conn)
if summary.roles_and_responsibilities
else []
)
+ prepared_organizations: dict[int, PreparedCorporateEntityResolution] = {}
+ prepared_affiliations: dict[int, PreparedAffiliatedOrganization] = {}
+ unavailable_affiliations: dict[int, tuple[str, str | None, str]] = {}
+ for role_index, role in enumerate(summary.roles_and_responsibilities):
+ if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(
+ role.actor_name
+ ):
+ continue
+ if role.actor_type_code == ACTOR_TYPE_ORGANIZATION:
+ prepared_organizations[role_index] = (
+ await prepare_corporate_entity_resolution(
+ role.actor_name,
+ context_text,
+ hierarchy_inference_client,
+ verification_client,
+ candidates,
+ )
+ )
+ continue
+ if (
+ role.actor_type_code in {ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM}
+ and role.affiliated_organization_name
+ ):
+ try:
+ prepared_affiliations[role_index] = (
+ await prepare_affiliated_organization(
+ conn,
+ role.affiliated_organization_name,
+ context_text,
+ resolution_client,
+ verification_client,
+ hierarchy_inference_client,
+ candidates,
+ )
+ )
+ except (HttpClientError, OSError, TimeoutError, ValueError):
+ unavailable_affiliations[role_index] = (
+ role.affiliated_organization_name,
+ None,
+ "reason_no_live_client",
+ )
resolved_organization_ids: dict[int, str] = {}
resolved_organization_reasons: dict[int, str] = {}
resolved_affiliation_names: dict[int, str] = {}
resolved_affiliation_ids: dict[int, str] = {}
resolved_affiliation_reasons: dict[int, str] = {}
- for role_index, role in enumerate(summary.roles_and_responsibilities):
- if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(role.actor_name):
- continue
- if role.actor_type_code != ACTOR_TYPE_ORGANIZATION:
- if role.actor_type_code in {ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM} and role.affiliated_organization_name:
- try:
- _, resolved_name, corporate_entity_id, affiliation_reason = (
- await _resolve_affiliated_organization(
- conn,
- role.affiliated_organization_name,
- context_text,
- resolution_client,
- verification_client,
- hierarchy_inference_client,
- candidates,
- )
- )
- except (HttpClientError, OSError, TimeoutError, ValueError):
- # R&R affiliation is enrichment. A provider outage must
- # preserve the raw source name and the summary itself.
- resolved_name, corporate_entity_id, affiliation_reason = (
- role.affiliated_organization_name,
- None,
- "reason_no_live_client",
- )
- resolved_affiliation_names[role_index] = resolved_name
- if corporate_entity_id is not None:
- resolved_affiliation_ids[role_index] = corporate_entity_id
- elif affiliation_reason is not None:
- resolved_affiliation_reasons[role_index] = affiliation_reason
- continue
- corporate_entity_id, unresolved_reason = await get_or_create_corporate_entity(
- conn,
- role.actor_name,
- context_text,
- hierarchy_inference_client,
- verification_client,
- candidates,
- )
- if corporate_entity_id is not None:
- resolved_organization_ids[role_index] = corporate_entity_id
- elif unresolved_reason is not None:
- resolved_organization_reasons[role_index] = unresolved_reason
async with conn.transaction():
+ input_is_current = await _lock_current_summary_input(
+ conn,
+ post_id,
+ expected_source_body_sha256=expected_source_body_sha256,
+ expected_summary_input=normalized_summary_input,
+ require_image_evidence=require_image_evidence,
+ )
+ if not input_is_current:
+ raise RuntimeError("post summary input is no longer current")
+ for role_index, role in enumerate(summary.roles_and_responsibilities):
+ if role.actor_type_code == ACTOR_TYPE_TEAM and is_generic_team_actor(
+ role.actor_name
+ ):
+ continue
+ if role.actor_type_code != ACTOR_TYPE_ORGANIZATION:
+ if (
+ role.actor_type_code in {ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM}
+ and role.affiliated_organization_name
+ ):
+ prepared_affiliation = prepared_affiliations.get(role_index)
+ if prepared_affiliation is not None:
+ _, resolved_name, corporate_entity_id, affiliation_reason = (
+ await apply_prepared_affiliated_organization(
+ conn,
+ prepared_affiliation,
+ candidates,
+ )
+ )
+ else:
+ resolved_name, corporate_entity_id, affiliation_reason = (
+ unavailable_affiliations[role_index]
+ )
+ resolved_affiliation_names[role_index] = resolved_name
+ if corporate_entity_id is not None:
+ resolved_affiliation_ids[role_index] = corporate_entity_id
+ elif affiliation_reason is not None:
+ resolved_affiliation_reasons[role_index] = affiliation_reason
+ continue
+ corporate_entity_id, unresolved_reason = (
+ await apply_prepared_corporate_entity_resolution(
+ conn,
+ prepared_organizations[role_index],
+ candidates,
+ )
+ )
+ if corporate_entity_id is not None:
+ resolved_organization_ids[role_index] = corporate_entity_id
+ elif unresolved_reason is not None:
+ resolved_organization_reasons[role_index] = unresolved_reason
await _replace_summary_projection(
conn,
post_id,
@@ -494,14 +602,18 @@ async def persist_post_summary(
resolved_organization_ids,
resolved_affiliation_names,
resolved_affiliation_ids,
+ summary_input_digest,
resolved_organization_reasons,
resolved_affiliation_reasons,
)
-
- payload = await fetch_persisted_summary(conn, post_id)
- if payload is None:
- raise RuntimeError("persist_post_summary wrote no row")
- return payload
+ payload = await fetch_persisted_summary(
+ conn,
+ post_id,
+ summary_input=normalized_summary_input,
+ )
+ if payload is None:
+ raise RuntimeError("persist_post_summary wrote no row")
+ return payload
async def _resolve_existing_cataloged_person_id(
@@ -535,6 +647,7 @@ async def _replace_summary_projection(
resolved_organization_ids: dict[int, str],
resolved_affiliation_names: dict[int, str],
resolved_affiliation_ids: dict[int, str],
+ summary_input_digest: str,
resolved_organization_reasons: dict[int, str] | None = None,
resolved_affiliation_reasons: dict[int, str] | None = None,
) -> None:
@@ -561,10 +674,12 @@ async def _replace_summary_projection(
await conn.execute("delete from post_project_mention where post_id = $1", post_id)
await conn.execute(
"insert into post_summary_result "
- "(post_id, korean_summary, summary_contract_version) values ($1, $2, $3)",
+ "(post_id, korean_summary, summary_contract_version, summary_input_sha256) "
+ "values ($1, $2, $3, $4)",
post_id,
summary.korean_summary,
POST_SUMMARY_CONTRACT_VERSION,
+ summary_input_digest,
)
for project in summary.project_mentions:
project_key = normalize_project_key(project.canonical_name)
diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh
index c57304cc9..7e065ce27 100644
--- a/docker/postgres-init/migrate.sh
+++ b/docker/postgres-init/migrate.sh
@@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
migration_name=${migration##*/}
case "$migration_name" in
0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;;
- 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0176_*) ;;
+ 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0176_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
diff --git a/docs/adr/0052-plain-orchestrator-semantic-evidence.md b/docs/adr/0052-plain-orchestrator-semantic-evidence.md
index ccffe1a0c..3046968b3 100644
--- a/docs/adr/0052-plain-orchestrator-semantic-evidence.md
+++ b/docs/adr/0052-plain-orchestrator-semantic-evidence.md
@@ -55,13 +55,28 @@ current evidence. The Buyer UI renders the localized ontology label and
localized extraction/provenance labels; it never renders the ontology IRI or
contextual-orchestrator/storage identifiers as user-facing text.
-A reader GET does not synchronously refresh an existing text summary from an
-older contract. It returns that row immediately with `summary_status=stale`,
+A reader GET does not synchronously refresh an existing text-only summary from
+an older contract. It returns that row immediately with `summary_status=stale`,
while `scripts/backfill_post_summaries.py --post-id ...` remains the explicit,
-durable operator path for regeneration. This preserves readable evidence and
-its uncertainty without making a popup wait for the two sequential
-orchestrator calls. Image-bearing posts retain the post-content readiness
-boundary before any summary is returned.
+durable operator path for regeneration. Source-post open and source rendering
+never wait for summary, VISION, or embedding work.
+
+An image-bearing post has a stricter evidence boundary. The summary endpoint
+enqueues or observes the durable post-content job and does not call VISION
+synchronously. It withholds both current and stale persisted summaries until
+the durable job for the current raw-body SHA-256 has status `succeeded` and the
+parent image and every persisted visual region have status `described`.
+Queued or running evidence is reported as processing; a terminal failure is
+reported unavailable until the explicit ADR 0115 retry. Once ready, a current
+persisted summary may be returned only when its normalized summary-input SHA-256
+matches the exact ordered persisted semantic-unit, parent-image, and
+region-evidence text. A stale or legacy-unbound image-bearing summary is
+regenerated. New image-bearing
+summaries use only persisted semantic units in document order, including
+completed OCR and captions; an unavailable placeholder is never promoted into
+summary evidence. Text-only summaries bind the same column to the normalized
+source text; a source revision makes the prior row explicitly stale continuity
+rather than current evidence.
Ask Agent citations expose the persisted source and semantic facts associated
with each cited post through a Buyer-safe projection. Prompt metadata such as
@@ -77,6 +92,8 @@ for reading the complete body and related evidence.
and explicit ontology relations have a dedicated bounded response.
- The channel incurs two additional orchestrator requests and therefore a bounded
latency/cost increase on explicit regeneration, not on a stale reader GET.
+- Image evidence can delay only the image-bearing summary projection; opening
+ and reading the source post remains immediate.
- Plain line parsing is intentionally narrow; unsupported provider output is
rejected rather than promoted into ontology facts.
diff --git a/docs/adr/0098-valkey-backed-post-content-ingestion.md b/docs/adr/0098-valkey-backed-post-content-ingestion.md
index 7e686d763..b08ee51c2 100644
--- a/docs/adr/0098-valkey-backed-post-content-ingestion.md
+++ b/docs/adr/0098-valkey-backed-post-content-ingestion.md
@@ -45,11 +45,17 @@ placed in a stream message.
unresolved structure decisions when structure adjudication is configured.
Incomplete provider output is retried with an explicit failure code rather
than being reported as succeeded. The frontend polls that status while
- continuing to show the source post.
+ continuing to show the source post. Persisted units, images, and regions are
+ exposed only when the job is `succeeded` for the exact current raw-body
+ SHA-256. Otherwise those derived arrays are withheld and the independently
+ loaded current raw source body remains the rendering fallback.
## Consequences
-- Slow VISION and region embedding work no longer blocks summary or post-open.
+- Slow VISION and region embedding work never blocks source-post open or source
+ rendering. Region embeddings do not block summary. For an image-bearing
+ post, persisted parent-image and region descriptions are source evidence and
+ therefore block only the current summary projection until they are complete.
- A provider failure is recorded and can be retried without deleting the raw
source body or fabricating buyer content.
- Valkey is load-bearing as a wake-up queue, but PostgreSQL remains the durable
@@ -72,6 +78,17 @@ permanent false-ready result merely because its text-unit embeddings exist.
The existing bounded automatic retry limit and the explicit terminal retry
operation in ADR 0115 remain unchanged.
+The narrower `post_content_summary_is_ready` predicate checks only the
+persisted VISION evidence required by an image-bearing summary; embeddings and
+non-image structure decisions remain outside that predicate. Readiness also
+requires the durable job row to match the current raw-body SHA-256 and have
+status `post_content_ingestion_succeeded`; described units from an earlier
+body cannot make a newly queued, running, or failed revision ready. The
+summary read path may enqueue or observe the durable job. It MUST NOT call VISION directly
+or summarize an unavailable image placeholder. Queued and running jobs remain
+processing. A terminal failed job remains unavailable until ADR 0115's explicit
+operator retry.
+
When contextual-orchestrator is configured, the same predicate also requires
every persisted unit to have a non-`unresolved` structure decision. Without an
available structure channel, `unresolved` remains an explicit unavailable
@@ -84,6 +101,9 @@ existing `(post_id, source_body_sha256)` wake-up to Valkey, and `_claim_job`
reclaims it under the same lease predicate. This keeps a process restart or
lost consumer from leaving a job permanently running while retaining
at-least-once persistence semantics.
+Duplicate wake-ups for a fresh `running` lease are ignored before applying the
+attempt-limit rule. A final permitted attempt becomes terminal only when that
+lease is stale; a duplicate wake-up cannot fail work that is still active.
On worker startup, the stream cursor begins at the current Valkey stream tail,
not at `0-0`. Historical wake-ups are not authoritative work state; the
@@ -91,12 +111,30 @@ normalized PostgreSQL ledger is scanned and queued/stale rows are republished
after the cursor is established. This prevents a restart from replaying an
unbounded historical stream before processing current work.
-Lease recovery also fences completion by `attempt_count`. A worker whose
-15-minute lease was reclaimed may finish after the replacement worker has
-started; its success, retry, or terminal failure transition is accepted only
-when the PostgreSQL row is still `running` for that exact attempt. A stale
-worker therefore cannot overwrite the newer attempt or append a false status
-event.
+Lease recovery fences persistence and completion with the claimed source-body
+SHA-256 plus `attempt_count` as a monotonic claim identity. `attempt_count` is
+incremented on every claim and is never reset by a changed digest or explicit
+retry, so an A-to-B-to-A body sequence cannot recreate an old claim identity.
+The bounded automatic-retry count is derived from the existing status-event
+ledger after the latest non-failure queued boundary. Before replacing artifacts
+or completing/failing work, the worker locks the job and source row and
+requires the job to remain `running` for that exact attempt and digest and the
+source body to still hash to that digest. A stale worker therefore cannot
+overwrite newer artifacts, complete a requeued attempt, or append a false
+status event.
+
+A missing ledger row is always inserted as `queued`, even if pre-ledger
+artifacts happen to satisfy the structural completeness predicate. Those
+artifacts have no binding to the current raw-body digest; only a fenced worker
+or the explicit ADR 0115 backfill finalization may register success.
+
+The synchronous operator backfill performs provider work before its short
+database transaction. Inside that transaction it locks and rechecks the
+current source body and non-active ledger row, records the bound ledger
+success, and replaces all derived artifacts atomically. It cannot overwrite an
+active worker's artifacts or commit success for a body that changed during
+provider work. The synchronous source-import adapter uses the same finalization
+fence so every production artifact writer participates in that serialization.
## Corpus backfill (2026-08-20)
diff --git a/docs/adr/0114-stale-summary-buyer-continuity.md b/docs/adr/0114-stale-summary-buyer-continuity.md
index 17cdfc004..2c2e9f761 100644
--- a/docs/adr/0114-stale-summary-buyer-continuity.md
+++ b/docs/adr/0114-stale-summary-buyer-continuity.md
@@ -20,18 +20,40 @@ though the source post remains authorized and available.
2. The post-summary endpoint first attempts a current summary. If the
orchestrator is unavailable or the refresh returns an incomplete provider
response, it returns the last persisted summary with
- `summary_status: "stale"` and its stored contract version.
+ `summary_status: "stale"` and its stored contract version. This continuity
+ applies immediately to text-only posts. An image-bearing stale summary is
+ withheld until its persisted parent and region descriptions are complete,
+ then regenerated unless its persisted normalized summary-input SHA-256
+ matches the exact current ordered image-evidence text. Legacy rows with no
+ input binding are never current. For text-only posts, a normalized-input
+ mismatch downgrades a current-contract row to explicit stale continuity.
3. The buyer popup labels the stale state and offers a retry action. Stale
content is never labelled current and is never used to create new catalog
identities or semantic rows.
4. A successful contextual-orchestrator refresh remains the only path that
atomically replaces the stale projection. Failed refreshes never delete the
prior summary or source body.
+5. After provider work and before replacing any summary-owned semantic or
+ shared catalog projection, persistence locks and rechecks the current
+ source-body SHA-256. For image-bearing input it also requires the exact
+ current succeeded content job and re-reads the ordered persisted image
+ evidence; that evidence must match the normalized summary input byte for
+ byte. Organization name and hierarchy provider calls first produce frozen,
+ no-write proposals before that transaction begins. After the recheck,
+ applying those proposals to shared name/catalog tables, replacing the
+ summary-owned projection, and fetching the current payload complete in the
+ same transaction while the source/evidence lock remains held. Provider
+ calls never run while source, job, or catalog advisory locks are held. A
+ source or evidence change during provider work therefore rejects every
+ proposed catalog mutation, leaves the prior summary projection intact, and
+ cannot return the superseded result as current.
## Consequences
- Buyers can read source-grounded prior context while the semantic gateway is
- unavailable instead of seeing a fail-closed summary panel.
+ unavailable instead of seeing a fail-closed summary panel. Image-bearing
+ posts remain fail-closed at the VISION evidence boundary without delaying
+ source-post open or source rendering.
- The UI makes the refresh boundary visible, so an old contract cannot be
mistaken for current ontology evidence.
- A durable background refresh remains useful for large-scale regeneration;
diff --git a/docs/adr/0115-explicit-terminal-content-retry.md b/docs/adr/0115-explicit-terminal-content-retry.md
index 8a75c8dc6..5fbf259d6 100644
--- a/docs/adr/0115-explicit-terminal-content-retry.md
+++ b/docs/adr/0115-explicit-terminal-content-retry.md
@@ -14,10 +14,12 @@ would silently weaken the retry limit and could create an endless loop.
## Decision
Keep automatic retry and read-time behavior unchanged. Provide an explicit,
-single-post operator command that may requeue only a `failed` job, resets its
-attempt counter, recomputes the current source-body digest, appends an audit
+single-post operator command that may requeue only a `failed` job, starts a new
+bounded retry cycle, recomputes the current source-body digest, appends an audit
status event, and publishes one Valkey wake-up. The command is not exposed as
-a public HTTP route and does not reset a queued, running, or succeeded job.
+a public HTTP route and does not reset a queued, running, or succeeded job. It
+never resets the monotonic `attempt_count` claim identity; a later claim must
+remain distinguishable from every worker that ran before the operator retry.
The command must use the existing queue function and must not call a provider
directly. It is an operational recovery action, not a buyer-visible status
@@ -25,10 +27,12 @@ override; the worker still performs the normal VISION, structure, and
embedding completeness checks.
The synchronous operator backfill is a separate repair path. After it
-persists derived evidence, it must call the queue module's ledger-finalization
-function in a database transaction. It must never leave a previously failed
-job marked failed while presenting newly persisted content as a successful
-backfill.
+finishes provider work, it must call the queue module's ledger-finalization
+function and replace derived evidence in one database transaction. The
+finalizer locks and rechecks the current raw body and rejects an active worker;
+artifact replacement and ledger success therefore commit or roll back
+together. It must never leave a previously failed job marked failed while
+presenting newly persisted content as a successful backfill.
## Consequences
diff --git a/lineageweave/post_content_persistence.py b/lineageweave/post_content_persistence.py
index 32caa944b..6ad72e884 100644
--- a/lineageweave/post_content_persistence.py
+++ b/lineageweave/post_content_persistence.py
@@ -11,6 +11,7 @@
import hashlib
import logging
import math
+from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
from .chunking import Chunk, chunk_by_source_body
@@ -79,12 +80,17 @@ async def persist_post_content(
normalized_result: Any | None = None,
structure_client: PostStructureClient | None = None,
post_title: str = "",
-) -> int:
+ expected_source_body_sha256: str | None = None,
+ expected_attempt_count: int | None = None,
+ transaction_fence: Callable[[Any], Awaitable[None]] | None = None,
+) -> int | None:
"""Replace one post's normalized content artifacts and return unit count.
Provider calls happen before the short database transaction. A failed or
unavailable embedding call writes no vector row; it never writes a zero or
guessed vector. The raw body remains in ``source_post`` for future retry.
+ A supplied worker claim that is no longer current returns ``None`` without
+ replacing any persisted artifact.
"""
normalized = normalized_result or normalize_post_body(body, vision_client)
chunks = chunk_by_source_body(body)
@@ -225,7 +231,43 @@ async def persist_post_content(
},
)
+ if (expected_source_body_sha256 is None) != (expected_attempt_count is None):
+ raise ValueError("post-content persistence claim fence must be complete")
+ if expected_source_body_sha256 is not None and transaction_fence is not None:
+ raise ValueError("post-content persistence accepts only one transaction fence")
+
async with conn.transaction():
+ if expected_source_body_sha256 is not None:
+ source_row = await conn.fetchrow(
+ "select post_body from source_post where post_id = $1 for update",
+ post_id,
+ )
+ if source_row is None:
+ return None
+ current_body = source_row["post_body"]
+ if not isinstance(current_body, str):
+ return None
+ current_digest = hashlib.sha256(current_body.encode("utf-8")).hexdigest()
+ if current_digest != expected_source_body_sha256 or current_body != body:
+ return None
+ claim_row = await conn.fetchrow(
+ """
+ select status_code
+ from post_content_ingestion_job
+ where post_id = $1
+ and source_body_sha256 = $2
+ and attempt_count = $3
+ and status_code = 'post_content_ingestion_running'
+ for update
+ """,
+ post_id,
+ expected_source_body_sha256,
+ expected_attempt_count,
+ )
+ if claim_row is None:
+ return None
+ if transaction_fence is not None:
+ await transaction_fence(conn)
await conn.execute("delete from post_content_unit where post_id = $1", post_id)
unit_ids: dict[int, str] = {}
for chunk, unit_text, style in prepared:
diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql
index d3f253969..6a5fc06f1 100644
--- a/migrations/0001_initial_schema.sql
+++ b/migrations/0001_initial_schema.sql
@@ -202,6 +202,11 @@ create index post_evaluation_response_post_idx on post_evaluation_response (post
create table post_summary_result (
post_id uuid primary key references source_post (post_id) on delete cascade,
korean_summary text not null,
+ summary_input_sha256 text
+ constraint post_summary_result_summary_input_sha256_check check (
+ summary_input_sha256 is null
+ or summary_input_sha256 ~ '^[0-9a-f]{64}$'
+ ),
computed_at timestamptz not null default now()
);
diff --git a/migrations/0008_post_summary_result.sql b/migrations/0008_post_summary_result.sql
index 288448bef..e7f99d10f 100644
--- a/migrations/0008_post_summary_result.sql
+++ b/migrations/0008_post_summary_result.sql
@@ -6,6 +6,11 @@
create table if not exists post_summary_result (
post_id uuid primary key references source_post (post_id) on delete cascade,
korean_summary text not null,
+ summary_input_sha256 text
+ constraint post_summary_result_summary_input_sha256_check check (
+ summary_input_sha256 is null
+ or summary_input_sha256 ~ '^[0-9a-f]{64}$'
+ ),
computed_at timestamptz not null default now()
);
diff --git a/migrations/0139_post_summary_input_binding.sql b/migrations/0139_post_summary_input_binding.sql
new file mode 100644
index 000000000..3fa3efd99
--- /dev/null
+++ b/migrations/0139_post_summary_input_binding.sql
@@ -0,0 +1,16 @@
+-- ADR 0052 / 0098 / 0114: bind a summary to its normalized input evidence.
+
+alter table post_summary_result
+ add column if not exists summary_input_sha256 text;
+
+alter table post_summary_result
+ drop constraint if exists post_summary_result_summary_input_sha256_check;
+
+alter table post_summary_result
+ add constraint post_summary_result_summary_input_sha256_check check (
+ summary_input_sha256 is null
+ or summary_input_sha256 ~ '^[0-9a-f]{64}$'
+ );
+
+comment on column post_summary_result.summary_input_sha256 is
+ 'SHA-256 of the exact normalized text or ordered persisted image evidence summarized';
diff --git a/migrations/rollback/0139_post_summary_input_binding.sql b/migrations/rollback/0139_post_summary_input_binding.sql
new file mode 100644
index 000000000..5cdb9a319
--- /dev/null
+++ b/migrations/rollback/0139_post_summary_input_binding.sql
@@ -0,0 +1,7 @@
+-- Roll back the normalized summary-input binding introduced by migration 0139.
+
+alter table post_summary_result
+ drop constraint if exists post_summary_result_summary_input_sha256_check;
+
+alter table post_summary_result
+ drop column if exists summary_input_sha256;
diff --git a/scripts/backfill_post_content.py b/scripts/backfill_post_content.py
index 684b419f4..55372fa9b 100755
--- a/scripts/backfill_post_content.py
+++ b/scripts/backfill_post_content.py
@@ -243,6 +243,20 @@ async def backfill_post_content(
if described_images == 0 and not normalized.text.strip():
result["skipped_posts"] += 1
continue
+ backfill_post_id = str(row["post_id"])
+ backfill_body = str(row["post_body"] or "")
+
+ async def finalize_backfill(
+ inner_conn: asyncpg.Connection,
+ current_post_id: str = backfill_post_id,
+ current_body: str = backfill_body,
+ ) -> None:
+ await record_post_content_backfill_success(
+ inner_conn,
+ current_post_id,
+ current_body,
+ )
+
await persist_post_content(
conn,
str(row["post_id"]),
@@ -253,13 +267,8 @@ async def backfill_post_content(
normalized_result=normalized,
structure_client=structure_client,
post_title=row["post_title"],
+ transaction_fence=finalize_backfill,
)
- async with conn.transaction():
- await record_post_content_backfill_success(
- conn,
- str(row["post_id"]),
- str(row["post_body"] or ""),
- )
result["processed_posts"] += 1
if described_images:
result["described_posts"] += 1
diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py
index 572b700ac..608faed79 100755
--- a/scripts/backfill_post_summaries.py
+++ b/scripts/backfill_post_summaries.py
@@ -22,6 +22,10 @@
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
+from backend.app.post_content_queue import (
+ record_post_content_backfill_success,
+ source_body_sha256,
+)
from backend.app.post_summary_ingestion import persist_post_summary
from lineageweave.corporate_hierarchy_inference import (
NullCorporateHierarchyInferenceClient,
@@ -284,6 +288,20 @@ async def backfill_post_summaries(
normalized = normalize_post_body(row["post_body"], vision_client=vision_client)
if not normalized.text.strip():
raise ValueError("normalized post body is empty")
+ backfill_post_id = str(row["post_id"])
+ backfill_body = str(row["post_body"] or "")
+
+ async def finalize_backfill(
+ inner_conn: asyncpg.Connection,
+ current_post_id: str = backfill_post_id,
+ current_body: str = backfill_body,
+ ) -> None:
+ await record_post_content_backfill_success(
+ inner_conn,
+ current_post_id,
+ current_body,
+ )
+
await persist_post_content(
conn,
str(row["post_id"]),
@@ -294,6 +312,7 @@ async def backfill_post_summaries(
normalized_result=normalized,
structure_client=structure_client,
post_title=row["post_title"],
+ transaction_fence=finalize_backfill,
)
summary = await asyncio.to_thread(
summary_client.summarize_with_hints,
@@ -306,6 +325,8 @@ async def backfill_post_summaries(
str(row["post_id"]),
summary,
post_body=normalized.text,
+ expected_source_body_sha256=source_body_sha256(row["post_body"]),
+ require_image_evidence=bool(normalized.image_results),
hierarchy_inference_client=NullCorporateHierarchyInferenceClient(),
verification_client=NullRelationVerificationClient(),
)
diff --git a/scripts/import_postgresql_posts.py b/scripts/import_postgresql_posts.py
index acabc04ee..7e2c5fb15 100644
--- a/scripts/import_postgresql_posts.py
+++ b/scripts/import_postgresql_posts.py
@@ -32,6 +32,7 @@
from backend.app.analysis_run_start import configured_tepp_client
from backend.app.customer_hint_ingestion import reconcile_customer_hints
from backend.app.lineage_ingestion import rebuild_lineage
+from backend.app.post_content_queue import record_post_content_backfill_success
from lineageweave.corporate_hierarchy_inference import (
ContextualOrchestratorHierarchyInferenceClient,
NullCorporateHierarchyInferenceClient,
@@ -660,6 +661,20 @@ def resolve_body(row: Any, row_number: int) -> str:
},
)
with use_llm_metadata(metadata):
+ imported_post_id = str(post_id)
+ imported_body = body
+
+ async def finalize_import(
+ inner_conn: asyncpg.Connection,
+ current_post_id: str = imported_post_id,
+ current_body: str = imported_body,
+ ) -> None:
+ await record_post_content_backfill_success(
+ inner_conn,
+ current_post_id,
+ current_body,
+ )
+
await persist_post_content(
target,
str(post_id),
@@ -669,6 +684,7 @@ def resolve_body(row: Any, row_number: int) -> str:
embedding_model_code=args.embedding_model or None,
structure_client=structure_client,
post_title=title,
+ transaction_fence=finalize_import,
)
imported += 1
cleanup = await cleanup_synthetic_seed(target, apply=True)
diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py
index 7b32154c4..227faea33 100644
--- a/scripts/seed_demo_data.py
+++ b/scripts/seed_demo_data.py
@@ -31,6 +31,7 @@
import psycopg2
from lineageweave.http_client import get_json_list, post_form
+from lineageweave.post_content_normalization import normalize_post_body
from lineageweave.post_summary import ACTOR_TYPE_PERSON, POST_SUMMARY_CONTRACT_VERSION
from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable
@@ -130,6 +131,7 @@ def seed(
cur.execute((migrations / "0023_analysis_run_outbox.sql").read_text())
cur.execute((migrations / "0024_source_post_revision.sql").read_text())
cur.execute((migrations / "0025_role_person_catalog_identity.sql").read_text())
+ cur.execute((migrations / "0139_post_summary_input_binding.sql").read_text())
cur.execute(
"""
insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values
@@ -524,12 +526,25 @@ def _seed_reconstructed_lineage(cur, author_account_id, corporate_entity_id, pro
def _write_post_summary(cur, post_id, summary) -> None:
"""Replace the stored summary for ``post_id`` (idempotent re-seed)."""
+ from backend.app.post_summary_ingestion import summary_input_sha256
+
+ cur.execute("select post_body from source_post where post_id = %s", (post_id,))
+ source_row = cur.fetchone()
+ if source_row is None:
+ raise ValueError("synthetic summary target is unavailable")
+ normalized_input = normalize_post_body(source_row[0]).text
cur.execute("delete from post_summary_person_mention where post_id = %s", (post_id,))
cur.execute("delete from post_summary_result where post_id = %s", (post_id,))
cur.execute(
"insert into post_summary_result "
- "(post_id, korean_summary, summary_contract_version) values (%s, %s, %s)",
- (post_id, summary.korean_summary, POST_SUMMARY_CONTRACT_VERSION),
+ "(post_id, korean_summary, summary_contract_version, summary_input_sha256) "
+ "values (%s, %s, %s, %s)",
+ (
+ post_id,
+ summary.korean_summary,
+ POST_SUMMARY_CONTRACT_VERSION,
+ summary_input_sha256(normalized_input),
+ ),
)
for ordinal, event_text in enumerate(summary.key_events):
cur.execute(
diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py
index 4cb650222..442081e92 100644
--- a/tests/test_ingestion_transaction_contracts.py
+++ b/tests/test_ingestion_transaction_contracts.py
@@ -3,23 +3,27 @@
from __future__ import annotations
import asyncio
+import hashlib
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any
+import pytest
+
from backend.app import corporate_entity_ingestion as corporate_ingestion
from backend.app import keyman_ingestion
from backend.app import post_summary_ingestion as summary_ingestion
from lineageweave.corporate_hierarchy_inference import HierarchyProposal
+from lineageweave.corporate_hierarchy_resolution import score_corporate_entity
from lineageweave.keyman_extraction import OUR_SIDE, PersonMention
from lineageweave.knowledge_graph import NODE_PERSON
from lineageweave.post_summary import (
ACTOR_TYPE_ORGANIZATION,
ACTOR_TYPE_PERSON,
ACTOR_TYPE_TEAM,
- PostSummary,
POST_SUMMARY_CONTRACT_VERSION,
+ PostSummary,
RoleResponsibility,
)
from lineageweave.relation_verification import STATUS_CORROBORATED
@@ -177,12 +181,227 @@ def test_locked_candidate_recheck_reuses_concurrently_created_entity() -> None:
assert events[-1] == "transaction:exit"
+def test_prepared_sibling_alias_reuses_first_applied_catalog_entity() -> None:
+ """Sibling plans re-score the evolving snapshot before creating a row."""
+ events: list[Any] = []
+ inserted_id = uuid.uuid4()
+ connection = _CorporateConnection(events, inserted_id=inserted_id)
+ candidates: list[Any] = []
+
+ async def prepare_then_apply() -> tuple[
+ tuple[str | None, str | None],
+ tuple[str | None, str | None],
+ ]:
+ first = await corporate_ingestion.prepare_corporate_entity_resolution(
+ "Alpha",
+ "Synthetic sibling-alias context",
+ _InferenceClient(events),
+ _VerificationClient(events),
+ candidates,
+ )
+ second = await corporate_ingestion.prepare_corporate_entity_resolution(
+ "Alphx",
+ "Synthetic sibling-alias context",
+ _InferenceClient(events),
+ _VerificationClient(events),
+ candidates,
+ )
+ first_result = await corporate_ingestion.apply_prepared_corporate_entity_resolution(
+ connection,
+ first,
+ candidates,
+ )
+ second_result = await corporate_ingestion.apply_prepared_corporate_entity_resolution(
+ connection,
+ second,
+ candidates,
+ )
+ return first_result, second_result
+
+ first_result, second_result = asyncio.run(prepare_then_apply())
+
+ assert first_result == second_result == (str(inserted_id), None)
+ assert [event for event in events if event == "entity_insert"] == ["entity_insert"]
+ assert [candidate.entity_name for candidate in candidates] == ["Alpha"]
+ assert score_corporate_entity("Alphx", candidates).top_score == pytest.approx(0.8)
+
+
+def test_prepared_child_excludes_resolved_parent_from_alias_reuse() -> None:
+ """A fuzzy parent name cannot collapse a corroborated child hierarchy."""
+ events: list[Any] = []
+ parent_id = uuid.uuid4()
+ child_id = uuid.uuid4()
+ inserted_rows: list[dict[str, Any]] = []
+
+ class HierarchyInference:
+ available = True
+
+ def infer(self, organization_name: str, _context_text: str) -> HierarchyProposal:
+ if organization_name == "Synthetiqx":
+ return HierarchyProposal(level_code="plant", parent_name="Synthetic")
+ return HierarchyProposal(level_code="company", parent_name=None)
+
+ class HierarchyVerification:
+ available = True
+
+ def verify(self, _subject: str, _relation: str) -> SimpleNamespace:
+ return SimpleNamespace(status_code=STATUS_CORROBORATED)
+
+ class HierarchyConnection:
+ def transaction(self) -> _RecordedTransaction:
+ return _RecordedTransaction(events)
+
+ async def execute(self, query: str, *_args: Any) -> str:
+ assert "pg_advisory_xact_lock" in query
+ return "SELECT 1"
+
+ async def fetch(self, _query: str, *_args: Any) -> list[dict[str, Any]]:
+ return list(inserted_rows)
+
+ async def fetchrow(self, query: str, *args: Any) -> dict[str, uuid.UUID]:
+ assert "insert into corporate_entity" in query
+ organization_name = args[2]
+ entity_id = parent_id if organization_name == "Synthetic" else child_id
+ inserted_rows.append(
+ {
+ "corporate_entity_id": entity_id,
+ "entity_name": organization_name,
+ "parent_entity_id": args[0],
+ }
+ )
+ return {"corporate_entity_id": entity_id}
+
+ async def prepare_parent_and_child() -> tuple[
+ tuple[str | None, str | None],
+ tuple[str | None, str | None],
+ list[Any],
+ ]:
+ candidates: list[Any] = []
+ inference = HierarchyInference()
+ verification = HierarchyVerification()
+ parent = await corporate_ingestion.prepare_corporate_entity_resolution(
+ "Synthetic",
+ "Synthetic parent-child context",
+ inference,
+ verification,
+ candidates,
+ )
+ child = await corporate_ingestion.prepare_corporate_entity_resolution(
+ "Synthetiqx",
+ "Synthetic parent-child context",
+ inference,
+ verification,
+ candidates,
+ )
+ connection = HierarchyConnection()
+ parent_result = (
+ await corporate_ingestion.apply_prepared_corporate_entity_resolution(
+ connection,
+ parent,
+ candidates,
+ )
+ )
+ child_result = (
+ await corporate_ingestion.apply_prepared_corporate_entity_resolution(
+ connection,
+ child,
+ candidates,
+ )
+ )
+ return parent_result, child_result, candidates
+
+ parent_result, child_result, candidates = asyncio.run(prepare_parent_and_child())
+
+ assert score_corporate_entity("Synthetiqx", candidates[:1]).top_score == pytest.approx(
+ 0.8421052631578947
+ )
+ assert parent_result == (str(parent_id), None)
+ assert child_result == (str(child_id), None)
+ assert [row["entity_name"] for row in inserted_rows] == ["Synthetic", "Synthetiqx"]
+ assert inserted_rows[1]["parent_entity_id"] == str(parent_id)
+
+
+def test_prepared_parent_chain_applies_parent_before_child_without_provider_locks() -> None:
+ """The composite caller preserves provider-first, parent-first semantics."""
+ events: list[Any] = []
+ parent_id = uuid.uuid4()
+ child_id = uuid.uuid4()
+
+ class ParentInference:
+ available = True
+
+ def infer(self, organization_name: str, _context_text: str) -> HierarchyProposal:
+ events.append(("inference", organization_name))
+ if organization_name == "Synthetic Child":
+ return HierarchyProposal(
+ level_code="plant",
+ parent_name="Synthetic Parent",
+ )
+ return HierarchyProposal(level_code="company", parent_name=None)
+
+ class ParentVerification:
+ available = True
+
+ def verify(self, subject: str, relation: str) -> SimpleNamespace:
+ events.append(("verification", subject, relation))
+ return SimpleNamespace(status_code=STATUS_CORROBORATED)
+
+ class ParentConnection:
+ def transaction(self) -> _RecordedTransaction:
+ events.append("transaction:open")
+ return _RecordedTransaction(events)
+
+ async def execute(self, query: str, *_args: Any) -> str:
+ assert "pg_advisory_xact_lock" in query
+ events.append("creation_lock")
+ return "SELECT 1"
+
+ async def fetch(self, _query: str, *_args: Any) -> list[Any]:
+ return []
+
+ async def fetchrow(self, query: str, *args: Any) -> dict[str, uuid.UUID]:
+ assert "insert into corporate_entity" in query
+ organization_name = args[2]
+ events.append(("entity_insert", organization_name, args[0]))
+ return {
+ "corporate_entity_id": (
+ parent_id if organization_name == "Synthetic Parent" else child_id
+ )
+ }
+
+ result = asyncio.run(
+ corporate_ingestion.get_or_create_corporate_entity(
+ ParentConnection(),
+ "Synthetic Child",
+ "Synthetic parent-chain context",
+ ParentInference(),
+ ParentVerification(),
+ [],
+ )
+ )
+
+ assert result == (str(child_id), None)
+ inserts = [event for event in events if isinstance(event, tuple) and event[0] == "entity_insert"]
+ assert inserts == [
+ ("entity_insert", "Synthetic Parent", None),
+ ("entity_insert", "Synthetic Child", str(parent_id)),
+ ]
+ first_transaction = events.index("transaction:open")
+ last_provider = max(
+ index
+ for index, event in enumerate(events)
+ if isinstance(event, tuple) and event[0] in {"inference", "verification"}
+ )
+ assert last_provider < first_transaction
+
+
class _SummaryConnection:
"""Minimal connection that records every post-summary database operation."""
def __init__(self, events: list[Any]) -> None:
self._events = events
self.in_transaction = False
+ self.summary_input_sha256: str | None = None
def transaction(self) -> _RecordedTransaction:
self._events.append("transaction:open")
@@ -191,17 +410,24 @@ def transaction(self) -> _RecordedTransaction:
async def execute(self, query: str, *args: Any) -> str:
assert self.in_transaction
compact = " ".join(query.split())
- self._events.append(("execute", compact))
+ self._events.append(("execute", compact, args))
+ if compact.startswith("insert into post_summary_result"):
+ assert "summary_input_sha256" in compact
+ self.summary_input_sha256 = args[-1]
return "OK"
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
compact = " ".join(query.split())
self._events.append(("fetchrow", compact))
- if compact.startswith("select korean_summary, summary_contract_version from post_summary_result"):
- assert not self.in_transaction
+ if compact.startswith(
+ "select korean_summary, summary_contract_version, summary_input_sha256 "
+ "from post_summary_result"
+ ):
+ assert self.in_transaction
return {
"korean_summary": "합성 요약",
"summary_contract_version": POST_SUMMARY_CONTRACT_VERSION,
+ "summary_input_sha256": self.summary_input_sha256,
}
if compact.startswith("select resolved_organization_name, verification_status_code"):
assert not self.in_transaction
@@ -222,7 +448,7 @@ async def fetchval(self, query: str, *args: Any) -> Any:
async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
compact = " ".join(query.split())
self._events.append(("fetch", compact))
- assert not self.in_transaction
+ assert self.in_transaction
if "from post_summary_action" in compact:
return []
if "from post_summary_quantitative_observation" in compact:
@@ -281,6 +507,12 @@ async def persist_edges(conn, post_id) -> list[Any]:
monkeypatch.setattr(summary_ingestion, "upsert_team", upsert_team)
monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+ async def current_input(*_args, **_kwargs) -> bool:
+ events.append("input_lock")
+ return True
+
+ monkeypatch.setattr(summary_ingestion, "_lock_current_summary_input", current_input)
+
summary = PostSummary(
korean_summary="합성 요약",
key_events=("검토 완료",),
@@ -299,10 +531,15 @@ async def persist_edges(conn, post_id) -> list[Any]:
connection,
str(uuid.uuid4()),
summary,
+ post_body="Synthetic normalized summary input.",
+ expected_source_body_sha256=hashlib.sha256(
+ b"Synthetic normalized summary input."
+ ).hexdigest(),
)
)
enter_index = events.index("transaction:enter")
+ lock_index = events.index("input_lock")
exit_index = events.index("transaction:exit")
required_sql = (
"delete from post_summary_person_mention",
@@ -324,31 +561,139 @@ async def persist_edges(conn, post_id) -> list[Any]:
)
assert enter_index < operation_index < exit_index
assert events.index("candidate_load") < enter_index
- assert enter_index < events.index("team_upsert") < exit_index
+ assert enter_index < lock_index < events.index("team_upsert") < exit_index
assert enter_index < events.index("edge_persist") < exit_index
assert payload["korean_summary"] == "합성 요약"
+ assert connection.summary_input_sha256 == hashlib.sha256(
+ b"Synthetic normalized summary input."
+ ).hexdigest()
+
+
+def test_summary_source_revision_after_provider_work_preserves_prior_projection(
+ monkeypatch,
+) -> None:
+ events: list[Any] = []
+ connection = _SummaryConnection(events)
+ proposals: list[str] = []
+ catalog_writes: list[str] = []
+
+ async def stale_input(*_args, **_kwargs) -> bool:
+ return False
+
+ async def must_not_replace(*_args, **_kwargs) -> None:
+ raise AssertionError("stale provider output must not replace summary projections")
+
+ async def load_candidates(*_args, **_kwargs) -> list[Any]:
+ return []
+
+ async def prepare_organization(*_args, **_kwargs):
+ proposals.append("organization")
+ return object()
+
+ async def prepare_affiliation(*_args, **_kwargs):
+ proposals.append("affiliation")
+ return object()
+
+ async def apply_organization(*_args, **_kwargs):
+ catalog_writes.append("organization")
+ return None, "reason_no_catalog_entry"
+
+ async def apply_affiliation(*_args, **_kwargs):
+ catalog_writes.append("affiliation")
+ return "Synthetic Affiliate", "Synthetic Affiliate", None, None
+
+ monkeypatch.setattr(summary_ingestion, "_lock_current_summary_input", stale_input)
+ monkeypatch.setattr(summary_ingestion, "_replace_summary_projection", must_not_replace)
+ monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
+ monkeypatch.setattr(
+ summary_ingestion,
+ "prepare_corporate_entity_resolution",
+ prepare_organization,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ summary_ingestion,
+ "prepare_affiliated_organization",
+ prepare_affiliation,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ summary_ingestion,
+ "apply_prepared_corporate_entity_resolution",
+ apply_organization,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ summary_ingestion,
+ "apply_prepared_affiliated_organization",
+ apply_affiliation,
+ raising=False,
+ )
+
+ with pytest.raises(RuntimeError, match="no longer current"):
+ asyncio.run(
+ summary_ingestion.persist_post_summary(
+ connection,
+ str(uuid.uuid4()),
+ PostSummary(
+ korean_summary="합성 요약이다.",
+ roles_and_responsibilities=(
+ RoleResponsibility(
+ actor_name="Synthetic Organization",
+ responsibility="합성 책임",
+ actor_type_code=ACTOR_TYPE_ORGANIZATION,
+ ),
+ RoleResponsibility(
+ actor_name="Synthetic Person",
+ responsibility="합성 후속",
+ actor_type_code=ACTOR_TYPE_PERSON,
+ affiliated_organization_name="Synthetic Affiliate",
+ ),
+ ),
+ ),
+ post_body="Synthetic provider input.",
+ expected_source_body_sha256=hashlib.sha256(
+ b"Synthetic original source."
+ ).hexdigest(),
+ require_image_evidence=False,
+ )
+ )
+
+ assert not any(
+ isinstance(event, tuple)
+ and event[0] == "execute"
+ and "delete from post_summary_result" in event[1]
+ for event in events
+ )
+ assert proposals == ["organization", "affiliation"]
+ assert catalog_writes == []
-def test_organization_enrichment_finishes_before_summary_transaction(monkeypatch) -> None:
- """LLM verification and the advisory-lock transaction precede summary writes."""
+def test_blocking_organization_provider_finishes_before_current_source_lock(
+ monkeypatch,
+) -> None:
+ """Network proposal work never holds the current source transaction."""
events: list[Any] = []
connection = _SummaryConnection(events)
corporate_entity_id = str(uuid.uuid4())
+ provider_started = asyncio.Event()
+ provider_release = asyncio.Event()
+ prepared = object()
async def load_candidates(conn) -> list[Any]:
events.append(("candidate_load", conn.in_transaction))
return []
- async def resolve_organization(
- conn,
- organization_name,
- context_text,
- inference_client,
- verification_client,
- candidates,
- ) -> tuple[str, str | None]:
- events.append(("organization_resolve", conn.in_transaction))
- assert not conn.in_transaction
+ async def prepare_organization(*_args, **_kwargs):
+ events.append(("organization_prepare", connection.in_transaction))
+ provider_started.set()
+ await provider_release.wait()
+ return prepared
+
+ async def apply_organization(conn, proposal, candidates) -> tuple[str, str | None]:
+ assert proposal is prepared
+ events.append(("organization_apply", conn.in_transaction))
+ assert conn.in_transaction
return corporate_entity_id, None
async def persist_edges(conn, post_id) -> list[Any]:
@@ -357,9 +702,26 @@ async def persist_edges(conn, post_id) -> list[Any]:
return []
monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
- monkeypatch.setattr(summary_ingestion, "get_or_create_corporate_entity", resolve_organization)
+ monkeypatch.setattr(
+ summary_ingestion,
+ "prepare_corporate_entity_resolution",
+ prepare_organization,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ summary_ingestion,
+ "apply_prepared_corporate_entity_resolution",
+ apply_organization,
+ raising=False,
+ )
monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+ async def current_input(*_args, **_kwargs) -> bool:
+ events.append("input_lock")
+ return True
+
+ monkeypatch.setattr(summary_ingestion, "_lock_current_summary_input", current_input)
+
summary = PostSummary(
korean_summary="합성 요약",
roles_and_responsibilities=(
@@ -371,18 +733,33 @@ async def persist_edges(conn, post_id) -> list[Any]:
),
)
- asyncio.run(
- summary_ingestion.persist_post_summary(
- connection,
- str(uuid.uuid4()),
- summary,
+ async def persist_while_provider_is_blocked() -> bool:
+ task = asyncio.create_task(
+ summary_ingestion.persist_post_summary(
+ connection,
+ str(uuid.uuid4()),
+ summary,
+ post_body="Synthetic organization evidence.",
+ expected_source_body_sha256=hashlib.sha256(
+ b"Synthetic organization evidence."
+ ).hexdigest(),
+ )
)
- )
+ await asyncio.wait_for(provider_started.wait(), timeout=1)
+ lock_was_held = connection.in_transaction
+ provider_release.set()
+ await task
+ return lock_was_held
+
+ assert asyncio.run(persist_while_provider_is_blocked()) is False
assert ("candidate_load", False) in events
- assert ("organization_resolve", False) in events
- resolve_index = events.index(("organization_resolve", False))
+ assert ("organization_prepare", False) in events
+ assert ("organization_apply", True) in events
+ prepare_index = events.index(("organization_prepare", False))
+ apply_index = events.index(("organization_apply", True))
enter_index = events.index("transaction:enter")
+ lock_index = events.index("input_lock")
exit_index = events.index("transaction:exit")
mention_index = next(
index
@@ -400,7 +777,7 @@ async def persist_edges(conn, post_id) -> list[Any]:
)
assert "cataloged_corporate_entity_id" in role_insert
assert "cataloged_person_id" in role_insert
- assert resolve_index < enter_index < mention_index < exit_index
+ assert prepare_index < enter_index < lock_index < apply_index < mention_index < exit_index
class _KeymanConnection:
@@ -448,22 +825,22 @@ def test_keyman_organization_enrichment_finishes_before_write_transaction(monkey
connection = _KeymanConnection(events)
corporate_entity_id = str(uuid.uuid4())
- async def resolve_name(conn, resolution_client, verification_client, organization_name, post_body) -> str:
+ prepared = object()
+
+ async def prepare_affiliation(conn, *_args, **_kwargs):
events.append(("organization_resolve", conn.in_transaction))
assert not conn.in_transaction
- return "Aurora Grid Power"
+ return prepared
- async def resolve_organization(
+ async def apply_affiliation(
conn,
- organization_name,
- context_text,
- inference_client,
- verification_client,
+ proposal,
candidates,
- ) -> tuple[str, str | None]:
+ ) -> tuple[str, str, str | None, str | None]:
+ assert proposal is prepared
events.append(("organization_create", conn.in_transaction))
assert not conn.in_transaction
- return corporate_entity_id, None
+ return "AGP", "Aurora Grid Power", corporate_entity_id, None
class _Client:
available = True
@@ -477,8 +854,16 @@ def extract(self, post_title: str, post_body: str) -> list[PersonMention]:
)
]
- monkeypatch.setattr(keyman_ingestion, "resolve_organization_name", resolve_name)
- monkeypatch.setattr(keyman_ingestion, "get_or_create_corporate_entity", resolve_organization)
+ monkeypatch.setattr(
+ keyman_ingestion,
+ "prepare_affiliated_organization",
+ prepare_affiliation,
+ )
+ monkeypatch.setattr(
+ keyman_ingestion,
+ "apply_prepared_affiliated_organization",
+ apply_affiliation,
+ )
asyncio.run(
keyman_ingestion.ingest_post_keymen(
@@ -521,6 +906,8 @@ def test_fetch_persisted_summary_returns_stored_person_catalog_id() -> None:
"""A persisted person role keeps catalog_node_id for the chip button."""
person_id = str(uuid.uuid4())
+ summary_input = "Synthetic person projection evidence."
+ summary_digest = hashlib.sha256(summary_input.encode("utf-8")).hexdigest()
events: list[Any] = []
class _PersonFetchConnection:
@@ -529,10 +916,14 @@ class _PersonFetchConnection:
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
compact = " ".join(query.split())
events.append(("fetchrow", compact))
- if compact.startswith("select korean_summary, summary_contract_version from post_summary_result"):
+ if compact.startswith(
+ "select korean_summary, summary_contract_version, summary_input_sha256 "
+ "from post_summary_result"
+ ):
return {
"korean_summary": "합성 요약",
"summary_contract_version": POST_SUMMARY_CONTRACT_VERSION,
+ "summary_input_sha256": summary_digest,
}
raise AssertionError(f"unexpected fetchrow query: {compact}")
@@ -572,7 +963,11 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
raise AssertionError(f"unexpected fetch query: {compact}")
payload = asyncio.run(
- summary_ingestion.fetch_persisted_summary(_PersonFetchConnection(), str(uuid.uuid4()))
+ summary_ingestion.fetch_persisted_summary(
+ _PersonFetchConnection(),
+ str(uuid.uuid4()),
+ summary_input=summary_input,
+ )
)
assert payload is not None
role = payload["roles_and_responsibilities"][0]
@@ -634,6 +1029,11 @@ async def persist_edges(conn, post_id) -> list[Any]:
monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+ async def current_input(*_args, **_kwargs) -> bool:
+ return True
+
+ monkeypatch.setattr(summary_ingestion, "_lock_current_summary_input", current_input)
+
payload = asyncio.run(
summary_ingestion.persist_post_summary(
connection,
@@ -647,7 +1047,11 @@ async def persist_edges(conn, post_id) -> list[Any]:
actor_type_code=ACTOR_TYPE_PERSON,
),
),
- ),
+ ),
+ post_body="Synthetic person evidence.",
+ expected_source_body_sha256=hashlib.sha256(
+ b"Synthetic person evidence."
+ ).hexdigest(),
)
)
role_insert = next(
@@ -684,6 +1088,11 @@ async def persist_edges(conn, post_id) -> list[Any]:
monkeypatch.setattr(summary_ingestion, "_load_corporate_entity_candidates", load_candidates)
monkeypatch.setattr(summary_ingestion, "persist_edges_for_post", persist_edges)
+ async def current_input(*_args, **_kwargs) -> bool:
+ return True
+
+ monkeypatch.setattr(summary_ingestion, "_lock_current_summary_input", current_input)
+
asyncio.run(
summary_ingestion.persist_post_summary(
connection,
@@ -697,7 +1106,11 @@ async def persist_edges(conn, post_id) -> list[Any]:
actor_type_code=ACTOR_TYPE_PERSON,
),
),
- ),
+ ),
+ post_body="Synthetic uncataloged person evidence.",
+ expected_source_body_sha256=hashlib.sha256(
+ b"Synthetic uncataloged person evidence."
+ ).hexdigest(),
)
)
mention_inserts = [
diff --git a/tests/test_organization_name_resolution_ingestion.py b/tests/test_organization_name_resolution_ingestion.py
index afdfa8f32..c2de3712c 100644
--- a/tests/test_organization_name_resolution_ingestion.py
+++ b/tests/test_organization_name_resolution_ingestion.py
@@ -6,7 +6,10 @@
import pytest
import backend.app.organization_name_resolution_ingestion as ingestion
-from lineageweave.relation_verification import STATUS_CORROBORATED, STATUS_UNCORROBORATED
+from lineageweave.relation_verification import (
+ STATUS_CORROBORATED,
+ STATUS_UNCORROBORATED,
+)
class _Connection:
@@ -73,3 +76,34 @@ def test_new_resolution_is_persisted_but_only_verified_name_is_returned(
assert result == expected
assert len(conn.executed) == 1
assert "organization_name_resolution" in conn.executed[0][0]
+
+
+def test_prepared_name_resolution_defers_cache_write_until_apply(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ ingestion,
+ "resolve_and_verify_organization_name",
+ lambda *_args: _resolution(STATUS_CORROBORATED),
+ )
+ conn = _Connection()
+
+ prepared = asyncio.run(
+ ingestion.prepare_organization_name_resolution(
+ conn,
+ _Client(),
+ _Client(),
+ "AGP",
+ "context",
+ )
+ )
+
+ assert prepared.resolved_name == "Aurora Grid Power"
+ assert conn.executed == []
+ assert (
+ asyncio.run(
+ ingestion.apply_prepared_organization_name_resolution(conn, prepared)
+ )
+ == "Aurora Grid Power"
+ )
+ assert len(conn.executed) == 1
diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py
index c979b2ee6..571aa2c3b 100644
--- a/tests/test_person_mention_projection.py
+++ b/tests/test_person_mention_projection.py
@@ -369,8 +369,16 @@ async def _exercise_projection_contract(
),
),
),
+ post_body="Synthetic body",
+ expected_source_body_sha256=summary_ingestion.source_body_sha256(
+ "Synthetic body"
+ ),
+ )
+ summary_payload = await fetch_persisted_summary(
+ connection,
+ post_id,
+ summary_input="Synthetic body",
)
- summary_payload = await fetch_persisted_summary(connection, post_id)
assert summary_payload is not None
assert [
action["project_name"]
@@ -402,6 +410,10 @@ async def _exercise_projection_contract(
connection,
post_id,
PostSummary(korean_summary="역할이 제거된 합성 요약"),
+ post_body="Synthetic body",
+ expected_source_body_sha256=summary_ingestion.source_body_sha256(
+ "Synthetic body"
+ ),
)
assert await visible_mention_post_ids(
connection, summary_person_id, lambda row: True
@@ -660,11 +672,27 @@ async def _exercise_homonym_organization_role_binding(
)
)
- async def resolve_mentioned_organization(*_args, **_kwargs) -> tuple[str, None]:
+ prepared = object()
+
+ async def prepare_mentioned_organization(*_args, **_kwargs):
+ return prepared
+
+ async def apply_mentioned_organization(
+ _conn,
+ proposal,
+ _candidates,
+ ) -> tuple[str, None]:
+ assert proposal is prepared
return mentioned_id, None
- original = summary_ingestion.get_or_create_corporate_entity
- summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization
+ original_prepare = summary_ingestion.prepare_corporate_entity_resolution
+ original_apply = summary_ingestion.apply_prepared_corporate_entity_resolution
+ summary_ingestion.prepare_corporate_entity_resolution = (
+ prepare_mentioned_organization
+ )
+ summary_ingestion.apply_prepared_corporate_entity_resolution = (
+ apply_mentioned_organization
+ )
try:
payload = await persist_post_summary(
connection,
@@ -679,15 +707,24 @@ async def resolve_mentioned_organization(*_args, **_kwargs) -> tuple[str, None]:
),
),
),
+ post_body="Synthetic body",
+ expected_source_body_sha256=summary_ingestion.source_body_sha256(
+ "Synthetic body"
+ ),
)
finally:
- summary_ingestion.get_or_create_corporate_entity = original
+ summary_ingestion.prepare_corporate_entity_resolution = original_prepare
+ summary_ingestion.apply_prepared_corporate_entity_resolution = original_apply
roles = payload["roles_and_responsibilities"]
assert len(roles) == 1
assert roles[0]["catalog_node_id"] == mentioned_id
assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY
- fetched = await fetch_persisted_summary(connection, post_id)
+ fetched = await fetch_persisted_summary(
+ connection,
+ post_id,
+ summary_input="Synthetic body",
+ )
assert fetched is not None
assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id
assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id
@@ -757,8 +794,16 @@ async def _exercise_same_name_person_catalog_order(
),
),
),
+ post_body="Synthetic body",
+ expected_source_body_sha256=summary_ingestion.source_body_sha256(
+ "Synthetic body"
+ ),
+ )
+ payload = await fetch_persisted_summary(
+ connection,
+ post_id,
+ summary_input="Synthetic body",
)
- payload = await fetch_persisted_summary(connection, post_id)
assert payload is not None
roles = payload["roles_and_responsibilities"]
assert len(roles) == 1
@@ -835,6 +880,10 @@ async def _exercise_two_project_event_streams_stay_separate(
),
),
),
+ post_body="Synthetic body",
+ expected_source_body_sha256=summary_ingestion.source_body_sha256(
+ "Synthetic body"
+ ),
)
events_by_text = {
@@ -847,7 +896,11 @@ async def _exercise_two_project_event_streams_stay_separate(
# either declared project just because two exist on this post.
assert events_by_text["관련 없는 일반 공지"] is None
- fetched = await fetch_persisted_summary(connection, post_id)
+ fetched = await fetch_persisted_summary(
+ connection,
+ post_id,
+ summary_input="Synthetic body",
+ )
assert fetched is not None
fetched_events_by_text = {
event["event_text"]: event["project_name"] for event in fetched["key_event_details"]
diff --git a/tests/test_post_content_api_readiness.py b/tests/test_post_content_api_readiness.py
new file mode 100644
index 000000000..e208712c5
--- /dev/null
+++ b/tests/test_post_content_api_readiness.py
@@ -0,0 +1,180 @@
+"""Reader content projections must be bound to the current durable job."""
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app import main
+from backend.app.post_content_queue import (
+ QUEUED,
+ SUCCEEDED,
+ PostContentJobRequest,
+ source_body_sha256,
+)
+
+_POST_ID = "00000000-0000-0000-0000-000000000001"
+_BODY = "A current synthetic source body."
+_UNIT = {
+ "unit_index": 0,
+ "unit_kind_code": "dom",
+ "unit_label": "p",
+ "unit_text": "A current derived unit.",
+ "indent_level": 0,
+ "decision_source_code": "explicit",
+ "structure_confidence": 1.0,
+ "evidence_text": "Synthetic explicit structure.",
+}
+
+
+class _Transaction:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args: object) -> bool:
+ return False
+
+
+class _Connection:
+ def __init__(self, status_code: str, *, binding_body: str = _BODY) -> None:
+ self.status_code = status_code
+ self.binding_body = binding_body
+ self.source_reads = 0
+ self.lock_queries: list[str] = []
+
+ def transaction(self) -> _Transaction:
+ return _Transaction()
+
+ async def fetchrow(self, query: str, *_args: object):
+ self.lock_queries.append(" ".join(query.split()))
+ if "join post_content_ingestion_job" in query:
+ return {
+ "post_body": self.binding_body,
+ "source_body_sha256": source_body_sha256(_BODY),
+ "status_code": self.status_code,
+ }
+ if "from post_content_ingestion_job" in query:
+ return {
+ "source_body_sha256": source_body_sha256(_BODY),
+ "status_code": self.status_code,
+ }
+ assert "select post_body from source_post" in query
+ self.source_reads += 1
+ return {"post_body": _BODY if self.source_reads == 1 else self.binding_body}
+
+ async def fetch(self, query: str, *_args: object):
+ if "from post_content_unit unit" in query and "join post_content_image" not in query:
+ return [_UNIT]
+ if "join post_content_image image" in query:
+ return []
+ raise AssertionError("unexpected content query")
+
+
+class _Pool:
+ def __init__(self, status_code: str, *, binding_body: str = _BODY) -> None:
+ self.status_code = status_code
+ self.binding_body = binding_body
+ self.connection: _Connection | None = None
+
+ @asynccontextmanager
+ async def acquire(self):
+ self.connection = _Connection(self.status_code, binding_body=self.binding_body)
+ yield self.connection
+
+
+def _configure(
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ status_code: str,
+) -> None:
+ async def visible(*_args, **_kwargs):
+ return {}
+
+ async def incomplete(*_args, **_kwargs) -> bool:
+ return False
+
+ async def ensure(*_args, **_kwargs) -> PostContentJobRequest:
+ return PostContentJobRequest(
+ _POST_ID,
+ source_body_sha256(_BODY),
+ status_code,
+ False,
+ )
+
+ monkeypatch.setattr(main, "_load_visible_post", visible)
+ monkeypatch.setattr(main, "post_content_is_complete", incomplete)
+ monkeypatch.setattr(main, "ensure_post_content_job", ensure)
+ monkeypatch.setattr(
+ main,
+ "load_settings",
+ lambda: SimpleNamespace(
+ embedding_model="",
+ orchestrator_base_url="",
+ orchestrator_api_key="",
+ ),
+ )
+
+
+def test_processing_content_withholds_unbound_persisted_units(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _configure(monkeypatch, status_code=QUEUED)
+
+ payload = asyncio.run(
+ main.read_post_content(
+ _POST_ID,
+ account=object(),
+ pool=_Pool(QUEUED),
+ valkey=None,
+ )
+ )
+
+ assert payload["status"] == "processing"
+ assert payload["units"] == []
+ assert payload["images"] == []
+
+
+def test_exact_current_succeeded_content_exposes_persisted_units(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _configure(monkeypatch, status_code=SUCCEEDED)
+
+ pool = _Pool(SUCCEEDED)
+ payload = asyncio.run(
+ main.read_post_content(
+ _POST_ID,
+ account=object(),
+ pool=pool,
+ valkey=None,
+ )
+ )
+
+ assert payload["status"] == "ready"
+ assert payload["units"][0]["unit_text"] == _UNIT["unit_text"]
+ assert pool.connection is not None
+ binding_queries = pool.connection.lock_queries[-2:]
+ assert "from source_post" in binding_queries[0]
+ assert "post_content_ingestion_job" not in binding_queries[0]
+ assert "from post_content_ingestion_job" in binding_queries[1]
+
+
+def test_source_change_after_enqueue_withholds_succeeded_artifacts(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _configure(monkeypatch, status_code=SUCCEEDED)
+
+ payload = asyncio.run(
+ main.read_post_content(
+ _POST_ID,
+ account=object(),
+ pool=_Pool(SUCCEEDED, binding_body="A newer synthetic source body."),
+ valkey=None,
+ )
+ )
+
+ assert payload["status"] == "processing"
+ assert payload["units"] == []
+ assert payload["images"] == []
diff --git a/tests/test_post_content_persistence.py b/tests/test_post_content_persistence.py
index 829d4b40b..1e1bf2f8a 100644
--- a/tests/test_post_content_persistence.py
+++ b/tests/test_post_content_persistence.py
@@ -1,25 +1,39 @@
from __future__ import annotations
import asyncio
+import hashlib
from lineageweave.post_content_persistence import persist_post_content
class _Transaction:
+ def __init__(self, owner: _Connection) -> None:
+ self.owner = owner
+
async def __aenter__(self):
+ self.owner.in_transaction = True
return self
async def __aexit__(self, exc_type, exc, traceback):
+ self.owner.in_transaction = False
return False
class _Connection:
- def __init__(self) -> None:
+ def __init__(
+ self,
+ claim_row: dict[str, object] | None = None,
+ *,
+ source_body: str | None = None,
+ ) -> None:
self.executed: list[tuple[str, tuple[object, ...]]] = []
self.fetched: list[str] = []
+ self.claim_row = claim_row
+ self.source_body = source_body
+ self.in_transaction = False
def transaction(self) -> _Transaction:
- return _Transaction()
+ return _Transaction(self)
async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
@@ -31,6 +45,12 @@ async def fetchval(self, query: str, *args: object) -> str:
return "unit-1"
return "embedding-1"
+ async def fetchrow(self, query: str, *args: object):
+ self.fetched.append(query)
+ if "from source_post" in query and "post_content_ingestion_job" not in query:
+ return {"post_body": self.source_body}
+ return self.claim_row
+
class _EmbeddingClient:
available = True
@@ -81,3 +101,89 @@ def test_persist_post_content_keeps_units_when_embedding_provider_fails() -> Non
assert unit_count == 1
assert any("post_content_unit" in query for query, _args in conn.executed)
assert not any("post_content_embedding_value" in query for query, _args in conn.executed)
+
+
+def test_stale_claim_cannot_replace_current_post_content_artifacts() -> None:
+ body = "A synthetic source revision."
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
+ conn = _Connection(claim_row=None, source_body=body)
+
+ unit_count = asyncio.run(
+ persist_post_content(
+ conn,
+ "post-1",
+ body,
+ expected_source_body_sha256=digest,
+ expected_attempt_count=7,
+ )
+ )
+
+ assert unit_count is None
+ assert "from source_post" in conn.fetched[0]
+ assert "from post_content_ingestion_job" in conn.fetched[1]
+ assert not any("delete from post_content_unit" in query for query, _args in conn.executed)
+
+
+def test_claim_with_changed_source_body_cannot_replace_artifacts() -> None:
+ body = "The source body used by an older worker."
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
+ conn = _Connection(source_body="A newer source body.")
+
+ unit_count = asyncio.run(
+ persist_post_content(
+ conn,
+ "post-1",
+ body,
+ expected_source_body_sha256=digest,
+ expected_attempt_count=8,
+ )
+ )
+
+ assert unit_count is None
+ assert not any("delete from post_content_unit" in query for query, _args in conn.executed)
+
+
+def test_worker_artifact_fence_locks_source_before_job() -> None:
+ body = "A current synthetic worker body."
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
+ conn = _Connection(claim_row={"post_body": body}, source_body=body)
+
+ unit_count = asyncio.run(
+ persist_post_content(
+ conn,
+ "post-1",
+ body,
+ expected_source_body_sha256=digest,
+ expected_attempt_count=9,
+ )
+ )
+
+ assert unit_count == 1
+ lock_queries = [" ".join(query.split()) for query in conn.fetched[:2]]
+ assert "from source_post" in lock_queries[0]
+ assert "post_content_ingestion_job" not in lock_queries[0]
+ assert "from post_content_ingestion_job" in lock_queries[1]
+ assert "source_post" not in lock_queries[1]
+
+
+def test_operator_fence_and_artifact_replacement_share_one_transaction() -> None:
+ conn = _Connection()
+ events: list[str] = []
+
+ async def fence(inner_conn: _Connection) -> None:
+ assert inner_conn is conn
+ assert inner_conn.in_transaction
+ events.append("fence")
+
+ unit_count = asyncio.run(
+ persist_post_content(
+ conn,
+ "post-1",
+ "A synthetic backfill body.",
+ transaction_fence=fence,
+ )
+ )
+
+ assert unit_count == 1
+ assert events == ["fence"]
+ assert any("delete from post_content_unit" in query for query, _args in conn.executed)
diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py
index b4a8be584..d767da0e8 100644
--- a/tests/test_post_content_queue.py
+++ b/tests/test_post_content_queue.py
@@ -13,18 +13,19 @@
FAILED,
POST_CONTENT_RETRY_INTERVAL,
POST_CONTENT_STREAM_KEY,
- STALE_RUNNING_INTERVAL,
QUEUED,
RUNNING,
+ STALE_RUNNING_INTERVAL,
SUCCEEDED,
- record_post_content_backfill_success,
- requeue_failed_post_content_job,
+ fetch_post_summary_source,
+ post_body_has_images,
post_content_api_status,
post_content_is_complete,
- post_content_summary_status_message,
- post_content_summary_is_ready,
- post_body_has_images,
post_content_stream_fields,
+ post_content_summary_is_ready,
+ post_content_summary_status_message,
+ record_post_content_backfill_success,
+ requeue_failed_post_content_job,
source_body_sha256,
)
@@ -58,11 +59,44 @@ def test_summary_status_distinguishes_terminal_image_failure_from_processing() -
assert "still being processed" in post_content_summary_status_message(RUNNING)
+def test_adrs_keep_post_open_separate_from_image_summary_readiness() -> None:
+ semantic_contract = (
+ _ROOT / "docs/adr/0052-plain-orchestrator-semantic-evidence.md"
+ ).read_text()
+ ingestion_contract = (
+ _ROOT / "docs/adr/0098-valkey-backed-post-content-ingestion.md"
+ ).read_text()
+ stale_contract = (
+ _ROOT / "docs/adr/0114-stale-summary-buyer-continuity.md"
+ ).read_text()
+ semantic_contract = " ".join(semantic_contract.split())
+ ingestion_contract = " ".join(ingestion_contract.split())
+ stale_contract = " ".join(stale_contract.split())
+
+ assert "Source-post open and source rendering" in semantic_contract
+ assert "withholds both current and stale persisted summaries" in semantic_contract
+ assert "never blocks source-post open or source" in ingestion_contract
+ assert "MUST NOT call VISION directly" in ingestion_contract
+ assert "image-bearing stale summary is" in stale_contract
+ assert "normalized summary-input SHA-256" in semantic_contract
+ assert "durable job row to match the current raw-body SHA-256" in ingestion_contract
+ assert "Legacy rows with no input binding are never current" in stale_contract
+ assert "Provider calls never run while source" in stale_contract
+
+
def test_summary_waits_for_image_evidence_and_detects_images_without_body_logging() -> None:
class FakeConnection:
- async def fetchval(self, query: str, *_args: object) -> int:
+ async def fetchval(self, query: str, *args: object) -> int:
+ assert "post_content_ingestion_job" in query
+ assert "job.source_body_sha256 = $2" in query
+ assert "job.status_code = $3" in query
assert "post_content_image" in query
assert "description_status_code <> 'described'" in query
+ assert args == (
+ "00000000-0000-0000-0000-000000000001",
+ "ab" * 32,
+ SUCCEEDED,
+ )
return 0
assert post_body_has_images(
@@ -72,13 +106,73 @@ async def fetchval(self, query: str, *_args: object) -> int:
assert (
asyncio.run(
post_content_summary_is_ready(
- FakeConnection(), "00000000-0000-0000-0000-000000000001"
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ "ab" * 32,
)
)
is False
)
+def test_summary_input_binding_migration_is_wired_for_every_database_path() -> None:
+ migration = (_ROOT / "migrations/0139_post_summary_input_binding.sql").read_text()
+ rollback = (
+ _ROOT / "migrations/rollback/0139_post_summary_input_binding.sql"
+ ).read_text()
+ initial = (_ROOT / "migrations/0001_initial_schema.sql").read_text()
+ standalone = (_ROOT / "migrations/0008_post_summary_result.sql").read_text()
+ replay = (_ROOT / "docker/postgres-init/migrate.sh").read_text()
+ seed = (_ROOT / "scripts/seed_demo_data.py").read_text()
+
+ assert "add column if not exists summary_input_sha256" in migration
+ assert "post_summary_result_summary_input_sha256_check" in migration
+ assert "drop column if exists summary_input_sha256" in rollback
+ assert "summary_input_sha256 text" in initial
+ assert "summary_input_sha256 text" in standalone
+ assert "0139_*" in replay
+ assert "0139_post_summary_input_binding.sql" in seed
+
+
+def test_summary_source_orders_parent_and_region_vision_evidence() -> None:
+ class FakeConnection:
+ async def fetch(self, query: str, post_id: str) -> list[dict[str, object]]:
+ assert "post_content_image_region" in query
+ assert "order by unit.unit_index, region.region_index" in query
+ return [
+ {
+ "unit_index": 0,
+ "unit_text": "Synthetic paragraph.",
+ "region_index": None,
+ "extracted_text": None,
+ "image_caption": None,
+ },
+ {
+ "unit_index": 1,
+ "unit_text": "[image: Synthetic parent caption.]",
+ "region_index": 0,
+ "extracted_text": "Synthetic table header",
+ "image_caption": "Synthetic upper region.",
+ },
+ {
+ "unit_index": 1,
+ "unit_text": "[image: Synthetic parent caption.]",
+ "region_index": 1,
+ "extracted_text": "Synthetic table row",
+ "image_caption": "Synthetic lower region.",
+ },
+ ]
+
+ source = asyncio.run(fetch_post_summary_source(FakeConnection(), "synthetic-post"))
+
+ assert source == (
+ "Synthetic paragraph.\n\n"
+ "[image: Synthetic parent caption.]\n\n"
+ "[image region 0]\nSynthetic upper region.\nSynthetic table header\n\n"
+ "[image region 1]\nSynthetic lower region.\nSynthetic table row"
+ )
+
+
def test_embedding_gap_is_not_complete_content() -> None:
class FakeConnection:
async def fetchval(self, query: str, *_args: object) -> int:
@@ -225,7 +319,7 @@ async def execute(self, query: str, *args: object):
assert any("set source_body_sha256" in query for query, _args in conn.executed)
-def test_existing_units_register_as_succeeded_without_a_wakeup() -> None:
+def test_preledger_units_cannot_register_current_digest_as_succeeded() -> None:
from backend.app.post_content_queue import ensure_post_content_job
class FakeConnection:
@@ -251,8 +345,8 @@ async def execute(self, query: str, *args: object):
)
)
- assert job.status_code == SUCCEEDED
- assert job.should_publish is False
+ assert job.status_code == QUEUED
+ assert job.should_publish is True
assert any("insert into post_content_ingestion_job" in query for query, _args in conn.executed)
@@ -285,7 +379,7 @@ async def execute(self, *_args: object) -> None:
assert job.should_publish is False
-def test_changed_body_resets_a_terminal_job_and_republishes() -> None:
+def test_changed_body_starts_a_retry_cycle_without_reusing_claim_identity() -> None:
from backend.app.post_content_queue import ensure_post_content_job
class FakeConnection:
@@ -316,7 +410,7 @@ async def execute(self, query: str, *args: object) -> None:
assert job.status_code == QUEUED
assert job.should_publish is True
- assert any("attempt_count = 0" in query for query, _args in conn.executed)
+ assert not any("attempt_count = 0" in query for query, _args in conn.executed)
def test_recovery_query_carries_one_bounded_retry_interval() -> None:
@@ -325,12 +419,14 @@ def test_recovery_query_carries_one_bounded_retry_interval() -> None:
assert "queued_at timestamptz not null" in migration
-def test_explicit_retry_resets_only_one_failed_job() -> None:
+def test_explicit_retry_requeues_only_one_failed_job_without_reusing_claim_identity() -> None:
executed: list[tuple[str, tuple[object, ...]]] = []
class FakeConnection:
async def fetchrow(self, query: str, *_args: object):
assert "for update" in query
+ if "from source_post" in query:
+ return {"post_body": "current body"}
return {"status_code": FAILED}
async def fetchval(self, query: str, *_args: object) -> int:
@@ -353,7 +449,7 @@ async def execute(self, query: str, *args: object) -> str:
assert request.should_publish is True
assert request.source_body_sha256 == source_body_sha256("current body")
assert len(executed) == 2
- assert "attempt_count = 0" in executed[0][0]
+ assert "attempt_count = 0" not in executed[0][0]
assert executed[1][1][-1] == "operator requested an explicit post-content retry"
@@ -389,10 +485,15 @@ async def fetchrow(self, _query: str, *_args: object):
def test_backfill_success_clears_terminal_error_and_records_succeeded() -> None:
executed: list[tuple[str, tuple[object, ...]]] = []
+ lock_order: list[str] = []
class FakeConnection:
async def fetchrow(self, query: str, *_args: object):
assert "for update" in query
+ if "from source_post" in query:
+ lock_order.append("source")
+ return {"post_body": "current body"}
+ lock_order.append("job")
return {"status_code": FAILED}
async def fetchval(self, query: str, *_args: object) -> int:
@@ -413,11 +514,32 @@ async def execute(self, query: str, *args: object) -> str:
assert request.status_code == SUCCEEDED
assert request.should_publish is False
+ assert lock_order == ["source", "job"]
assert len(executed) == 2
assert "last_error_code = null" in executed[0][0]
assert executed[1][1][-1] == "operator backfill persisted post-content evidence"
+def test_backfill_success_rejects_a_source_revision_after_provider_work() -> None:
+ class FakeConnection:
+ async def fetchrow(self, query: str, *_args: object):
+ if "from source_post" in query:
+ return {"post_body": "newer body"}
+ return {"status_code": FAILED}
+
+ async def execute(self, *_args: object) -> None:
+ raise AssertionError("a stale backfill must not update the ledger")
+
+ with pytest.raises(ValueError, match="source body changed"):
+ asyncio.run(
+ record_post_content_backfill_success(
+ FakeConnection(),
+ "00000000-0000-0000-0000-000000000001",
+ "older body",
+ )
+ )
+
+
def test_recovery_republishes_due_rows_in_queued_at_order() -> None:
from contextlib import asynccontextmanager
@@ -479,3 +601,9 @@ def test_migration_contains_normalized_job_and_status_event_tables() -> None:
def test_migration_replay_window_includes_post_content_queue() -> None:
migrate = (_ROOT / "docker/postgres-init/migrate.sh").read_text()
assert "0050_*)" in migrate
+
+
+def test_claim_identity_is_never_reset_by_queue_transitions() -> None:
+ source = (_ROOT / "backend/app/post_content_queue.py").read_text()
+ assert "attempt_count = 0" not in source
+ assert "attempt_count = attempt_count + 1" not in source
diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py
index 2ee30f6e3..0d9eba17b 100644
--- a/tests/test_post_content_worker.py
+++ b/tests/test_post_content_worker.py
@@ -15,8 +15,12 @@
QUEUED,
RUNNING,
SUCCEEDED,
+ source_body_sha256,
)
+_BODY = "A synthetic post body with a retrieval unit."
+_DIGEST = source_body_sha256(_BODY)
+
class _Transaction:
async def __aenter__(self):
@@ -31,14 +35,25 @@ def __init__(self, row: dict[str, object] | None = None, values: list[object] |
self.row = row
self.values = list(values or [])
self.executed: list[tuple[str, tuple[object, ...]]] = []
+ self.queries: list[str] = []
def transaction(self) -> _Transaction:
return _Transaction()
- async def fetchrow(self, *_args: object):
+ async def fetchrow(self, query: str, *args: object):
+ self.queries.append(" ".join(query.split()))
+ if "attempt_count = $3" in query and self.row is not None:
+ if int(self.row["job_attempt_count"]) != int(args[2]):
+ return None
+ if str(self.row["job_source_body_sha256"]) != str(args[1]):
+ return None
+ if str(self.row["job_status_code"]) != str(args[3]):
+ return None
return self.row
async def fetchval(self, query: str, *_args: object):
+ if "returning attempt_count" in query and self.row is not None:
+ return int(self.row["job_attempt_count"]) + 1
if self.values:
return self.values.pop(0)
if "status_ordinal" in query:
@@ -47,6 +62,10 @@ async def fetchval(self, query: str, *_args: object):
async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
+ if query.lstrip().startswith("update"):
+ return "UPDATE 1"
+ if query.lstrip().startswith("insert"):
+ return "INSERT 0 1"
return "OK"
@@ -59,14 +78,24 @@ async def acquire(self):
yield self.connection
-def _row(status: str, attempt_count: int, *, started_at: object = None) -> dict[str, object]:
+def _row(
+ status: str,
+ attempt_count: int,
+ *,
+ cycle_attempt_count: int | None = None,
+ started_at: object = None,
+) -> dict[str, object]:
return {
"job_status_code": status,
"job_attempt_count": attempt_count,
+ "job_cycle_attempt_count": (
+ attempt_count if cycle_attempt_count is None else cycle_attempt_count
+ ),
+ "job_source_body_sha256": _DIGEST,
"job_started_at": started_at,
"job_queued_at": "queued-at",
"source_detail_state_code": "A",
- "post_body": "A synthetic post body with a retrieval unit.",
+ "post_body": _BODY,
"post_title": "Synthetic post title",
}
@@ -88,7 +117,7 @@ def test_terminal_failed_job_ignores_a_stale_duplicate_wakeup() -> None:
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="",
)
)
@@ -109,7 +138,7 @@ def test_worker_drops_a_writing_post_even_if_a_stale_job_row_leaks_through(
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="",
)
)
@@ -125,7 +154,7 @@ def test_duplicate_wakeup_before_retry_delay_is_not_claimable() -> None:
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="",
)
)
@@ -141,16 +170,53 @@ def test_due_retry_is_claimed_and_attempt_is_incremented() -> None:
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="",
)
)
assert claimed is not None
- assert any("attempt_count = attempt_count + 1" in query for query, _args in connection.executed)
+ assert claimed["job_attempt_count"] == 2
+ assert claimed["job_cycle_attempt_count"] == 2
assert any(args[1] == RUNNING for query, args in connection.executed if len(args) > 1 and "set status_code" in query)
+def test_worker_claim_locks_source_before_job() -> None:
+ connection = _Connection(_row(QUEUED, 0))
+
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ _DIGEST,
+ embedding_model_code="",
+ )
+ )
+
+ assert claimed is not None
+ assert "from source_post" in connection.queries[0]
+ assert "post_content_ingestion_job" not in connection.queries[0]
+ assert "from post_content_ingestion_job" in connection.queries[1]
+ assert "source_post" not in connection.queries[1]
+
+
+def test_fresh_retry_cycle_preserves_a_high_monotonic_claim_identity() -> None:
+ connection = _Connection(_row(QUEUED, 41, cycle_attempt_count=0))
+
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ _DIGEST,
+ embedding_model_code="",
+ )
+ )
+
+ assert claimed is not None
+ assert claimed["job_attempt_count"] == 42
+ assert claimed["job_cycle_attempt_count"] == 1
+
+
def test_successful_job_reclaims_when_configured_evidence_is_incomplete(monkeypatch) -> None:
connection = _Connection(_row(SUCCEEDED, 0), values=[False])
calls: list[str] = []
@@ -164,7 +230,7 @@ async def incomplete(*_args, **_kwargs) -> bool:
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="embedding-model",
require_structure=True,
)
@@ -175,11 +241,11 @@ async def incomplete(*_args, **_kwargs) -> bool:
def test_incomplete_provider_output_is_requeued_with_a_failure_code(monkeypatch) -> None:
- connection = _Connection(values=[2])
+ connection = _Connection(_row(RUNNING, 2, cycle_attempt_count=2))
pool = _Pool(connection)
async def claim(*_args, **_kwargs):
- return _row(RUNNING, 1)
+ return _row(RUNNING, 2, cycle_attempt_count=2)
async def persist(*_args, **_kwargs):
return 1
@@ -206,7 +272,7 @@ async def incomplete(*_args, **_kwargs):
post_content_worker.process_post_content_job(
pool,
post_id="00000000-0000-0000-0000-000000000001",
- source_body_digest="a" * 64,
+ source_body_digest=_DIGEST,
vision_factory=lambda: client,
embedding_factory=lambda: client,
structure_factory=lambda: client,
@@ -218,11 +284,11 @@ async def incomplete(*_args, **_kwargs):
def test_transient_provider_error_is_requeued_before_attempt_limit(monkeypatch) -> None:
- connection = _Connection(values=[2])
+ connection = _Connection(_row(RUNNING, 2, cycle_attempt_count=2))
pool = _Pool(connection)
async def claim(*_args, **_kwargs):
- return _row(RUNNING, 1)
+ return _row(RUNNING, 2, cycle_attempt_count=2)
async def persist(*_args, **_kwargs):
raise TimeoutError("provider timeout")
@@ -245,7 +311,7 @@ async def persist(*_args, **_kwargs):
post_content_worker.process_post_content_job(
pool,
post_id="00000000-0000-0000-0000-000000000001",
- source_body_digest="a" * 64,
+ source_body_digest=_DIGEST,
vision_factory=lambda: client,
embedding_factory=lambda: client,
structure_factory=lambda: client,
@@ -258,7 +324,9 @@ async def persist(*_args, **_kwargs):
def test_failure_at_attempt_limit_is_terminal_and_visible() -> None:
- connection = _Connection(values=[POST_CONTENT_MAX_ATTEMPTS])
+ connection = _Connection(
+ _row(RUNNING, 19, cycle_attempt_count=POST_CONTENT_MAX_ATTEMPTS)
+ )
asyncio.run(
post_content_worker._finish_failed_job(
@@ -266,7 +334,9 @@ def test_failure_at_attempt_limit_is_terminal_and_visible() -> None:
"00000000-0000-0000-0000-000000000001",
failure_code="post_content_ingestion_failed",
detail_text="provider outage",
- expected_attempt_count=POST_CONTENT_MAX_ATTEMPTS,
+ expected_source_body_sha256=_DIGEST,
+ expected_attempt_count=19,
+ cycle_attempt_count=POST_CONTENT_MAX_ATTEMPTS,
)
)
@@ -275,7 +345,7 @@ def test_failure_at_attempt_limit_is_terminal_and_visible() -> None:
def test_stale_worker_cannot_retry_after_lease_recovery() -> None:
- connection = _Connection(values=[2])
+ connection = _Connection(_row(RUNNING, 2, cycle_attempt_count=2))
asyncio.run(
post_content_worker._finish_failed_job(
@@ -283,7 +353,9 @@ def test_stale_worker_cannot_retry_after_lease_recovery() -> None:
"00000000-0000-0000-0000-000000000001",
failure_code="post_content_ingestion_failed",
detail_text="late provider failure",
+ expected_source_body_sha256=_DIGEST,
expected_attempt_count=1,
+ cycle_attempt_count=1,
)
)
@@ -306,7 +378,7 @@ async def fetchval(self, query: str, *_args: object):
post_content_worker._claim_job(
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
- "a" * 64,
+ _DIGEST,
embedding_model_code="",
)
)
@@ -315,6 +387,30 @@ async def fetchval(self, query: str, *_args: object):
assert any("$1::timestamptz + $2::interval" in query for query in connection.queries)
+def test_duplicate_wakeup_cannot_fail_a_fresh_running_final_attempt() -> None:
+ connection = _Connection(
+ _row(
+ RUNNING,
+ POST_CONTENT_MAX_ATTEMPTS,
+ cycle_attempt_count=POST_CONTENT_MAX_ATTEMPTS,
+ started_at="fresh-start",
+ ),
+ values=[False],
+ )
+
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ _DIGEST,
+ embedding_model_code="",
+ )
+ )
+
+ assert claimed is None
+ assert not any("set status_code" in query for query, _args in connection.executed)
+
+
def test_supervised_worker_restarts_after_unexpected_error(monkeypatch) -> None:
calls = 0
@@ -358,8 +454,97 @@ async def execute(self, query: str, *args: object) -> str:
_Pool(connection),
"00000000-0000-0000-0000-000000000001",
SUCCEEDED,
+ expected_source_body_sha256=_DIGEST,
expected_attempt_count=1,
)
)
assert not any("insert into post_content_ingestion_job_status_event" in query for query, _args in connection.executed)
+
+
+def test_completion_transition_rechecks_running_digest_and_attempt() -> None:
+ connection = _Connection(_row(RUNNING, 7, cycle_attempt_count=1))
+
+ asyncio.run(
+ post_content_worker._finish_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ SUCCEEDED,
+ expected_source_body_sha256=_DIGEST,
+ expected_attempt_count=7,
+ )
+ )
+
+ query, args = next(
+ (query, args)
+ for query, args in connection.executed
+ if "update post_content_ingestion_job" in query
+ )
+ assert "source_body_sha256 = $10" in query
+ assert "status_code = $11" in query
+ assert args[8:] == (7, _DIGEST, RUNNING)
+ assert "from source_post" in connection.queries[0]
+ assert "post_content_ingestion_job" not in connection.queries[0]
+ assert "from post_content_ingestion_job" in connection.queries[1]
+
+
+def test_claim_rejects_a_job_digest_that_no_longer_matches_the_locked_source() -> None:
+ connection = _Connection(
+ {**_row(QUEUED, 4, cycle_attempt_count=0), "post_body": "A newer source revision."}
+ )
+
+ claimed = asyncio.run(
+ post_content_worker._claim_job(
+ _Pool(connection),
+ "00000000-0000-0000-0000-000000000001",
+ _DIGEST,
+ embedding_model_code="",
+ )
+ )
+
+ assert claimed is None
+ assert connection.executed == []
+
+
+def test_worker_passes_immutable_claim_fence_to_persistence(monkeypatch) -> None:
+ connection = _Connection()
+ captured: dict[str, object] = {}
+
+ async def claim(*_args, **_kwargs):
+ return _row(RUNNING, 11, cycle_attempt_count=1)
+
+ async def stale_persist(*_args, **kwargs):
+ captured.update(kwargs)
+
+ async def must_not_check(*_args, **_kwargs):
+ raise AssertionError("a rejected persistence claim must stop processing")
+
+ monkeypatch.setattr(post_content_worker, "_claim_job", claim)
+ monkeypatch.setattr(post_content_worker, "persist_post_content", stale_persist)
+ monkeypatch.setattr(post_content_worker, "post_content_is_complete", must_not_check)
+ monkeypatch.setattr(
+ post_content_worker,
+ "load_settings",
+ lambda: SimpleNamespace(
+ embedding_model="",
+ orchestrator_base_url="",
+ orchestrator_api_key="",
+ ),
+ )
+ monkeypatch.setattr(post_content_worker, "normalize_post_body", lambda *_args: object())
+ client = SimpleNamespace(available=True)
+
+ asyncio.run(
+ post_content_worker.process_post_content_job(
+ _Pool(connection),
+ post_id="00000000-0000-0000-0000-000000000001",
+ source_body_digest=_DIGEST,
+ vision_factory=lambda: client,
+ embedding_factory=lambda: client,
+ structure_factory=lambda: client,
+ )
+ )
+
+ assert captured["expected_source_body_sha256"] == _DIGEST
+ assert captured["expected_attempt_count"] == 11
+ assert not connection.executed
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 0f4ff9a36..147e025e2 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -14,15 +14,21 @@
from __future__ import annotations
+import asyncio
+import hashlib
import os
import uuid
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
+import asyncpg
import psycopg2
import psycopg2.errors
import pytest
+from backend.app.post_content_queue import RUNNING
+from backend.app.post_content_worker import _lock_current_claim
+
_ADMIN_DSN = os.environ.get(
"LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres"
)
@@ -101,6 +107,16 @@
/ "migrations"
/ "0138_planned_facility_relation_predicate.sql"
)
+_SUMMARY_INPUT_BINDING_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0139_post_summary_input_binding.sql"
+)
+_POST_CONTENT_QUEUE_MIGRATION = (
+ Path(__file__).resolve().parents[1]
+ / "migrations"
+ / "0050_post_content_ingestion_queue.sql"
+)
def _postgres_available() -> bool:
@@ -159,6 +175,10 @@ def schema_db():
)
cur.execute(planned_predicate_migration)
cur.execute(planned_predicate_migration)
+ summary_input_migration = _SUMMARY_INPUT_BINDING_MIGRATION.read_text()
+ cur.execute(summary_input_migration)
+ cur.execute(summary_input_migration)
+ cur.execute(_POST_CONTENT_QUEUE_MIGRATION.read_text())
conn.commit()
yield conn
finally:
@@ -169,6 +189,116 @@ def schema_db():
admin_conn.close()
+def test_post_content_claim_lock_order_avoids_source_job_deadlock(schema_db) -> None:
+ """A source holder can lock the job while a worker waits on that source."""
+ post_id = uuid.uuid4()
+ body = "A synthetic body for the lock-order regression."
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
+ with schema_db.cursor() as cur:
+ cur.execute(
+ """
+ insert into common_lookup_value (lookup_category, lookup_code, lookup_label)
+ values
+ ('corporate_entity_level', 'company', 'Company'),
+ ('voc_type', 'voc', 'Voice of Customer'),
+ ('post_visibility', 'public', 'Public')
+ """
+ )
+ cur.execute(
+ """
+ with synthetic_entity as (
+ insert into corporate_entity (
+ corporate_entity_code, entity_name, entity_level_code
+ ) values ('SYNTHETIC-LOCK-ORG', 'Synthetic Lock Org', 'company')
+ returning corporate_entity_id
+ ), synthetic_account as (
+ insert into user_account (
+ external_subject_id, display_name, email_address
+ ) values (
+ 'synthetic-lock-account', 'Synthetic Lock User',
+ 'synthetic.lock@example.test'
+ ) returning user_account_id
+ )
+ insert into source_post (
+ post_id, author_account_id, corporate_entity_id,
+ post_title, post_body, voc_type_code, visibility_code
+ )
+ select %s, user_account_id, corporate_entity_id,
+ 'Synthetic lock post', %s, 'voc', 'public'
+ from synthetic_account cross join synthetic_entity
+ """,
+ (str(post_id), body),
+ )
+ cur.execute(
+ """
+ insert into post_content_ingestion_job (
+ post_id, source_body_sha256, status_code, attempt_count, started_at
+ ) values (%s, %s, %s, 1, now())
+ """,
+ (str(post_id), digest, RUNNING),
+ )
+ schema_db.commit()
+
+ async def exercise() -> None:
+ parsed_admin_dsn = urlsplit(_ADMIN_DSN)
+ database_dsn = urlunsplit(
+ parsed_admin_dsn._replace(path=f"/{schema_db.info.dbname}")
+ )
+ first = await asyncpg.connect(database_dsn)
+ worker = await asyncpg.connect(database_dsn)
+ observer = await asyncpg.connect(database_dsn)
+ first_tx = first.transaction()
+ await first_tx.start()
+ worker_task: asyncio.Task[bool] | None = None
+ try:
+ await first.fetchrow(
+ "select post_body from source_post where post_id = $1 for update",
+ post_id,
+ )
+
+ async def lock_worker_claim() -> bool:
+ async with worker.transaction():
+ return await _lock_current_claim(
+ worker,
+ str(post_id),
+ expected_source_body_sha256=digest,
+ expected_attempt_count=1,
+ )
+
+ worker_task = asyncio.create_task(lock_worker_claim())
+ for _attempt in range(100):
+ waiting = await observer.fetchval(
+ "select wait_event_type = 'Lock' from pg_stat_activity where pid = $1",
+ worker.get_server_pid(),
+ )
+ if waiting:
+ break
+ await asyncio.sleep(0.01)
+ else:
+ raise AssertionError("worker did not reach the source-row lock")
+
+ await asyncio.wait_for(
+ first.fetchrow(
+ "select status_code from post_content_ingestion_job "
+ "where post_id = $1 for update",
+ post_id,
+ ),
+ timeout=1,
+ )
+ await first_tx.commit()
+ assert await asyncio.wait_for(worker_task, timeout=1) is True
+ finally:
+ if first.is_in_transaction():
+ await first_tx.rollback()
+ if worker_task is not None and not worker_task.done():
+ worker_task.cancel()
+ await first.close()
+ await worker.close()
+ await observer.close()
+
+ asyncio.run(exercise())
+
+
def test_migration_applies_cleanly(schema_db) -> None:
with schema_db.cursor() as cur:
cur.execute(
@@ -217,6 +347,21 @@ def test_migration_applies_cleanly(schema_db) -> None:
assert expected <= tables
+def test_summary_result_accepts_only_normalized_sha256_binding(schema_db) -> None:
+ with schema_db.cursor() as cur:
+ cur.execute(
+ "select is_nullable from information_schema.columns "
+ "where table_name = 'post_summary_result' "
+ "and column_name = 'summary_input_sha256'"
+ )
+ assert cur.fetchone() == ("YES",)
+ cur.execute(
+ "select pg_get_constraintdef(oid) from pg_constraint "
+ "where conname = 'post_summary_result_summary_input_sha256_check'"
+ )
+ assert "[0-9a-f]{64}" in cur.fetchone()[0]
+
+
def test_planned_facility_relationship_predicate_persists(schema_db) -> None:
"""Migration 0138 must admit the source-backed ADR 0142 relationship."""
with schema_db.cursor() as cur:
diff --git a/tests/test_stale_summary_continuity.py b/tests/test_stale_summary_continuity.py
index ec3df7db9..a88fd4990 100644
--- a/tests/test_stale_summary_continuity.py
+++ b/tests/test_stale_summary_continuity.py
@@ -1,11 +1,16 @@
"""Regression tests for reader-visible stale summary continuity."""
import asyncio
+import hashlib
+from types import SimpleNamespace
+from typing import Self
import pytest
-from backend.app import main
+from backend.app import main, post_summary_ingestion
+from backend.app.post_content_queue import FAILED, QUEUED, SUCCEEDED, source_body_sha256
from backend.app.post_summary_ingestion import fetch_persisted_summary
+from lineageweave.post_summary import POST_SUMMARY_CONTRACT_VERSION
class _StaleSummaryConnection:
@@ -21,6 +26,21 @@ async def fetch(self, query: str, post_id: str) -> list[dict[str, object]]:
return []
+class _BoundSummaryConnection(_StaleSummaryConnection):
+ """One current-contract row with a configurable normalized-input binding."""
+
+ def __init__(self, summary_input_sha256: str | None) -> None:
+ self.summary_input_sha256 = summary_input_sha256
+
+ async def fetchrow(self, query: str, post_id: str) -> dict[str, object]:
+ assert "summary_input_sha256" in query
+ return {
+ "korean_summary": "Persisted synthetic evidence.",
+ "summary_contract_version": POST_SUMMARY_CONTRACT_VERSION,
+ "summary_input_sha256": self.summary_input_sha256,
+ }
+
+
def test_stale_summary_is_hidden_by_default() -> None:
"""Current-contract reads must not silently present legacy semantics."""
result = asyncio.run(fetch_persisted_summary(_StaleSummaryConnection(), "post-id"))
@@ -38,6 +58,121 @@ def test_stale_summary_can_be_returned_with_explicit_status() -> None:
assert result["korean_summary"] == "Previously persisted evidence."
+def test_current_summary_requires_exact_normalized_input_binding() -> None:
+ normalized_input = "Synthetic normalized evidence."
+ digest = hashlib.sha256(normalized_input.encode("utf-8")).hexdigest()
+ connection = _BoundSummaryConnection(digest)
+
+ current = asyncio.run(
+ fetch_persisted_summary(
+ connection,
+ "post-id",
+ summary_input=normalized_input,
+ )
+ )
+ mismatch = asyncio.run(
+ fetch_persisted_summary(
+ connection,
+ "post-id",
+ summary_input="Revised synthetic evidence.",
+ )
+ )
+ revised_stale = asyncio.run(
+ fetch_persisted_summary(
+ connection,
+ "post-id",
+ summary_input="Revised synthetic evidence.",
+ allow_stale=True,
+ )
+ )
+
+ assert current is not None
+ assert current["summary_status"] == "current"
+ assert mismatch is None
+ assert revised_stale is not None
+ assert revised_stale["summary_status"] == "stale"
+
+
+def test_summary_persistence_lock_rejects_a_changed_current_source() -> None:
+ class ChangedSourceConnection:
+ async def fetchrow(self, *_args, **_kwargs):
+ return {"post_body": "A newer synthetic body."}
+
+ result = asyncio.run(
+ post_summary_ingestion._lock_current_summary_input(
+ ChangedSourceConnection(),
+ "post-id",
+ expected_source_body_sha256=source_body_sha256("An older synthetic body."),
+ expected_summary_input="An older synthetic body.",
+ require_image_evidence=False,
+ )
+ )
+
+ assert result is False
+
+
+def test_summary_persistence_lock_rejects_changed_ordered_image_evidence() -> None:
+ body = ''
+ lock_queries: list[str] = []
+
+ class ChangedEvidenceConnection:
+ async def fetchrow(self, query: str, *_args, **_kwargs):
+ lock_queries.append(" ".join(query.split()))
+ if "post_content_ingestion_job" in query:
+ return {"status_code": "post_content_ingestion_succeeded"}
+ return {"post_body": body}
+
+ async def fetch(self, *_args, **_kwargs):
+ return [
+ {
+ "unit_index": 0,
+ "unit_text": "[image: current]",
+ "region_index": 0,
+ "extracted_text": "New evidence",
+ "image_caption": "Current panel",
+ }
+ ]
+
+ result = asyncio.run(
+ post_summary_ingestion._lock_current_summary_input(
+ ChangedEvidenceConnection(),
+ "post-id",
+ expected_source_body_sha256=source_body_sha256(body),
+ expected_summary_input="Earlier evidence",
+ require_image_evidence=True,
+ )
+ )
+
+ assert result is False
+ assert "from source_post" in lock_queries[0]
+ assert "post_content_ingestion_job" not in lock_queries[0]
+ assert "from post_content_ingestion_job" in lock_queries[1]
+
+
+def test_legacy_unbound_summary_is_only_explicit_stale_continuity() -> None:
+ connection = _BoundSummaryConnection(None)
+
+ current = asyncio.run(
+ fetch_persisted_summary(
+ connection,
+ "post-id",
+ summary_input="Synthetic normalized evidence.",
+ )
+ )
+ stale = asyncio.run(
+ fetch_persisted_summary(
+ connection,
+ "post-id",
+ summary_input="Synthetic normalized evidence.",
+ allow_stale=True,
+ )
+ )
+
+ assert current is None
+ assert stale is not None
+ assert stale["summary_status"] == "stale"
+
+
def test_reader_returns_stale_summary_without_waiting_for_orchestrator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -66,8 +201,10 @@ async def persisted_summary(
_conn: object,
_post_id: str,
*,
+ summary_input: str | None = None,
allow_stale: bool = False,
) -> dict[str, object] | None:
+ assert summary_input == "Synthetic release chronology."
if not allow_stale:
return None
return {
@@ -96,3 +233,248 @@ async def persisted_summary(
assert result["summary_status"] == "stale"
assert result["summary_contract_version"] == 18
+
+
+class _ImageSummaryConnection:
+ """Asyncpg-shaped source-body connection for synthetic image-summary reads."""
+
+ async def fetchrow(self, query: str, post_id: str) -> dict[str, object]:
+ assert "select post_body" in query
+ return {
+ "post_body": "
Synthetic evidence.
" + "