|
18 | 18 | from types import MappingProxyType |
19 | 19 | from typing import TYPE_CHECKING, Any, cast |
20 | 20 |
|
21 | | -from ..adapters.codex_jsonl.canonicalize import ProposedChangeSet |
| 21 | +from ..adapters.codex_jsonl.canonicalize import ProposedChangeSet, ProposedOccurrence |
22 | 22 | from ..adapters.contracts import ( |
23 | 23 | ADAPTER_ID, |
24 | 24 | ADAPTER_VERSION, |
@@ -365,6 +365,7 @@ class _TableSpec: |
365 | 365 | "transition_version", |
366 | 366 | "start_at_us", |
367 | 367 | "end_at_us", |
| 368 | + "start_source_rank", |
368 | 369 | "start_source_order", |
369 | 370 | "end_source_order", |
370 | 371 | "completion_basis", |
@@ -1391,8 +1392,8 @@ def _write_facts( |
1391 | 1392 | lifecycle: tuple[tuple[object, ...], ...], |
1392 | 1393 | rows: tuple[PreparedRow, ...], |
1393 | 1394 | ) -> None: |
1394 | | - self._insert_lifecycle_many(lifecycle) |
1395 | 1395 | self._apply_rows(rows) |
| 1396 | + self._insert_lifecycle_many(lifecycle) |
1396 | 1397 |
|
1397 | 1398 | def _write_metadata( |
1398 | 1399 | self, |
@@ -1519,6 +1520,128 @@ def _validate_write_set( |
1519 | 1520 | ) |
1520 | 1521 | if request.publication_id == plan.parent_publication_id: |
1521 | 1522 | raise PublicationWriteError("publication cannot parent itself") |
| 1523 | + self._validate_turn_provenance(write_set) |
| 1524 | + |
| 1525 | + def _validate_turn_provenance(self, write_set: PublicationWriteSet) -> None: |
| 1526 | + """Validate the persisted turn coordinate before any writer mutation. |
| 1527 | +
|
| 1528 | + A turn's primary occurrence is the only admissible bridge to its source |
| 1529 | + manifestation. The check stays on the prepared write set so an |
| 1530 | + invalid or mixed cohort cannot reach the transaction and rely on a |
| 1531 | + deferred foreign-key error after partial work. |
| 1532 | + """ |
| 1533 | + |
| 1534 | + turn_rows = [row for row in write_set.rows if row.table == "turns"] |
| 1535 | + if not turn_rows: |
| 1536 | + return |
| 1537 | + |
| 1538 | + observations: dict[str, list[AdapterObservation]] = {} |
| 1539 | + for observation in write_set.changes.observations: |
| 1540 | + if observation.observation_type == "TurnBoundaryObserved": |
| 1541 | + observations.setdefault(observation.logical_id, []).append(observation) |
| 1542 | + |
| 1543 | + occurrences: dict[str, ProposedOccurrence] = {} |
| 1544 | + for occurrence in write_set.changes.occurrences: |
| 1545 | + occurrence_id = occurrence.occurrence_id |
| 1546 | + previous = occurrences.get(occurrence_id) |
| 1547 | + if previous is not None and previous != occurrence: |
| 1548 | + raise PublicationWriteError( |
| 1549 | + f"primary occurrence is ambiguous: {occurrence_id}" |
| 1550 | + ) |
| 1551 | + occurrences[occurrence_id] = occurrence |
| 1552 | + |
| 1553 | + inventories: dict[int, SourceInventory] = {} |
| 1554 | + for inventory in ( |
| 1555 | + *write_set.changes.selected_sources, |
| 1556 | + *write_set.changes.deferred_sources, |
| 1557 | + ): |
| 1558 | + previous_inventory = inventories.get(inventory.manifestation_key) |
| 1559 | + if previous_inventory is not None and previous_inventory != inventory: |
| 1560 | + raise PublicationWriteError( |
| 1561 | + "source manifestation is ambiguous: " |
| 1562 | + f"{inventory.manifestation_key}" |
| 1563 | + ) |
| 1564 | + inventories[inventory.manifestation_key] = inventory |
| 1565 | + |
| 1566 | + for row in turn_rows: |
| 1567 | + turn_id = str(row.values["turn_id"]) |
| 1568 | + candidates = observations.get(turn_id, []) |
| 1569 | + if not candidates: |
| 1570 | + raise PublicationWriteError( |
| 1571 | + f"turn primary occurrence has no source observation: {turn_id}" |
| 1572 | + ) |
| 1573 | + selected = max(candidates, key=lambda item: item.sort_key) |
| 1574 | + tied = [item for item in candidates if item.sort_key == selected.sort_key] |
| 1575 | + if len(tied) != 1: |
| 1576 | + raise PublicationWriteError( |
| 1577 | + f"turn primary occurrence is ambiguous: {turn_id}" |
| 1578 | + ) |
| 1579 | + |
| 1580 | + occurrence_id = str(row.values["primary_occurrence_id"]) |
| 1581 | + if occurrence_id != selected.occurrence_id: |
| 1582 | + raise PublicationWriteError( |
| 1583 | + f"turn primary occurrence does not match observation: {turn_id}" |
| 1584 | + ) |
| 1585 | + resolved_occurrence = occurrences.get(occurrence_id) |
| 1586 | + if resolved_occurrence is None: |
| 1587 | + raise PublicationWriteError( |
| 1588 | + f"turn primary occurrence is unresolved: {occurrence_id}" |
| 1589 | + ) |
| 1590 | + if resolved_occurrence.semantic_logical_id != turn_id: |
| 1591 | + raise PublicationWriteError( |
| 1592 | + f"turn primary occurrence belongs to another entity: {turn_id}" |
| 1593 | + ) |
| 1594 | + |
| 1595 | + source = resolved_occurrence.source_range |
| 1596 | + resolved_inventory = inventories.get(source.manifestation_key) |
| 1597 | + if resolved_inventory is None: |
| 1598 | + raise PublicationWriteError( |
| 1599 | + "turn primary occurrence has no source manifestation: " |
| 1600 | + f"{occurrence_id}" |
| 1601 | + ) |
| 1602 | + if ( |
| 1603 | + resolved_inventory.manifestation_id != source.manifestation_id |
| 1604 | + or resolved_inventory.content_revision != source.source_revision |
| 1605 | + ): |
| 1606 | + raise PublicationWriteError( |
| 1607 | + f"turn occurrence/manifestation provenance mismatches: {turn_id}" |
| 1608 | + ) |
| 1609 | + |
| 1610 | + expected_order = selected.source_order |
| 1611 | + if expected_order is None: |
| 1612 | + expected_order = source.record_ordinal |
| 1613 | + if expected_order is None: |
| 1614 | + raise PublicationWriteError( |
| 1615 | + f"turn source order provenance is missing: {turn_id}" |
| 1616 | + ) |
| 1617 | + if selected.source_rank != resolved_inventory.source_rank: |
| 1618 | + raise PublicationWriteError( |
| 1619 | + f"turn source rank mismatches its manifestation: {turn_id}" |
| 1620 | + ) |
| 1621 | + if row.values["start_source_rank"] != resolved_inventory.source_rank: |
| 1622 | + raise PublicationWriteError( |
| 1623 | + f"turn persisted source rank mismatches provenance: {turn_id}" |
| 1624 | + ) |
| 1625 | + if row.values["start_source_order"] != expected_order: |
| 1626 | + raise PublicationWriteError( |
| 1627 | + f"turn persisted source order mismatches provenance: {turn_id}" |
| 1628 | + ) |
| 1629 | + |
| 1630 | + start_at_us = row.values["start_at_us"] |
| 1631 | + end_at_us = row.values["end_at_us"] |
| 1632 | + end_source_order = row.values["end_source_order"] |
| 1633 | + if end_at_us is not None and start_at_us is not None and start_at_us > end_at_us: |
| 1634 | + raise PublicationWriteError( |
| 1635 | + f"turn lifecycle times are reversed: {turn_id}" |
| 1636 | + ) |
| 1637 | + if end_source_order is not None and end_source_order < expected_order: |
| 1638 | + raise PublicationWriteError( |
| 1639 | + f"turn lifecycle source order is reversed: {turn_id}" |
| 1640 | + ) |
| 1641 | + if end_at_us is not None and end_source_order is None: |
| 1642 | + raise PublicationWriteError( |
| 1643 | + f"terminal turn is missing end source order: {turn_id}" |
| 1644 | + ) |
1522 | 1645 |
|
1523 | 1646 | def _recheck_parent(self, expected: str | None) -> None: |
1524 | 1647 | row = self._connection.execute( |
@@ -1696,6 +1819,7 @@ def _lifecycle_values( |
1696 | 1819 | transition.terminal_error_category, |
1697 | 1820 | transition.measurement_mask, |
1698 | 1821 | transition.first_seen_publication_id, |
| 1822 | + transition.session_id, |
1699 | 1823 | ) |
1700 | 1824 |
|
1701 | 1825 | def _validate_existing_identities( |
|
0 commit comments