diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 492412b..165e31e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,12 @@ name: CI on: pull_request: + # Restricted to main so a branch with an open PR is not built twice for the + # same commit: an unfiltered `push` fires alongside `pull_request` on every + # branch push. Keeping main here still covers the post-merge commit and any + # push that lands without a PR. push: + branches: [main] permissions: contents: read diff --git a/PAA.md b/PAA.md index 686fbfe..bdc96fc 100644 --- a/PAA.md +++ b/PAA.md @@ -19,8 +19,8 @@ where the implementation deliberately stops. | `SqliteEventStore` | Evidence/event log substrate | Supplies the default append-only SQLite store, including storage-level update/delete rejection. | | `store_evidence`, `verify_evidence` | Evidence record binding | Content-addresses exact evidence bytes with SHA-256 and fails closed on missing or changed bytes. | | `OperatingRecord`, `SqliteOperatingRecordStore` | Operating accounting | Stores usage, prices and provenance separately from evidence files and autonomy events, retrievable by subject; no transition rule reads it. | -| `import_events` | Archive replay | Imports already validated contract-shaped events without regenerating identifiers or timestamps. The legacy conformance capture proves field and projection continuity across extraction. | -| `paa-contracts` conformance suite | Published contract | Checks schema vocabulary, declarations, event histories, evidence addressing, invalid semantic cases, and the pre-cutover capture against the same packaged corpus. | +| `import_events` | Archive replay | Imports already validated contract-shaped events without regenerating identifiers or timestamps. | +| `paa-contracts` conformance suite | Published contract | Checks schema vocabulary, declarations, event histories, evidence addressing, and invalid semantic cases against the same packaged corpus. | ## Lifecycle coverage @@ -45,18 +45,7 @@ field loss, that the lifecycle can produce them, that content addresses are re-derived from bytes, and that runtime-owned negative cases fail for the published reason. -`examples/legacy-archive/pre-cutover-capture.json` adds the consumer-boundary -proof. It was generated with the source consumer's pre-cutover lifecycle implementation at -commit `721c37facac64f12a164e510c9a0aa647a960cba`, then imported into the -extracted runtime. The test reproduces its event rows, motion projection, and -resolved position exactly. - -The source consumer's production `autonomy_events` table contained zero rows at cutover. The -capture is therefore evidence from the real pre-cutover implementation, not a -claim that a production autonomy transition occurred. Keeping that distinction -in the artifact is part of the citation bar. - -## Honest non-matches +## Non-matches ### Evaluator verdict production @@ -124,11 +113,6 @@ cross-document corpus validation remains in the published conformance tooling. ## Scope of the claim -The accurate claim is: - -> `paa-runtime` implements PAA's declared autonomy-transition lifecycle and -> passes the published conformance corpus, including replay of a history -> captured from the source consumer's pre-cutover implementation. - -It is not a claim that the runtime implements evaluation, worker attestation, -or every consumer's governed effect. +`paa-runtime` implements PAA's declared autonomy-transition lifecycle and +passes the published conformance corpus. It does not implement evaluation, +worker attestation, or any consumer's governed effect. diff --git a/README.md b/README.md index ab4a44e..62dfc3e 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ For a complete disposable propose → approve → demote walk, run the `SqliteEventStore` is the default and most consumers should use it. `EventStore` is a protocol so that a consumer whose governed effect and the position read authorizing it must commit in a *single lock domain* can implement it over its own connection — a runtime-owned database cannot offer that guarantee across process boundaries. It is one insert, four reads, and two transaction context managers. -The trade-off is named rather than hidden. With the default store, a consumer that resolves a position and then performs the effect it authorizes does so across two lock domains: a demotion committed in between is not seen by the effect already in flight. That window is small and the failure is a stale *permit*, not a corrupt history — but it is real, and a consumer for which it is unacceptable implements `EventStore` over the same connection its effect commits on. +With the default store, a consumer that resolves a position and then performs the effect it authorizes does so across two lock domains: a demotion committed in between is not seen by the effect already in flight. That window is small and the failure is a stale *permit*, not a corrupt history — but it is real, and a consumer for which it is unacceptable implements `EventStore` over the same connection its effect commits on. ## Development @@ -113,10 +113,7 @@ uv run mypy src/paa_runtime ``` The implementation-to-spec mapping, including explicit non-matches, is in -[`PAA.md`](PAA.md). The conformance corpus also includes a contract-shaped -history captured from the source consumer's pre-cutover implementation. Its production database had -zero autonomy events at cutover, so that artifact is deliberately labeled an -implementation capture rather than production transition history. +[`PAA.md`](PAA.md). ### Conformance @@ -133,13 +130,12 @@ uv sync --extra conformance uv run pytest conformance ``` -The `conformance` path is required, not decoration. `testpaths` is `tests`, so +The `conformance` path is required. `testpaths` is `tests`, so a bare `uv run pytest` runs the unit suite and nothing else — which is what lets the unit suite stay green for someone who cloned only this repo. The conformance suite is opt-in by *invocation* rather than by skip marker: when it is asked to run and the artifacts are absent, `paa_contracts` raises at import -and the run fails loudly, because a conformance suite reporting green over an -empty corpus is the one failure mode it must not have. +and the run fails loudly rather than reporting green over an empty corpus. What it asserts, per fixture class: @@ -168,7 +164,7 @@ package has no import path to point them at — it governs motions it writes itself, enforcing those rules at write time rather than by inspecting a finished document. -One honest non-match: the published demotion history binds to a +One non-match: the published demotion history binds to a `paa-decision-artifact`, while `demote` generates and content-addresses its own evidence so an emergency demotion never blocks on an operator producing an artifact first. Its event stream reproduces the published one in every field diff --git a/conformance/__init__.py b/conformance/__init__.py index 48d4186..e00bc7d 100644 --- a/conformance/__init__.py +++ b/conformance/__init__.py @@ -2,9 +2,8 @@ Every fixture, schema, and negative case here comes from ``paa-contracts`` — the artifacts paa.dev publishes — rather than from fixtures of this -repo's own. That is the entire point: "passes the published conformance -suite" has to be a claim about the contract, not about this repo's idea -of it. +repo's own, so that "passes the published conformance suite" is a claim +about the contract, not about this repo's idea of it. This package is deliberately not collected by ``uv run pytest``. The unit suite under ``tests/`` must pass for someone who cloned only this repo, @@ -15,8 +14,7 @@ uv run --with ../paadotdev/packages/paa-contracts pytest conformance Opt-in by *invocation*, never by skip marker. A suite that skips when its -fixtures are missing reports green over an empty corpus, which is the one -failure mode a conformance suite must not have — so when these modules -are asked to run and the artifacts are absent, ``paa_contracts`` raises -at import and the run fails loudly. +fixtures are missing reports green over an empty corpus, so when these +modules are asked to run and the artifacts are absent, ``paa_contracts`` +raises at import and the run fails loudly. """ diff --git a/conformance/conftest.py b/conformance/conftest.py index 37c231d..a048829 100644 --- a/conformance/conftest.py +++ b/conformance/conftest.py @@ -18,10 +18,9 @@ def pytest_report_header() -> list[str]: Packaged data and a site checkout are the same bytes from the same commit, so which one is active never changes an answer — but it - changes what a failure *means*, which is why it belongs in the run - output. A header prints unconditionally; a fixture only reports when - something requests it, which is how this started life and why nothing - ever saw it. + changes what a failure *means*, so it belongs in the run output. A + header prints unconditionally, unlike a fixture, which reports only + when something requests it. """ return [ f"paa-contracts: {contracts.__version__} ({contracts.DATA_SOURCE})", @@ -54,10 +53,10 @@ def build_registry(documents: Sequence[dict[str, Any]]) -> tuple[ProducerRegistr The registry is consumer domain data the runtime deliberately does not own, so a conformance run has to supply one. Deriving it from the - corpus is the honest choice: it makes the claim "given a registry that - registers what these declarations reference, the loader accepts them", - which is the claim an implementation can actually make about fixtures - whose producers live in somebody else's codebase. + corpus makes the claim "given a registry that registers what these + declarations reference, the loader accepts them", which is the claim + an implementation can actually make about fixtures whose producers + live in somebody else's codebase. Every entry is registered ``implemented``. The implemented/future split governs whether a consumer has built the producer yet — a fact diff --git a/conformance/test_corpus_integrity.py b/conformance/test_corpus_integrity.py index 392372a..8f237ce 100644 --- a/conformance/test_corpus_integrity.py +++ b/conformance/test_corpus_integrity.py @@ -175,8 +175,8 @@ class TestUnclaimedStages: identity drift, illegal motion ordering — which is real logic, not a schema pass, and it does not exist in Python yet. - The ratchet that keeps this honest: the JS validator covering these - is not deleted until they are claimed here. Counts pinned so the gap + The ratchet: the JS validator covering these is not deleted until + they are claimed here. Counts pinned so the gap stays measured. """ diff --git a/conformance/test_legacy_archive.py b/conformance/test_legacy_archive.py deleted file mode 100644 index 9d55b9d..0000000 --- a/conformance/test_legacy_archive.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Replay a history captured from a consumer's pre-cutover implementation.""" - -from __future__ import annotations - -import json -from typing import Any - -import paa_contracts as contracts -from jsonschema import Draft202012Validator, FormatChecker - -from paa_runtime import RuntimeConfig, SqliteEventStore, list_motions, show -from paa_runtime.replay import import_events - - -def test_legacy_pre_cutover_capture_reproduces_its_projections( - runtime_config: RuntimeConfig, -) -> None: - archive_path = contracts.EXAMPLES_ROOT / "legacy-archive" / "pre-cutover-capture.json" - archive: dict[str, Any] = json.loads(archive_path.read_text(encoding="utf-8")) - - assert archive["capture_kind"] == "pre-cutover-implementation" - assert archive["production_event_count_at_cutover"] == 0 - - validator = Draft202012Validator( - contracts.load_schema("paa-autonomy-event"), format_checker=FormatChecker(), - ) - for event in archive["events"]: - validator.validate(event) - - store = SqliteEventStore(runtime_config.db_path) - try: - import_events(store, archive["events"]) - - stored = [event.to_json_dict() for event in store.get_autonomy_events()] - assert stored == archive["events"] - - positions = [] - for expected in archive["positions"]: - actual = show( - store, runtime_config, - task=expected["task"], scope=expected["scope"], - ) - latest = actual.pop("latest_position_event") - actual["latest_position_event_id"] = latest["id"] if latest else None - positions.append(actual) - assert positions == archive["positions"] - - assert [motion.to_json_dict() for motion in list_motions(store)] == archive["motions"] - finally: - store.close() diff --git a/examples/legacy-archive/pre-cutover-capture.json b/examples/legacy-archive/pre-cutover-capture.json deleted file mode 100644 index 500310f..0000000 --- a/examples/legacy-archive/pre-cutover-capture.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "capture_kind": "pre-cutover-implementation", - "source_commit": "721c37facac64f12a164e510c9a0aa647a960cba", - "production_event_count_at_cutover": 0, - "events": [ - { - "actor": "capture-operator", - "created_at": "2026-08-11T19:07:04.074003Z", - "declaration_version": 1, - "event": "motion_proposed", - "event_schema": "paa-autonomy-event/0.1.0-draft", - "evidence_ref": "evidence/paa/76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315/evidence.json", - "evidence_sha256": "76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315", - "from_position": "hitl", - "id": "c8b91619-3f1f-4553-bcf4-3e9fa6838545", - "motion_id": "36205f56-87a5-4343-9d2c-44308b23a1a8", - "reason": "captured pre-cutover promotion", - "scope": "publish:bluesky", - "task": "outbound_content_publish", - "to_position": "hotl" - }, - { - "actor": "capture-operator", - "created_at": "2026-08-11T19:07:04.083086Z", - "declaration_version": 1, - "event": "motion_approved", - "event_schema": "paa-autonomy-event/0.1.0-draft", - "evidence_ref": "evidence/paa/76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315/evidence.json", - "evidence_sha256": "76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315", - "from_position": "hitl", - "id": "9384e9eb-1511-4059-937e-04c62b98d415", - "motion_id": "36205f56-87a5-4343-9d2c-44308b23a1a8", - "reason": "approved captured pre-cutover promotion", - "scope": "publish:bluesky", - "task": "outbound_content_publish", - "to_position": "hotl" - }, - { - "actor": "capture-operator", - "created_at": "2026-08-11T19:07:04.083179Z", - "declaration_version": 1, - "event": "position_changed", - "event_schema": "paa-autonomy-event/0.1.0-draft", - "evidence_ref": "evidence/paa/76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315/evidence.json", - "evidence_sha256": "76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315", - "from_position": "hitl", - "id": "1393f60e-3560-4000-ba51-ec92161db0bb", - "motion_id": "36205f56-87a5-4343-9d2c-44308b23a1a8", - "reason": "approved captured pre-cutover promotion", - "scope": "publish:bluesky", - "task": "outbound_content_publish", - "to_position": "hotl" - } - ], - "positions": [ - { - "current_position": "hotl", - "declaration_version": 1, - "deployment": "active", - "initial_position": "hitl", - "latest_position_event_id": "1393f60e-3560-4000-ba51-ec92161db0bb", - "scope": "publish:bluesky", - "task": "outbound_content_publish" - } - ], - "motions": [ - { - "approved_at": "2026-08-11T19:07:04.083086Z", - "approved_by": "capture-operator", - "approved_reason": "approved captured pre-cutover promotion", - "declaration_version": 1, - "evidence_ref": "evidence/paa/76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315/evidence.json", - "evidence_sha256": "76ea9113c9000f7fab53d48dd9149b101686cceda42798f84469a050f1294315", - "executed_at": "2026-08-11T19:07:04.083179Z", - "from_position": "hitl", - "motion_id": "36205f56-87a5-4343-9d2c-44308b23a1a8", - "proposed_at": "2026-08-11T19:07:04.074003Z", - "proposed_by": "capture-operator", - "proposed_reason": "captured pre-cutover promotion", - "rejected_at": null, - "rejected_by": null, - "rejected_reason": null, - "scope": "publish:bluesky", - "status": "executed", - "task": "outbound_content_publish", - "to_position": "hotl" - } - ] -} diff --git a/examples/refund_quickstart/README.md b/examples/refund_quickstart/README.md index c40130a..0beb95e 100644 --- a/examples/refund_quickstart/README.md +++ b/examples/refund_quickstart/README.md @@ -16,5 +16,4 @@ The script creates an isolated temporary database and evidence tree, proposes repository. `refund_approval.v1.yaml` remains in the shared contract corpus so the quickstart, conformance suite, and paa.dev schema reference use one fixture. -This is adoption-oriented synthetic pedagogy. The separately labeled -pre-cutover capture is the cross-implementation evidence artifact. +The fixtures here are synthetic teaching examples, not captured history. diff --git a/packages/paa-contracts/README.md b/packages/paa-contracts/README.md index 949d521..ea560be 100644 --- a/packages/paa-contracts/README.md +++ b/packages/paa-contracts/README.md @@ -2,7 +2,7 @@ The published contract artifacts of the [Progressive Autonomy Architecture](https://www.paa.dev): five normative JSON Schemas, the positive fixture corpus every implementation is checked against, and the table-driven invalid-case matrices. -No runtime logic, no dependencies. This package is data and honest paths to it. +No runtime logic, no dependencies. This package is data, plus paths to it. ## Why it exists @@ -63,7 +63,7 @@ for case in contracts.invalid_cases("event", stage="event_semantic"): ## The stage split -Every invalid case declares `expected.stage`, and that field is an ownership boundary rather than a label: +Every invalid case declares `expected.stage`, an ownership boundary: | Stage | Owner | Why | |---|---|---| @@ -77,9 +77,9 @@ Filter with `invalid_cases(kind, stage=...)` and take only what you own. `case_s The artifacts are not checked into this directory. `hatch_build.py` pulls them from the repo root at build time — that hook is where the inclusion logic lives, not `pyproject.toml`, which only registers it. So the files paa.dev serves at their published URLs, the files the validator reads, and the files a conformance suite loads are the same bytes from the same commit. -Vendoring copies here would put two sources of truth one careless commit apart and make every conformance claim a claim about the copy. Build-time inclusion makes "one source of truth" a property of the build instead of a rule someone has to remember. +Vendoring copies here would put two sources of truth one careless commit apart and make every conformance claim a claim about the copy. Build-time inclusion makes one source of truth a property of the build. -The consequence: a wheel can only be built from a full checkout of this repo. That is correct — a release cut from anything less than the whole tree would be a partial contract. +The consequence: a wheel can only be built from a full checkout of this repo — a release cut from anything less than the whole tree would be a partial contract. ## Contents diff --git a/packages/paa-contracts/hatch_build.py b/packages/paa-contracts/hatch_build.py index 355aa55..36daecd 100644 --- a/packages/paa-contracts/hatch_build.py +++ b/packages/paa-contracts/hatch_build.py @@ -35,7 +35,6 @@ ("schemas", "schemas"), ("examples/paa-tasks", "examples/paa-tasks"), ("examples/runtime-conformance", "examples/runtime-conformance"), - ("examples/legacy-archive", "examples/legacy-archive"), ) diff --git a/packages/paa-contracts/scripts/verify_built_wheel.py b/packages/paa-contracts/scripts/verify_built_wheel.py index fef7524..99a5d98 100644 --- a/packages/paa-contracts/scripts/verify_built_wheel.py +++ b/packages/paa-contracts/scripts/verify_built_wheel.py @@ -29,7 +29,6 @@ "schemas", "examples/paa-tasks", "examples/runtime-conformance", - "examples/legacy-archive", ) _DATA_MARKER = "/_data/" diff --git a/packages/paa-contracts/src/paa_contracts/__init__.py b/packages/paa-contracts/src/paa_contracts/__init__.py index 2a4bd48..9f396da 100644 --- a/packages/paa-contracts/src/paa_contracts/__init__.py +++ b/packages/paa-contracts/src/paa_contracts/__init__.py @@ -3,14 +3,12 @@ This package is the contract side of the PAA split: the five normative JSON Schemas, the positive fixture corpus every implementation is checked against, and the table-driven invalid-case matrices. It contains no runtime logic and -has no dependencies — it is data, plus honest paths to that data. +has no dependencies — it is data, plus paths to that data. -The dependency direction is the point. An implementation depends on the -contract; the contract never depends on an implementation. ``paa-runtime`` is -the first consumer, a task-schema conformance test is the second, and a -second implementation in any language gets its fixtures the same way the first -one does. That is implementation-neutrality made mechanical instead of -asserted. +An implementation depends on the contract; the contract never depends on an +implementation. ``paa-runtime`` is the first consumer, a task-schema +conformance test is the second, and a second implementation in any language +gets its fixtures the same way the first one does. Nothing here is a copy. The artifacts are pulled from the repo root at build time by hatch_build.py, so the files the site serves at https://www.paa.dev @@ -102,7 +100,7 @@ class SetMutation(TypedDict): class ExpectedFailure(TypedDict): """What the mutated document must be rejected for. - ``stage`` is the ownership boundary, not decoration. ``structural`` cases + ``stage`` is the ownership boundary. ``structural`` cases are Ajv's vocabulary — its error keywords and JSON pointers — and belong to the site's validator. Every other stage is expressed in the runtime's own vocabulary and belongs to an implementation's conformance suite. A @@ -132,12 +130,11 @@ class InvalidCase(TypedDict): #: Every directory that has to be present for a corpus to count as complete. #: Checked in full rather than by sampling one of them: a partial artifact set #: satisfies imports and then returns empty tuples from the accessors, which -#: is the silent-pass failure this module exists to refuse. +#: would let a suite iterate nothing and report success. _REQUIRED_ROOTS: tuple[str, ...] = ( "schemas", "examples/paa-tasks", "examples/runtime-conformance", - "examples/legacy-archive", ) @@ -158,14 +155,14 @@ def _resolve_data_root() -> tuple[Path, Literal["packaged", "worktree"]]: suite can see it. Both modes resolve to the same bytes from the same commit, so which one is - active never changes an answer — but it changes what a failure means, - which is why it is reported rather than hidden. - - Completeness is required of both, and that is the part worth stating. - Accepting a root because one expected directory is present would let a - wheel missing a fixture tree import cleanly as ``packaged``, after which - every accessor for the missing tree returns ``()`` and a conformance suite - iterates nothing and reports success. The build-time check in + active never changes an answer — but it changes what a failure means, so + it is reported. + + Completeness is required of both. Accepting a root because one expected + directory is present would let a wheel missing a fixture tree import + cleanly as ``packaged``, after which every accessor for the missing tree + returns ``()`` and a conformance suite iterates nothing and reports + success. The build-time check in scripts/verify_built_wheel.py cannot help a consumer who installs such a wheel; this can. """ @@ -242,8 +239,8 @@ def schema_version(schema_id: SchemaId) -> str: Read from the schema rather than mirrored into a constant here. Package version and schema-family versions drift independently on purpose — a - packaging fix should not imply a contract revision — so the only honest - source for a family version is the schema file that declares it. + packaging fix should not imply a contract revision — so the source for a + family version is the schema file that declares it. """ version = load_schema(schema_id).get("x-paa-schema-version") if not isinstance(version, str): diff --git a/packages/paa-contracts/tests/test_contracts.py b/packages/paa-contracts/tests/test_contracts.py index b13270a..ed8ee1a 100644 --- a/packages/paa-contracts/tests/test_contracts.py +++ b/packages/paa-contracts/tests/test_contracts.py @@ -31,7 +31,6 @@ def test_resolution_requires_every_artifact_root(self, tmp_path: Path) -> None: assert contracts._missing_roots(tmp_path) == ( "examples/paa-tasks", "examples/runtime-conformance", - "examples/legacy-archive", ) def test_the_resolved_root_is_complete(self) -> None: diff --git a/tests/test_replay.py b/tests/test_replay.py new file mode 100644 index 0000000..0fc1b7e --- /dev/null +++ b/tests/test_replay.py @@ -0,0 +1,90 @@ +"""Tests for import_events, the archive-replay boundary.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest + +from paa_runtime.events import CURRENT_EVENT_SCHEMA +from paa_runtime.replay import import_events +from paa_runtime.sqlite_store import SqliteEventStore + +_TASK = "outbound_content_publish" +_SCOPE = "publish:bluesky" +_SHA = "b" * 64 +_EVIDENCE_REF = f"evidence/paa/{_SHA}/evidence.json" + + +def _make_store(tmp_path: Path) -> SqliteEventStore: + return SqliteEventStore(tmp_path / "autonomy_events.db") + + +def _event(**overrides: Any) -> dict[str, Any]: + """One contract-shaped event, in the exported JSON field names. + + Deliberately built here rather than read from a fixture: import_events + takes whatever a validated archive contains, so the thing worth pinning + is that the fields survive the call, not that some particular file exists. + """ + event: dict[str, Any] = { + "id": "11111111-1111-4111-8111-111111111111", + "motion_id": "22222222-2222-4222-8222-222222222222", + "task": _TASK, + "declaration_version": 1, + "scope": _SCOPE, + "event": "motion_proposed", + "from_position": "hitl", + "to_position": "hotl", + "evidence_ref": _EVIDENCE_REF, + "evidence_sha256": _SHA, + "actor": "capture-operator", + "reason": "imported", + "created_at": "2026-08-11T19:07:04.074003Z", + "event_schema": CURRENT_EVENT_SCHEMA, + } + event.update(overrides) + return event + + +class TestImportEvents: + def test_preserves_every_field_verbatim(self, tmp_path: Path) -> None: + # The point of the function: an archive's identifiers and timestamps + # are the record. Regenerating either would silently rewrite history + # that another implementation already published. + store = _make_store(tmp_path) + events = [ + _event(), + _event( + id="33333333-3333-4333-8333-333333333333", + event="motion_approved", + created_at="2026-08-11T19:07:04.083086Z", + ), + ] + try: + import_events(store, events) + assert [e.to_json_dict() for e in store.get_autonomy_events()] == events + finally: + store.close() + + def test_imports_atomically(self, tmp_path: Path) -> None: + # One transaction, so a bad row late in an archive cannot leave a + # partial history behind for the next run to append onto. + store = _make_store(tmp_path) + duplicate = [_event(), _event(reason="same id as the first")] + try: + with pytest.raises(sqlite3.IntegrityError): + import_events(store, duplicate) + assert store.get_autonomy_events() == [] + finally: + store.close() + + def test_importing_nothing_is_a_no_op(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + try: + import_events(store, []) + assert store.get_autonomy_events() == [] + finally: + store.close()