Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 56 additions & 15 deletions backend/app/source_post_voice_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING
from uuid import uuid4

if TYPE_CHECKING:
import asyncpg
Expand Down Expand Up @@ -88,9 +89,46 @@ async def persist_additional_voice_assignment(
truth_status_code: str,
evidence_post_id: str,
) -> None:
"""Atomically bind one additional Voice to an authorized evidence post."""
assignment_iri = str(LW[f"voice-assignment/{post_id}/{voice_type_code}"])
"""Retain cutoff history when authorized additional Voice evidence changes."""
async with conn.transaction():
# Imported-primary changes hold this same row lock. Read the current
# interval only after acquiring it, including when this write waited.
await conn.execute(
"select post_id from source_post where post_id = $1::uuid for update",
post_id,
)
current = await conn.fetchrow(
"""
select voice.is_primary, voice.truth_status_code,
evidence.node_id as evidence_post_id
from source_post_voice voice
left join provenance_assertion assertion
on assertion.assertion_id = voice.provenance_assertion_id
and assertion.relation_code = 'prov_was_derived_from'
left join provenance_resource_binding evidence
on evidence.resource_id = assertion.object_resource_id
and evidence.node_type_code = 'node_post'
where voice.post_id = $1::uuid and voice.voice_type_code = $2
and voice.effective_to is null
""",
post_id,
voice_type_code,
)
if current is not None:
if current["is_primary"]:
raise PrimaryVoiceAssignmentError(
"the imported primary Voice cannot be changed through the additional-voice path"
)
if (
current["truth_status_code"] == truth_status_code
and str(current["evidence_post_id"]) == evidence_post_id
):
return
change_at = await conn.fetchval("select clock_timestamp()")
assignment_id = uuid4()
assignment_iri = str(
LW[f"voice-assignment/{post_id}/{voice_type_code}/{assignment_id}"]
)
evidence_resource_id = await _post_resource_id(conn, evidence_post_id)
assignment_resource_id = await conn.fetchval(
"""
Expand Down Expand Up @@ -139,28 +177,31 @@ async def persist_additional_voice_assignment(
)
if assertion_id is None:
raise RuntimeError("Voice evidence derivation was not persisted")
stored = await conn.fetchrow(
await conn.execute(
"""
update source_post_voice
set effective_to = $3
where post_id = $1::uuid and voice_type_code = $2
and effective_to is null and not is_primary
""",
post_id,
voice_type_code,
change_at,
)
await conn.execute(
"""
insert into source_post_voice
(post_id, voice_type_code, is_primary, truth_status_code,
(voice_assignment_id, post_id, voice_type_code, is_primary, truth_status_code,
provenance_assertion_id, effective_from, recorded_at)
values ($1::uuid, $2, false, $3, $4::uuid, now(), now())
on conflict (post_id, voice_type_code) where effective_to is null do update
set truth_status_code = excluded.truth_status_code,
provenance_assertion_id = excluded.provenance_assertion_id,
recorded_at = now()
where not source_post_voice.is_primary
returning voice_type_code
values ($6::uuid, $1::uuid, $2, false, $3, $4::uuid, $5, $5)
""",
post_id,
voice_type_code,
truth_status_code,
assertion_id,
change_at,
assignment_id,
)
if stored is None:
raise PrimaryVoiceAssignmentError(
"the imported primary Voice cannot be changed through the additional-voice path"
)


__all__ = ["PrimaryVoiceAssignmentError", "persist_additional_voice_assignment"]
43 changes: 43 additions & 0 deletions docs/adr/0256-evidence-bearing-voice-combinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,49 @@ compound lookup codes.

## Data model

### Amendment: preserve revisions of additional evidence (2026-09-05)

Status: proposed amendment; protected integration and release acceptance pending.

In the context of correcting an additional Voice's recorded evidence, facing
loss of earlier cutoff states, we decided for serialized half-open assignment
revisions and against overwriting the current row or rejecting all corrections,
to achieve auditable historical truth and derivation evidence, accepting one
additional persisted interval per material correction and per-Post lock waits.

An authorized repeat write may change an additional Voice's truth state or
derivation evidence. Updating its current row in place destroys the earlier
cutoff view. Serialize these writes with imported-primary changes by locking
the carrying `source_post` row before reading the current assignment. An exact
repeat of the same truth state and bound evidence Post is a no-op. Otherwise,
close the existing additional interval and insert a new assignment at the same
database `clock_timestamp()`, read after the lock is acquired. Retain the old
truth state, assertion, start, and recorded time. A primary conflict fails
before writing provenance. Any failure rolls back the entire replacement.

Each new additional interval uses its existing `voice_assignment_id` UUID in
its canonical PROV Entity IRI, under
`voice-assignment/{post_id}/{voice_type_code}/{voice_assignment_id}`. Earlier
IRIs and assertions remain unchanged. Reusing a post/code-only Entity for a
later interval would merge distinct derivations; overwriting or rejecting all
authorized corrections would respectively erase history or remove the existing
upsert capability. Neither alternative satisfies the cutoff contract.

This reuses migration 0237/0243 identities and half-open intervals; it adds no
schema, Voice code, inference, or release number. Public payload shapes and
evidence authorization stay unchanged. PostgreSQL row locking and the database
clock supply ordering; no application timestamp repair is permitted. Historical
states already overwritten before this amendment remain unavailable. Synthetic
PostgreSQL tests must prove correction, exact retry, rollback, concurrent writes,
primary protection, and distinct persisted PROV derivations. Authenticated API
and rendered UI acceptance remain separate requirements.

Authority: [PostgreSQL transaction isolation](https://www.postgresql.org/docs/18/transaction-iso.html)
and [W3C PROV-O derivation](https://www.w3.org/TR/prov-o/#wasDerivedFrom),
alongside ADR 0252's existing database-clock and interval contract. These sources
support concurrency and provenance semantics, not stakeholder classification or
population inference.

```mermaid
classDiagram
class SourcePost {
Expand Down
Loading
Loading