Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
17b25b3
feat(ontology): govern complete Voice-of-X taxonomy (ADR 0232)
seonghobae Aug 26, 2026
09838e1
fix(ontology): separate post voices from organization relations
Aug 26, 2026
fea73e1
docs: align Voice-of-X changelog vocabulary
Aug 26, 2026
cc3dfc1
docs(ontology): record Voice-of-X composition gap
seonghobae Aug 26, 2026
dce623a
feat(ontology): persist evidence-bearing voice combinations
seonghobae Aug 26, 2026
8b8a9be
docs(gap): track stacked voice-combination delivery
seonghobae Aug 26, 2026
461a4d1
fix(db): enforce Voice-of-X lookup category
seonghobae Aug 26, 2026
118cc38
feat(api): expose authorized Voice-of-X combinations
seonghobae Aug 26, 2026
771a8ed
fix(db): validate Voice assignment truth state
seonghobae Aug 26, 2026
182f5b8
feat: publish qualified voice evidence
seonghobae Aug 26, 2026
0a8ce31
docs: audit combined voice evidence scene
seonghobae Aug 26, 2026
b0f6ca4
fix: preserve combined voice behavior
Aug 26, 2026
684df3d
fix: preserve combined voice cutoff semantics
seonghobae Aug 26, 2026
e7816ce
feat(ui): localize all Voice labels
seonghobae Aug 26, 2026
90b3999
fix(ontology): retain visible post voices
seonghobae Aug 26, 2026
52bb01b
docs(adr): reserve Voice combinations as 0251
seonghobae Aug 26, 2026
e3ecb3b
feat(voice): add governed evidence assignments
seonghobae Aug 26, 2026
edc0ab9
feat(ui): show recorded Voice perspectives
seonghobae Aug 26, 2026
991753d
fix: preserve Voice assignment evidence states
Aug 26, 2026
d206fd8
fix: preserve Voice evidence provenance
seonghobae Aug 26, 2026
9db158c
docs: refresh Voice delivery evidence
seonghobae Aug 26, 2026
9c0a3e1
feat(ui): author evidence-backed Voice perspectives
seonghobae Aug 26, 2026
850494c
fix(frontend): localize expanded voice filters
Aug 26, 2026
021cc75
Merge remote-tracking branch 'origin/feat/voice-of-x-complete-taxonom…
seonghobae Aug 26, 2026
f0d072a
docs: refresh Voice delivery evidence
seonghobae Aug 26, 2026
e4d72e4
fix: expose full Voice catalog for authoring
seonghobae Aug 26, 2026
48af574
docs: record Voice authoring catalog gap closure
seonghobae Aug 26, 2026
2644fbb
fix(frontend): align Regulator Voice fixtures
seonghobae Aug 26, 2026
3ad9d74
docs: align Voice evidence terminology
seonghobae Aug 26, 2026
c6a4c26
test: prove authenticated Voice persistence
seonghobae Aug 26, 2026
34f8d10
docs: record authenticated Voice evidence
seonghobae Aug 26, 2026
55a5b27
fix(frontend): filter exact ontology IRIs
seonghobae Aug 26, 2026
bf35587
fix(frontend): retain canonical Voice assignment IRIs
seonghobae Aug 26, 2026
11b7fff
docs: record exact JSON-LD filtering evidence
seonghobae Aug 26, 2026
4aab6e5
fix(frontend): match canonical ontology encoding
seonghobae Aug 26, 2026
bb2eec1
docs: align Voice implementation head
seonghobae Aug 26, 2026
d738bd0
docs: reconcile downstream Voice stack
seonghobae Aug 26, 2026
dde0a83
fix: label Voice source navigation accurately
seonghobae Aug 26, 2026
3e18e7c
docs: refresh Voice exact-head evidence
seonghobae Aug 26, 2026
c731706
feat: expose authorized Voice derivation evidence
seonghobae Aug 26, 2026
3db9c44
fix: preserve paged Voice JSON-LD evidence
seonghobae Aug 26, 2026
ebb4ef1
docs: record Voice evidence paging head
seonghobae Aug 26, 2026
d5fe482
test: reuse migrated Voice vocabulary
seonghobae Aug 26, 2026
b2e0c96
docs: refresh Voice gate evidence
seonghobae Aug 26, 2026
3e332fd
docs: distinguish Voice cutoff from history
seonghobae Aug 26, 2026
201ac4e
fix: retain primary Voice assignment history
Aug 26, 2026
7a259a4
fix: keep Voice migration replay conflict-free
Aug 26, 2026
c293323
feat(ontology): preserve temporal imported primary Voice intervals (A…
seonghobae Aug 27, 2026
11fc2fa
docs(adr): renumber Voice combinations after collision
Aug 27, 2026
ad6dc5d
Merge protected main into Voice combinations stack
Aug 27, 2026
474dfdf
feat(ontology): preserve temporal imported primary Voice intervals (A…
seonghobae Aug 27, 2026
4b48c55
test(db): restore Voice migration fixture syntax
Aug 27, 2026
0ff51f8
fix(db): keep Voice history replay-safe
Aug 27, 2026
f778a01
Merge commit '4b48c5594' into codex/pr752-repair
Aug 27, 2026
b0eedab
test(db): prove replay after Voice history change
Aug 27, 2026
d0f6e7e
Merge commit 'c293323e79069ee6a7969533c4daf22b6ae0a8c5' into codex/pr…
Aug 27, 2026
123fdb7
fix(db): assign Voice history a unique migration number
Aug 27, 2026
05f5bf6
feat(ontology): promote temporal Voice history onto main
Aug 27, 2026
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
16 changes: 12 additions & 4 deletions backend/app/ontology_neighborhood_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,10 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any
"is_primary": assignment.is_primary,
"truth_status_code": assignment.truth_status_code,
"recorded_at": assignment.recorded_at.isoformat(),
"effective_from": assignment.effective_from.isoformat(),
"effective_to": assignment.effective_to.isoformat()
if assignment.effective_to
else None,
"provenance_reference": assignment.provenance_reference,
"evidence_post_id": assignment.evidence_post_id,
}
Expand All @@ -890,6 +894,7 @@ async def _load_voice_assignments(
"""
select voice.post_id, voice.voice_type_code, lookup.lookup_label, voice.is_primary,
voice.truth_status_code, voice.recorded_at,
voice.effective_from, voice.effective_to,
case when evidence.node_id = any($1::uuid[]) then evidence.node_id end
as evidence_post_id
from source_post_voice voice
Expand All @@ -903,10 +908,11 @@ async def _load_voice_assignments(
and evidence.node_type_code = 'node_post'
where voice.post_id = any($1::uuid[])
and (voice.is_primary or evidence.node_id = any($1::uuid[]))
and (($2::timestamptz is null and voice.effective_to is null)
or ($2::timestamptz is not null
and voice.effective_from <= $2
and (voice.effective_to is null or $2 < voice.effective_to)))
and voice.effective_from <= coalesce($2::timestamptz, $3::timestamptz)
and (
voice.effective_to is null
or coalesce($2::timestamptz, $3::timestamptz) < voice.effective_to
)
Comment on lines +911 to +915

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: No-cutoff read keyed to snapshot_at

When knowledge_cutoff is null the predicate now keys off snapshot_at rather than effective_to is null. For a fresh live read the two agree, but a voice with effective_from after the frozen snapshot is now dropped. This matches the ADR's snapshot-consistency goal for paged continuation, and half-open intervals still yield exactly one primary.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

and voice.recorded_at <= $3::timestamptz
order by voice.post_id, voice.is_primary desc,
lookup.display_order, voice.voice_type_code
Expand Down Expand Up @@ -936,6 +942,8 @@ async def _load_voice_assignments(
if not row["is_primary"]
else "Imported primary voice"
),
effective_from=row["effective_from"],
effective_to=row["effective_to"],
evidence_post_id=(
str(row["evidence_post_id"])
if row["evidence_post_id"] is not None
Expand Down
97 changes: 97 additions & 0 deletions docs/adr/0252-temporal-primary-voice-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# ADR 0252: Temporal history for imported primary Voice

## Status

Accepted (2026-08-27). Extends ADR 0251 and closes issue #748.

## Context

ADR 0251 records when a Voice assignment starts, but migration 0237 deletes
the former imported primary when `source_post.voc_type_code` changes. The live
value is honest, yet an authorized knowledge-cutoff read after that update can
no longer recover the primary that was effective at the cutoff. The existing
`(post_id, voice_type_code)` key also cannot represent A → B → A.

OWL-Time distinguishes instants from intervals and gives an interval explicit
beginning and end bounds. PostgreSQL range types and exclusion constraints are
the native database mechanism for rejecting overlapping periods. Neither
source supplies a missing business-effective instant, so LineageWeave must not
invent one: an imported change becomes effective at the database transaction
instant when no source change instant exists.

## Decision

- Keep `source_post_voice` as the normalized assignment relation. Add nullable
`effective_to`; each row is a half-open interval
`[effective_from, effective_to)`. Null means current.
- Change the key to `(post_id, voice_type_code, effective_from)`, allowing the
same atomic Voice to recur in non-overlapping periods.
- Use PostgreSQL GiST exclusion constraints to reject overlapping primary
intervals for one Post. A partial unique index also permits at most one
current row for a `(post_id, voice_type_code)` pair.
- When the imported primary changes, one trigger transaction closes both the
current primary and any current additional assignment for the incoming
Voice, then inserts the new observed primary at one trigger-execution
timestamp. PostgreSQL `clock_timestamp()` is read after the source-row lock
is acquired, so a waiting concurrent update cannot backdate its interval to
the earlier statement start. It never overwrites or fabricates the former
interval.
- Live reads select `effective_to is null`. Cutoff reads select the row whose
interval contains the cutoff. Ontology continuation reads use their frozen
`snapshot_at` when no knowledge cutoff was requested, so a page minted
before a change cannot silently switch to the new primary.
- Existing rows migrate as open intervals. Migration replay changes neither
their starts nor their history. History before ADR 0252 remains unavailable
because the deleted facts cannot be reconstructed honestly.
- This is valid-time history for a source assignment, not psychometric or
mathematical modeling. No weight, confidence, inference, or new Voice code
is introduced.

## Data model

```mermaid
classDiagram
class SourcePost {
uuid post_id
text voc_type_code
}
class SourcePostVoice {
uuid post_id
text voice_type_code
boolean is_primary
timestamptz effective_from
timestamptz effective_to
timestamptz recorded_at
}
SourcePost "1" --> "1..*" SourcePostVoice
```

```mermaid
sequenceDiagram
participant Import
participant SourcePost
participant VoiceHistory
Import->>SourcePost: update primary A to B
SourcePost->>VoiceHistory: close current A after source-row lock
SourcePost->>VoiceHistory: close current additional B, if present
SourcePost->>VoiceHistory: insert observed primary B at same instant
VoiceHistory-->>Import: one non-overlapping current primary
```

## Consequences

- A → B → A is auditable without copying source content or exposing real
identifiers.
- Half-open bounds assign the exact change instant to the new primary and avoid
double matches.
- The exclusion constraint adds a GiST index and write-time check. This table
is bounded by Voice assignments per Post; partitioning is not warranted
until observed volume or lock evidence shows otherwise.

## References

Cox, S. J. D., & Little, C. (2022). *Time ontology in OWL*. World Wide
Web Consortium. https://www.w3.org/TR/owl-time/

PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation:
Range types*. https://www.postgresql.org/docs/18/rangetypes.html
4 changes: 3 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ decision from them.

| Supporting document | Normative ADR |
|---|---|
| [`product-requirements.md`](../product-requirements.md) | Product requirements projection across the ADR set; ADRs remain normative |
| [`product-requirements.md`](../product-requirements.md) | Product requirements projection across the ADR set; ADRs remain normative, including [0252](0252-temporal-primary-voice-history.md) |
| [`product-technical-gap-baseline.md`](../product-technical-gap-baseline.md) | Product/technical traceability projection across the ADR set; ADRs remain normative |
| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md), [0238](0238-source-conversation-turn-import-contract.md) |
| [`voice-combination-technical-requirements.md`](../voice-combination-technical-requirements.md) | [0246](0246-expanded-voice-of-x-post-taxonomy.md), [0251](0251-evidence-bearing-voice-combinations.md), [0252](0252-temporal-primary-voice-history.md) |
| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md) |
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate research-notes row in ADR map

The supporting-document map now lists lineage-bi-research-notes.md twice. The added copy duplicates the existing row but drops the 0238 reference, leaving two conflicting entries for one document.

Suggested change
| [`voice-combination-technical-requirements.md`](../voice-combination-technical-requirements.md) | [0246](0246-expanded-voice-of-x-post-taxonomy.md), [0251](0251-evidence-bearing-voice-combinations.md), [0252](0252-temporal-primary-voice-history.md) |
| [`lineage-bi-research-notes.md`](../lineage-bi-research-notes.md) | [0084](0084-lineage-research-grounding.md), [0062](0062-semantic-unit-embedding.md), [0064](0064-lineage-evidence-and-tree-assembly.md), [0024](0024-rankweave-fusion-fail-closed.md), [0165](0165-quantity-script-display.md), [0167](0167-rankweave-ranking-channel-evidence.md), [0169](0169-ask-batched-lineage-graph.md), [0172](0172-event-lineage-channel-evidence.md), [0202](0202-ask-event-time-filter.md), [0223](0223-explicit-semantic-content-unit-kinds.md) |
| [`voice-combination-technical-requirements.md`](../voice-combination-technical-requirements.md) | [0246](0246-expanded-voice-of-x-post-taxonomy.md), [0251](0251-evidence-bearing-voice-combinations.md), [0252](0252-temporal-primary-voice-history.md) |
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

| [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) |
| [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) |
| [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0207](0207-repository-case-ontology-namespace-canonical.md), [0157](0157-public-ontology-namespace-identity.md) |
Expand Down
60 changes: 60 additions & 0 deletions docs/voice-combination-technical-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Voice-of-X Combination Technical Requirements

This supporting TRD projects ADR 0246, ADR 0251, and ADR 0252. Those ADRs are
normative when this document and an implementation differ.

## Scope

LineageWeave represents a Post's explicitly supplied stakeholder perspectives
without assuming a company, B2B2C chain, or exhaustive industry taxonomy. One
imported primary Voice and zero or more evidence-bearing additional Voices are
atomic assignments; combinations are sets of rows, never compound codes.

## Requirements

| ID | Requirement | Verification |
|---|---|---|
| VOC-TR-1 | `source_post.voc_type_code` owns the imported primary; additional assignments cannot demote it | Database trigger and API conflict tests |
| VOC-TR-2 | Every additional Voice references a normalized PROV-O derivation and governed truth status | Foreign keys, category trigger, authenticated write test |
| VOC-TR-3 | Primary assignments use non-overlapping half-open effective intervals and allow A → B → A under serialized concurrent source updates | GiST exclusion constraint and PostgreSQL integration tests |
| VOC-TR-4 | Live reads select current rows; cutoff reads select the containing interval; ontology continuation uses its frozen snapshot when no cutoff exists | Backend SQL-contract tests and authenticated cutoff API test |
| VOC-TR-5 | Post, filter, ontology JSON-LD, exact-value CSV, and UI apply the same RBAC/ABAC and source-eligibility boundary | API, SHACL, frontend interaction, and accessibility tests |
| VOC-TR-6 | Voice stays separate from counterparty relationship, role, topic, channel, lifecycle, and stakeholder salience | ADR/schema review and ontology round-trip tests |
| VOC-TR-7 | Migration replay preserves existing starts and never reconstructs deleted pre-migration history | Migration replay test and non-identifying runtime evidence |

## Read contract

```text
reference_time = knowledge_cutoff ?? ontology_snapshot ?? live
live = effective_to IS NULL
historical = effective_from <= reference_time < effective_to
open historical = effective_from <= reference_time AND effective_to IS NULL
```

The interval is lower-inclusive and upper-exclusive. The exact primary-change
instant belongs to the new primary, so a read cannot return two primary rows.

## Component flow

```mermaid
flowchart LR
Import[Authorized source import] --> SourcePost[(source_post)]
SourcePost --> Trigger[Primary Voice sync trigger]
Trigger --> History[(source_post_voice intervals)]
Admin[post_admin + visible evidence] --> API[Voice assignment API]
API --> Provenance[(PROV-O assertion)]
Provenance --> History
History --> PostRead[Post and filters]
History --> Ontology[Ontology JSON-LD and CSV]
PostRead --> UI[Post and board UI]
Ontology --> Explorer[Ontology explorer]
```

## Failure behavior

- Missing or hidden evidence rejects or omits the additional assignment; it is
never replaced with a placeholder.
- Unknown Voice/truth categories fail with a database check error.
- Overlapping imported-primary intervals fail at the database boundary.
- Cutoffs before retained history return an explicit unavailable state rather
than the current value.
51 changes: 33 additions & 18 deletions lineageweave/ontology_neighborhood.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ class OntologyVoiceAssignment:
is_primary: bool
truth_status_code: str
recorded_at: datetime
effective_from: datetime
provenance_reference: str
effective_to: datetime | None = None
evidence_post_id: str | None = None

def __post_init__(self) -> None:
Expand All @@ -260,6 +262,16 @@ def __post_init__(self) -> None:
raise OntologyNeighborhoodError(
"naive_timestamp", "voice assignment recorded_at must be offset-aware"
)
if self.effective_from.tzinfo is None or (
self.effective_to is not None and self.effective_to.tzinfo is None
):
raise OntologyNeighborhoodError(
"naive_timestamp", "voice assignment effective bounds must be offset-aware"
)
if self.effective_to is not None and self.effective_from >= self.effective_to:
raise OntologyNeighborhoodError(
"invalid_interval", "voice assignment effective interval is empty or inverted"
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -324,8 +336,10 @@ def exact_value_rows(self) -> tuple[dict[str, str], ...]:
"target_type_code": "node_voice_type",
"truth_status_code": assignment.truth_status_code,
"recorded_at": assignment.recorded_at.isoformat(),
"valid_from": "",
"valid_to": "",
"valid_from": assignment.effective_from.isoformat(),
"valid_to": assignment.effective_to.isoformat()
if assignment.effective_to
else "",
"evidence_count": "1" if assignment.evidence_post_id or assignment.is_primary else "0",
"evidence_post_id": assignment.evidence_post_id or (
assignment.post_id if assignment.is_primary else ""
Expand Down Expand Up @@ -414,23 +428,24 @@ def jsonld_document(self) -> dict[str, object]:
if evidence_iri is not None
else {}
)
graph.append(
{
"@id": assignment_iri,
"@type": str(LW.VoiceAssignment),
str(LW.assignedVoiceType): {"@id": assignment.voice_type_iri},
str(LW.primaryVoiceAssignment): {
"@value": assignment.is_primary,
"@type": "xsd:boolean",
},
**provenance,
"lw:truthStatus": assignment.truth_status_code,
"prov:generatedAtTime": {
"@value": assignment.recorded_at.isoformat(),
"@type": "xsd:dateTimeStamp",
},
}
item: dict[str, object] = {
"@id": assignment_iri,
"@type": str(LW.VoiceAssignment),
str(LW.assignedVoiceType): {"@id": assignment.voice_type_iri},
str(LW.primaryVoiceAssignment): {
"@value": assignment.is_primary,
"@type": "xsd:boolean",
},
**provenance,
"lw:truthStatus": assignment.truth_status_code,
}
_add_jsonld_times(
item,
assignment.recorded_at,
assignment.effective_from,
assignment.effective_to,
)
graph.append(item)
graph.append(
{
"@id": assignment.voice_type_iri,
Expand Down
5 changes: 3 additions & 2 deletions migrations/0237_source_post_voice_combination.sql
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ update source_post_voice voice

insert into source_post_voice
(post_id, voice_type_code, is_primary, truth_status_code, effective_from)
select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at
select post.post_id, post.voc_type_code, true, 'truth_observed',
least(post.created_at, clock_timestamp())
from source_post post
where not exists (
select 1 from source_post_voice voice
Expand Down Expand Up @@ -159,7 +160,7 @@ begin
new.voc_type_code,
true,
'truth_observed',
case when tg_op = 'INSERT' then new.created_at else change_at end
case when tg_op = 'INSERT' then least(new.created_at, change_at) else change_at end
)
on conflict (post_id, voice_type_code) where effective_to is null do update
set is_primary = true,
Expand Down
57 changes: 57 additions & 0 deletions migrations/0243_source_post_voice_history.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-- ADR 0252: preserve non-overlapping imported primary Voice intervals.

begin;

create extension if not exists btree_gist;

alter table source_post_voice
add column if not exists effective_to timestamptz;

alter table source_post_voice
drop constraint if exists source_post_voice_effective_interval_check;
alter table source_post_voice
add constraint source_post_voice_effective_interval_check
check (effective_to is null or effective_from < effective_to);

create unique index if not exists source_post_voice_current_pair_idx
on source_post_voice (post_id, voice_type_code)
where effective_to is null;
Comment on lines +16 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Duplicate current-pair unique index

The new migration re-adds effective_to and a unique index on (post_id, voice_type_code) where effective_to is null, which source_post_voice_current_type_idx in the earlier migration already provides. The result is two identical unique indexes under different names, adding redundant write-time cost.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


alter table source_post_voice
drop constraint if exists source_post_voice_primary_period_excl;
alter table source_post_voice
add constraint source_post_voice_primary_period_excl
exclude using gist (
post_id with =,
tstzrange(effective_from, effective_to, '[)') with &&
) where (is_primary);

create or replace function synchronize_source_post_primary_voice()
returns trigger
language plpgsql
as $$
declare
change_at timestamptz := clock_timestamp();
begin
update source_post_voice
set effective_to = change_at
where post_id = new.post_id
and effective_to is null
and (is_primary or voice_type_code = new.voc_type_code);

insert into source_post_voice
(post_id, voice_type_code, is_primary, truth_status_code,
effective_from, recorded_at)
values (
new.post_id,
new.voc_type_code,
true,
'truth_observed',
case when tg_op = 'INSERT' then least(new.created_at, change_at) else change_at end,
change_at
);
Comment on lines +36 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: 0243 trigger replaces 0237's upsert with close-then-insert

Sorted migration order makes 0243's synchronize_source_post_primary_voice the effective definition, dropping the ON CONFLICT upsert. It closes every current primary and any current row matching the incoming voice before a plain insert, which keeps both the current-pair unique index and the primary exclusion constraint satisfied and allows A to B to A.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return new;
end;
$$;

commit;
1 change: 1 addition & 0 deletions tests/test_ontology_neighborhood.py
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None:
is_primary=False,
truth_status_code=TRUTH_OBSERVED,
recorded_at=T0,
effective_from=T0,
provenance_reference="Evidence-backed additional voice",
evidence_post_id=POST_ID,
)
Expand Down
Loading
Loading