From d1c53b1d91852c90453857c770a1623f6265be59 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 19 Oct 2026 23:13:51 -0600 Subject: [PATCH 1/4] Record finite self-reference transport evidence and consumer stop --- .../self-reference-family-transport-303.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 agent-notes/self-reference-family-transport-303.md diff --git a/agent-notes/self-reference-family-transport-303.md b/agent-notes/self-reference-family-transport-303.md new file mode 100644 index 0000000..9b48ee1 --- /dev/null +++ b/agent-notes/self-reference-family-transport-303.md @@ -0,0 +1,21 @@ +# Self-reference family batch transport (#303 consumer) + +## Goal +Extend existing occurrence transport with an explicit finite batch operation so self-reference targets' family-owned identities are known before rewriting references. No consumer semantics or identity invented in Quire. + +## State +- Isolated branch `feat/self-reference-family-transport-303`, base/default `03f7173cd2cc0eb72cd595498ac762f92708bc8c`. Main checkout dirty files preserved; read global and project AGENTS/CLAUDE, existing transport note, family_transport.py, reference metadata/rewriter, charter callback and tests. +- Consumer source proof: Propstore coreference IDs hash supports only; attacks are a separate relation updated without changing IDs. Missing self-FK metadata and sequential row transport prevent complete attack transport. Dung 1995 Definition 2 p.326 models attacks as a binary relation over abstract arguments; Schultz/Wisnesky 2015 p.11 requires the image map commute with reference operations. Primary page images read, no extracted text. +- Implemented 56 added generic lines plus owner tests, still uncommitted/unpublished. Focused new-file run PASSED: **17 passed in 1.06s**, `logs/test-runs/self-reference-family-transport-20261019-230808.log`. Covers all six orderings with one/two moved external targets, legacy referrer closure, self loop, unchanged objects/IDs, dangling refs, missing callback, and explicit rejection of identity depending incompatibly on resolved self refs. +- Consumer first focused run FAILED (12 failed, 23 passed): fixtures omit the canonical tree on their fixed proposal branch; an existing merge preparation edge passes string identities to a typed reference placement (`AttributeError: 'str' object has no attribute 'argument_id'`). Stopped all implementation/publication under global AGENTS.md. No draft, pin update or full CI yet. This generic operation is not claimed integrated until those consumer edges are corrected and verified with parent direction. + +## Decisions and why +- Smallest operation (~60 lines): caller supplies Sequence of typed documents; use charter self-FKs to build reverse dependencies; seed affected rows from currently mapped FKs, plan each affected row once with its existing callback and propagate actual identity changes to referrers; perform existing one-row transport after all images are assigned. This is finite reachability, not a guessed hash fixed point. Final single-image enforcement rejects callback identities inconsistent with resolved self references. +- Unchanged objects remain unchanged. No-self-FK families use existing transport. Source refs validate with existing owner. No new FK parser, callback metadata, source scan, compatibility alias, storage format, or commit/ref concurrency mechanism. +- Active cleanup-refactor control: generic transport is already owned here (keep/extend); consumer replaces its row-at-a-time caller with this owner batch. No wrong file/surface requires deletion. Search: no Propstore imports/domain strings, no recursion/hash algorithm. Runtime: edited owner tests plus consumer production-path regressions, full PR CI. + +## Surprises +- None yet. + +## Next step +Await parent direction after consumer test failure. If continued, correct fixed-branch fixtures and the generic typed-reference preparation edge, verify consumer behavior, then publish separate owner/consumer drafts and follow CI. Do not merge or mark ready; preserve current isolated uncommitted code/tests and logs. From 55f1ad123ba37bb3cbd64a8ede4d96c8f3da0e97 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 19 Oct 2026 23:19:42 -0600 Subject: [PATCH 2/4] Transport finite self-referencing family batches with typed refs --- .../self-reference-family-transport-303.md | 4 +- quire/families.py | 13 ++ quire/family_transport.py | 57 ++++++- tests/test_family_self_transport.py | 145 ++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/test_family_self_transport.py diff --git a/agent-notes/self-reference-family-transport-303.md b/agent-notes/self-reference-family-transport-303.md index 9b48ee1..703179d 100644 --- a/agent-notes/self-reference-family-transport-303.md +++ b/agent-notes/self-reference-family-transport-303.md @@ -8,6 +8,8 @@ Extend existing occurrence transport with an explicit finite batch operation so - Consumer source proof: Propstore coreference IDs hash supports only; attacks are a separate relation updated without changing IDs. Missing self-FK metadata and sequential row transport prevent complete attack transport. Dung 1995 Definition 2 p.326 models attacks as a binary relation over abstract arguments; Schultz/Wisnesky 2015 p.11 requires the image map commute with reference operations. Primary page images read, no extracted text. - Implemented 56 added generic lines plus owner tests, still uncommitted/unpublished. Focused new-file run PASSED: **17 passed in 1.06s**, `logs/test-runs/self-reference-family-transport-20261019-230808.log`. Covers all six orderings with one/two moved external targets, legacy referrer closure, self loop, unchanged objects/IDs, dangling refs, missing callback, and explicit rejection of identity depending incompatibly on resolved self refs. - Consumer first focused run FAILED (12 failed, 23 passed): fixtures omit the canonical tree on their fixed proposal branch; an existing merge preparation edge passes string identities to a typed reference placement (`AttributeError: 'str' object has no attribute 'argument_id'`). Stopped all implementation/publication under global AGENTS.md. No draft, pin update or full CI yet. This generic operation is not claimed integrated until those consumer edges are corrected and verified with parent direction. +- Parent explicitly authorized diagnosed corrections and continued ordinary test diagnosis within scope. Added `FamilyDefinition.ref_from_document`: the declared flat placement's existing factory constructs the actual typed ref from the declared document identity; no application convention or fallback. Propstore consumes that owner operation. Corrected fixtures seed canonical proposal snapshots and use existing author/transaction owners for legacy records. +- Corrected owner tests PASSED: **18 passed in 0.72s**, `logs/test-runs/self-reference-typed-ref-20261019-231748.log`, including real transport -> typed ref -> store preparation. Corrected consumer tests PASSED: **35 passed in 32.32s**, consumer `coreference-owner-corrections-20261019-231745.log`; includes actual app authoring, cyclic/self attacks, Dung sidecar query, evidence conflicts, legacy reads and retained graph/path-collision behavior. No checks weakened. Original failed logs remain intact. ## Decisions and why - Smallest operation (~60 lines): caller supplies Sequence of typed documents; use charter self-FKs to build reverse dependencies; seed affected rows from currently mapped FKs, plan each affected row once with its existing callback and propagate actual identity changes to referrers; perform existing one-row transport after all images are assigned. This is finite reachability, not a guessed hash fixed point. Final single-image enforcement rejects callback identities inconsistent with resolved self references. @@ -18,4 +20,4 @@ Extend existing occurrence transport with an explicit finite batch operation so - None yet. ## Next step -Await parent direction after consumer test failure. If continued, correct fixed-branch fixtures and the generic typed-reference preparation edge, verify consumer behavior, then publish separate owner/consumer drafts and follow CI. Do not merge or mark ready; preserve current isolated uncommitted code/tests and logs. +Publish the tested owner source as a draft; pin that exact commit in the consumer's manifest and every lock reference, publish its separate draft and follow both full CI runs. No merge or ready transition; retain original failure evidence. diff --git a/quire/families.py b/quire/families.py index a7b0bb9..8522dc3 100644 --- a/quire/families.py +++ b/quire/families.py @@ -10,6 +10,7 @@ ArtifactFamily, ArtifactHandle, ArtifactPlacementPolicy, + FlatYamlPlacement, PreparedArtifact, RefBlobLocator, ) @@ -178,6 +179,18 @@ def reference_index_from_records( keys=self.reference_keys, ) + def ref_from_document(self, document: TDoc) -> TRef: + """Construct a flat document's typed ref with its declared factory.""" + placement = self.artifact_family.placement + if not isinstance(placement, FlatYamlPlacement): + raise TypeError(f"family {self.name!r} does not have a flat identity placement") + if self.identity_field is None: + raise ValueError(f"family {self.name!r} has no identity field") + identities = ReferenceKey.field(self.identity_field)(document) + if len(identities) != 1: + raise ValueError("a document ref requires a single identity") + return placement.ref_factory(identities[0]) + def contract_body(self) -> dict[str, object]: body: dict[str, object] = { "accessor": self.accessor_name, diff --git a/quire/family_transport.py b/quire/family_transport.py index 293e7a4..ef7d494 100644 --- a/quire/family_transport.py +++ b/quire/family_transport.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field from types import MappingProxyType from typing import Any, TypeVar @@ -97,6 +97,61 @@ def transport( self.assign(family.name, old_ids[0], new_ids[0]) return result + def transport_family( + self, + family: FamilyDefinition[Any, Any, Any, TDoc], + documents: Sequence[TDoc], + ) -> tuple[TDoc, ...]: + """Transport a finite batch after planning its affected self-reference IDs. + + The family owns identity. Planning uses its callback with external FKs + mapped and self FKs unchanged, then follows incoming references when an + ID moves. Final transport must produce the same planned IDs; callbacks + that require a different recursive identity construction fail the + existing single-image check. No recursive hashes are guessed here. + """ + self_specs = tuple(s for s in family.foreign_keys if s.target_family == family.name) + if not self_specs: + return tuple(self.transport(family, document) for document in documents) + if family.identity_field is None: + raise ValueError(f"family {family.name!r} has no identity field") + identity = ReferenceKey.field(family.identity_field) + external_specs = tuple(s for s in family.foreign_keys if s.target_family != family.name) + dependents: dict[str, list[int]] = {} + pending: list[int] = [] + for index, document in enumerate(documents): + for spec in family.foreign_keys: + validate_foreign_key(spec, document, self.source_indexes[spec.target_family]) + if map_foreign_keys(document, family.foreign_keys, self._reference_image) is not document: + pending.append(index) + for spec in self_specs: + for reference in ReferenceKey.field(spec.source_field)(document): + source_id = self.source_indexes[family.name].require_id(reference) + dependents.setdefault(source_id, []).append(index) + planned: set[int] = set() + while pending: + index = pending.pop() + if index in planned: + continue + planned.add(index) + if family.transport_identity is None: + raise UnsupportedFamilyTransportError( + f"family {family.name!r} has no identity operation for reference transport" + ) + document = documents[index] + external = map_foreign_keys(document, external_specs, self._reference_image) + old_ids = identity(document) + new_ids = identity(family.transport_identity(external)) + if len(old_ids) != 1 or len(new_ids) != 1: + raise ValueError("family transport requires a single source and result identity") + self.assign(family.name, old_ids[0], new_ids[0]) + if old_ids[0] != new_ids[0]: + pending.extend(dependents.get(old_ids[0], ())) + results = tuple(self.transport(family, document) for document in documents) + for index in planned: + self.assign(family.name, identity(documents[index])[0], identity(results[index])[0]) + return results + def iter_images(self) -> Iterator[FamilyReferenceImage]: """Yield assigned occurrence images, including unchanged explicit images.""" for family, images in sorted(self._images.items()): diff --git a/tests/test_family_self_transport.py b/tests/test_family_self_transport.py new file mode 100644 index 0000000..30a6301 --- /dev/null +++ b/tests/test_family_self_transport.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from itertools import permutations +from typing import Annotated + +import msgspec +import pytest + +from quire.charter_class import CharterDoc, charter, charter_field +from quire.artifacts import FlatYamlPlacement +from quire.charters import registry_from_charters +from quire.family_store import DocumentFamilyStore +from quire.git_store import GitStore +from quire.family_transport import FamilyInstanceMap, UnsupportedFamilyTransportError +from quire.references import ForeignKeySpec, ForeignKeyValidationError +from quire.versions import VersionId + + +@charter(key="item", name="item", contract_version="2026.10.20", placement="items", identity_field="item_id") +class Item(CharterDoc): + item_id: str + + +def _identity(document: Link) -> Link: + return msgspec.structs.replace(document, link_id=f"link:{document.item}") + + +@dataclass(frozen=True) +class LinkRef: + link_id: str + + +@charter(key="link", name="link", contract_version="2026.10.20", + placement=FlatYamlPlacement("links", LinkRef, ref_field="link_id"), + identity_field="link_id", transport_identity=_identity) +class Link(CharterDoc): + link_id: str + item: Annotated[str, charter_field(foreign_key=ForeignKeySpec( + "item", VersionId("2026.10.20"), "link", "item", "item", "item_id", + ))] + related: Annotated[tuple[str, ...], charter_field(foreign_key=ForeignKeySpec( + "related", VersionId("2026.10.20"), "link", "related[]", "link", "link_id", + required=False, many=True, + ))] = () + evidence: str = "authored" + + +def _registry(): + return registry_from_charters(Item.__charter__, Link.__charter__, name="self-test", + contract_version=VersionId("2026.10.20")) + + +def _instance(documents: tuple[Link, ...]) -> FamilyInstanceMap: + registry = _registry() + return FamilyInstanceMap("pinned", { + "item": registry.by_name("item").reference_index_from_records(tuple(Item(i) for i in ("a", "b", "c"))), + "link": registry.by_name("link").reference_index_from_records(documents), + }) + + +@pytest.mark.parametrize("order", tuple(permutations(range(3)))) +@pytest.mark.parametrize("moved", [("a",), ("a", "c")]) +def test_cyclic_batch_is_order_independent_and_moves_legacy_referrers(order, moved) -> None: + originals = ( + Link("old-a", "a", ("old-b",), "first"), + Link("old-b", "b", ("old-a",), "second"), + Link("old-c", "c", ("old-b",), "third"), + ) + documents = tuple(originals[i] for i in order) + instance = _instance(originals) + for source in moved: + instance.assign("item", source, source + "-image") + results = instance.transport_family(_registry().by_name("link"), documents) + image = {document.link_id: f"link:{document.item}{'-image' if document.item in moved else ''}" + for document in originals} + assert results == tuple(msgspec.structs.replace( + document, link_id=image[document.link_id], + item=document.item + "-image" if document.item in moved else document.item, + related=tuple(image[target] for target in document.related), + ) for document in documents) + assert {(i.source_id, i.target_id) for i in instance.iter_images() if i.family == "link"} == set(image.items()) + assert originals[1].related == ("old-a",) + + +def test_self_loop_is_preserved_without_recursive_identity() -> None: + document = Link("old-a", "a", ("old-a",)) + instance = _instance((document,)) + instance.assign("item", "a", "new-a") + assert instance.transport_family(_registry().by_name("link"), (document,)) == ( + Link("link:new-a", "new-a", ("link:new-a",)), + ) + + +def test_unchanged_cyclic_batch_keeps_original_objects_and_ids() -> None: + documents = (Link("old-a", "a", ("old-b",)), Link("old-b", "b", ("old-a",))) + instance = _instance(documents) + results = instance.transport_family(_registry().by_name("link"), documents) + assert all(result is original for result, original in zip(results, documents, strict=True)) + assert tuple(instance.iter_images()) == () + + +def test_dangling_source_self_reference_is_not_silently_kept() -> None: + document = Link("old-a", "a", ("missing",)) + with pytest.raises(ForeignKeyValidationError, match="does not resolve"): + _instance((document,)).transport_family(_registry().by_name("link"), (document,)) + + +def test_batch_without_identity_operation_rejects_changes() -> None: + document = Link("old-a", "a", ("old-a",)) + instance = _instance((document,)) + instance.assign("item", "a", "new-a") + with pytest.raises(UnsupportedFamilyTransportError, match="identity operation"): + instance.transport_family(replace(_registry().by_name("link"), transport_identity=None), (document,)) + + +def test_identity_depending_on_rewritten_self_reference_is_not_guessed() -> None: + document = Link("old-a", "a", ("old-a",)) + instance = _instance((document,)) + instance.assign("item", "a", "new-a") + family = replace(_registry().by_name("link"), transport_identity=lambda doc: msgspec.structs.replace( + doc, link_id=f"{doc.item}:{doc.related[0]}", + )) + with pytest.raises(ValueError, match="conflicting images"): + instance.transport_family(family, (document,)) + + +def test_transported_document_uses_declared_typed_ref_for_preparation() -> None: + @dataclass(frozen=True) + class Owner: + branch: str = "master" + + family = _registry().by_name("link") + document = Link("old-a", "a", ("old-a",), "original evidence") + instance = _instance((document,)) + instance.assign("item", "a", "new-a") + result, = instance.transport_family(family, (document,)) + ref = family.ref_from_document(result) + assert ref == LinkRef("link:new-a") + store = DocumentFamilyStore(owner=Owner(), backend=GitStore.init_memory()) + prepared = store.prepare(family.artifact_family, ref, result) + assert prepared.ref == ref + assert prepared.address.require_path() == "links/link:new-a.yaml" + assert prepared.document == result + assert result.evidence == document.evidence From 8feacdd81a87d275c72ae54a5dcaa05af4169162 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 19 Oct 2026 23:26:26 -0600 Subject: [PATCH 3/4] Record self-reference draft and green owner consumer matrices --- agent-notes/self-reference-family-transport-303.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agent-notes/self-reference-family-transport-303.md b/agent-notes/self-reference-family-transport-303.md index 703179d..d4dbc33 100644 --- a/agent-notes/self-reference-family-transport-303.md +++ b/agent-notes/self-reference-family-transport-303.md @@ -6,10 +6,12 @@ Extend existing occurrence transport with an explicit finite batch operation so ## State - Isolated branch `feat/self-reference-family-transport-303`, base/default `03f7173cd2cc0eb72cd595498ac762f92708bc8c`. Main checkout dirty files preserved; read global and project AGENTS/CLAUDE, existing transport note, family_transport.py, reference metadata/rewriter, charter callback and tests. - Consumer source proof: Propstore coreference IDs hash supports only; attacks are a separate relation updated without changing IDs. Missing self-FK metadata and sequential row transport prevent complete attack transport. Dung 1995 Definition 2 p.326 models attacks as a binary relation over abstract arguments; Schultz/Wisnesky 2015 p.11 requires the image map commute with reference operations. Primary page images read, no extracted text. -- Implemented 56 added generic lines plus owner tests, still uncommitted/unpublished. Focused new-file run PASSED: **17 passed in 1.06s**, `logs/test-runs/self-reference-family-transport-20261019-230808.log`. Covers all six orderings with one/two moved external targets, legacy referrer closure, self loop, unchanged objects/IDs, dangling refs, missing callback, and explicit rejection of identity depending incompatibly on resolved self refs. +- Initial implementation added 56 generic lines plus owner tests. Focused new-file run PASSED: **17 passed in 1.06s**, `logs/test-runs/self-reference-family-transport-20261019-230808.log`. Covers all six orderings with one/two moved external targets, legacy referrer closure, self loop, unchanged objects/IDs, dangling refs, missing callback, and explicit rejection of identity depending incompatibly on resolved self refs. - Consumer first focused run FAILED (12 failed, 23 passed): fixtures omit the canonical tree on their fixed proposal branch; an existing merge preparation edge passes string identities to a typed reference placement (`AttributeError: 'str' object has no attribute 'argument_id'`). Stopped all implementation/publication under global AGENTS.md. No draft, pin update or full CI yet. This generic operation is not claimed integrated until those consumer edges are corrected and verified with parent direction. - Parent explicitly authorized diagnosed corrections and continued ordinary test diagnosis within scope. Added `FamilyDefinition.ref_from_document`: the declared flat placement's existing factory constructs the actual typed ref from the declared document identity; no application convention or fallback. Propstore consumes that owner operation. Corrected fixtures seed canonical proposal snapshots and use existing author/transaction owners for legacy records. - Corrected owner tests PASSED: **18 passed in 0.72s**, `logs/test-runs/self-reference-typed-ref-20261019-231748.log`, including real transport -> typed ref -> store preparation. Corrected consumer tests PASSED: **35 passed in 32.32s**, consumer `coreference-owner-corrections-20261019-231745.log`; includes actual app authoring, cyclic/self attacks, Dung sidecar query, evidence conflicts, legacy reads and retained graph/path-collision behavior. No checks weakened. Original failed logs remain intact. +- FINAL draft https://github.com/propstore/quire/pull/20 at exact source `55f1ad123ba37bb3cbd64a8ede4d96c8f3da0e97`, OPEN/DRAFT. PR CI https://github.com/propstore/quire/actions/runs/39374405169 and push CI https://github.com/propstore/quire/actions/runs/39374322926 both completed SUCCESS at this exact head. All four Ubuntu/Windows Python 3.11/3.13 jobs per run passed lint/typecheck/tests/build, each **605 passed, 12 benchmarks deselected**. All eight checks green. +- Consumer draft https://github.com/propstore/propstore/pull/324 at `f5f5713fc49e020afa76050e726311f49e20c614` pins this exact source in manifest and both lock references; full CI https://github.com/propstore/propstore/actions/runs/39374494927 succeeded: **1981 passed, 4 skipped, 4 warnings in 77.25s**. Both drafts remain unmerged; #303 remains open and untouched. No blocker. Final note-only checkpoint stays local; untracked logs preserve original test evidence. ## Decisions and why - Smallest operation (~60 lines): caller supplies Sequence of typed documents; use charter self-FKs to build reverse dependencies; seed affected rows from currently mapped FKs, plan each affected row once with its existing callback and propagate actual identity changes to referrers; perform existing one-row transport after all images are assigned. This is finite reachability, not a guessed hash fixed point. Final single-image enforcement rejects callback identities inconsistent with resolved self references. @@ -20,4 +22,4 @@ Extend existing occurrence transport with an explicit finite batch operation so - None yet. ## Next step -Publish the tested owner source as a draft; pin that exact commit in the consumer's manifest and every lock reference, publish its separate draft and follow both full CI runs. No merge or ready transition; retain original failure evidence. +Parent independently reviews exact owner `55f1ad123ba37bb3cbd64a8ede4d96c8f3da0e97` and consumer `f5f5713fc49e020afa76050e726311f49e20c614`. No merge or ready transition; retain original failure evidence. Scope reached the requested finite self-reference transport/typed-ref boundary; no recursive identity or concurrency redesign. From 2dab8d1fc3fdd75d81d1706b8a1600651ca57e87 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 19 Oct 2026 23:39:46 -0600 Subject: [PATCH 4/4] Expose family-owned batch transport admission before coalescence --- .../self-reference-family-transport-303.md | 3 ++- quire/charter_class.py | 4 ++- quire/families.py | 7 ++++++ quire/family_transport.py | 7 +++++- tests/test_family_self_transport.py | 25 +++++++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/agent-notes/self-reference-family-transport-303.md b/agent-notes/self-reference-family-transport-303.md index d4dbc33..b4a70bc 100644 --- a/agent-notes/self-reference-family-transport-303.md +++ b/agent-notes/self-reference-family-transport-303.md @@ -4,6 +4,7 @@ Extend existing occurrence transport with an explicit finite batch operation so self-reference targets' family-owned identities are known before rewriting references. No consumer semantics or identity invented in Quire. ## State +- REVIEW correction: reproduced real Propstore merge `1409906437aed51275a43505fc3fc8a9d29d27df` collapsing valid equal-support legacy A/B mutual attacks into self-attacks; preferred clusters changed 1 -> 0. Source/parents valid and old rows round-trip. Dung p.327 Example 9 vs p.328 Lemma 15 establishes different semantics. This is a consumer invariant, so Quire retains non-injective maps. Added optional typed `validate_transport(originals, images)` callback to family declaration/decorator/contract and finite-batch exits, before consumer coalescence. Coreference owns its injectivity admission; no policy/domain string/hash changes in Quire. Focused new/edited owner file: **20 passed in 0.77s**, `family-transport-admission-20261019-233812.log`, including explicit acceptance of a non-injective reciprocal-link map without family policy and delivery of complete before/after batches to a rejecting family callback. Consumer edited file: **12 passed in 13.94s**, `legacy-cycle-collapse-rejection-20261019-233809.log`. Original reproduction retained; corrected draft publication/CI next. - Isolated branch `feat/self-reference-family-transport-303`, base/default `03f7173cd2cc0eb72cd595498ac762f92708bc8c`. Main checkout dirty files preserved; read global and project AGENTS/CLAUDE, existing transport note, family_transport.py, reference metadata/rewriter, charter callback and tests. - Consumer source proof: Propstore coreference IDs hash supports only; attacks are a separate relation updated without changing IDs. Missing self-FK metadata and sequential row transport prevent complete attack transport. Dung 1995 Definition 2 p.326 models attacks as a binary relation over abstract arguments; Schultz/Wisnesky 2015 p.11 requires the image map commute with reference operations. Primary page images read, no extracted text. - Initial implementation added 56 generic lines plus owner tests. Focused new-file run PASSED: **17 passed in 1.06s**, `logs/test-runs/self-reference-family-transport-20261019-230808.log`. Covers all six orderings with one/two moved external targets, legacy referrer closure, self loop, unchanged objects/IDs, dangling refs, missing callback, and explicit rejection of identity depending incompatibly on resolved self refs. @@ -22,4 +23,4 @@ Extend existing occurrence transport with an explicit finite batch operation so - None yet. ## Next step -Parent independently reviews exact owner `55f1ad123ba37bb3cbd64a8ede4d96c8f3da0e97` and consumer `f5f5713fc49e020afa76050e726311f49e20c614`. No merge or ready transition; retain original failure evidence. Scope reached the requested finite self-reference transport/typed-ref boundary; no recursive identity or concurrency redesign. +Publish this bounded family-admission extension to existing draft #20, update consumer's exact pin, publish its coreference-owned correction to draft #324, and follow full fresh CI. Parent re-review remains required; no merge/ready transition or recursive identity/concurrency redesign. diff --git a/quire/charter_class.py b/quire/charter_class.py index 69bf81b..d41690e 100644 --- a/quire/charter_class.py +++ b/quire/charter_class.py @@ -18,7 +18,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field as _dc_field, replace from enum import Enum import sys @@ -639,6 +639,7 @@ def charter( accessor: str | None = None, family_foreign_keys: tuple[ForeignKeySpec, ...] = (), transport_identity: Callable[[Any], Any] | None = None, + validate_transport: Callable[[Sequence[Any], Sequence[Any]], None] | None = None, reference_keys: tuple[Any, ...] = (), family_metadata: Mapping[str, object] | None = None, semantic_metadata: Mapping[str, object] | None = None, @@ -752,6 +753,7 @@ def decorate(cls: _T) -> _T: accessor=accessor, foreign_keys=family_foreign_keys, transport_identity=transport_identity, + validate_transport=validate_transport, identity_field=identity_field, reference_keys=cast("tuple[Any, ...]", reference_keys), metadata=family_metadata, diff --git a/quire/families.py b/quire/families.py index 8522dc3..256358b 100644 --- a/quire/families.py +++ b/quire/families.py @@ -88,6 +88,7 @@ class FamilyDeclaration(Generic[TOwner, TKey, TRef, TDoc]): identity_field: str | None = None reference_keys: tuple[ReferenceKey, ...] = () metadata: Mapping[str, object] | None = None + validate_transport: Callable[[Sequence[TDoc], Sequence[TDoc]], None] | None = None def to_artifact_family(self) -> ArtifactFamily[TOwner, TRef, TDoc]: return ArtifactFamily( @@ -115,6 +116,7 @@ def to_definition(self) -> FamilyDefinition[TOwner, TKey, TRef, TDoc]: foreign_keys=self.foreign_keys, identity_policy=self.identity_policy, transport_identity=self.transport_identity, + validate_transport=self.validate_transport, identity_field=self.identity_field, reference_keys=self.reference_keys, metadata=self.metadata, @@ -134,6 +136,7 @@ class FamilyDefinition(Generic[TOwner, TKey, TRef, TDoc]): identity_field: str | None = None reference_keys: tuple[ReferenceKey, ...] = () metadata: Mapping[str, object] | None = None + validate_transport: Callable[[Sequence[TDoc], Sequence[TDoc]], None] | None = None def __post_init__(self) -> None: if not self.name: @@ -208,6 +211,10 @@ def contract_body(self) -> dict[str, object]: body["transport_identity"] = ( f"{self.transport_identity.__module__}.{self.transport_identity.__qualname__}" ) + if self.validate_transport is not None: + body["validate_transport"] = ( + f"{self.validate_transport.__module__}.{self.validate_transport.__qualname__}" + ) if self.identity_field is not None: body["identity_field"] = self.identity_field if self.reference_keys: diff --git a/quire/family_transport.py b/quire/family_transport.py index ef7d494..41f02e1 100644 --- a/quire/family_transport.py +++ b/quire/family_transport.py @@ -112,7 +112,10 @@ def transport_family( """ self_specs = tuple(s for s in family.foreign_keys if s.target_family == family.name) if not self_specs: - return tuple(self.transport(family, document) for document in documents) + results = tuple(self.transport(family, document) for document in documents) + if family.validate_transport is not None: + family.validate_transport(documents, results) + return results if family.identity_field is None: raise ValueError(f"family {family.name!r} has no identity field") identity = ReferenceKey.field(family.identity_field) @@ -150,6 +153,8 @@ def transport_family( results = tuple(self.transport(family, document) for document in documents) for index in planned: self.assign(family.name, identity(documents[index])[0], identity(results[index])[0]) + if family.validate_transport is not None: + family.validate_transport(documents, results) return results def iter_images(self) -> Iterator[FamilyReferenceImage]: diff --git a/tests/test_family_self_transport.py b/tests/test_family_self_transport.py index 30a6301..6089d06 100644 --- a/tests/test_family_self_transport.py +++ b/tests/test_family_self_transport.py @@ -143,3 +143,28 @@ class Owner: assert prepared.address.require_path() == "links/link:new-a.yaml" assert prepared.document == result assert result.evidence == document.evidence + + +def test_generic_batch_still_allows_noninjective_images_without_family_policy() -> None: + documents = (Link("old-a", "a", ("old-b",)), Link("old-b", "a", ("old-a",))) + instance = _instance(documents) + instance.assign("item", "a", "new-a") + results = instance.transport_family(_registry().by_name("link"), documents) + assert results == (Link("link:new-a", "new-a", ("link:new-a",)),) * 2 + + +def test_family_admission_receives_originals_and_complete_images_before_coalescence() -> None: + documents = (Link("old-a", "a", ("old-b",)), Link("old-b", "a", ("old-a",))) + observed = [] + + def reject_collapse(originals, images): + observed.append((tuple(originals), tuple(images))) + raise ValueError("family rejects collapse") + + family = replace(_registry().by_name("link"), validate_transport=reject_collapse) + assert family.contract_body()["validate_transport"].endswith(".reject_collapse") + instance = _instance(documents) + instance.assign("item", "a", "new-a") + with pytest.raises(ValueError, match="family rejects collapse"): + instance.transport_family(family, documents) + assert observed == [(documents, (Link("link:new-a", "new-a", ("link:new-a",)),) * 2)]