Skip to content

symfonic.services.privacy.suite

suite

CS-20 โ€” the contract suite every subject-bearing adapter has to pass.

Shipped in src/ rather than tests/ because CON-S-4 requires it to run against the reference implementation and against every real backend, including backends that live in another repository. An integration imports :func:run_subject_data_store_suite, hands it a factory, and gets a report.

The last case is the one that matters and the one a hand-written adapter test almost never has: writers racing an erasure. A store that checks the tombstone itself, in its own code, before writing will pass every other case here and fail this one โ€” which is exactly the diagnosis EFX-ER-3 wants surfaced.

run_subject_data_store_suite async

run_subject_data_store_suite(store_factory: Callable[[], SubjectDataStore], *, fence_factory: Callable[[], ErasureFence]) -> SuiteReport

Run every CS-20 case against a freshly built store, and report.

A fresh store per case: sharing one would let an earlier case's leftovers decide a later one, and a suite whose verdict depends on execution order is not evidence.

Source code in src/symfonic/services/privacy/suite.py
async def run_subject_data_store_suite(
    store_factory: Callable[[], SubjectDataStore],
    *,
    fence_factory: Callable[[], ErasureFence],
) -> SuiteReport:
    """Run every CS-20 case against a freshly built store, and report.

    A fresh store per case: sharing one would let an earlier case's leftovers
    decide a later one, and a suite whose verdict depends on execution order is
    not evidence.
    """
    cases: list[SuiteCase] = []
    participant_id = store_factory().describe().participant_id

    cases.append(
        SuiteCase("describe-declares-a-participant-id", bool(participant_id))
    )

    # Every case after this one commits through the store's conditional-write
    # entry point (EFX-ER-3). A store that does not declare one is *reported*
    # here rather than crashing the suite three cases later: an integrator who
    # implemented the published port and got an AttributeError learns nothing
    # about which obligation they missed.
    if not isinstance(store_factory(), SubjectDataStoreWriter):
        cases.append(
            SuiteCase(
                "declares-a-conditional-write-entry-point",
                False,
                "no `write(scope, record, *, fence, expected_generation)`; the "
                "remaining cases commit through it and were not run",
            )
        )
        return SuiteReport(
            participant_id=participant_id, subject=_SUBJECT, cases=tuple(cases)
        )
    cases.append(SuiteCase("declares-a-conditional-write-entry-point", True))

    store, fence = store_factory(), fence_factory()
    outcome = await _write(store, fence, _SUBJECT, "one")
    cases.append(
        SuiteCase("write-commits-at-the-current-generation", bool(outcome.committed))
    )

    store, fence = store_factory(), fence_factory()
    await _write(store, fence, _SUBJECT, "mine")
    await _write(store, fence, _CHILD, "child")
    await _write(store, fence, _NEIGHBOUR, "not-mine")
    fragment = await store.export_subject(_SUBJECT)
    bodies = {record.get("body") for record in fragment.records}
    cases.append(
        SuiteCase(
            "export-is-subtree-scoped",
            not fragment.exportable or bodies == {"mine", "child"},
            f"exported {sorted(str(b) for b in bodies)}",
        )
    )

    store, fence = store_factory(), fence_factory()
    await _write(store, fence, _SUBJECT, "one")
    await fence.publish_tombstone(_SUBJECT.scope_key, reason="cs-20")
    receipt = await store.erase_subject(_SUBJECT, fence)
    absent = await store.confirm_absent(_SUBJECT)
    cases.append(
        SuiteCase(
            "erase-then-confirm-absent",
            bool(receipt.confirmed_absent and absent),
            f"erased={receipt.erased}",
        )
    )

    store, fence = store_factory(), fence_factory()
    await fence.publish_tombstone(_SUBJECT.scope_key, reason="cs-20")
    denied = await _write(store, fence, _SUBJECT, "after")
    cases.append(
        SuiteCase(
            "write-denied-after-tombstone",
            not denied.committed and await store.confirm_absent(_SUBJECT),
        )
    )

    # The sub-scope version of the same case, and the one an adapter is most
    # likely to fail: erasure is subtree-scoped, so the tenant tombstone has to
    # refuse a write at the child too. A store (or a fence) that matches the
    # exact key lets the child write commit, and the subtree-scoped export then
    # returns the subject that was just erased.
    store, fence = store_factory(), fence_factory()
    await fence.publish_tombstone(_SUBJECT.scope_key, reason="cs-20")
    denied_child = await _write(store, fence, _CHILD, "after-child")
    exported = await store.export_subject(_SUBJECT)
    cases.append(
        SuiteCase(
            "write-denied-after-ancestor-tombstone",
            not denied_child.committed
            and not exported.records
            and await store.confirm_absent(_SUBJECT),
            f"child={_CHILD.scope_key} tombstone={_SUBJECT.scope_key}",
        )
    )

    cases.append(await _concurrency_case(store_factory, fence_factory))
    return SuiteReport(
        participant_id=participant_id, subject=_SUBJECT, cases=tuple(cases)
    )