From 17b25b36b537bdaea27e74cf3bebf0a4afa1496c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 22:43:05 +0900 Subject: [PATCH 01/53] feat(ontology): govern complete Voice-of-X taxonomy (ADR 0232) Expand voc_type from five to twelve post types (Supplier, Employee, Business, Regulator, Investor, Society, Process added) grounded in stakeholder identification theory and quality-engineering practice, and mirror six new counterparty relationship codes so any post class can type each named organization. Ontology SKOS concepts/object properties, idempotent migration 0222, classifier vocabulary + prompt, round-trip and parser tests stay in sync. --- CHANGELOG.md | 9 ++ docs/adr/0232-complete-voice-of-x-taxonomy.md | 136 ++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 100 +++++++++++-- .../entity_relationship_classification.py | 56 ++++++-- .../0222_voice_of_x_complete_taxonomy.sql | 39 +++++ ...test_entity_relationship_classification.py | 46 +++++- tests/test_ontology.py | 111 +++++++++++--- 7 files changed, 457 insertions(+), 40 deletions(-) create mode 100644 docs/adr/0232-complete-voice-of-x-taxonomy.md create mode 100644 migrations/0222_voice_of_x_complete_taxonomy.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 641306055..26229a076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project are documented here. Format follows ### Added +- Complete general Voice-of-X taxonomy (ADR 0232): the governed `voc_type` + scheme expands from five to twelve post types -- adding Voice of Supplier, + Employee, Business, Regulator, Investor, Society, and Process, grounded in + stakeholder identification theory and quality-engineering measurement + practice -- and mirrors six new counterparty relationship codes + (`rel_voe`..`rel_vops`) so any post class can type each organization it + names. Ontology SKOS concepts and object properties, idempotent migration + 0222, and the entity-relationship classification prompt stay in round-trip + sync. - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/docs/adr/0232-complete-voice-of-x-taxonomy.md b/docs/adr/0232-complete-voice-of-x-taxonomy.md new file mode 100644 index 000000000..82d989942 --- /dev/null +++ b/docs/adr/0232-complete-voice-of-x-taxonomy.md @@ -0,0 +1,136 @@ +# ADR 0232: Complete general Voice-of-X taxonomy for post types and counterparty relationships + +## Status + +Accepted (2026-08-26). Extends ADR 0207 decision 8 (the then-governed +five-value `voc_type` scheme) and the six-code `entity_relationship_type` +vocabulary; supersedes the "vos is relationship-only" asymmetry. + +## Context + +`source_post.voc_type_code` classifies what a post records, and +`post_counterparty_entity.relationship_type_code` classifies each named +organization's relation to the post author's org. Both draw on a shared +"Voice of X" mental model, but until now only five post types +(`voc`, `vocc`, `voco`, `vom`, `vop`) and six relationship codes +(`rel_voc`, `rel_vocc`, `rel_voco`, `rel_vom`, `rel_vop`, `rel_vos`) +were governed. Real correspondence sources speak with more distinct +voices than those five: staff write about their own workplace, regulators +issue notices, investors comment, communities and media react, suppliers +answer (previously expressible only as a *relationship*, never as a post +type), internal business units report, and automated systems emit process +signal. A closed five-value scheme forced every such record into the +nearest of five buckets or blocked import. + +## Decision + +Adopt the complete stakeholder-voice taxonomy as the governed vocabulary, +grounded in stakeholder identification theory and quality-engineering +measurement practice: + +1. **Stakeholder classes** (Freeman, 1984; Mitchell, Agle, & Wood, 1997) + enumerate who can hold a voice toward an organization: customers, + employees, owners/investors, suppliers, government/regulators, + communities/society, competitors, partners. Each class that can author + correspondence becomes one `voc_type` code. +2. **Quality-engineering voices** add the two non-personal voices Six + Sigma measurement practice recognizes alongside Voice of the Customer: + Voice of the Business (internal management) and Voice of the Process + -- the system speaking through its own signal (Shewhart, 1931; + Deming, 1986). +3. **Customer-chain depth** keeps the B2B2C chain extension already + governed (`vocc`, one hop down the chain per Griffin & Hauser's + (1993) need-interrogation practice of following the use chain). +4. **Market** stays the aggregate residual voice not attributable to one + named party (`vom`). + +The complete `voc_type` set becomes twelve codes: + +| Code | Label | Grounding | +| --- | --- | --- | +| `voc` | Voice of Customer | Griffin & Hauser (1993) | +| `vocc` | Voice of Customer's Customer | chain extension of (1) | +| `voco` | Voice of Competitor | Freeman (1984): competitors | +| `vom` | Voice of Market | aggregate residual signal | +| `vop` | Voice of Partner | Freeman (1984): partners/alliances | +| `vos` | Voice of Supplier | Freeman (1984): suppliers | +| `voe` | Voice of Employee | Heskett et al. (1994), service-profit chain | +| `vob` | Voice of Business | internal-management voice (quality practice) | +| `vor` | Voice of Regulator | Mitchell et al. (1997): government/regulatory salience | +| `voi` | Voice of Investor | Freeman (1984): owners/stockholders | +| `voso` | Voice of Society | Mitchell et al. (1997): community/media stakeholders | +| `vops` | Voice of Process | Shewhart (1931); Deming (1986): process signal | + +Mirror each new voice class in `entity_relationship_type` so a post of +any voice class can still type its named counterparties: + +| Code | Meaning for the named organization | +| --- | --- | +| `rel_voe` | employee-voice signal involving this organization | +| `rel_vob` | internal-business-unit signal involving this organization | +| `rel_vor` | this organization regulates the post author's org | +| `rel_voi` | this organization invests in / holds capital in the author's org | +| `rel_voso` | community/society-level signal involving this organization | +| `rel_vops` | process/system-generated signal involving this organization | + +`rel_vos` already existed and now has its matching post type `vos`. + +Deliberate non-proliferation (documented, not invented): + +- **Reseller/channel intermediaries stay under Partner.** Distribution + economics distinguishes resellers from strategic alliances, but both + are "organizations the author works through"; minting a separate code + would duplicate evidence without changing any downstream decision. +- **Prospects and churned customers stay under Customer.** In Voice-of- + Customer practice lost and prospective buyers are customers' voices at + different lifecycle stages (Griffin & Hauser, 1993 sample across + current and lost users), not a different stakeholder class. +- **End-user vs buyer within a customer account stays under Customer** + plus existing person-level Keyman structure; the account-level voice + class does not change. + +Codes remain globally unique lowercase literals in +`common_lookup_value.lookup_code`; relationship codes keep the `rel_` +prefix because bare `voc`-style codes are claimed by the post-type +category itself. + +## Consequences + +- `migrations/0222_voice_of_x_complete_taxonomy.sql` seeds both + categories idempotently (`ON CONFLICT ... DO UPDATE`, scoped by + category, mirroring migration 0042). +- `docs/ontology/lineageweave-kg.ttl` declares seven more SKOS concepts + under `:postTypeScheme` and six more `owl:ObjectProperty` relationship + projections; the round-trip test in `tests/test_ontology.py` reads the + new migration file and no longer asserts the five-value cap or the + "vos is never a post type" invariant. +- `lineageweave/entity_relationship_classification.py` extends the valid + code set and the classification prompt; unknown codes are still + dropped, never guessed. +- Existing rows and stored data are untouched: all seven new codes are + additive, display orders 5-11 follow the seeded order, and no code is + renamed or retired. +- The API resolves labels from `common_lookup_value`, so filter options + and labels flow to clients without frontend changes. + +## References + +Deming, W. E. (1986). *Out of the crisis*. MIT Press. + +Freeman, R. E. (1984). *Strategic management: A stakeholder approach*. +Pitman. + +Griffin, A., & Hauser, J. R. (1993). The voice of the customer. +*Marketing Science, 12*(1), 1-27. https://doi.org/10.1287/mksc.12.1.1 + +Heskett, J. L., Jones, T. O., Loveman, G. W., Sasser, W. E., & +Schlesinger, L. A. (1994). Putting the service-profit chain to work. +*Harvard Business Review, 72*(2), 164-174. + +Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of +stakeholder identification and salience: Defining the principle of who +and what really counts. *Academy of Management Review, 22*(4), 853-886. +https://doi.org/10.5465/amr.1997.9711022105 + +Shewhart, W. A. (1931). *Economic control of quality of manufactured +product*. D. Van Nostrand. diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 0aeb57f9a..1eaeeaade 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -56,7 +56,7 @@ :Post a owl:Class ; rdfs:label "Post" ; - rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, or Partner." ; + rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, Partner, Supplier, Employee, Business, Regulator, Investor, Society, or Process (ADR 0232)." ; :lookupCode "node_post" . :Person a owl:Class ; @@ -200,6 +200,45 @@ rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . +# ADR 0232 mirrors the complete stakeholder-voice taxonomy in the +# counterparty vocabulary so a post of ANY voice class can still type +# each organization it names. +:hasVoeRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Employee relationship" ; + rdfs:comment "An employee-voice signal involving this organization (rel_voe)." ; + :lookupCode "rel_voe" . + +:hasVobRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Business relationship" ; + rdfs:comment "An internal-business-unit signal involving this organization (rel_vob)." ; + :lookupCode "rel_vob" . + +:hasVorRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Regulator relationship" ; + rdfs:comment "This organization regulates the post author's organization (rel_vor)." ; + :lookupCode "rel_vor" . + +:hasVoiRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Investor relationship" ; + rdfs:comment "This organization invests in / holds capital in the post author's organization (rel_voi)." ; + :lookupCode "rel_voi" . + +:hasVosoRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Society relationship" ; + rdfs:comment "A community/society-level signal involving this organization (rel_voso)." ; + :lookupCode "rel_voso" . + +:hasVopsRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; + rdfs:label "has Voice-of-Process relationship" ; + rdfs:comment "A process/system-generated signal involving this organization (rel_vops)." ; + :lookupCode "rel_vops" . + ################################################################# # Datatype properties -- node attribute projections. # @@ -275,17 +314,19 @@ ################################################################# # SKOS -- voc_type (post type classification) # -# The five-value VOC source vocabulary migrations/0042 governs. There -# are exactly five seeded codes: vos exists only as a relationship type -# (rel_vos above), never as a post type, so no Voice-of-Supplier concept -# belongs here. Adding "voc_type" to the ontology-covered categories -# puts these codes under tests/test_ontology.py's round-trip check -- -# closing the previously documented expected gap. +# The complete stakeholder-voice vocabulary ADR 0232 governs: +# migrations/0042 seeds the original five codes and migrations/0222 +# seeds the seven additions, so every stakeholder class that can author +# correspondence (Freeman, 1984; Mitchell et al., 1997) plus the two +# quality-engineering voices (Voice of Business; Voice of Process, +# Shewhart, 1931) has exactly one concept here. Adding "voc_type" to +# the ontology-covered categories puts all twelve codes under +# tests/test_ontology.py's round-trip check. ################################################################# :postTypeScheme a skos:ConceptScheme ; rdfs:label "Post type scheme" ; - rdfs:comment "Voice-based classification of what a source post records, per the governed five-value voc_type lookup category (migrations/0042)." . + rdfs:comment "Voice-based classification of what a source post records, per the governed twelve-code voc_type lookup category (migrations/0042 + 0222)." . :voiceOfCustomerType a skos:Concept ; skos:inScheme :postTypeScheme ; @@ -317,6 +358,49 @@ rdfs:comment "A partner organization's voice." ; :lookupCode "vop" . +# ADR 0232 additions -- the complete stakeholder-voice taxonomy. +:voiceOfSupplierType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Supplier"@en ; + rdfs:comment "A supplier organization's own voice about supplying the author's organization." ; + :lookupCode "vos" . + +:voiceOfEmployeeType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Employee"@en ; + rdfs:comment "A staff member's voice about their workplace, per the service-profit chain (Heskett et al., 1994)." ; + :lookupCode "voe" . + +:voiceOfBusinessType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Business"@en ; + rdfs:comment "An internal business unit's or management's voice (quality-engineering practice)." ; + :lookupCode "vob" . + +:voiceOfRegulatorType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Regulator"@en ; + rdfs:comment "A government or compliance authority's voice, per regulatory stakeholder salience (Mitchell et al., 1997)." ; + :lookupCode "vor" . + +:voiceOfInvestorType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Investor"@en ; + rdfs:comment "An owner's or capital provider's voice (Freeman, 1984)." ; + :lookupCode "voi" . + +:voiceOfSocietyType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Society"@en ; + rdfs:comment "A community, media, or public stakeholder's voice (Mitchell et al., 1997)." ; + :lookupCode "voso" . + +:voiceOfProcessType a skos:Concept ; + skos:inScheme :postTypeScheme ; + skos:prefLabel "Voice of Process"@en ; + rdfs:comment "The system/process speaking through its own generated signal (Shewhart, 1931; Deming, 1986)." ; + :lookupCode "vops" . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py index 331f4957e..48d759470 100644 --- a/lineageweave/entity_relationship_classification.py +++ b/lineageweave/entity_relationship_classification.py @@ -1,19 +1,21 @@ """Pluggable entity-relationship classification: for each organization named in a post's text, what is that organization's relationship to the post author's own organization -- partner, competitor, customer, -customer's-customer, market, or supplier? +customer's-customer, market, supplier, or one of the six further +stakeholder-voice classes ADR 0232 governs? Grounded in relation extraction from text (Zelenko, Aone, & Richardella, 2003): classifying the semantic relation between a document's subject and a named entity mentioned in it, rather than treating the entity as an -undifferentiated string. This maps onto the product's own six-way +undifferentiated string. This maps onto the product's twelve-way vocabulary -- ``rel_voc``/``rel_vom``/``rel_vop``/``rel_vocc``/``rel_voco``/ -``rel_vos`` (``rel_`` prefixed: ``common_lookup_value.lookup_code`` is +``rel_vos`` plus the six ADR 0232 mirrors listed below (``rel_`` prefixed: +``common_lookup_value.lookup_code`` is unique GLOBALLY across categories, and bare ``voc``/``vom`` are already claimed by ``source_post.voc_type_code``'s own category) -- which in practice -collapses to "customer" and "competitor" most of the time; ``rel_vos`` -(supplier) is the unusual case that still needs to classify correctly -because it is rare, not because it never happens. +collapses to "customer" and "competitor" most of the time; the rarer codes +still need to classify correctly because they are rare, not because they +never happen. Same pluggable-client, never-fake-a-missing-channel discipline as ``keyman_extraction``: :class:`NullEntityRelationshipClient` makes the @@ -44,8 +46,18 @@ VOP = "rel_vop" # Voice of Partner VOCC = "rel_vocc" # Voice of Customer's Customer -- one hop further down the chain VOCO = "rel_voco" # Voice of Competitor -VOS = "rel_vos" # Voice of Supplier -- the uncommon edge case -_VALID_RELATIONSHIP_CODES = frozenset({VOC, VOM, VOP, VOCC, VOCO, VOS}) +VOS = "rel_vos" # Voice of Supplier +VOE = "rel_voe" # Voice of Employee -- employee-voice signal involving this org +VOB = "rel_vob" # Voice of Business -- internal-business-unit signal involving this org +VOR = "rel_vor" # Voice of Regulator -- this org regulates the post author's org +VOI = "rel_voi" # Voice of Investor -- this org invests in the post author's org +VOSO = ( + "rel_voso" # Voice of Society -- community/society-level signal involving this org +) +VOPS = "rel_vops" # Voice of Process -- process/system signal involving this org +_VALID_RELATIONSHIP_CODES = frozenset( + {VOC, VOM, VOP, VOCC, VOCO, VOS, VOE, VOB, VOR, VOI, VOSO, VOPS} +) @dataclass(frozen=True) @@ -98,6 +110,18 @@ def classify( customers (one hop further down the chain), not a direct customer rel_voco = this organization is a competitor rel_vos = this organization is a supplier to the post author's org + rel_voe = an employee-voice signal involving this organization + (e.g. its staff speak through the post) + rel_vob = an internal business unit or management voice involving + this organization + rel_vor = this organization is a regulator of / compliance authority over + the post author's org + rel_voi = this organization invests in or holds capital in the post + author's org (shareholder, investor, lender) + rel_voso = a community, media, or public-society-level signal involving + this organization + rel_vops = a process/system-generated signal (monitoring alert, automated + report) involving this organization An organization can genuinely be more than one of these across different parts of its business (e.g. a current customer that also competes with the @@ -108,7 +132,8 @@ def classify( Reply with ONLY a JSON array (no markdown fences, no prose), where each element has exactly these fields: "organization_name": exactly one of the names from the list below - "relationship_type_code": one of rel_voc, rel_vom, rel_vop, rel_vocc, rel_voco, rel_vos + "relationship_type_code": one of rel_voc, rel_vom, rel_vop, rel_vocc, rel_voco, + rel_vos, rel_voe, rel_vob, rel_vor, rel_voi, rel_voso, rel_vops Organizations to classify: {organization_names} @@ -154,7 +179,11 @@ def parse_classification_response( continue if code not in _VALID_RELATIONSHIP_CODES: continue - results.append(OrganizationRelationship(organization_name=name, relationship_type_code=code)) + results.append( + OrganizationRelationship( + organization_name=name, relationship_type_code=code + ) + ) return results @@ -164,7 +193,12 @@ class ContextualOrchestratorEntityRelationshipClient: available = True def __init__( - self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 + self, + base_url: str, + api_key: str, + *, + reasoning_effort: str = "auto", + timeout: float = 180.0, ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/migrations/0222_voice_of_x_complete_taxonomy.sql b/migrations/0222_voice_of_x_complete_taxonomy.sql new file mode 100644 index 000000000..19b131719 --- /dev/null +++ b/migrations/0222_voice_of_x_complete_taxonomy.sql @@ -0,0 +1,39 @@ +-- ADR 0232: complete general Voice-of-X taxonomy. +-- Extends the governed voc_type scheme from five to twelve codes and +-- mirrors the six new voice classes in entity_relationship_type so any +-- post can type its named counterparties. Additive only: existing rows, +-- codes, and display orders are untouched. Idempotent on replay, scoped +-- by category like migration 0042. + +begin; + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) +values + ('voc_type', 'vos', 'Voice of Supplier', 5), + ('voc_type', 'voe', 'Voice of Employee', 6), + ('voc_type', 'vob', 'Voice of Business', 7), + ('voc_type', 'vor', 'Voice of Regulator', 8), + ('voc_type', 'voi', 'Voice of Investor', 9), + ('voc_type', 'voso', 'Voice of Society', 10), + ('voc_type', 'vops', 'Voice of Process', 11) +on conflict (lookup_code) do update + set lookup_category = excluded.lookup_category, + lookup_label = excluded.lookup_label, + display_order = excluded.display_order + where common_lookup_value.lookup_category = 'voc_type'; + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) +values + ('entity_relationship_type', 'rel_voe', 'Employee-voice signal involving this organization', 6), + ('entity_relationship_type', 'rel_vob', 'Internal-business-unit signal involving this organization', 7), + ('entity_relationship_type', 'rel_vor', 'Regulates the post author''s organization', 8), + ('entity_relationship_type', 'rel_voi', 'Invests in the post author''s organization', 9), + ('entity_relationship_type', 'rel_voso', 'Community/society-level signal involving this organization', 10), + ('entity_relationship_type', 'rel_vops', 'Process/system signal involving this organization', 11) +on conflict (lookup_code) do update + set lookup_category = excluded.lookup_category, + lookup_label = excluded.lookup_label, + display_order = excluded.display_order + where common_lookup_value.lookup_category = 'entity_relationship_type'; + +commit; diff --git a/tests/test_entity_relationship_classification.py b/tests/test_entity_relationship_classification.py index e22c76713..5a079f5eb 100644 --- a/tests/test_entity_relationship_classification.py +++ b/tests/test_entity_relationship_classification.py @@ -29,6 +29,8 @@ def test_null_relationship_client_is_unavailable_not_empty_relations() -> None: assert client.available is False with pytest.raises(RuntimeError): client.classify("any title", "any body", ["Acme Corp"]) + + from lineageweave.fixtures import ambiguous_entity_relationship_post @@ -45,7 +47,9 @@ def test_parses_a_well_formed_json_array() -> None: def test_entry_naming_an_organization_not_in_the_input_list_is_skipped() -> None: - content = '[{"organization_name": "Unlisted Corp", "relationship_type_code": "rel_voc"}]' + content = ( + '[{"organization_name": "Unlisted Corp", "relationship_type_code": "rel_voc"}]' + ) assert parse_classification_response(content, ["Acme Corp"]) == [] @@ -58,6 +62,32 @@ def test_empty_array_is_no_relationships() -> None: assert parse_classification_response("[]", ["Acme Corp"]) == [] +def test_every_adr_0232_voice_class_code_is_accepted_not_dropped() -> None: + """ADR 0232's six new relationship codes must classify, not silently + drop -- a dropped entry is the failure mode the parser guards against, + so extending the vocabulary without extending acceptance would be a + silent regression. + """ + codes = [ + "rel_voe", + "rel_vob", + "rel_vor", + "rel_voi", + "rel_voso", + "rel_vops", + ] + content = ( + "[" + + ", ".join( + f'{{"organization_name": "Acme Corp", "relationship_type_code": "{code}"}}' + for code in codes + ) + + "]" + ) + results = parse_classification_response(content, ["Acme Corp"]) + assert [r.relationship_type_code for r in results] == codes + + def test_invalid_json_returns_empty_list() -> None: assert parse_classification_response("not json", ["Acme Corp"]) == [] @@ -106,14 +136,22 @@ def test_classified_names_attach_cataloged_org_ids_or_stay_null() -> None: rows = attach_resolved_entity_ids( [ - {"counterparty_entity_name": "Demo Corp", "relationship_type_code": "rel_voc"}, - {"counterparty_entity_name": "Northridge Grid", "relationship_type_code": "rel_voc"}, + { + "counterparty_entity_name": "Demo Corp", + "relationship_type_code": "rel_voc", + }, + { + "counterparty_entity_name": "Northridge Grid", + "relationship_type_code": "rel_voc", + }, ], [ CorporateEntityCandidate("corp-1", "Demo Corp"), CorporateEntityCandidate("corp-2", "Test Corp"), ], ) - by_name = {row["counterparty_entity_name"]: row["corporate_entity_id"] for row in rows} + by_name = { + row["counterparty_entity_name"]: row["corporate_entity_id"] for row in rows + } assert by_name["Demo Corp"] == "corp-1" assert by_name["Northridge Grid"] is None diff --git a/tests/test_ontology.py b/tests/test_ontology.py index e883916f7..efb77200f 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -35,22 +35,36 @@ ontology_annotations, ) -_SEED_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" +_SEED_SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "seed_demo_data.py" +) # Several covered categories add lookup rows via their own migration # SQL rather than literally embedded in seed_demo_data.py's own source # text -- read alongside it below so the round-trip still sees them: # 0012 (ADR 0006: prov_person/prov_organization), 0014 (ADR 0007: # prov_team), 0016 (ADR 0009: node_team/edge_mention_team/ -# edge_team_affiliation/edge_mention_organization), and 0042 (ADR 0207: -# the five governed voc_type post-type codes), and 0220 (ADR 0222: -# node_project/edge_mention_project). +# edge_team_affiliation/edge_mention_organization), 0042 (ADR 0207: +# the original five voc_type post-type codes) + 0222 (ADR 0232: the +# seven further Voice-of-X codes and their relationship mirrors), and +# 0220 (ADR 0222: node_project/edge_mention_project). _ADDITIONAL_LOOKUP_MIGRATION_PATHS = ( - Path(__file__).resolve().parents[1] / "migrations" / "0060_role_responsibility_agent_type.sql", - Path(__file__).resolve().parents[1] / "migrations" / "0014_role_responsibility_team_actor_type.sql", - Path(__file__).resolve().parents[1] / "migrations" / "0016_cross_post_actor_identity.sql", + Path(__file__).resolve().parents[1] + / "migrations" + / "0060_role_responsibility_agent_type.sql", + Path(__file__).resolve().parents[1] + / "migrations" + / "0014_role_responsibility_team_actor_type.sql", + Path(__file__).resolve().parents[1] + / "migrations" + / "0016_cross_post_actor_identity.sql", Path(__file__).resolve().parents[1] / "migrations" / "0042_voc_type_vocabulary.sql", - Path(__file__).resolve().parents[1] / "migrations" / "0220_ontology_project_node.sql", + Path(__file__).resolve().parents[1] + / "migrations" + / "0222_voice_of_x_complete_taxonomy.sql", + Path(__file__).resolve().parents[1] + / "migrations" + / "0220_ontology_project_node.sql", ) # The categories this ontology covers (ADR 0004's scope, extended by @@ -128,7 +142,9 @@ def test_knowledge_graph_lookup_constants_are_declared_in_the_ontology() -> None EDGE_MENTION_PROJECT, EDGE_CO_MENTION, ): - assert code in declared, f"{code} is written by knowledge_graph.py but missing from lineageweave-kg.ttl" + assert code in declared, ( + f"{code} is written by knowledge_graph.py but missing from lineageweave-kg.ttl" + ) def test_iri_for_lookup_code_resolves_a_real_term() -> None: @@ -146,7 +162,10 @@ def test_ontology_annotations_carry_iri_and_label_for_a_node_type() -> None: "ontology_label": "Person", } assert ontology_annotations("node_post")["ontology_label"] == "Post" - assert ontology_annotations("node_corporate_entity")["ontology_label"] == "Corporate entity" + assert ( + ontology_annotations("node_corporate_entity")["ontology_label"] + == "Corporate entity" + ) def test_ontology_annotations_use_skos_preferred_labels_for_concepts() -> None: @@ -160,8 +179,17 @@ def test_ontology_annotations_use_skos_preferred_labels_for_concepts() -> None: "voco": "Voice of Competitor", "vom": "Voice of Market", "vop": "Voice of Partner", + "vos": "Voice of Supplier", + "voe": "Voice of Employee", + "vob": "Voice of Business", + "vor": "Voice of Regulator", + "voi": "Voice of Investor", + "voso": "Voice of Society", + "vops": "Voice of Process", } - assert {code: ontology_annotations(code)["ontology_label"] for code in expected} == expected + assert { + code: ontology_annotations(code)["ontology_label"] for code in expected + } == expected def test_every_declared_lookup_term_has_one_runtime_label() -> None: @@ -220,7 +248,11 @@ def test_prov_agent_type_terms_resolve_and_subclass_real_prov_o() -> None: assert iri_for_lookup_code("prov_person") == str(LW.RoleActorPerson) assert iri_for_lookup_code("prov_organization") == str(LW.RoleActorOrganization) assert (LW.RoleActorPerson, RDFS.subClassOf, URIRef(prov.Person)) in graph - assert (LW.RoleActorOrganization, RDFS.subClassOf, URIRef(prov.Organization)) in graph + assert ( + LW.RoleActorOrganization, + RDFS.subClassOf, + URIRef(prov.Organization), + ) in graph def test_prov_team_type_resolves_and_subclasses_real_org_ontology() -> None: @@ -281,7 +313,11 @@ def test_semantic_project_terms_preserve_post_evidence_and_confidence() -> None: ) assert (LW.mentionsProject, RDFS.domain, LW.Post) in graph assert (LW.mentionsProject, RDFS.range, LW.Project) in graph - assert (LW.mentionsProject, RDFS.label, Literal("mentions project", lang="en")) in graph + assert ( + LW.mentionsProject, + RDFS.label, + Literal("mentions project", lang="en"), + ) in graph assert (LW.projectEvidence, RDFS.domain, LW.ProjectMention) in graph assert (LW.projectEvidence, RDFS.range, XSD.string) in graph assert (LW.semanticConfidence, RDFS.range, XSD.decimal) in graph @@ -366,8 +402,9 @@ def test_shared_timestamps_declare_no_domain_to_avoid_multi_domain_entailment() def test_post_type_scheme_covers_the_governed_voc_vocabulary() -> None: - """ADR 0207 decision 8: the five seeded voc_type codes become SKOS - concepts; vos exists only as rel_vos and must NOT appear here. + """ADR 0232: the complete twelve-code stakeholder-voice vocabulary + becomes SKOS concepts; every seeded code resolves, including vos, + which ADR 0207 had restricted to its rel_vos relationship mirror. """ graph = load_ontology() scheme_members = { @@ -379,11 +416,51 @@ def test_post_type_scheme_covers_the_governed_voc_vocabulary() -> None: (LW.voiceOfCompetitorType, "voco"), (LW.voiceOfMarketType, "vom"), (LW.voiceOfPartnerType, "vop"), + (LW.voiceOfSupplierType, "vos"), + (LW.voiceOfEmployeeType, "voe"), + (LW.voiceOfBusinessType, "vob"), + (LW.voiceOfRegulatorType, "vor"), + (LW.voiceOfInvestorType, "voi"), + (LW.voiceOfSocietyType, "voso"), + (LW.voiceOfProcessType, "vops"), } for concept, code in expected: assert concept in scheme_members, str(concept) assert iri_for_lookup_code(code) == str(concept) assert len(scheme_members) == len(expected) seeded = _seeded_lookup_codes_for_covered_categories() - assert {"voc", "vocc", "voco", "vom", "vop"} <= seeded - assert iri_for_lookup_code("vos") is None # relationship type only + assert { + "voc", + "vocc", + "voco", + "vom", + "vop", + "vos", + "voe", + "vob", + "vor", + "voi", + "voso", + "vops", + } <= seeded + + +def test_voice_of_x_relationship_properties_resolve_for_the_full_taxonomy() -> None: + """ADR 0232: each new voice class mirrors into an object property the + Knowledge Graph can project, with Post -> CorporateEntity direction + matching the stored counterparty rows. + """ + graph = load_ontology() + expected = { + ("rel_voe", LW.hasVoeRelationship), + ("rel_vob", LW.hasVobRelationship), + ("rel_vor", LW.hasVorRelationship), + ("rel_voi", LW.hasVoiRelationship), + ("rel_voso", LW.hasVosoRelationship), + ("rel_vops", LW.hasVopsRelationship), + ("rel_vos", LW.hasVosRelationship), + } + for code, prop in expected: + assert iri_for_lookup_code(code) == str(prop), code + assert (prop, RDFS.domain, LW.Post) in graph, str(prop) + assert (prop, RDFS.range, LW.CorporateEntity) in graph, str(prop) From 09838e1fbdd79bda856f3bb418386132554e784f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 23:09:32 +0900 Subject: [PATCH 02/53] fix(ontology): separate post voices from organization relations --- CHANGELOG.md | 14 +- docs/adr/0232-complete-voice-of-x-taxonomy.md | 136 ------------------ .../0246-expanded-voice-of-x-post-taxonomy.md | 87 +++++++++++ docs/ontology/lineageweave-kg.ttl | 68 ++------- docs/product-requirements.md | 2 +- .../entity_relationship_classification.py | 56 ++------ .../0222_voice_of_x_complete_taxonomy.sql | 39 ----- migrations/0235_voice_of_x_post_taxonomy.sql | 24 ++++ ...test_entity_relationship_classification.py | 46 +----- tests/test_ontology.py | 31 ++-- 10 files changed, 154 insertions(+), 349 deletions(-) delete mode 100644 docs/adr/0232-complete-voice-of-x-taxonomy.md create mode 100644 docs/adr/0246-expanded-voice-of-x-post-taxonomy.md delete mode 100644 migrations/0222_voice_of_x_complete_taxonomy.sql create mode 100644 migrations/0235_voice_of_x_post_taxonomy.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 26229a076..b0b269f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,11 @@ All notable changes to this project are documented here. Format follows ### Added -- Complete general Voice-of-X taxonomy (ADR 0232): the governed `voc_type` - scheme expands from five to twelve post types -- adding Voice of Supplier, - Employee, Business, Regulator, Investor, Society, and Process, grounded in - stakeholder identification theory and quality-engineering measurement - practice -- and mirrors six new counterparty relationship codes - (`rel_voe`..`rel_vops`) so any post class can type each organization it - names. Ontology SKOS concepts and object properties, idempotent migration - 0222, and the entity-relationship classification prompt stay in round-trip - sync. +- Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` + scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, + Society, and Process as source-post categories. Ontology SKOS concepts and + idempotent migration 0235 stay in round-trip sync; counterparty relationships + remain a separate evidence contract. - Persist explicit paragraph, list, table, MathML formula, and caller-parsed conversation-turn semantic-unit kinds without inferring absent boundaries. - Event Lineage now persists each reconstructed connection's independent diff --git a/docs/adr/0232-complete-voice-of-x-taxonomy.md b/docs/adr/0232-complete-voice-of-x-taxonomy.md deleted file mode 100644 index 82d989942..000000000 --- a/docs/adr/0232-complete-voice-of-x-taxonomy.md +++ /dev/null @@ -1,136 +0,0 @@ -# ADR 0232: Complete general Voice-of-X taxonomy for post types and counterparty relationships - -## Status - -Accepted (2026-08-26). Extends ADR 0207 decision 8 (the then-governed -five-value `voc_type` scheme) and the six-code `entity_relationship_type` -vocabulary; supersedes the "vos is relationship-only" asymmetry. - -## Context - -`source_post.voc_type_code` classifies what a post records, and -`post_counterparty_entity.relationship_type_code` classifies each named -organization's relation to the post author's org. Both draw on a shared -"Voice of X" mental model, but until now only five post types -(`voc`, `vocc`, `voco`, `vom`, `vop`) and six relationship codes -(`rel_voc`, `rel_vocc`, `rel_voco`, `rel_vom`, `rel_vop`, `rel_vos`) -were governed. Real correspondence sources speak with more distinct -voices than those five: staff write about their own workplace, regulators -issue notices, investors comment, communities and media react, suppliers -answer (previously expressible only as a *relationship*, never as a post -type), internal business units report, and automated systems emit process -signal. A closed five-value scheme forced every such record into the -nearest of five buckets or blocked import. - -## Decision - -Adopt the complete stakeholder-voice taxonomy as the governed vocabulary, -grounded in stakeholder identification theory and quality-engineering -measurement practice: - -1. **Stakeholder classes** (Freeman, 1984; Mitchell, Agle, & Wood, 1997) - enumerate who can hold a voice toward an organization: customers, - employees, owners/investors, suppliers, government/regulators, - communities/society, competitors, partners. Each class that can author - correspondence becomes one `voc_type` code. -2. **Quality-engineering voices** add the two non-personal voices Six - Sigma measurement practice recognizes alongside Voice of the Customer: - Voice of the Business (internal management) and Voice of the Process - -- the system speaking through its own signal (Shewhart, 1931; - Deming, 1986). -3. **Customer-chain depth** keeps the B2B2C chain extension already - governed (`vocc`, one hop down the chain per Griffin & Hauser's - (1993) need-interrogation practice of following the use chain). -4. **Market** stays the aggregate residual voice not attributable to one - named party (`vom`). - -The complete `voc_type` set becomes twelve codes: - -| Code | Label | Grounding | -| --- | --- | --- | -| `voc` | Voice of Customer | Griffin & Hauser (1993) | -| `vocc` | Voice of Customer's Customer | chain extension of (1) | -| `voco` | Voice of Competitor | Freeman (1984): competitors | -| `vom` | Voice of Market | aggregate residual signal | -| `vop` | Voice of Partner | Freeman (1984): partners/alliances | -| `vos` | Voice of Supplier | Freeman (1984): suppliers | -| `voe` | Voice of Employee | Heskett et al. (1994), service-profit chain | -| `vob` | Voice of Business | internal-management voice (quality practice) | -| `vor` | Voice of Regulator | Mitchell et al. (1997): government/regulatory salience | -| `voi` | Voice of Investor | Freeman (1984): owners/stockholders | -| `voso` | Voice of Society | Mitchell et al. (1997): community/media stakeholders | -| `vops` | Voice of Process | Shewhart (1931); Deming (1986): process signal | - -Mirror each new voice class in `entity_relationship_type` so a post of -any voice class can still type its named counterparties: - -| Code | Meaning for the named organization | -| --- | --- | -| `rel_voe` | employee-voice signal involving this organization | -| `rel_vob` | internal-business-unit signal involving this organization | -| `rel_vor` | this organization regulates the post author's org | -| `rel_voi` | this organization invests in / holds capital in the author's org | -| `rel_voso` | community/society-level signal involving this organization | -| `rel_vops` | process/system-generated signal involving this organization | - -`rel_vos` already existed and now has its matching post type `vos`. - -Deliberate non-proliferation (documented, not invented): - -- **Reseller/channel intermediaries stay under Partner.** Distribution - economics distinguishes resellers from strategic alliances, but both - are "organizations the author works through"; minting a separate code - would duplicate evidence without changing any downstream decision. -- **Prospects and churned customers stay under Customer.** In Voice-of- - Customer practice lost and prospective buyers are customers' voices at - different lifecycle stages (Griffin & Hauser, 1993 sample across - current and lost users), not a different stakeholder class. -- **End-user vs buyer within a customer account stays under Customer** - plus existing person-level Keyman structure; the account-level voice - class does not change. - -Codes remain globally unique lowercase literals in -`common_lookup_value.lookup_code`; relationship codes keep the `rel_` -prefix because bare `voc`-style codes are claimed by the post-type -category itself. - -## Consequences - -- `migrations/0222_voice_of_x_complete_taxonomy.sql` seeds both - categories idempotently (`ON CONFLICT ... DO UPDATE`, scoped by - category, mirroring migration 0042). -- `docs/ontology/lineageweave-kg.ttl` declares seven more SKOS concepts - under `:postTypeScheme` and six more `owl:ObjectProperty` relationship - projections; the round-trip test in `tests/test_ontology.py` reads the - new migration file and no longer asserts the five-value cap or the - "vos is never a post type" invariant. -- `lineageweave/entity_relationship_classification.py` extends the valid - code set and the classification prompt; unknown codes are still - dropped, never guessed. -- Existing rows and stored data are untouched: all seven new codes are - additive, display orders 5-11 follow the seeded order, and no code is - renamed or retired. -- The API resolves labels from `common_lookup_value`, so filter options - and labels flow to clients without frontend changes. - -## References - -Deming, W. E. (1986). *Out of the crisis*. MIT Press. - -Freeman, R. E. (1984). *Strategic management: A stakeholder approach*. -Pitman. - -Griffin, A., & Hauser, J. R. (1993). The voice of the customer. -*Marketing Science, 12*(1), 1-27. https://doi.org/10.1287/mksc.12.1.1 - -Heskett, J. L., Jones, T. O., Loveman, G. W., Sasser, W. E., & -Schlesinger, L. A. (1994). Putting the service-profit chain to work. -*Harvard Business Review, 72*(2), 164-174. - -Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of -stakeholder identification and salience: Defining the principle of who -and what really counts. *Academy of Management Review, 22*(4), 853-886. -https://doi.org/10.5465/amr.1997.9711022105 - -Shewhart, W. A. (1931). *Economic control of quality of manufactured -product*. D. Van Nostrand. diff --git a/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md b/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md new file mode 100644 index 000000000..fa227a35a --- /dev/null +++ b/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md @@ -0,0 +1,87 @@ +# ADR 0246: Expanded Voice-of-X post taxonomy + +## Status + +Accepted (2026-08-26). Extends ADR 0207 decision 8 without claiming that the +result is an exhaustive stakeholder taxonomy. + +## Context + +`source_post.voc_type_code` records what kind of voice a source post carries. +The existing five-code scheme (`voc`, `vocc`, `voco`, `vom`, `vop`) cannot +represent supplier, employee, internal-business, regulator, investor, +society, or process-generated source records without changing the source +classification or rejecting the import. + +`post_counterparty_entity.relationship_type_code` answers a different +question: how a named organization relates to the post author's organization. +A post voice is not evidence that every organization named in the post has the +same relationship. This ADR therefore does not mirror post-type codes into the +counterparty relationship vocabulary. Any future relationship term requires +its own direction, evidence contract, and source-grounded definition. + +## Decision + +Add seven product-controlled concepts to the existing `voc_type` scheme: + +| Code | Label | Source category represented | +| --- | --- | --- | +| `vos` | Voice of Supplier | supplier-authored or supplier-originated record | +| `voe` | Voice of Employee | employee-authored or employee-originated record | +| `vob` | Voice of Business | internal-management or business-unit record | +| `vor` | Voice of Regulator | regulator-authored or regulator-originated record | +| `voi` | Voice of Investor | investor-authored or investor-originated record | +| `voso` | Voice of Society | community or public-stakeholder record | +| `vops` | Voice of Process | process- or system-generated record | + +These are governed LineageWeave codes, not a claim that Freeman (1984), +Mitchell et al. (1997), ISO 16355-4, or quality-engineering literature defines +this exact twelve-code list. The cited works support distinguishing stakeholder +voices and process evidence; they do not establish an exact term-level +crosswalk. `vocc`, `vom`, and the product's code abbreviations remain existing +local vocabulary. + +The scheme stays open to a later evidence-backed extension. Import preserves +the supplied code and provenance; no classifier, keyword rule, default, or +weight assigns one of these values. Missing or unsupported source codes remain +unavailable rather than being forced into a nearby category. + +Codes remain globally unique lowercase literals in +`common_lookup_value.lookup_code`. + +## Consequences + +- `migrations/0235_voice_of_x_post_taxonomy.sql` adds the seven `voc_type` + values idempotently and does not alter existing rows. +- `docs/ontology/lineageweave-kg.ttl` publishes one SKOS concept for each new + code under `:postTypeScheme`. +- The ontology round-trip test proves that the migration and published + vocabulary agree. +- The API's existing lookup-label path supplies filter values and labels; no + customer-facing explanation exposes database, migration, or classifier + boundaries. +- The counterparty relationship classifier remains on its independently + governed six-code vocabulary. + +## References + +Freeman, R. E. (1984). *Strategic management: A stakeholder approach*. +Pitman. + +Heskett, J. L., Jones, T. O., Loveman, G. W., Sasser, W. E., & +Schlesinger, L. A. (1994). Putting the service-profit chain to work. +*Harvard Business Review, 72*(2), 164-174. + +International Organization for Standardization. (2017). *Applications of +statistical and related methods to new technology and product development +process—Part 4: Analysis of non-quantitative and quantitative Voice of +Customer and Voice of Stakeholder* (ISO Standard No. 16355-4:2017). +https://www.iso.org/standard/62607.html + +Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of +stakeholder identification and salience: Defining the principle of who and +what really counts. *Academy of Management Review, 22*(4), 853-886. +https://doi.org/10.5465/amr.1997.9711022105 + +Shewhart, W. A. (1931). *Economic control of quality of manufactured +product*. D. Van Nostrand. diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 1eaeeaade..5c1301aa1 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -56,7 +56,7 @@ :Post a owl:Class ; rdfs:label "Post" ; - rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, Partner, Supplier, Employee, Business, Regulator, Investor, Society, or Process (ADR 0232)." ; + rdfs:comment "A source_post row: one record typed by the voc_type scheme (:postTypeScheme) -- Voice of Customer, Customer's Customer, Competitor, Market, Partner, Supplier, Employee, Business, Regulator, Investor, Society, or Process (ADR 0246)." ; :lookupCode "node_post" . :Person a owl:Class ; @@ -200,45 +200,6 @@ rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . -# ADR 0232 mirrors the complete stakeholder-voice taxonomy in the -# counterparty vocabulary so a post of ANY voice class can still type -# each organization it names. -:hasVoeRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Employee relationship" ; - rdfs:comment "An employee-voice signal involving this organization (rel_voe)." ; - :lookupCode "rel_voe" . - -:hasVobRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Business relationship" ; - rdfs:comment "An internal-business-unit signal involving this organization (rel_vob)." ; - :lookupCode "rel_vob" . - -:hasVorRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Regulator relationship" ; - rdfs:comment "This organization regulates the post author's organization (rel_vor)." ; - :lookupCode "rel_vor" . - -:hasVoiRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Investor relationship" ; - rdfs:comment "This organization invests in / holds capital in the post author's organization (rel_voi)." ; - :lookupCode "rel_voi" . - -:hasVosoRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Society relationship" ; - rdfs:comment "A community/society-level signal involving this organization (rel_voso)." ; - :lookupCode "rel_voso" . - -:hasVopsRelationship a owl:ObjectProperty ; - rdfs:domain :Post ; rdfs:range :CorporateEntity ; - rdfs:label "has Voice-of-Process relationship" ; - rdfs:comment "A process/system-generated signal involving this organization (rel_vops)." ; - :lookupCode "rel_vops" . - ################################################################# # Datatype properties -- node attribute projections. # @@ -314,19 +275,18 @@ ################################################################# # SKOS -- voc_type (post type classification) # -# The complete stakeholder-voice vocabulary ADR 0232 governs: -# migrations/0042 seeds the original five codes and migrations/0222 -# seeds the seven additions, so every stakeholder class that can author -# correspondence (Freeman, 1984; Mitchell et al., 1997) plus the two -# quality-engineering voices (Voice of Business; Voice of Process, -# Shewhart, 1931) has exactly one concept here. Adding "voc_type" to +# The expanded post-voice vocabulary ADR 0246 governs: +# migrations/0042 seeds the original five codes and migrations/0235 +# seeds the seven additions. These are product-controlled source categories, +# not an assertion that the cited literature defines an exhaustive twelve-code +# taxonomy. Adding "voc_type" to # the ontology-covered categories puts all twelve codes under # tests/test_ontology.py's round-trip check. ################################################################# :postTypeScheme a skos:ConceptScheme ; rdfs:label "Post type scheme" ; - rdfs:comment "Voice-based classification of what a source post records, per the governed twelve-code voc_type lookup category (migrations/0042 + 0222)." . + rdfs:comment "Voice-based classification of what a source post records, per the governed twelve-code voc_type lookup category (migrations/0042 + 0235)." . :voiceOfCustomerType a skos:Concept ; skos:inScheme :postTypeScheme ; @@ -358,7 +318,7 @@ rdfs:comment "A partner organization's voice." ; :lookupCode "vop" . -# ADR 0232 additions -- the complete stakeholder-voice taxonomy. +# ADR 0246 additions -- expanded source-post voice categories. :voiceOfSupplierType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Supplier"@en ; @@ -368,37 +328,37 @@ :voiceOfEmployeeType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Employee"@en ; - rdfs:comment "A staff member's voice about their workplace, per the service-profit chain (Heskett et al., 1994)." ; + rdfs:comment "An employee-authored or employee-originated source record." ; :lookupCode "voe" . :voiceOfBusinessType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Business"@en ; - rdfs:comment "An internal business unit's or management's voice (quality-engineering practice)." ; + rdfs:comment "An internal-management or business-unit source record." ; :lookupCode "vob" . :voiceOfRegulatorType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Regulator"@en ; - rdfs:comment "A government or compliance authority's voice, per regulatory stakeholder salience (Mitchell et al., 1997)." ; + rdfs:comment "A regulator-authored or regulator-originated source record." ; :lookupCode "vor" . :voiceOfInvestorType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Investor"@en ; - rdfs:comment "An owner's or capital provider's voice (Freeman, 1984)." ; + rdfs:comment "An investor-authored or investor-originated source record." ; :lookupCode "voi" . :voiceOfSocietyType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Society"@en ; - rdfs:comment "A community, media, or public stakeholder's voice (Mitchell et al., 1997)." ; + rdfs:comment "A community or public-stakeholder source record." ; :lookupCode "voso" . :voiceOfProcessType a skos:Concept ; skos:inScheme :postTypeScheme ; skos:prefLabel "Voice of Process"@en ; - rdfs:comment "The system/process speaking through its own generated signal (Shewhart, 1931; Deming, 1986)." ; + rdfs:comment "A process- or system-generated source record." ; :lookupCode "vops" . ################################################################# diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 0d456f2ae..0f000c1e3 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -220,7 +220,7 @@ A release claim requires one exact protected-main head that proves: - Product/data boundary: ADR 0001, ADR 0089. - Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213. - Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065, - ADR 0184, ADR 0207, ADR 0222. + ADR 0184, ADR 0207, ADR 0222, ADR 0246. - Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217. - LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079. - Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205. diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py index 48d759470..331f4957e 100644 --- a/lineageweave/entity_relationship_classification.py +++ b/lineageweave/entity_relationship_classification.py @@ -1,21 +1,19 @@ """Pluggable entity-relationship classification: for each organization named in a post's text, what is that organization's relationship to the post author's own organization -- partner, competitor, customer, -customer's-customer, market, supplier, or one of the six further -stakeholder-voice classes ADR 0232 governs? +customer's-customer, market, or supplier? Grounded in relation extraction from text (Zelenko, Aone, & Richardella, 2003): classifying the semantic relation between a document's subject and a named entity mentioned in it, rather than treating the entity as an -undifferentiated string. This maps onto the product's twelve-way +undifferentiated string. This maps onto the product's own six-way vocabulary -- ``rel_voc``/``rel_vom``/``rel_vop``/``rel_vocc``/``rel_voco``/ -``rel_vos`` plus the six ADR 0232 mirrors listed below (``rel_`` prefixed: -``common_lookup_value.lookup_code`` is +``rel_vos`` (``rel_`` prefixed: ``common_lookup_value.lookup_code`` is unique GLOBALLY across categories, and bare ``voc``/``vom`` are already claimed by ``source_post.voc_type_code``'s own category) -- which in practice -collapses to "customer" and "competitor" most of the time; the rarer codes -still need to classify correctly because they are rare, not because they -never happen. +collapses to "customer" and "competitor" most of the time; ``rel_vos`` +(supplier) is the unusual case that still needs to classify correctly +because it is rare, not because it never happens. Same pluggable-client, never-fake-a-missing-channel discipline as ``keyman_extraction``: :class:`NullEntityRelationshipClient` makes the @@ -46,18 +44,8 @@ VOP = "rel_vop" # Voice of Partner VOCC = "rel_vocc" # Voice of Customer's Customer -- one hop further down the chain VOCO = "rel_voco" # Voice of Competitor -VOS = "rel_vos" # Voice of Supplier -VOE = "rel_voe" # Voice of Employee -- employee-voice signal involving this org -VOB = "rel_vob" # Voice of Business -- internal-business-unit signal involving this org -VOR = "rel_vor" # Voice of Regulator -- this org regulates the post author's org -VOI = "rel_voi" # Voice of Investor -- this org invests in the post author's org -VOSO = ( - "rel_voso" # Voice of Society -- community/society-level signal involving this org -) -VOPS = "rel_vops" # Voice of Process -- process/system signal involving this org -_VALID_RELATIONSHIP_CODES = frozenset( - {VOC, VOM, VOP, VOCC, VOCO, VOS, VOE, VOB, VOR, VOI, VOSO, VOPS} -) +VOS = "rel_vos" # Voice of Supplier -- the uncommon edge case +_VALID_RELATIONSHIP_CODES = frozenset({VOC, VOM, VOP, VOCC, VOCO, VOS}) @dataclass(frozen=True) @@ -110,18 +98,6 @@ def classify( customers (one hop further down the chain), not a direct customer rel_voco = this organization is a competitor rel_vos = this organization is a supplier to the post author's org - rel_voe = an employee-voice signal involving this organization - (e.g. its staff speak through the post) - rel_vob = an internal business unit or management voice involving - this organization - rel_vor = this organization is a regulator of / compliance authority over - the post author's org - rel_voi = this organization invests in or holds capital in the post - author's org (shareholder, investor, lender) - rel_voso = a community, media, or public-society-level signal involving - this organization - rel_vops = a process/system-generated signal (monitoring alert, automated - report) involving this organization An organization can genuinely be more than one of these across different parts of its business (e.g. a current customer that also competes with the @@ -132,8 +108,7 @@ def classify( Reply with ONLY a JSON array (no markdown fences, no prose), where each element has exactly these fields: "organization_name": exactly one of the names from the list below - "relationship_type_code": one of rel_voc, rel_vom, rel_vop, rel_vocc, rel_voco, - rel_vos, rel_voe, rel_vob, rel_vor, rel_voi, rel_voso, rel_vops + "relationship_type_code": one of rel_voc, rel_vom, rel_vop, rel_vocc, rel_voco, rel_vos Organizations to classify: {organization_names} @@ -179,11 +154,7 @@ def parse_classification_response( continue if code not in _VALID_RELATIONSHIP_CODES: continue - results.append( - OrganizationRelationship( - organization_name=name, relationship_type_code=code - ) - ) + results.append(OrganizationRelationship(organization_name=name, relationship_type_code=code)) return results @@ -193,12 +164,7 @@ class ContextualOrchestratorEntityRelationshipClient: available = True def __init__( - self, - base_url: str, - api_key: str, - *, - reasoning_effort: str = "auto", - timeout: float = 180.0, + self, base_url: str, api_key: str, *, reasoning_effort: str = "auto", timeout: float = 180.0 ) -> None: self._base_url = base_url.rstrip("/") self._api_key = api_key diff --git a/migrations/0222_voice_of_x_complete_taxonomy.sql b/migrations/0222_voice_of_x_complete_taxonomy.sql deleted file mode 100644 index 19b131719..000000000 --- a/migrations/0222_voice_of_x_complete_taxonomy.sql +++ /dev/null @@ -1,39 +0,0 @@ --- ADR 0232: complete general Voice-of-X taxonomy. --- Extends the governed voc_type scheme from five to twelve codes and --- mirrors the six new voice classes in entity_relationship_type so any --- post can type its named counterparties. Additive only: existing rows, --- codes, and display orders are untouched. Idempotent on replay, scoped --- by category like migration 0042. - -begin; - -insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) -values - ('voc_type', 'vos', 'Voice of Supplier', 5), - ('voc_type', 'voe', 'Voice of Employee', 6), - ('voc_type', 'vob', 'Voice of Business', 7), - ('voc_type', 'vor', 'Voice of Regulator', 8), - ('voc_type', 'voi', 'Voice of Investor', 9), - ('voc_type', 'voso', 'Voice of Society', 10), - ('voc_type', 'vops', 'Voice of Process', 11) -on conflict (lookup_code) do update - set lookup_category = excluded.lookup_category, - lookup_label = excluded.lookup_label, - display_order = excluded.display_order - where common_lookup_value.lookup_category = 'voc_type'; - -insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) -values - ('entity_relationship_type', 'rel_voe', 'Employee-voice signal involving this organization', 6), - ('entity_relationship_type', 'rel_vob', 'Internal-business-unit signal involving this organization', 7), - ('entity_relationship_type', 'rel_vor', 'Regulates the post author''s organization', 8), - ('entity_relationship_type', 'rel_voi', 'Invests in the post author''s organization', 9), - ('entity_relationship_type', 'rel_voso', 'Community/society-level signal involving this organization', 10), - ('entity_relationship_type', 'rel_vops', 'Process/system signal involving this organization', 11) -on conflict (lookup_code) do update - set lookup_category = excluded.lookup_category, - lookup_label = excluded.lookup_label, - display_order = excluded.display_order - where common_lookup_value.lookup_category = 'entity_relationship_type'; - -commit; diff --git a/migrations/0235_voice_of_x_post_taxonomy.sql b/migrations/0235_voice_of_x_post_taxonomy.sql new file mode 100644 index 000000000..f05ef630b --- /dev/null +++ b/migrations/0235_voice_of_x_post_taxonomy.sql @@ -0,0 +1,24 @@ +-- ADR 0246: expanded Voice-of-X post taxonomy. +-- Adds seven source post voice codes without changing the independently +-- governed counterparty-relationship vocabulary. Additive only: existing +-- rows, codes, and display orders are untouched. Idempotent on replay, +-- scoped by category like migration 0042. + +begin; + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) +values + ('voc_type', 'vos', 'Voice of Supplier', 5), + ('voc_type', 'voe', 'Voice of Employee', 6), + ('voc_type', 'vob', 'Voice of Business', 7), + ('voc_type', 'vor', 'Voice of Regulator', 8), + ('voc_type', 'voi', 'Voice of Investor', 9), + ('voc_type', 'voso', 'Voice of Society', 10), + ('voc_type', 'vops', 'Voice of Process', 11) +on conflict (lookup_code) do update + set lookup_category = excluded.lookup_category, + lookup_label = excluded.lookup_label, + display_order = excluded.display_order + where common_lookup_value.lookup_category = 'voc_type'; + +commit; diff --git a/tests/test_entity_relationship_classification.py b/tests/test_entity_relationship_classification.py index 5a079f5eb..e22c76713 100644 --- a/tests/test_entity_relationship_classification.py +++ b/tests/test_entity_relationship_classification.py @@ -29,8 +29,6 @@ def test_null_relationship_client_is_unavailable_not_empty_relations() -> None: assert client.available is False with pytest.raises(RuntimeError): client.classify("any title", "any body", ["Acme Corp"]) - - from lineageweave.fixtures import ambiguous_entity_relationship_post @@ -47,9 +45,7 @@ def test_parses_a_well_formed_json_array() -> None: def test_entry_naming_an_organization_not_in_the_input_list_is_skipped() -> None: - content = ( - '[{"organization_name": "Unlisted Corp", "relationship_type_code": "rel_voc"}]' - ) + content = '[{"organization_name": "Unlisted Corp", "relationship_type_code": "rel_voc"}]' assert parse_classification_response(content, ["Acme Corp"]) == [] @@ -62,32 +58,6 @@ def test_empty_array_is_no_relationships() -> None: assert parse_classification_response("[]", ["Acme Corp"]) == [] -def test_every_adr_0232_voice_class_code_is_accepted_not_dropped() -> None: - """ADR 0232's six new relationship codes must classify, not silently - drop -- a dropped entry is the failure mode the parser guards against, - so extending the vocabulary without extending acceptance would be a - silent regression. - """ - codes = [ - "rel_voe", - "rel_vob", - "rel_vor", - "rel_voi", - "rel_voso", - "rel_vops", - ] - content = ( - "[" - + ", ".join( - f'{{"organization_name": "Acme Corp", "relationship_type_code": "{code}"}}' - for code in codes - ) - + "]" - ) - results = parse_classification_response(content, ["Acme Corp"]) - assert [r.relationship_type_code for r in results] == codes - - def test_invalid_json_returns_empty_list() -> None: assert parse_classification_response("not json", ["Acme Corp"]) == [] @@ -136,22 +106,14 @@ def test_classified_names_attach_cataloged_org_ids_or_stay_null() -> None: rows = attach_resolved_entity_ids( [ - { - "counterparty_entity_name": "Demo Corp", - "relationship_type_code": "rel_voc", - }, - { - "counterparty_entity_name": "Northridge Grid", - "relationship_type_code": "rel_voc", - }, + {"counterparty_entity_name": "Demo Corp", "relationship_type_code": "rel_voc"}, + {"counterparty_entity_name": "Northridge Grid", "relationship_type_code": "rel_voc"}, ], [ CorporateEntityCandidate("corp-1", "Demo Corp"), CorporateEntityCandidate("corp-2", "Test Corp"), ], ) - by_name = { - row["counterparty_entity_name"]: row["corporate_entity_id"] for row in rows - } + by_name = {row["counterparty_entity_name"]: row["corporate_entity_id"] for row in rows} assert by_name["Demo Corp"] == "corp-1" assert by_name["Northridge Grid"] is None diff --git a/tests/test_ontology.py b/tests/test_ontology.py index efb77200f..5fe5e5cde 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -45,8 +45,8 @@ # 0012 (ADR 0006: prov_person/prov_organization), 0014 (ADR 0007: # prov_team), 0016 (ADR 0009: node_team/edge_mention_team/ # edge_team_affiliation/edge_mention_organization), 0042 (ADR 0207: -# the original five voc_type post-type codes) + 0222 (ADR 0232: the -# seven further Voice-of-X codes and their relationship mirrors), and +# the original five voc_type post-type codes) + 0235 (ADR 0246: the +# seven further Voice-of-X post-type codes), and # 0220 (ADR 0222: node_project/edge_mention_project). _ADDITIONAL_LOOKUP_MIGRATION_PATHS = ( Path(__file__).resolve().parents[1] @@ -61,7 +61,7 @@ Path(__file__).resolve().parents[1] / "migrations" / "0042_voc_type_vocabulary.sql", Path(__file__).resolve().parents[1] / "migrations" - / "0222_voice_of_x_complete_taxonomy.sql", + / "0235_voice_of_x_post_taxonomy.sql", Path(__file__).resolve().parents[1] / "migrations" / "0220_ontology_project_node.sql", @@ -402,7 +402,7 @@ def test_shared_timestamps_declare_no_domain_to_avoid_multi_domain_entailment() def test_post_type_scheme_covers_the_governed_voc_vocabulary() -> None: - """ADR 0232: the complete twelve-code stakeholder-voice vocabulary + """ADR 0246: the expanded twelve-code source-post voice vocabulary becomes SKOS concepts; every seeded code resolves, including vos, which ADR 0207 had restricted to its rel_vos relationship mirror. """ @@ -445,22 +445,7 @@ def test_post_type_scheme_covers_the_governed_voc_vocabulary() -> None: } <= seeded -def test_voice_of_x_relationship_properties_resolve_for_the_full_taxonomy() -> None: - """ADR 0232: each new voice class mirrors into an object property the - Knowledge Graph can project, with Post -> CorporateEntity direction - matching the stored counterparty rows. - """ - graph = load_ontology() - expected = { - ("rel_voe", LW.hasVoeRelationship), - ("rel_vob", LW.hasVobRelationship), - ("rel_vor", LW.hasVorRelationship), - ("rel_voi", LW.hasVoiRelationship), - ("rel_voso", LW.hasVosoRelationship), - ("rel_vops", LW.hasVopsRelationship), - ("rel_vos", LW.hasVosRelationship), - } - for code, prop in expected: - assert iri_for_lookup_code(code) == str(prop), code - assert (prop, RDFS.domain, LW.Post) in graph, str(prop) - assert (prop, RDFS.range, LW.CorporateEntity) in graph, str(prop) +def test_post_voice_additions_do_not_invent_counterparty_relationships() -> None: + """ADR 0246 keeps source-post voice and named-organization relations distinct.""" + for code in ("rel_voe", "rel_vob", "rel_vor", "rel_voi", "rel_voso", "rel_vops"): + assert iri_for_lookup_code(code) is None From fea73e19c8b9662b4e6c3c842f1edaa6a3ef6d63 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 23:14:01 +0900 Subject: [PATCH 03/53] docs: align Voice-of-X changelog vocabulary --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b269f61..1aeaf1af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,9 @@ All notable changes to this project are documented here. Format follows - Node-attribute datatype properties grounded only in real schema columns (`postTitle`, `postBody`, `eventOccurredAt`, `personName`, `lastKnownJobTitle`, `entityName`, `entityCode`, shared domain-free - `createdAt`/`updatedAt`), a SKOS post-type scheme formalizing the governed - five-value `voc_type` vocabulary under the round-trip check, and logical + `createdAt`/`updatedAt`), a SKOS post-type scheme formalizing the initial + five-value `voc_type` vocabulary plus ADR 0246's seven additions under the + round-trip check, and logical constraints: `OurSidePerson owl:disjointWith CounterpartyPerson` plus the `hasAffiliate` inverse of `affiliatedWith` (ADR 0207). From cc3dfc144e1b4be1fa8c3a525fb059d4aba91fc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:53:58 +0900 Subject: [PATCH 04/53] docs(ontology): record Voice-of-X composition gap --- .../0246-expanded-voice-of-x-post-taxonomy.md | 15 +++++ docs/product-technical-gap-baseline.md | 58 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md b/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md index fa227a35a..de9ae6556 100644 --- a/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md +++ b/docs/adr/0246-expanded-voice-of-x-post-taxonomy.md @@ -65,6 +65,13 @@ Codes remain globally unique lowercase literals in ## References +AccountAbility. (2015). *AA1000 stakeholder engagement standard*. +https://www.accountability.org/standards/aa1000-stakeholder-engagement + +AccountAbility. (2025). *AccountAbility launches public consultation for the +AA1000 Stakeholder Engagement Standard (AA1000SES v3)*. +https://www.accountability.org/insights/accountability-launches-public-consultation-for-the-aa1000-stakeholder-engagement-standard-aa1000ses-v3 + Freeman, R. E. (1984). *Strategic management: A stakeholder approach*. Pitman. @@ -78,6 +85,14 @@ process—Part 4: Analysis of non-quantitative and quantitative Voice of Customer and Voice of Stakeholder* (ISO Standard No. 16355-4:2017). https://www.iso.org/standard/62607.html +International Organization for Standardization. (2023, December 19). +*Global Directory stakeholder categories*. +https://helpdesk-docs.iso.org/article/331-gd-stakeholders-categories + +International Organization for Standardization. (2010). *Guidance on social +responsibility* (ISO Standard No. 26000:2010). +https://www.iso.org/standard/42546.html + Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of stakeholder identification and salience: Defining the principle of who and what really counts. *Academy of Management Review, 22*(4), 853-886. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7704fa748..e2477079f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,63 @@ # Product & Technical Gap Baseline +> Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was +> `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was +> `fea73e19c8b9662b4e6c3c842f1edaa6a3ef6d63`. This candidate and the +> historical evidence below are not protected-main release evidence. + +## Voice-of-X product and technical gap + +ADR 0246 and PR #713 add Supplier, Employee, Business, Regulator, Investor, +Society, and Process to the original Customer, Customer's Customer, +Competitor, Market, and Partner source-post vocabulary. The migration, +published SKOS concepts, product requirements, changelog, and ontology +round-trip tests agree on the twelve codes. The design is organization-type +neutral: public bodies, nonprofits, communities, and automated processes do +not need to be forced into a B2B2C customer chain. + +The phrase "all Voice-of-X combinations" does not have a standards-backed +finite enumeration. ISO's own stakeholder-category guidance says that the +relevant category set varies by committee and subject; ISO 26000 requires +stakeholder identification and engagement across organizational contexts; +AA1000SES requires an inclusive, continuing identification process; and +Mitchell, Agle, and Wood (1997) model stakeholder salience from combinations +of power, legitimacy, and urgency rather than a fixed industry-role list. +Accordingly, ADR 0246 keeps the controlled vocabulary extensible and refuses +keyword inference, defaults, invented weights, or an asserted exhaustive +cross-product. + +The remaining user-visible gap is evidence-bearing composition. A post still +has one source-provided `voc_type_code`; the product cannot yet represent a +single record that intentionally carries multiple independently evidenced +voices, nor expose the combination in filters, exports, or the ontology +neighborhood. Do not solve this by adding every Cartesian-product code. The +acceptance boundary for a later ADR is a normalized, provenance-bearing +multi-voice association that: + +1. preserves the imported primary voice without reclassification; +2. records each additional voice with its own source/evidence and truth state; +3. keeps post voice distinct from named-counterparty relationship, actor role, + topic, channel, lifecycle, and stakeholder-salience attributes; +4. returns only authorized associations through API, JSON-LD, CSV, filters, + and UI; and +5. proves zero-, one-, and multi-voice states with synthetic fixtures, + migration replay, ontology/SHACL, API, accessibility, and Storybook edge + tests before any release claim. + +At this snapshot the repository had 23 open PRs and 10 open issues. PR #713 +was `MERGEABLE` but policy-blocked: exact-head backend, frontend, CodeQL, +ontology-publication, Semgrep, OSV, Trivy, Scorecard, Noema, Devin, and +CodeRabbit checks were successful; `coverage-source-tree` was queued; Strix +failed closed with `STRIX_PROVIDER_UNAVAILABLE`; and an independent approval +was still required. Auto-merge remains enabled. No self-approval, admin bypass, +or stale-head check transfer is permitted. + +References for this gap use the APA 7 entries in ADR 0246. Current supporting +standards pages were rechecked on 2026-08-27: ISO 26000:2010 remains applicable +to all organization types and AA1000SES v3 is under development for a planned +2027 release, so the repository continues to cite the published AA1000SES +(2015) contract rather than treating the draft as adopted policy. + > Dashboard delivery snapshot: 2026-08-26 07:15 KST. Protected `main` was > `494b54e2245040bcf02b45376f221c37cd437e76`. This local branch is not > protected-main release evidence. From dce623a165b2191c4796c00b0c8e83f1a6de6beb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 00:59:31 +0900 Subject: [PATCH 05/53] feat(ontology): persist evidence-bearing voice combinations --- CHANGELOG.md | 5 + ...247-evidence-bearing-voice-combinations.md | 105 ++++++++++++++++++ docs/ontology/lineageweave-kg.ttl | 24 ++++ docs/product-requirements.md | 2 +- docs/product-technical-gap-baseline.md | 21 ++-- .../0237_source_post_voice_combination.sql | 55 +++++++++ tests/test_ontology.py | 17 +++ tests/test_source_post_voice_schema.py | 33 ++++++ 8 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0247-evidence-bearing-voice-combinations.md create mode 100644 migrations/0237_source_post_voice_combination.sql create mode 100644 tests/test_source_post_voice_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aeaf1af3..03041ad35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows ### Added +- Normalized Voice-of-X composition persistence (ADR 0247): every imported + primary voice is mirrored into `source_post_voice`; each additional voice + requires its own PROV-O assertion and truth status. Compound lookup codes, + keyword inference, confidence thresholds, and invented weights remain out of + the contract; the ontology publishes qualified `VoiceAssignment` resources. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md new file mode 100644 index 000000000..7d07e51bf --- /dev/null +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -0,0 +1,105 @@ +# ADR 0247: Evidence-bearing Voice-of-X combinations + +## Status + +Accepted (2026-08-27). Extends ADR 0246 without replacing the imported +`source_post.voc_type_code` contract. + +## Context + +A record can carry more than one stakeholder perspective: for example, a +customer-authored record can preserve a downstream user's statement, or an +employee can report a process-generated signal. Encoding every pair or larger +combination as a new lookup code creates an unbounded Cartesian vocabulary and +loses the evidence for each component. + +No cited standard defines a finite, universal list of stakeholder +combinations. ISO stakeholder guidance explicitly allows the relevant +categories to vary by subject. ISO 26000 and AA1000SES instead require ongoing, +context-sensitive stakeholder identification and engagement. Mitchell, Agle, +and Wood (1997) likewise derive stakeholder salience from combinations of +attributes rather than from one exhaustive industry-role list. + +## Decision + +Represent composition as rows in normalized `source_post_voice`, not as +compound lookup codes. + +- The existing `source_post.voc_type_code` remains the authoritative imported + primary voice. A trigger mirrors it into exactly one primary association so + existing import, filtering, and lineage behavior remains stable. +- An additional voice uses another existing `voc_type` code and must reference + a normalized `provenance_assertion`. Missing evidence therefore cannot be + persisted as a positive association. +- The pair `(post_id, voice_type_code)` is unique. One partial unique index + permits only one primary voice while allowing any evidence-backed subset of + the governed vocabulary as additional voices. +- Voice remains distinct from counterparty relationship, actor role, topic, + channel, lifecycle, and stakeholder-salience attributes. No inference, + keyword rule, confidence threshold, or weight converts those dimensions into + a voice. +- The public ontology represents each row as a qualified `VoiceAssignment` + linked from its post. Each assignment names one atomic SKOS voice concept; + additional assignments retain evidence through `prov:wasDerivedFrom`. +- API, JSON-LD, filtering, and UI publication remain unavailable until a later + delivery slice applies the same post-read authorization boundary and exposes + provenance per association. Schema presence is not a customer-facing claim. + +## 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 + text truth_status_code + uuid provenance_assertion_id + timestamptz recorded_at + } + class LookupValue { + text lookup_code + text lookup_category + } + class ProvenanceAssertion { + uuid assertion_id + } + SourcePost "1" --> "1..*" SourcePostVoice + LookupValue "1" --> "0..*" SourcePostVoice + ProvenanceAssertion "0..1" --> "0..*" SourcePostVoice +``` + +## Consequences + +Migration 0237 is replay-safe, backfills one primary association per existing +post, synchronizes later inserts and primary-voice changes, and adds a +voice-first index for bounded filtering. It introduces no new Voice-of-X +category and stores no source content or identifying evidence in repository +artifacts. + +The next product slice must project authorized combinations through the API, +ontology/SHACL, exports, and accessible UI with synthetic zero-, one-, and +multi-voice tests. Until then, the association table is a persistence contract, +not proof that users can inspect combinations. + +## References + +AccountAbility. (2015). *AA1000 stakeholder engagement standard*. +https://www.accountability.org/standards/aa1000-stakeholder-engagement + +International Organization for Standardization. (2010). *Guidance on social +responsibility* (ISO Standard No. 26000:2010). +https://www.iso.org/standard/42546.html + +International Organization for Standardization. (2023, December 19). +*Global Directory stakeholder categories*. +https://helpdesk-docs.iso.org/article/331-gd-stakeholders-categories + +Mitchell, R. K., Agle, B. R., & Wood, D. J. (1997). Toward a theory of +stakeholder identification and salience: Defining the principle of who and +what really counts. *Academy of Management Review, 22*(4), 853–886. +https://doi.org/10.5465/amr.1997.9711022105 diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 5c1301aa1..0c201873b 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -361,6 +361,30 @@ rdfs:comment "A process- or system-generated source record." ; :lookupCode "vops" . +# ADR 0247 -- qualified, evidence-bearing combinations. A post links to one +# assignment per atomic voice instead of minting a term for each Cartesian +# combination. Additional assignments use prov:wasDerivedFrom to retain their +# evidence lineage. +:VoiceAssignment a owl:Class ; + rdfs:subClassOf prov:Entity ; + rdfs:label "Voice assignment"@en ; + rdfs:comment "One atomic Voice-of-X classification attached to a post with its own truth and provenance contract."@en . + +:hasVoiceAssignment a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range :VoiceAssignment ; + rdfs:label "has voice assignment"@en . + +:assignedVoiceType a owl:ObjectProperty ; + rdfs:domain :VoiceAssignment ; + rdfs:range skos:Concept ; + rdfs:label "assigned voice type"@en . + +:primaryVoiceAssignment a owl:DatatypeProperty ; + rdfs:domain :VoiceAssignment ; + rdfs:range xsd:boolean ; + rdfs:label "primary voice assignment"@en . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 0f000c1e3..3be61b623 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -220,7 +220,7 @@ A release claim requires one exact protected-main head that proves: - Product/data boundary: ADR 0001, ADR 0089. - Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213. - Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065, - ADR 0184, ADR 0207, ADR 0222, ADR 0246. + ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0247. - Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217. - LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079. - Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e2477079f..8d1711f0d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -26,16 +26,17 @@ Accordingly, ADR 0246 keeps the controlled vocabulary extensible and refuses keyword inference, defaults, invented weights, or an asserted exhaustive cross-product. -The remaining user-visible gap is evidence-bearing composition. A post still -has one source-provided `voc_type_code`; the product cannot yet represent a -single record that intentionally carries multiple independently evidenced -voices, nor expose the combination in filters, exports, or the ontology -neighborhood. Do not solve this by adding every Cartesian-product code. The -acceptance boundary for a later ADR is a normalized, provenance-bearing -multi-voice association that: - -1. preserves the imported primary voice without reclassification; -2. records each additional voice with its own source/evidence and truth state; +ADR 0247 and migration 0237 now define the persistence contract for +evidence-bearing composition. A post keeps one source-provided +`voc_type_code`, mirrored as its sole primary association, while every +additional voice requires a normalized PROV-O assertion and explicit truth +status. This implements the first two requirements below without adding +Cartesian-product codes. The remaining acceptance boundary is: + +1. preserve the imported primary voice without reclassification (implemented + in the candidate migration; runtime replay pending); +2. record each additional voice with its own source/evidence and truth state + (schema-enforced; no authorized write workflow shipped yet); 3. keeps post voice distinct from named-counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes; 4. returns only authorized associations through API, JSON-LD, CSV, filters, diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql new file mode 100644 index 000000000..c2eacd781 --- /dev/null +++ b/migrations/0237_source_post_voice_combination.sql @@ -0,0 +1,55 @@ +-- ADR 0247: normalized, evidence-bearing Voice-of-X combinations. +-- source_post.voc_type_code remains the imported primary voice. Additional +-- voices require a normalized PROV-O assertion instead of keyword inference. + +begin; + +create table if not exists source_post_voice ( + post_id uuid not null references source_post (post_id) on delete cascade, + voice_type_code text not null references common_lookup_value (lookup_code), + is_primary boolean not null default false, + truth_status_code text not null references common_lookup_value (lookup_code), + provenance_assertion_id uuid references provenance_assertion (assertion_id), + recorded_at timestamptz not null default now(), + primary key (post_id, voice_type_code), + check (is_primary or provenance_assertion_id is not null) +); + +create unique index if not exists source_post_voice_primary_idx + on source_post_voice (post_id) where is_primary; + +create index if not exists source_post_voice_type_idx + on source_post_voice (voice_type_code, post_id); + +insert into source_post_voice + (post_id, voice_type_code, is_primary, truth_status_code) +select post_id, voc_type_code, true, 'truth_observed' +from source_post +on conflict (post_id, voice_type_code) do update +set is_primary = true; + +create or replace function synchronize_source_post_primary_voice() +returns trigger +language plpgsql +as $$ +begin + delete from source_post_voice + where post_id = new.post_id + and is_primary + and voice_type_code <> new.voc_type_code; + + insert into source_post_voice + (post_id, voice_type_code, is_primary, truth_status_code) + values (new.post_id, new.voc_type_code, true, 'truth_observed') + on conflict (post_id, voice_type_code) do update + set is_primary = true; + return new; +end; +$$; + +drop trigger if exists source_post_primary_voice_sync on source_post; +create trigger source_post_primary_voice_sync +after insert or update of voc_type_code on source_post +for each row execute function synchronize_source_post_primary_voice(); + +commit; diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 5fe5e5cde..72de60b77 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -449,3 +449,20 @@ def test_post_voice_additions_do_not_invent_counterparty_relationships() -> None """ADR 0246 keeps source-post voice and named-organization relations distinct.""" for code in ("rel_voe", "rel_vob", "rel_vor", "rel_voi", "rel_voso", "rel_vops"): assert iri_for_lookup_code(code) is None + + +def test_voice_combinations_use_qualified_assignments() -> None: + """ADR 0247 composes atomic voices without Cartesian-product terms.""" + graph = load_ontology() + + assert (LW.VoiceAssignment, RDF.type, OWL.Class) in graph + assert ( + LW.VoiceAssignment, + RDFS.subClassOf, + URIRef("http://www.w3.org/ns/prov#Entity"), + ) in graph + assert (LW.hasVoiceAssignment, RDFS.domain, LW.Post) in graph + assert (LW.hasVoiceAssignment, RDFS.range, LW.VoiceAssignment) in graph + assert (LW.assignedVoiceType, RDFS.domain, LW.VoiceAssignment) in graph + assert (LW.assignedVoiceType, RDFS.range, SKOS.Concept) in graph + assert (LW.primaryVoiceAssignment, RDFS.range, XSD.boolean) in graph diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py new file mode 100644 index 000000000..d968431d5 --- /dev/null +++ b/tests/test_source_post_voice_schema.py @@ -0,0 +1,33 @@ +"""Static contract tests for ADR 0247's normalized Voice-of-X associations.""" + +from pathlib import Path + + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0237_source_post_voice_combination.sql" +) + + +def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: + """Additional voices require provenance while the imported primary remains mirrored.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "create table if not exists source_post_voice" in sql + assert "primary key (post_id, voice_type_code)" in sql + assert "check (is_primary or provenance_assertion_id is not null)" in sql + assert "truth_status_code text not null" in sql + assert "true, 'truth_observed'" in sql + assert "where is_primary" in sql + assert "select post_id, voc_type_code, true" in sql + assert "after insert or update of voc_type_code on source_post" in sql + assert "on conflict (post_id, voice_type_code) do update" in sql + + +def test_voice_combination_migration_uses_no_compound_or_inferred_voice_codes() -> None: + """Composition reuses governed atomic codes instead of minting pair codes or heuristics.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "insert into common_lookup_value" not in sql + assert "confidence" not in sql From 8b8a9be57bd189163a050be2614dff195a5051f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:00:03 +0900 Subject: [PATCH 06/53] docs(gap): track stacked voice-combination delivery --- docs/product-technical-gap-baseline.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8d1711f0d..6d8e3e3a7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -53,6 +53,13 @@ failed closed with `STRIX_PROVIDER_UNAVAILABLE`; and an independent approval was still required. Auto-merge remains enabled. No self-approval, admin bypass, or stale-head check transfer is permitted. +Stacked PR #717 at `dce623a1` carries ADR 0247, migration 0237, qualified +ontology terms, and focused contract tests. It targets #713's branch, not +protected `main`; its checks and review are candidate evidence only. After +#713 reaches protected main, #717 must be synchronized, retargeted to `main`, +and revalidated on its then-current head. Creating #717 raised the live queue +to 24 open PRs after the snapshot above. + References for this gap use the APA 7 entries in ADR 0246. Current supporting standards pages were rechecked on 2026-08-27: ISO 26000:2010 remains applicable to all organization types and AA1000SES v3 is under development for a planned From 461a4d125bb1a27b9d84e82c1b618e55ed34062e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:00:59 +0900 Subject: [PATCH 07/53] fix(db): enforce Voice-of-X lookup category --- ...247-evidence-bearing-voice-combinations.md | 3 +++ .../0237_source_post_voice_combination.sql | 23 +++++++++++++++++++ tests/test_source_post_voice_schema.py | 2 ++ 3 files changed, 28 insertions(+) diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index 7d07e51bf..c5f4508b4 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -34,6 +34,9 @@ compound lookup codes. - The pair `(post_id, voice_type_code)` is unique. One partial unique index permits only one primary voice while allowing any evidence-backed subset of the governed vocabulary as additional voices. +- A database trigger verifies that every association code belongs to the + `voc_type` lookup category; the global lookup-code foreign key alone does not + establish that category boundary. - Voice remains distinct from counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes. No inference, keyword rule, confidence threshold, or weight converts those dimensions into diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index c2eacd781..607fb9a56 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -21,6 +21,29 @@ create unique index if not exists source_post_voice_primary_idx create index if not exists source_post_voice_type_idx on source_post_voice (voice_type_code, post_id); +create or replace function validate_source_post_voice_type() +returns trigger +language plpgsql +as $$ +begin + if not exists ( + select 1 + from common_lookup_value + where lookup_category = 'voc_type' + and lookup_code = new.voice_type_code + ) then + raise exception 'source_post_voice requires a voc_type lookup code' + using errcode = '23514'; + end if; + return new; +end; +$$; + +drop trigger if exists source_post_voice_type_guard on source_post_voice; +create trigger source_post_voice_type_guard +before insert or update of voice_type_code on source_post_voice +for each row execute function validate_source_post_voice_type(); + insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code) select post_id, voc_type_code, true, 'truth_observed' diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index d968431d5..d484ff6a1 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -23,6 +23,8 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "select post_id, voc_type_code, true" in sql assert "after insert or update of voc_type_code on source_post" in sql assert "on conflict (post_id, voice_type_code) do update" in sql + assert "where lookup_category = 'voc_type'" in sql + assert "errcode = '23514'" in sql def test_voice_combination_migration_uses_no_compound_or_inferred_voice_codes() -> None: From 118cc380ac660345429ebd24ce6b2733c78443be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:14:58 +0900 Subject: [PATCH 08/53] feat(api): expose authorized Voice-of-X combinations --- CHANGELOG.md | 4 +- backend/app/main.py | 66 ++++++++++++++++- ...247-evidence-bearing-voice-combinations.md | 16 ++-- docs/product-technical-gap-baseline.md | 5 +- frontend/src/App.test.tsx | 28 +++++++ frontend/src/App.tsx | 7 +- frontend/src/api.ts | 9 +++ tests/test_post_filter_options.py | 3 +- tests/test_source_post_voice_schema.py | 2 + tests/test_source_state_serialization.py | 73 ++++++++++++++++++- 10 files changed, 195 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03041ad35..dd34498cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ All notable changes to this project are documented here. Format follows primary voice is mirrored into `source_post_voice`; each additional voice requires its own PROV-O assertion and truth status. Compound lookup codes, keyword inference, confidence thresholds, and invented weights remain out of - the contract; the ontology publishes qualified `VoiceAssignment` resources. + the contract; the ontology publishes qualified `VoiceAssignment` resources, + authorized post responses expose the assignments, filters match any assigned + voice, and post cards display combined labels. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/main.py b/backend/app/main.py index 6457bbde1..a9dc2bfb8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -587,11 +587,15 @@ def _serialize_post(post: asyncpg.Record, labels: dict[str, str] | None = None) project_evidence = post.get("project_evidence") or [] if isinstance(project_evidence, str): project_evidence = json.loads(project_evidence) + voice_types = post.get("voice_types") or [] + if isinstance(voice_types, str): + voice_types = json.loads(voice_types) return { "post_id": str(post["post_id"]), "post_title": post["post_title"], "voc_type_code": voc, "voc_type_label": resolved.get(voc, voc), + "voice_types": voice_types, "visibility_code": visibility, "visibility_label": resolved.get(visibility, visibility), "source_stage_code": post.get("source_stage_code"), @@ -691,6 +695,36 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor return await labels_for_codes(conn, codes) +async def _load_post_voice_types( + conn: asyncpg.Connection, post_id: str +) -> list[dict[str, Any]]: + """Return qualified Voice-of-X associations without exposing assertion ids.""" + rows = await conn.fetch( + """ + select voice.voice_type_code, lookup.lookup_label, voice.is_primary, + voice.truth_status_code, + voice.provenance_assertion_id is not null as evidence_available + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = $1 + order by voice.is_primary desc, lookup.display_order, voice.voice_type_code + """, + post_id, + ) + return [ + { + "code": row["voice_type_code"], + "label": row["lookup_label"], + "is_primary": row["is_primary"], + "truth_status_code": row["truth_status_code"], + "evidence_available": row["evidence_available"], + } + for row in rows + ] + + async def _post_filter_options( conn: asyncpg.Connection, corporate_entity_ids: frozenset[str], @@ -702,9 +736,10 @@ async def _post_filter_options( coalesce(lookup.lookup_label, option.code) as label, coalesce(lookup.display_order, 2147483647) as display_order from source_post post + left join source_post_voice voice on voice.post_id = post.post_id cross join lateral ( values ('post_visibility', post.visibility_code), - ('voc_type', post.voc_type_code) + ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code)) ) as option(lookup_category, code) left join common_lookup_value lookup on lookup.lookup_category = option.lookup_category @@ -1516,7 +1551,11 @@ async def list_posts( or affiliated.corporate_entity_code ilike '%' || $1 || '%') ) ) - and ($3::text[] is null or post.voc_type_code = any($3::text[])) + and ($3::text[] is null or exists ( + select 1 from source_post_voice voice_filter + where voice_filter.post_id = post.post_id + and voice_filter.voice_type_code = any($3::text[]) + )) and ($4::text is null or post.visibility_code = $4) order by search_priority asc, @@ -1545,7 +1584,8 @@ async def list_posts( else btrim(left(source_post_search_text(post.post_body), 420)) end as post_body_excerpt, char_length(coalesce(post.post_body, '')) > 420 as post_body_truncated, - coalesce(projects.project_evidence, '[]'::json) as project_evidence + coalesce(projects.project_evidence, '[]'::json) as project_evidence, + coalesce(voices.voice_types, '[]'::json) as voice_types from page join source_post post on post.post_id = page.post_id left join lateral ( @@ -1572,6 +1612,24 @@ async def list_posts( limit 5 ) project ) projects on true + left join lateral ( + select json_agg( + json_build_object( + 'code', voice.voice_type_code, + 'label', lookup.lookup_label, + 'is_primary', voice.is_primary, + 'truth_status_code', voice.truth_status_code, + 'evidence_available', voice.provenance_assertion_id is not null + ) + order by voice.is_primary desc, lookup.display_order, + voice.voice_type_code + ) as voice_types + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = page.post_id + ) voices on true order by case when $1::text is not null then page.search_priority end asc, case @@ -1654,6 +1712,7 @@ async def read_post( project_evidence = await _load_project_evidence( conn, post_id, row["source_project_code"], row["source_project_name"] ) + voice_types = await _load_post_voice_types(conn, post_id) known_at = None if as_of_clock is not None: known_at = await fetch_known_at_revision(conn, post_id, as_of_clock) @@ -1661,6 +1720,7 @@ async def read_post( **_serialize_post(row, labels), "post_body": row["post_body"], "project_evidence": project_evidence, + "voice_types": voice_types, } if known_at is not None: payload["known_at"] = known_at diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index c5f4508b4..2ccca12f6 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -44,9 +44,12 @@ compound lookup codes. - The public ontology represents each row as a qualified `VoiceAssignment` linked from its post. Each assignment names one atomic SKOS voice concept; additional assignments retain evidence through `prov:wasDerivedFrom`. -- API, JSON-LD, filtering, and UI publication remain unavailable until a later - delivery slice applies the same post-read authorization boundary and exposes - provenance per association. Schema presence is not a customer-facing claim. +- Authorized post list/detail responses expose ordered voice assignments with + labels, truth state, and evidence availability but never internal assertion + identifiers. Filters match any associated voice, and repeated post cards show + the combined labels. JSON-LD and evidence navigation remain unavailable + until a later slice; schema presence alone is not a broader publication + claim. ## Data model @@ -84,10 +87,9 @@ voice-first index for bounded filtering. It introduces no new Voice-of-X category and stores no source content or identifying evidence in repository artifacts. -The next product slice must project authorized combinations through the API, -ontology/SHACL, exports, and accessible UI with synthetic zero-, one-, and -multi-voice tests. Until then, the association table is a persistence contract, -not proof that users can inspect combinations. +The next product slice must project authorized combinations through JSON-LD, +SHACL, exports, and evidence navigation. Authenticated runtime and rendered +desktop/mobile acceptance remain required before a release claim. ## References diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6d8e3e3a7..5d9ec2c8b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -39,8 +39,9 @@ Cartesian-product codes. The remaining acceptance boundary is: (schema-enforced; no authorized write workflow shipped yet); 3. keeps post voice distinct from named-counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes; -4. returns only authorized associations through API, JSON-LD, CSV, filters, - and UI; and +4. return only authorized associations through API, JSON-LD, CSV, filters, + and UI (API list/detail, filters, and combined post-card labels implemented; + JSON-LD, CSV, and evidence navigation remain missing); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 2dee4513d..37fcd5273 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -117,6 +117,7 @@ describe("App, authenticated", () => { staleSummary?: boolean; contentAfterSummary?: boolean; organizationAliases?: boolean; + combinedVoices?: boolean; askLineageGraph?: boolean; askImageCitation?: boolean; askDelivery?: boolean; @@ -1143,6 +1144,26 @@ describe("App, authenticated", () => { post_title: "Public post", voc_type_code: "voc", voc_type_label: "Voice of Customer", + ...(options?.combinedVoices + ? { + voice_types: [ + { + code: "voc", + label: "Voice of Customer", + is_primary: true, + truth_status_code: "truth_observed", + evidence_available: false, + }, + { + code: "vops", + label: "Voice of Process", + is_primary: false, + truth_status_code: "truth_observed", + evidence_available: true, + }, + ], + } + : {}), visibility_code: "public", visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", @@ -1948,6 +1969,13 @@ describe("App, authenticated", () => { return Object.assign(fetchMock, { releaseMe, releasePostOne }); } + it("shows all evidence-bearing Voice-of-X labels on a post card", async () => { + stubBackend({ combinedVoices: true }); + render(); + + expect(await screen.findByText("Voice of Customer + Voice of Process")).toBeInTheDocument(); + }); + it("renders safe Ask Agent evidence under each cited post", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbba1d9f2..551188f4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4273,7 +4273,12 @@ function PostList({ - {t(post.voc_type_label ?? post.voc_type_code)} + + {(post.voice_types?.length + ? post.voice_types.map((voice) => voice.label) + : [post.voc_type_label ?? post.voc_type_code] + ).join(" + ")} + {t(post.visibility_label ?? post.visibility_code)} {post.source_detail_state_code ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fca5882d0..042f8c9a0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -5,6 +5,7 @@ export interface PostSummary { post_title: string; voc_type_code: string; voc_type_label?: string; + voice_types?: PostVoiceType[]; visibility_code: string; visibility_label?: string; source_stage_code?: string | null; @@ -32,6 +33,14 @@ export interface PostSummary { created_at: string; } +export interface PostVoiceType { + code: string; + label: string; + is_primary: boolean; + truth_status_code: string; + evidence_available: boolean; +} + export interface PostPage { posts: PostSummary[]; total_count: number; diff --git a/tests/test_post_filter_options.py b/tests/test_post_filter_options.py index 076f0ec25..6ac4dc570 100644 --- a/tests/test_post_filter_options.py +++ b/tests/test_post_filter_options.py @@ -56,7 +56,8 @@ def test_post_filter_options_use_one_authorized_source_scan() -> None: query, args = conn.calls[0] assert "cross join lateral" in query assert "('post_visibility', post.visibility_code)" in query - assert "('voc_type', post.voc_type_code)" in query + assert "left join source_post_voice voice" in query + assert "('voc_type', coalesce(voice.voice_type_code, post.voc_type_code))" in query assert "post.corporate_entity_id::text = any($1::text[])" in query assert "post.process_unit_id::text = any($2::text[])" in query assert "nullif(btrim(post.source_draft_code), '') is null" in query diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index d484ff6a1..7721e34df 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -1,5 +1,7 @@ """Static contract tests for ADR 0247's normalized Voice-of-X associations.""" +from __future__ import annotations + from pathlib import Path diff --git a/tests/test_source_state_serialization.py b/tests/test_source_state_serialization.py index 4eee0f5de..132dd9141 100644 --- a/tests/test_source_state_serialization.py +++ b/tests/test_source_state_serialization.py @@ -1,6 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime -from backend.app.main import _serialize_post +import asyncio + +from backend.app.main import _load_post_voice_types, _serialize_post def test_source_state_codes_are_serialized_without_inference() -> None: @@ -21,7 +23,7 @@ def test_source_state_codes_are_serialized_without_inference() -> None: "source_sales_pool_code": "POOL-1", "source_customer_code": "CUSTOMER-1", "source_project_code": "PROJECT-1", - "created_at": datetime(2026, 1, 1, tzinfo=timezone.utc), + "created_at": datetime(2026, 1, 1, tzinfo=UTC), }, {"voc": "Voice of Customer", "public": "Public"}, ) @@ -34,3 +36,68 @@ def test_source_state_codes_are_serialized_without_inference() -> None: assert payload["source_author_code"] == "author-1" assert payload["source_customer_code"] == "CUSTOMER-1" assert payload["source_project_code"] == "PROJECT-1" + + +def test_voice_combinations_are_serialized_without_internal_assertion_ids() -> None: + """A post exposes qualified voice evidence state, not provenance primary keys.""" + voice_types = [ + { + "code": "voc", + "label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "evidence_available": False, + }, + { + "code": "vops", + "label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + }, + ] + payload = _serialize_post( + { + "post_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "post_title": "Synthetic combined signal", + "voc_type_code": "voc", + "visibility_code": "public", + "voice_types": voice_types, + "created_at": datetime(2026, 1, 1, tzinfo=UTC), + } + ) + + assert payload["voice_types"] == voice_types + assert all("provenance_assertion_id" not in voice for voice in payload["voice_types"]) + + +def test_voice_loader_projects_evidence_availability_not_assertion_ids() -> None: + """The read boundary returns a boolean evidence cue and keeps internal ids private.""" + + class Connection: + async def fetch(self, query: str, post_id: str) -> list[dict[str, object]]: + assert "provenance_assertion_id is not null as evidence_available" in query + assert post_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + return [ + { + "voice_type_code": "vops", + "lookup_label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + } + ] + + rows = asyncio.run( + _load_post_voice_types(Connection(), "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") # type: ignore[arg-type] + ) + + assert rows == [ + { + "code": "vops", + "label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "evidence_available": True, + } + ] From 771a8edf0438076dd7f4f0d75defe794c4b06c9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:16:10 +0900 Subject: [PATCH 09/53] fix(db): validate Voice assignment truth state --- ...247-evidence-bearing-voice-combinations.md | 8 +++++-- .../0237_source_post_voice_combination.sql | 23 +++++++++++++++---- tests/test_source_post_voice_schema.py | 3 +++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index 2ccca12f6..ec32a6fad 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -35,8 +35,12 @@ compound lookup codes. permits only one primary voice while allowing any evidence-backed subset of the governed vocabulary as additional voices. - A database trigger verifies that every association code belongs to the - `voc_type` lookup category; the global lookup-code foreign key alone does not - establish that category boundary. + `voc_type` lookup category and every truth code belongs to + `ontology_truth_status`; + the global lookup-code foreign key alone does not establish either category + boundary. Promoting an existing additional voice to the imported primary + resets it to observed source evidence and removes the now-unneeded derived + assertion reference. - Voice remains distinct from counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes. No inference, keyword rule, confidence threshold, or weight converts those dimensions into diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index 607fb9a56..63d7297d4 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -21,7 +21,7 @@ create unique index if not exists source_post_voice_primary_idx create index if not exists source_post_voice_type_idx on source_post_voice (voice_type_code, post_id); -create or replace function validate_source_post_voice_type() +create or replace function validate_source_post_voice_codes() returns trigger language plpgsql as $$ @@ -35,21 +35,32 @@ begin raise exception 'source_post_voice requires a voc_type lookup code' using errcode = '23514'; end if; + if not exists ( + select 1 + from common_lookup_value + where lookup_category = 'ontology_truth_status' + and lookup_code = new.truth_status_code + ) then + raise exception 'source_post_voice requires an ontology_truth_status lookup code' + using errcode = '23514'; + end if; return new; end; $$; drop trigger if exists source_post_voice_type_guard on source_post_voice; create trigger source_post_voice_type_guard -before insert or update of voice_type_code on source_post_voice -for each row execute function validate_source_post_voice_type(); +before insert or update of voice_type_code, truth_status_code on source_post_voice +for each row execute function validate_source_post_voice_codes(); insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code) select post_id, voc_type_code, true, 'truth_observed' from source_post on conflict (post_id, voice_type_code) do update -set is_primary = true; +set is_primary = true, + truth_status_code = 'truth_observed', + provenance_assertion_id = null; create or replace function synchronize_source_post_primary_voice() returns trigger @@ -65,7 +76,9 @@ begin (post_id, voice_type_code, is_primary, truth_status_code) values (new.post_id, new.voc_type_code, true, 'truth_observed') on conflict (post_id, voice_type_code) do update - set is_primary = true; + set is_primary = true, + truth_status_code = 'truth_observed', + provenance_assertion_id = null; return new; end; $$; diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 7721e34df..29d20b23f 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -26,7 +26,10 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "after insert or update of voc_type_code on source_post" in sql assert "on conflict (post_id, voice_type_code) do update" in sql assert "where lookup_category = 'voc_type'" in sql + assert "where lookup_category = 'ontology_truth_status'" in sql assert "errcode = '23514'" in sql + assert sql.count("truth_status_code = 'truth_observed'") == 2 + assert sql.count("provenance_assertion_id = null") == 2 def test_voice_combination_migration_uses_no_compound_or_inferred_voice_codes() -> None: From 182f5b886ad65ec5ebceae3948db68a4279d6b88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:30:41 +0900 Subject: [PATCH 10/53] feat: publish qualified voice evidence --- CHANGELOG.md | 4 +- .../app/ontology_neighborhood_ingestion.py | 85 ++++++++++- ...247-evidence-bearing-voice-combinations.md | 16 ++- docs/ontology/lineageweave-kg-shapes.ttl | 28 ++++ docs/ontology/lineageweave-kg.ttl | 7 + docs/product-technical-gap-baseline.md | 29 ++-- frontend/src/api.ts | 12 ++ .../src/components/OntologyExplorer.test.tsx | 32 +++++ frontend/src/components/OntologyExplorer.tsx | 19 ++- frontend/src/ontologyLayout.test.ts | 46 +++++- frontend/src/ontologyLayout.ts | 44 +++++- lineageweave/ontology_neighborhood.py | 133 +++++++++++++++++- tests/test_ontology_neighborhood.py | 48 ++++++- tests/test_ontology_neighborhood_ingestion.py | 40 +++++- tests/test_ontology_shapes.py | 20 ++- 15 files changed, 521 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd34498cb..9459dd5b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ All notable changes to this project are documented here. Format follows keyword inference, confidence thresholds, and invented weights remain out of the contract; the ontology publishes qualified `VoiceAssignment` resources, authorized post responses expose the assignments, filters match any assigned - voice, and post cards display combined labels. + voice, post cards display combined labels, and the authorized neighborhood + carries the assignments through SHACL-validated JSON-LD, exact-value CSV, + and source-post evidence navigation. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index cbcfef440..53ba9aa9b 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -18,39 +18,41 @@ visible_team_mention_post_ids, ) from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL -from lineageweave.post_summary import parse_project_candidate_node_id from lineageweave.knowledge_graph import ( + EDGE_MENTION_PROJECT, NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, NODE_PROJECT, NODE_TEAM, - EDGE_MENTION_PROJECT, ) +from lineageweave.ontology import iri_for_lookup_code from lineageweave.ontology_neighborhood import ( DEFAULT_MAXIMUM_DEPTH, DEFAULT_MAXIMUM_EDGES, DEFAULT_MAXIMUM_NODES, HARD_MAXIMUM_EDGES, KNOWN_NODE_TYPES, + PROPERTY_SKOS_BROADER, NeighborhoodFact, - OntologyNodeMetadata, OntologyNeighborhood, OntologyNeighborhoodError, - PROPERTY_SKOS_BROADER, + OntologyNodeMetadata, + OntologyVoiceAssignment, assemble_ontology_neighborhood, fact_from_knowledge_graph_edge, skos_broader_fact, ) from lineageweave.ontology_source_cursor import ( - OntologySourceKey, - OntologySourceCursor, SOURCE_CURSOR_PREFIX, + OntologySourceCursor, + OntologySourceKey, mint_source_cursor, source_cursor_secret_from_env, source_key_from_row, verify_source_cursor, ) +from lineageweave.post_summary import parse_project_candidate_node_id NOT_FOUND_NEIGHBORHOOD_CODES = frozenset( {"focus_hidden", "focus_not_visible", "unknown_node_type", "dangling_endpoint"} @@ -758,11 +760,73 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any } for edge in neighborhood.edges ], + "voice_assignments": [ + { + "post_id": assignment.post_id, + "voice_type_code": assignment.voice_type_code, + "voice_type_iri": assignment.voice_type_iri, + "voice_type_label": assignment.voice_type_label, + "is_primary": assignment.is_primary, + "truth_status_code": assignment.truth_status_code, + "recorded_at": assignment.recorded_at.isoformat(), + "provenance_reference": assignment.provenance_reference, + } + for assignment in neighborhood.voice_assignments + ], "exact_value_rows": list(neighborhood.exact_value_rows()), "jsonld": neighborhood.jsonld_document(), } +async def _load_voice_assignments( + conn: asyncpg.Connection, + post_id: str, + *, + knowledge_cutoff: datetime | None, +) -> tuple[OntologyVoiceAssignment, ...]: + """Load qualified voices only after the focus post passed authorization.""" + rows = await conn.fetch( + """ + select voice.voice_type_code, lookup.lookup_label, voice.is_primary, + voice.truth_status_code, voice.recorded_at, + voice.provenance_assertion_id is not null as has_assertion + from source_post_voice voice + join common_lookup_value lookup + on lookup.lookup_category = 'voc_type' + and lookup.lookup_code = voice.voice_type_code + where voice.post_id = $1 + and ($2::timestamptz is null or voice.recorded_at <= $2) + order by voice.is_primary desc, lookup.display_order, voice.voice_type_code + """, + post_id, + knowledge_cutoff, + ) + assignments: list[OntologyVoiceAssignment] = [] + for row in rows: + voice_type_iri = iri_for_lookup_code(row["voice_type_code"]) + if voice_type_iri is None: + raise OntologyNeighborhoodError( + "unknown_property", "voice type has no published ontology term" + ) + assignments.append( + OntologyVoiceAssignment( + post_id=post_id, + voice_type_code=row["voice_type_code"], + voice_type_iri=voice_type_iri, + voice_type_label=row["lookup_label"], + is_primary=row["is_primary"], + truth_status_code=row["truth_status_code"], + recorded_at=row["recorded_at"], + provenance_reference=( + "Evidence-backed additional voice" + if row["has_assertion"] + else "Imported primary voice" + ), + ) + ) + return tuple(assignments) + + async def visible_ontology_neighborhood( conn: asyncpg.Connection, *, @@ -1074,6 +1138,15 @@ async def visible_ontology_neighborhood( cursor=assembler_cursor, source_truncated=source_truncated, ) + if focus_node_type_code == NODE_POST: + neighborhood = replace( + neighborhood, + voice_assignments=await _load_voice_assignments( + conn, + focus_node_id, + knowledge_cutoff=knowledge_cutoff, + ), + ) last_source_key = None neighborhood_edges = getattr(neighborhood, "edges", ()) for edge in reversed(neighborhood_edges): diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index ec32a6fad..ddd131cc0 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -51,9 +51,12 @@ compound lookup codes. - Authorized post list/detail responses expose ordered voice assignments with labels, truth state, and evidence availability but never internal assertion identifiers. Filters match any associated voice, and repeated post cards show - the combined labels. JSON-LD and evidence navigation remain unavailable - until a later slice; schema presence alone is not a broader publication - claim. + the combined labels. +- The authorized ontology neighborhood projects each association as a + qualified assignment in JSON-LD and the exact-value CSV. SHACL requires its + atomic voice concept, primary flag, and source-post evidence. The exact-value + table opens that already-authorized source post; it does not invent a graph + edge or expose an internal assertion identifier. ## Data model @@ -91,9 +94,10 @@ voice-first index for bounded filtering. It introduces no new Voice-of-X category and stores no source content or identifying evidence in repository artifacts. -The next product slice must project authorized combinations through JSON-LD, -SHACL, exports, and evidence navigation. Authenticated runtime and rendered -desktop/mobile acceptance remain required before a release claim. +The repository candidate projects authorized combinations through JSON-LD, +SHACL, CSV, and evidence navigation. Authenticated runtime, an authorized write +workflow for additional assignments, and rendered desktop/mobile acceptance +remain required before a release claim. ## References diff --git a/docs/ontology/lineageweave-kg-shapes.ttl b/docs/ontology/lineageweave-kg-shapes.ttl index 436eb401f..38adbc08c 100644 --- a/docs/ontology/lineageweave-kg-shapes.ttl +++ b/docs/ontology/lineageweave-kg-shapes.ttl @@ -166,6 +166,34 @@ sh:datatype xsd:string ; ] . +:VoiceAssignmentShape a sh:NodeShape ; + rdfs:label "Voice assignment shape" ; + sh:targetClass :VoiceAssignment ; + sh:property [ + sh:path :assignedVoiceType ; + sh:name "assigned voice type" ; + sh:description "Every qualified assignment names exactly one governed atomic Voice-of-X concept." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class ; + ] ; + sh:property [ + sh:path :primaryVoiceAssignment ; + sh:name "primary voice assignment" ; + sh:description "The imported-primary marker is explicit and single-valued." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:boolean ; + ] ; + sh:property [ + sh:path :voiceAssignmentEvidence ; + sh:name "voice assignment evidence" ; + sh:description "Every assignment retains exactly one authorized supporting source post." ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class :Post ; + ] . + :OurSidePersonShape a sh:NodeShape ; rdfs:label "Our-side person shape" ; sh:comment "Closed-world complement of :OurSidePerson owl:disjointWith :CounterpartyPerson: an instance of one can never be typed as the other." ; diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 0c201873b..9e6e89582 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -385,6 +385,13 @@ rdfs:range xsd:boolean ; rdfs:label "primary voice assignment"@en . +:voiceAssignmentEvidence a owl:ObjectProperty ; + rdfs:subPropertyOf prov:wasDerivedFrom ; + rdfs:domain :VoiceAssignment ; + rdfs:range :Post ; + rdfs:label "voice assignment evidence"@en ; + rdfs:comment "The authorized source post that supports this qualified voice assignment."@en . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5d9ec2c8b..0b0d3a15a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ > Voice-of-X delivery snapshot: 2026-08-27 KST. Protected `main` was > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was -> `fea73e19c8b9662b4e6c3c842f1edaa6a3ef6d63`. This candidate and the +> `cc3dfc144e1b4be1fa8c3a525fb059d4aba91fc3`. This candidate and the > historical evidence below are not protected-main release evidence. ## Voice-of-X product and technical gap @@ -40,23 +40,26 @@ Cartesian-product codes. The remaining acceptance boundary is: 3. keeps post voice distinct from named-counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes; 4. return only authorized associations through API, JSON-LD, CSV, filters, - and UI (API list/detail, filters, and combined post-card labels implemented; - JSON-LD, CSV, and evidence navigation remain missing); and + and UI (candidate API list/detail, filters, combined post-card labels, + qualified JSON-LD, exact-value CSV, SHACL, and source-post evidence + navigation implemented); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. At this snapshot the repository had 23 open PRs and 10 open issues. PR #713 -was `MERGEABLE` but policy-blocked: exact-head backend, frontend, CodeQL, -ontology-publication, Semgrep, OSV, Trivy, Scorecard, Noema, Devin, and -CodeRabbit checks were successful; `coverage-source-tree` was queued; Strix -failed closed with `STRIX_PROVIDER_UNAVAILABLE`; and an independent approval -was still required. Auto-merge remains enabled. No self-approval, admin bypass, -or stale-head check transfer is permitted. - -Stacked PR #717 at `dce623a1` carries ADR 0247, migration 0237, qualified -ontology terms, and focused contract tests. It targets #713's branch, not -protected `main`; its checks and review are candidate evidence only. After +was policy-blocked: exact-head frontend, CodeQL, ontology-publication, +Semgrep, OSV, Trivy, Scorecard, Noema, Devin, and CodeRabbit checks were +successful; the full suite and Strix were running; `coverage-source-tree` was +queued; and an independent approval was still required. Auto-merge remains +enabled. No self-approval, admin bypass, or stale-head check transfer is +permitted. + +Stacked PR #717 at `771a8edf` carries ADR 0247, migration 0237, qualified +ontology terms, persistence/API/UI tests, and the category-validation review +repairs. CodeRabbit and Devin were successful on that exact remote head before +this local projection slice. It targets #713's branch, not protected `main`; +its checks and review are candidate evidence only. After #713 reaches protected main, #717 must be synchronized, retargeted to `main`, and revalidated on its then-current head. Creating #717 raised the live queue to 24 open PRs after the snapshot above. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 042f8c9a0..8a67d783f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -927,6 +927,17 @@ export interface OntologyExactValueRow { evidence_count: string; } +export interface OntologyVoiceAssignmentPayload { + post_id: string; + voice_type_code: string; + voice_type_iri: string; + voice_type_label: string; + is_primary: boolean; + truth_status_code: string; + recorded_at: string; + provenance_reference: string; +} + export interface OntologyNeighborhoodPayload { focus_node_id: string; focus_node_type_code: string; @@ -936,6 +947,7 @@ export interface OntologyNeighborhoodPayload { nodes: OntologyGraphNodePayload[]; edges: OntologyGraphEdgePayload[]; exact_value_rows: OntologyExactValueRow[]; + voice_assignments?: OntologyVoiceAssignmentPayload[]; jsonld: Record; } diff --git a/frontend/src/components/OntologyExplorer.test.tsx b/frontend/src/components/OntologyExplorer.test.tsx index da876419d..d836ef208 100644 --- a/frontend/src/components/OntologyExplorer.test.tsx +++ b/frontend/src/components/OntologyExplorer.test.tsx @@ -274,6 +274,38 @@ describe("OntologyExplorer", () => { expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); + it("opens the source post for a qualified voice assignment", async () => { + const onOpenEvidence = vi.fn(); + const source = neighborhood(); + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "Open evidence: Demo public post" }), + ); + expect(onOpenEvidence).toHaveBeenCalledWith(POST_ID); + }); + it("keeps complete long node labels in the rendered graph and exact-value table", () => { const longLabel = "Synthetic multilingual procurement governance decision with complete provenance"; diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx index f5c6dbc99..0400f5a9a 100644 --- a/frontend/src/components/OntologyExplorer.tsx +++ b/frontend/src/components/OntologyExplorer.tsx @@ -259,6 +259,9 @@ export function OntologyExplorer({ setSelectedEdgeId(edgeId); setSelectedNodeKey(null); }} + onOpenVoiceEvidence={(postId) => + (onOpenEvidence ?? onSelectPost)?.(postId) + } /> ) : null} @@ -491,10 +494,12 @@ function OntologyExactValueTable({ payload, selectedEdgeId, onSelectEdge, + onOpenVoiceEvidence, }: { payload: OntologyNeighborhoodPayload; selectedEdgeId: string | null; onSelectEdge: (edgeId: string) => void; + onOpenVoiceEvidence: (postId: string) => void; }) { return (
( - diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index 7517456ab..d07a5b771 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OntologyNeighborhoodPayload } from "./api"; -import { accumulateNeighborhoodPages, layoutOntologyNeighborhood, neighborhoodCsv } from "./ontologyLayout"; +import { + accumulateNeighborhoodPages, + filterNeighborhood, + layoutOntologyNeighborhood, + neighborhoodCsv, +} from "./ontologyLayout"; const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; @@ -109,6 +114,45 @@ function payload(): OntologyNeighborhoodPayload { } describe("ontologyLayout", () => { + it("keeps evidence-bearing voice assignments in CSV, filters, and page accumulation", () => { + const source = payload(); + const assignment = { + post_id: POST_ID, + voice_type_code: "voc_customer", + voice_type_iri: "https://example.test/voice/customer", + voice_type_label: "Voice of Customer", + is_primary: false, + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "Evidence-backed additional voice", + }; + const row = { + ...source.exact_value_rows[0], + edge_id: `voice-assignment:${POST_ID}:voc_customer`, + property_code: "hasVoiceAssignment", + property_label: "Voice carried by this post", + target_node_id: assignment.voice_type_code, + target_label: assignment.voice_type_label, + target_type_code: "node_voice_type", + }; + const withVoice = { + ...source, + voice_assignments: [assignment], + exact_value_rows: [...source.exact_value_rows, row], + jsonld: { + "@graph": [ + { "@id": `https://example.test/voice-assignment/${POST_ID}/voc_customer` }, + { "@id": assignment.voice_type_iri }, + ], + }, + } satisfies OntologyNeighborhoodPayload; + + expect(neighborhoodCsv(withVoice)).toContain("Voice of Customer"); + expect(filterNeighborhood(withVoice, "customer")!.voice_assignments).toEqual([assignment]); + expect(filterNeighborhood(withVoice, "missing")!.voice_assignments).toEqual([assignment]); + expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]); + }); + it("is deterministic for a fixed payload", () => { const first = layoutOntologyNeighborhood(payload()); const second = layoutOntologyNeighborhood(payload()); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index 0cb6e0ebf..81d92ce11 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -191,12 +191,30 @@ export function filterNeighborhood( } const nodes = payload.nodes.filter((node) => keep.has(nodeKey(node.node_type_code, node.node_id))); const exact_value_rows = payload.exact_value_rows.filter((row) => - edges.some((edge) => edge.edge_id === row.edge_id), + edges.some((edge) => edge.edge_id === row.edge_id) || + (payload.voice_assignments ?? []).some( + (assignment) => + row.edge_id === `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}` && + keep.has(nodeKey("node_post", assignment.post_id)), + ), ); const visibleIds = new Set([ ...nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`), ...edges.map((edge) => `lw:edge/${edge.edge_id}`), ]); + const visibleNodeSuffixes = nodes.map( + (node) => `node/${encodeURIComponent(node.node_type_code)}/${node.node_id}`, + ); + const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) => + keep.has(nodeKey("node_post", assignment.post_id)), + ); + const visibleVoiceIds = new Set( + visibleVoiceAssignments.flatMap((assignment) => [ + assignment.voice_type_iri, + `voice-assignment/${assignment.post_id}/${assignment.voice_type_code}`, + ]), + ); + const visibleVoiceIdSuffixes = [...visibleVoiceIds]; const graph = payload.jsonld["@graph"]; const jsonld = Array.isArray(graph) ? { @@ -205,11 +223,21 @@ export function filterNeighborhood( (item): item is Record => typeof item === "object" && item !== null && typeof item["@id"] === "string" && - visibleIds.has(item["@id"]), + (visibleIds.has(item["@id"]) || + visibleNodeSuffixes.some((suffix) => item["@id"].endsWith(suffix)) || + visibleVoiceIds.has(item["@id"]) || + visibleVoiceIdSuffixes.some((id) => item["@id"].endsWith(id))), ), } : payload.jsonld; - return { ...payload, nodes, edges, exact_value_rows, jsonld }; + return { + ...payload, + nodes, + edges, + exact_value_rows, + voice_assignments: visibleVoiceAssignments, + jsonld, + }; } /** @@ -233,6 +261,15 @@ export function accumulateNeighborhoodPages( for (const row of next.exact_value_rows) { rows.set(row.edge_id, row); } + const voiceAssignments = new Map( + (current.voice_assignments ?? []).map((assignment) => [ + `${assignment.post_id}:${assignment.voice_type_code}`, + assignment, + ]), + ); + for (const assignment of next.voice_assignments ?? []) { + voiceAssignments.set(`${assignment.post_id}:${assignment.voice_type_code}`, assignment); + } const graphItems = new Map>(); for (const payload of [current, next]) { const graph = payload.jsonld["@graph"]; @@ -248,6 +285,7 @@ export function accumulateNeighborhoodPages( nodes: [...nodes.values()], edges: [...edges.values()], exact_value_rows: [...rows.values()], + voice_assignments: [...voiceAssignments.values()], jsonld: { ...next.jsonld, "@graph": [...graphItems.values()], diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index 8c18d5516..833aaee78 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -209,6 +209,44 @@ class OntologyGraphEdge: evidence_references: tuple[str, ...] +@dataclass(frozen=True) +class OntologyVoiceAssignment: + """One authorized, qualified Voice-of-X assignment for a visible post.""" + + post_id: str + voice_type_code: str + voice_type_iri: str + voice_type_label: str + is_primary: bool + truth_status_code: str + recorded_at: datetime + provenance_reference: str + + def __post_init__(self) -> None: + """Reject ungoverned or incomplete assignments at the export boundary.""" + if not all( + value.strip() + for value in ( + self.post_id, + self.voice_type_code, + self.voice_type_iri, + self.voice_type_label, + self.provenance_reference, + ) + ): + raise OntologyNeighborhoodError( + "invalid_voice_assignment", "voice assignment fields must be non-empty" + ) + if self.truth_status_code not in TRUTH_STATUS_CODES: + raise OntologyNeighborhoodError( + "unknown_truth_status", "voice assignment truth status is not governed" + ) + if self.recorded_at.tzinfo is None: + raise OntologyNeighborhoodError( + "naive_timestamp", "voice assignment recorded_at must be offset-aware" + ) + + @dataclass(frozen=True) class OntologyNeighborhood: """Bounded, deterministic neighborhood payload.""" @@ -220,6 +258,7 @@ class OntologyNeighborhood: truncated: bool next_cursor: str | None limitation_code: str | None + voice_assignments: tuple[OntologyVoiceAssignment, ...] = () def exact_value_rows(self) -> tuple[dict[str, str], ...]: """Keyboard/print/CSV rows for the same visible graph.""" @@ -245,6 +284,36 @@ def exact_value_rows(self) -> tuple[dict[str, str], ...]: "evidence_count": str(len(edge.evidence_references)), } ) + post_labels = { + node.node_id: node.display_label + for node in self.nodes + if node.node_type_code == NODE_POST + } + for assignment in self.voice_assignments: + source_label = post_labels.get(assignment.post_id) + if source_label is None: + raise OntologyNeighborhoodError( + "dangling_endpoint", "voice assignment references a missing post" + ) + rows.append( + { + "edge_id": _voice_assignment_id(assignment), + "source_node_id": assignment.post_id, + "source_label": source_label, + "source_type_code": NODE_POST, + "property_code": "hasVoiceAssignment", + "property_label": "Voice carried by this post", + "ontology_property_iri": str(LW.hasVoiceAssignment), + "target_node_id": assignment.voice_type_code, + "target_label": assignment.voice_type_label, + "target_type_code": "node_voice_type", + "truth_status_code": assignment.truth_status_code, + "recorded_at": assignment.recorded_at.isoformat(), + "valid_from": "", + "valid_to": "", + "evidence_count": "1", + } + ) return tuple(rows) def jsonld_document(self) -> dict[str, object]: @@ -297,9 +366,60 @@ def jsonld_document(self) -> dict[str, object]: } _add_jsonld_times(item, edge.recorded_at, edge.valid_from, edge.valid_to) graph.append(item) + assignments_by_post: dict[str, list[OntologyVoiceAssignment]] = {} + for assignment in self.voice_assignments: + assignments_by_post.setdefault(assignment.post_id, []).append(assignment) + for post_id, assignments in assignments_by_post.items(): + graph.append( + { + "@id": ontology_node_iri(NODE_POST, post_id), + str(LW.hasVoiceAssignment): [ + {"@id": _voice_assignment_iri(assignment)} + for assignment in assignments + ], + } + ) + for assignment in self.voice_assignments: + post_iri = ontology_node_iri(NODE_POST, assignment.post_id) + assignment_iri = _voice_assignment_iri(assignment) + 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", + }, + str(LW.voiceAssignmentEvidence): {"@id": post_iri}, + "prov:wasDerivedFrom": {"@id": post_iri}, + "lw:truthStatus": assignment.truth_status_code, + "prov:generatedAtTime": { + "@value": assignment.recorded_at.isoformat(), + "@type": "xsd:dateTimeStamp", + }, + } + ) + graph.append( + { + "@id": assignment.voice_type_iri, + "@type": "skos:Concept", + "skos:prefLabel": assignment.voice_type_label, + } + ) return {"@context": JSONLD_CONTEXT, "@graph": graph} +def _voice_assignment_id(assignment: OntologyVoiceAssignment) -> str: + """Return the deterministic exact-row id for one voice assignment.""" + return f"voice-assignment:{assignment.post_id}:{assignment.voice_type_code}" + + +def _voice_assignment_iri(assignment: OntologyVoiceAssignment) -> str: + """Return the canonical qualified-assignment IRI.""" + return str(LW[f"voice-assignment/{assignment.post_id}/{assignment.voice_type_code}"]) + + def _add_jsonld_times( item: dict[str, object], recorded_at: datetime | None, @@ -810,13 +930,7 @@ def _fact_sort_key(fact: NeighborhoodFact) -> tuple[str, str, str, str, str]: "DEFAULT_MAXIMUM_NODES", "INSTANCE_PROPERTY_CODES", "JSONLD_CONTEXT", - "NeighborhoodFact", "NODE_SHAPE", - "OntologyGraphEdge", - "OntologyGraphNode", - "OntologyNodeMetadata", - "OntologyNeighborhood", - "OntologyNeighborhoodError", "PROPERTY_AFFILIATED_WITH", "PROPERTY_CO_MENTIONED_WITH", "PROPERTY_MENTIONS", @@ -834,6 +948,13 @@ def _fact_sort_key(fact: NeighborhoodFact) -> tuple[str, str, str, str, str]: "TRUTH_REJECTED", "TRUTH_STATUS_CODES", "TRUTH_SUPERSEDED", + "NeighborhoodFact", + "OntologyGraphEdge", + "OntologyGraphNode", + "OntologyNeighborhood", + "OntologyNeighborhoodError", + "OntologyNodeMetadata", + "OntologyVoiceAssignment", "assemble_ontology_neighborhood", "canonicalize_property_code", "fact_from_knowledge_graph_edge", diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index de2749223..928160405 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timedelta, timezone import pytest @@ -22,6 +23,7 @@ ) from lineageweave.ontology import LW, ontology_node_iri from lineageweave.ontology_neighborhood import ( + HARD_MAXIMUM_NODES, PROPERTY_AFFILIATED_WITH, PROPERTY_CO_MENTIONED_WITH, PROPERTY_MENTIONS, @@ -36,12 +38,12 @@ TRUTH_INFERRED, TRUTH_OBSERVED, TRUTH_PROPOSED, - HARD_MAXIMUM_NODES, NeighborhoodFact, OntologyGraphEdge, - OntologyNodeMetadata, OntologyNeighborhood, OntologyNeighborhoodError, + OntologyNodeMetadata, + OntologyVoiceAssignment, assemble_ontology_neighborhood, canonicalize_property_code, fact_from_knowledge_graph_edge, @@ -855,6 +857,48 @@ def test_node_bound_truncation_drops_cursor_and_jsonld_rejects_dangling() -> Non assert rows == () +def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: + """One authorized post exports the same qualified voice through both projections.""" + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + ) + assignment = OntologyVoiceAssignment( + post_id=POST_ID, + voice_type_code="vops", + voice_type_iri=str(LW.voiceOfProcessType), + voice_type_label="Voice of Process", + is_primary=False, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + provenance_reference="Evidence-backed additional voice", + ) + neighborhood = replace(neighborhood, voice_assignments=(assignment,)) + + row = neighborhood.exact_value_rows()[0] + assert row["property_code"] == "hasVoiceAssignment" + assert row["target_label"] == "Voice of Process" + graph = neighborhood.jsonld_document()["@graph"] + assignment_iri = str(LW[f"voice-assignment/{POST_ID}/vops"]) + projected = next(item for item in graph if item.get("@id") == assignment_iri) + post_projection = next( + item + for item in graph + if item.get("@id") == ontology_node_iri(NODE_POST, POST_ID) + and str(LW.hasVoiceAssignment) in item + ) + assert post_projection[str(LW.hasVoiceAssignment)] == [{"@id": assignment_iri}] + assert projected[str(LW.assignedVoiceType)] == {"@id": str(LW.voiceOfProcessType)} + assert projected[str(LW.voiceAssignmentEvidence)] == { + "@id": ontology_node_iri(NODE_POST, POST_ID) + } + + with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): + replace(assignment, recorded_at=T0.replace(tzinfo=None)) + + def test_node_bound_truncation_keeps_nearer_hop_over_farther_alphabetically_earlier_type() -> None: """Trim by BFS distance, not by the raw "type:id" key string. diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index dd803c75c..39497a4d9 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -12,6 +12,7 @@ _load_labels, _load_node_metadata, _load_skos_facts, + _load_voice_assignments, focus_catalog_exists, neighborhood_error_detail, neighborhood_error_http_status, @@ -31,11 +32,11 @@ NODE_TEAM, ) from lineageweave.ontology_neighborhood import ( + PROPERTY_AFFILIATED_WITH, + TRUTH_OBSERVED, NeighborhoodFact, OntologyNeighborhoodError, OntologyNodeMetadata, - PROPERTY_AFFILIATED_WITH, - TRUTH_OBSERVED, assemble_ontology_neighborhood, fact_from_knowledge_graph_edge, ) @@ -876,6 +877,41 @@ def test_load_labels_ignores_unknown_node_types() -> None: assert labels == {} +def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() -> None: + """Qualified voices load only from the authorized post and hide assertion ids.""" + conn = ScriptedConn( + { + "from source_post_voice voice": [ + { + "voice_type_code": "voc", + "lookup_label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": False, + }, + { + "voice_type_code": "vops", + "lookup_label": "Voice of Process", + "is_primary": False, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": True, + }, + ] + } + ) + + assignments = asyncio.run( + _load_voice_assignments(conn, POST_ID, knowledge_cutoff=T0) + ) + + assert [assignment.voice_type_code for assignment in assignments] == ["voc", "vops"] + assert assignments[0].provenance_reference == "Imported primary voice" + assert assignments[1].provenance_reference == "Evidence-backed additional voice" + assert conn.calls[0][1] == (POST_ID, T0) + + def test_payload_serializes_optional_validity() -> None: fact = NeighborhoodFact( source_node_type_code=NODE_PERSON, diff --git a/tests/test_ontology_shapes.py b/tests/test_ontology_shapes.py index 6c74ebc63..f16a94d8a 100644 --- a/tests/test_ontology_shapes.py +++ b/tests/test_ontology_shapes.py @@ -19,9 +19,9 @@ from pathlib import Path import pytest +from pyshacl import validate as shacl_validate from rdflib import Graph, Literal, Namespace, URIRef from rdflib.namespace import RDF, XSD -from pyshacl import validate as shacl_validate from lineageweave.ontology import project_project_mention_rdf @@ -73,6 +73,11 @@ def _representative_projection() -> Graph: Literal("2026-08-25T01:23:45+00:00", datatype=XSD.dateTime), ) ) + voice_assignment = URIRef(LW + "voice-assignment/post-alpha/voc") + data.add((voice_assignment, RDF.type, LWn.VoiceAssignment)) + data.add((voice_assignment, LWn.assignedVoiceType, LWn.voiceOfCustomerType)) + data.add((voice_assignment, LWn.primaryVoiceAssignment, Literal(True))) + data.add((voice_assignment, LWn.voiceAssignmentEvidence, post)) person = URIRef(LW + "person-okonkwo") data.add((person, RDF.type, LWn.Person)) data.add((person, LWn.personName, Literal("Sam Okonkwo"))) @@ -104,6 +109,19 @@ def _representative_projection() -> Graph: return data +def test_voice_assignment_requires_source_evidence() -> None: + """A projected Voice assignment without its authorized source post fails closed.""" + data = _representative_projection() + LWn = Namespace(LW) + assignment = URIRef(LW + "voice-assignment/post-alpha/voc") + data.remove((assignment, LWn.voiceAssignmentEvidence, None)) + + conforms, report = _conforms(data) + + assert conforms is False + assert "voice assignment evidence" in report.lower() + + def test_shipped_shapes_conform_to_shacl_specification() -> None: """The shapes artifact itself must be valid SHACL before it may gate anything else -- validated with no data graph attached to it. From 0a8ce31f09202b98be554d55666751e478d87a08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:37:50 +0900 Subject: [PATCH 11/53] docs: audit combined voice evidence scene --- CHANGELOG.md | 3 +- ...247-evidence-bearing-voice-combinations.md | 7 +-- docs/product-technical-gap-baseline.md | 6 ++- docs/storybook-inventory.md | 2 +- .../components/OntologyExplorer.stories.tsx | 53 +++++++++++++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9459dd5b3..26882c775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ All notable changes to this project are documented here. Format follows authorized post responses expose the assignments, filters match any assigned voice, post cards display combined labels, and the authorized neighborhood carries the assignments through SHACL-validated JSON-LD, exact-value CSV, - and source-post evidence navigation. + and source-post evidence navigation. Storybook includes the combined primary + plus additional Voice evidence state for desktop and narrow-screen audit. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index ddd131cc0..0a26f47a5 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -95,9 +95,10 @@ category and stores no source content or identifying evidence in repository artifacts. The repository candidate projects authorized combinations through JSON-LD, -SHACL, CSV, and evidence navigation. Authenticated runtime, an authorized write -workflow for additional assignments, and rendered desktop/mobile acceptance -remain required before a release claim. +SHACL, CSV, and evidence navigation. Its synthetic Storybook desktop/mobile +scene verifies a focused evidence action and a contained horizontally +scrollable exact-value table. Authenticated runtime and an authorized write +workflow for additional assignments remain required before a release claim. ## References diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0b0d3a15a..6c6d754b6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -45,7 +45,11 @@ Cartesian-product codes. The remaining acceptance boundary is: navigation implemented); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge - tests before any release claim. + tests before any release claim. The candidate `CombinedVoiceEvidence` scene + covers primary-plus-additional assignments; desktop and mobile screenshots + were inspected on 2026-08-27. At 390 CSS pixels the document did not + overflow, the named exact-value region remained horizontally scrollable, + and the source-post evidence action remained visible and labeled. At this snapshot the repository had 23 open PRs and 10 open issues. PR #713 was policy-blocked: exact-head frontend, CodeQL, ontology-publication, diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 81ab3a8af..2c6175440 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -9,7 +9,7 @@ operator-facing control you can click before changing product CSS. | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | -| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | +| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses their source-post evidence action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0247 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx index 5237795d3..cb079520b 100644 --- a/frontend/src/components/OntologyExplorer.stories.tsx +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -213,6 +213,48 @@ const rejectedNeighborhood: OntologyNeighborhoodPayload = { ], }; +const combinedVoiceNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + voice_assignments: [ + { + post_id: POST_ID, + voice_type_code: "voc", + voice_type_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#voiceOfCustomerType", + voice_type_label: "Voice of Customer", + is_primary: true, + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "Imported primary voice", + }, + { + post_id: POST_ID, + voice_type_code: "vops", + voice_type_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#voiceOfProcessType", + voice_type_label: "Voice of Process", + is_primary: false, + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "Evidence-backed additional voice", + }, + ], + exact_value_rows: [ + ...demoNeighborhood.exact_value_rows, + ...[ + ["voc", "Voice of Customer"], + ["vops", "Voice of Process"], + ].map(([code, label]) => ({ + ...demoNeighborhood.exact_value_rows[0], + edge_id: `voice-assignment:${POST_ID}:${code}`, + property_code: "hasVoiceAssignment", + property_label: "Voice carried by this post", + ontology_property_iri: "https://contextualwisdomlab.github.io/LineageWeave/ontology#hasVoiceAssignment", + target_node_id: code, + target_label: label, + target_type_code: "node_voice_type", + })), + ], +}; + const meta = { title: "Evidence/OntologyExplorer", component: OntologyExplorer, @@ -229,6 +271,17 @@ type Story = StoryObj; export const DesktopNeighborhood: Story = {}; +export const CombinedVoiceEvidence: Story = { + args: { neighborhood: combinedVoiceNeighborhood }, + play: ({ canvasElement }) => { + const evidence = canvasElement.querySelector( + 'button[aria-label="Open evidence: Demo public post"]', + ); + if (!evidence) throw new Error("Voice assignment evidence control was not rendered"); + evidence.focus(); + }, +}; + export const LongLabelsAndEvidenceTable: Story = { args: { neighborhood: { From b0f6ca4a07ccaebe51aae4ca066ca177357860ac Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 01:49:04 +0900 Subject: [PATCH 12/53] fix: preserve combined voice behavior --- frontend/src/App.test.tsx | 12 ++++++++++++ frontend/src/App.tsx | 10 +++++++--- migrations/0237_source_post_voice_combination.sql | 7 ++++--- tests/test_source_post_voice_schema.py | 3 ++- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 37fcd5273..40d0084c9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1175,6 +1175,9 @@ describe("App, authenticated", () => { voc_type_options: [ { code: "voc", label: "Voice of Customer" }, { code: "vop", label: "Voice of Partner" }, + ...(options?.combinedVoices + ? [{ code: "vops", label: "Voice of Process" }] + : []), ], visibility_options: [{ code: "public", label: "Public" }], }, @@ -1976,6 +1979,15 @@ describe("App, authenticated", () => { expect(await screen.findByText("Voice of Customer + Voice of Process")).toBeInTheDocument(); }); + it("keeps a post whose additional voice matches the board filter", async () => { + stubBackend({ combinedVoices: true }); + render(); + + await screen.findByRole("button", { name: "View post: Public post" }); + await userEvent.click(screen.getByRole("checkbox", { name: "Voice of Process" })); + expect(screen.getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + }); + it("renders safe Ask Agent evidence under each cited post", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 551188f4c..4a9d76145 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4085,7 +4085,11 @@ function PostList({ })); const filteredPosts = loadedPosts .filter((post) => { - const matchesType = typeFilter.length === 0 || typeFilter.includes(post.voc_type_code); + const matchesType = + typeFilter.length === 0 || + (post.voice_types?.length + ? post.voice_types.some((voice) => typeFilter.includes(voice.code)) + : typeFilter.includes(post.voc_type_code)); const matchesVisibility = visibilityFilter === "all" || post.visibility_code === visibilityFilter; return matchesType && matchesVisibility; }) @@ -4275,8 +4279,8 @@ function PostList({ {(post.voice_types?.length - ? post.voice_types.map((voice) => voice.label) - : [post.voc_type_label ?? post.voc_type_code] + ? post.voice_types.map((voice) => t(voice.label)) + : [t(post.voc_type_label ?? post.voc_type_code)] ).join(" + ")} {t(post.visibility_label ?? post.visibility_code)} diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index 63d7297d4..b621a22e1 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -54,13 +54,14 @@ before insert or update of voice_type_code, truth_status_code on source_post_voi for each row execute function validate_source_post_voice_codes(); insert into source_post_voice - (post_id, voice_type_code, is_primary, truth_status_code) -select post_id, voc_type_code, true, 'truth_observed' + (post_id, voice_type_code, is_primary, truth_status_code, recorded_at) +select post_id, voc_type_code, true, 'truth_observed', created_at from source_post on conflict (post_id, voice_type_code) do update set is_primary = true, truth_status_code = 'truth_observed', - provenance_assertion_id = null; + provenance_assertion_id = null, + recorded_at = least(source_post_voice.recorded_at, excluded.recorded_at); create or replace function synchronize_source_post_primary_voice() returns trigger diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 29d20b23f..1c5c341b3 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -22,7 +22,8 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "truth_status_code text not null" in sql assert "true, 'truth_observed'" in sql assert "where is_primary" in sql - assert "select post_id, voc_type_code, true" in sql + assert "select post_id, voc_type_code, true, 'truth_observed', created_at" in sql + assert "recorded_at = least(source_post_voice.recorded_at, excluded.recorded_at)" in sql assert "after insert or update of voc_type_code on source_post" in sql assert "on conflict (post_id, voice_type_code) do update" in sql assert "where lookup_category = 'voc_type'" in sql From 684df3d8c3664cdb6fc47c61a5892cd14bec5072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:50:47 +0900 Subject: [PATCH 13/53] fix: preserve combined voice cutoff semantics --- CHANGELOG.md | 3 +++ .../app/ontology_neighborhood_ingestion.py | 2 +- ...247-evidence-bearing-voice-combinations.md | 6 +++++ docs/product-technical-gap-baseline.md | 7 ++++-- .../0237_source_post_voice_combination.sql | 22 ++++++++++++++----- tests/test_ontology_neighborhood_ingestion.py | 1 + tests/test_source_post_voice_schema.py | 4 ++-- 7 files changed, 34 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26882c775..8bc5f5f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ All notable changes to this project are documented here. Format follows carries the assignments through SHACL-validated JSON-LD, exact-value CSV, and source-post evidence navigation. Storybook includes the combined primary plus additional Voice evidence state for desktop and narrow-screen audit. + Board filters match additional as well as primary voices, labels retain the + active locale, and cutoff reads use assignment-effective time rather than + migration recording time. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 53ba9aa9b..b8a33d098 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -795,7 +795,7 @@ async def _load_voice_assignments( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = $1 - and ($2::timestamptz is null or voice.recorded_at <= $2) + and ($2::timestamptz is null or voice.effective_from <= $2) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index 0a26f47a5..63d0a159b 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -34,6 +34,11 @@ compound lookup codes. - The pair `(post_id, voice_type_code)` is unique. One partial unique index permits only one primary voice while allowing any evidence-backed subset of the governed vocabulary as additional voices. +- `effective_from` records when an assignment became applicable. The initial + imported primary starts at the source post's `created_at`; a later imported + primary change and every added evidence-bearing voice start when recorded. + Knowledge-cutoff reads use this effective instant, so migration time does not + erase the primary voice from an older authorized view. - A database trigger verifies that every association code belongs to the `voc_type` lookup category and every truth code belongs to `ontology_truth_status`; @@ -72,6 +77,7 @@ classDiagram boolean is_primary text truth_status_code uuid provenance_assertion_id + timestamptz effective_from timestamptz recorded_at } class LookupValue { diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6c6d754b6..473c97554 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -30,7 +30,9 @@ ADR 0247 and migration 0237 now define the persistence contract for evidence-bearing composition. A post keeps one source-provided `voc_type_code`, mirrored as its sole primary association, while every additional voice requires a normalized PROV-O assertion and explicit truth -status. This implements the first two requirements below without adding +status. An explicit assignment-effective instant preserves a backfilled +primary at historical cutoffs without antedating later primary changes. This +implements the first two requirements below without adding Cartesian-product codes. The remaining acceptance boundary is: 1. preserve the imported primary voice without reclassification (implemented @@ -42,7 +44,8 @@ Cartesian-product codes. The remaining acceptance boundary is: 4. return only authorized associations through API, JSON-LD, CSV, filters, and UI (candidate API list/detail, filters, combined post-card labels, qualified JSON-LD, exact-value CSV, SHACL, and source-post evidence - navigation implemented); and + navigation implemented; the board re-filter matches every associated voice + and restores locale translation for each displayed label); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. The candidate `CombinedVoiceEvidence` scene diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index b621a22e1..1e7bbd52f 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -10,11 +10,15 @@ create table if not exists source_post_voice ( is_primary boolean not null default false, truth_status_code text not null references common_lookup_value (lookup_code), provenance_assertion_id uuid references provenance_assertion (assertion_id), + effective_from timestamptz not null default now(), recorded_at timestamptz not null default now(), primary key (post_id, voice_type_code), check (is_primary or provenance_assertion_id is not null) ); +alter table source_post_voice + add column if not exists effective_from timestamptz not null default now(); + create unique index if not exists source_post_voice_primary_idx on source_post_voice (post_id) where is_primary; @@ -54,14 +58,13 @@ before insert or update of voice_type_code, truth_status_code on source_post_voi for each row execute function validate_source_post_voice_codes(); insert into source_post_voice - (post_id, voice_type_code, is_primary, truth_status_code, recorded_at) + (post_id, voice_type_code, is_primary, truth_status_code, effective_from) select post_id, voc_type_code, true, 'truth_observed', created_at from source_post on conflict (post_id, voice_type_code) do update set is_primary = true, truth_status_code = 'truth_observed', - provenance_assertion_id = null, - recorded_at = least(source_post_voice.recorded_at, excluded.recorded_at); + provenance_assertion_id = null; create or replace function synchronize_source_post_primary_voice() returns trigger @@ -74,12 +77,19 @@ begin and voice_type_code <> new.voc_type_code; insert into source_post_voice - (post_id, voice_type_code, is_primary, truth_status_code) - values (new.post_id, new.voc_type_code, true, 'truth_observed') + (post_id, voice_type_code, is_primary, truth_status_code, effective_from) + values ( + new.post_id, + new.voc_type_code, + true, + 'truth_observed', + case when tg_op = 'INSERT' then new.created_at else now() end + ) on conflict (post_id, voice_type_code) do update set is_primary = true, truth_status_code = 'truth_observed', - provenance_assertion_id = null; + provenance_assertion_id = null, + effective_from = excluded.effective_from; return new; end; $$; diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 39497a4d9..7f123b02f 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -909,6 +909,7 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - assert [assignment.voice_type_code for assignment in assignments] == ["voc", "vops"] assert assignments[0].provenance_reference == "Imported primary voice" assert assignments[1].provenance_reference == "Evidence-backed additional voice" + assert "voice.effective_from <= $2" in conn.calls[0][0] assert conn.calls[0][1] == (POST_ID, T0) diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 1c5c341b3..0e9318f6e 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -4,7 +4,6 @@ from pathlib import Path - MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -20,10 +19,11 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "primary key (post_id, voice_type_code)" in sql assert "check (is_primary or provenance_assertion_id is not null)" in sql assert "truth_status_code text not null" in sql + assert "effective_from timestamptz not null" in sql assert "true, 'truth_observed'" in sql assert "where is_primary" in sql assert "select post_id, voc_type_code, true, 'truth_observed', created_at" in sql - assert "recorded_at = least(source_post_voice.recorded_at, excluded.recorded_at)" in sql + assert "case when tg_op = 'insert' then new.created_at else now() end" in sql assert "after insert or update of voc_type_code on source_post" in sql assert "on conflict (post_id, voice_type_code) do update" in sql assert "where lookup_category = 'voc_type'" in sql From e7816ce6eeaf29e8e6685e7fe300c7979ba95843 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:00:15 +0900 Subject: [PATCH 14/53] feat(ui): localize all Voice labels --- CHANGELOG.md | 3 +- docs/product-technical-gap-baseline.md | 3 +- frontend/src/i18n.test.ts | 23 +++++++++++++++ frontend/src/i18n.ts | 40 ++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc5f5f11..869bdaa1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ All notable changes to this project are documented here. Format follows plus additional Voice evidence state for desktop and narrow-screen audit. Board filters match additional as well as primary voices, labels retain the active locale, and cutoff reads use assignment-effective time rather than - migration recording time. + migration recording time. All twelve governed atomic Voice labels are + translated across the five supported product locales. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 473c97554..43abe3a27 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -45,7 +45,8 @@ Cartesian-product codes. The remaining acceptance boundary is: and UI (candidate API list/detail, filters, combined post-card labels, qualified JSON-LD, exact-value CSV, SHACL, and source-post evidence navigation implemented; the board re-filter matches every associated voice - and restores locale translation for each displayed label); and + and all twelve governed atomic labels are localized across English, Korean, + Chinese, Japanese, and Vietnamese); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. The candidate `CombinedVoiceEvidence` scene diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index ae085d010..59c69b76a 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -305,6 +305,21 @@ describe("i18n", () => { }); describe("locale-aware source labels", () => { + const allVoiceLabels = [ + "Voice of Customer", + "Voice of Customer's Customer", + "Voice of Competitor", + "Voice of Market", + "Voice of Partner", + "Voice of Supplier", + "Voice of Employee", + "Voice of Business", + "Voice of Regulator", + "Voice of Investor", + "Voice of Society", + "Voice of Process", + ] as const; + it.each([ ["en", "Voice of Customer", "Public"], ["ko", "고객의 소리", "공개"], @@ -316,4 +331,12 @@ describe("locale-aware source labels", () => { expect(t("Voice of Customer")).toBe(customerVoice); expect(t("Public")).toBe(visibility); }); + + it.each(["ko", "zh", "ja", "vi"] as const)( + "translates every governed atomic Voice label in %s", + (locale) => { + setLocale(locale); + for (const label of allVoiceLabels) expect(t(label)).not.toBe(label); + }, + ); }); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 390be07d8..722b42f08 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -139,7 +139,17 @@ const TRANSLATIONS: Partial>> = { "All VOC types": "모든 VOC 유형", "All visibility": "모든 공개 범위", "Voice of Customer": "고객의 소리", + "Voice of Customer's Customer": "고객의 고객의 소리", + "Voice of Competitor": "경쟁사의 소리", + "Voice of Partner": "파트너의 소리", "Voice of Market": "시장의 소리", + "Voice of Supplier": "공급자의 소리", + "Voice of Employee": "직원의 소리", + "Voice of Business": "기업의 소리", + "Voice of Regulator": "규제기관의 소리", + "Voice of Investor": "투자자의 소리", + "Voice of Society": "사회의 소리", + "Voice of Process": "프로세스의 소리", Public: "공개", Private: "비공개", "Newest first": "최신순", @@ -666,7 +676,17 @@ const TRANSLATIONS: Partial>> = { "All VOC types": "所有 VOC 类型", "All visibility": "所有可见范围", "Voice of Customer": "客户之声", + "Voice of Customer's Customer": "客户的客户之声", + "Voice of Competitor": "竞争对手之声", + "Voice of Partner": "合作伙伴之声", "Voice of Market": "市场之声", + "Voice of Supplier": "供应商之声", + "Voice of Employee": "员工之声", + "Voice of Business": "企业之声", + "Voice of Regulator": "监管机构之声", + "Voice of Investor": "投资者之声", + "Voice of Society": "社会之声", + "Voice of Process": "流程之声", Public: "公开", Private: "私有", "Newest first": "最新优先", @@ -1209,7 +1229,17 @@ const TRANSLATIONS: Partial>> = { "All VOC types": "すべての VOC 種類", "All visibility": "すべての公開範囲", "Voice of Customer": "顧客の声", + "Voice of Customer's Customer": "顧客の顧客の声", + "Voice of Competitor": "競合他社の声", + "Voice of Partner": "パートナーの声", "Voice of Market": "市場の声", + "Voice of Supplier": "サプライヤーの声", + "Voice of Employee": "従業員の声", + "Voice of Business": "企業の声", + "Voice of Regulator": "規制当局の声", + "Voice of Investor": "投資家の声", + "Voice of Society": "社会の声", + "Voice of Process": "プロセスの声", Public: "公開", Private: "非公開", "Newest first": "新しい順", @@ -1731,7 +1761,17 @@ const TRANSLATIONS: Partial>> = { "All VOC types": "Tất cả loại VOC", "All visibility": "Tất cả phạm vi hiển thị", "Voice of Customer": "Tiếng nói khách hàng", + "Voice of Customer's Customer": "Tiếng nói khách hàng của khách hàng", + "Voice of Competitor": "Tiếng nói đối thủ cạnh tranh", + "Voice of Partner": "Tiếng nói đối tác", "Voice of Market": "Tiếng nói thị trường", + "Voice of Supplier": "Tiếng nói nhà cung cấp", + "Voice of Employee": "Tiếng nói nhân viên", + "Voice of Business": "Tiếng nói doanh nghiệp", + "Voice of Regulator": "Tiếng nói cơ quan quản lý", + "Voice of Investor": "Tiếng nói nhà đầu tư", + "Voice of Society": "Tiếng nói xã hội", + "Voice of Process": "Tiếng nói quy trình", Public: "Công khai", Private: "Riêng tư", "Newest first": "Mới nhất trước", From 90b3999ccb584f5cc135f217e2b1084004493f9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:03:28 +0900 Subject: [PATCH 15/53] fix(ontology): retain visible post voices --- CHANGELOG.md | 4 ++- .../app/ontology_neighborhood_ingestion.py | 26 ++++++++++------ ...247-evidence-bearing-voice-combinations.md | 4 ++- docs/product-technical-gap-baseline.md | 3 +- tests/test_ontology_neighborhood_ingestion.py | 31 +++++++++++++++++-- 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869bdaa1d..ca4e2c1a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ All notable changes to this project are documented here. Format follows Board filters match additional as well as primary voices, labels retain the active locale, and cutoff reads use assignment-effective time rather than migration recording time. All twelve governed atomic Voice labels are - translated across the five supported product locales. + translated across the five supported product locales. Ontology neighborhoods + load assignments for every authorized visible Post in one bounded query, + including Person-, Organization-, Team-, and Project-focused exploration. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index b8a33d098..9b3509c1e 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -780,25 +780,28 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any async def _load_voice_assignments( conn: asyncpg.Connection, - post_id: str, + post_ids: Sequence[str], *, knowledge_cutoff: datetime | None, ) -> tuple[OntologyVoiceAssignment, ...]: - """Load qualified voices only after the focus post passed authorization.""" + """Load qualified voices only for posts admitted to the visible neighborhood.""" + if not post_ids: + return () rows = await conn.fetch( """ - select voice.voice_type_code, lookup.lookup_label, voice.is_primary, + select voice.post_id, voice.voice_type_code, lookup.lookup_label, voice.is_primary, voice.truth_status_code, voice.recorded_at, voice.provenance_assertion_id is not null as has_assertion from source_post_voice voice join common_lookup_value lookup on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code - where voice.post_id = $1 + where voice.post_id = any($1::uuid[]) and ($2::timestamptz is null or voice.effective_from <= $2) - order by voice.is_primary desc, lookup.display_order, voice.voice_type_code + order by voice.post_id, voice.is_primary desc, + lookup.display_order, voice.voice_type_code """, - post_id, + list(post_ids), knowledge_cutoff, ) assignments: list[OntologyVoiceAssignment] = [] @@ -810,7 +813,7 @@ async def _load_voice_assignments( ) assignments.append( OntologyVoiceAssignment( - post_id=post_id, + post_id=str(row["post_id"]), voice_type_code=row["voice_type_code"], voice_type_iri=voice_type_iri, voice_type_label=row["lookup_label"], @@ -1138,12 +1141,17 @@ async def visible_ontology_neighborhood( cursor=assembler_cursor, source_truncated=source_truncated, ) - if focus_node_type_code == NODE_POST: + visible_post_ids = tuple( + node.node_id + for node in neighborhood.nodes + if node.node_type_code == NODE_POST + ) + if visible_post_ids: neighborhood = replace( neighborhood, voice_assignments=await _load_voice_assignments( conn, - focus_node_id, + visible_post_ids, knowledge_cutoff=knowledge_cutoff, ), ) diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0247-evidence-bearing-voice-combinations.md index 63d0a159b..8c2021a52 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0247-evidence-bearing-voice-combinations.md @@ -61,7 +61,9 @@ compound lookup codes. qualified assignment in JSON-LD and the exact-value CSV. SHACL requires its atomic voice concept, primary flag, and source-post evidence. The exact-value table opens that already-authorized source post; it does not invent a graph - edge or expose an internal assertion identifier. + edge or expose an internal assertion identifier. A single bounded query loads + assignments for every authorized Post in the neighborhood, regardless of + whether the focus is a Post, Person, Organization, Team, or Project. ## Data model diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 43abe3a27..446be6189 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -46,7 +46,8 @@ Cartesian-product codes. The remaining acceptance boundary is: qualified JSON-LD, exact-value CSV, SHACL, and source-post evidence navigation implemented; the board re-filter matches every associated voice and all twelve governed atomic labels are localized across English, Korean, - Chinese, Japanese, and Vietnamese); and + Chinese, Japanese, and Vietnamese; one bounded query projects assignments + for every authorized Post even when another node type is the focus); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. The candidate `CombinedVoiceEvidence` scene diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 7f123b02f..f0b89f891 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -824,6 +824,17 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: "person_affiliation affiliation": [post_row], "post_team_mention": [post_row], "select post_id from source_post where post_id = any": [{"post_id": POST_ID}], + "from source_post_voice voice": [ + { + "post_id": POST_ID, + "voice_type_code": "voc", + "lookup_label": "Voice of Customer", + "is_primary": True, + "truth_status_code": "truth_observed", + "recorded_at": T0, + "has_assertion": False, + } + ], } post_neighborhood = asyncio.run( visible_ontology_neighborhood( @@ -843,6 +854,10 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: ) ) assert person_neighborhood.focus_node_id == PERSON_ID + assert [ + assignment.voice_type_code + for assignment in person_neighborhood.voice_assignments + ] == ["voc"] corp_neighborhood = asyncio.run( visible_ontology_neighborhood( ScriptedConn({**shared_labels, "select 1 from corporate_entity": {"ignored": 1}}), @@ -883,6 +898,7 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - { "from source_post_voice voice": [ { + "post_id": POST_ID, "voice_type_code": "voc", "lookup_label": "Voice of Customer", "is_primary": True, @@ -891,6 +907,7 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - "has_assertion": False, }, { + "post_id": POST_ID, "voice_type_code": "vops", "lookup_label": "Voice of Process", "is_primary": False, @@ -903,14 +920,24 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - ) assignments = asyncio.run( - _load_voice_assignments(conn, POST_ID, knowledge_cutoff=T0) + _load_voice_assignments(conn, [POST_ID], knowledge_cutoff=T0) ) assert [assignment.voice_type_code for assignment in assignments] == ["voc", "vops"] assert assignments[0].provenance_reference == "Imported primary voice" assert assignments[1].provenance_reference == "Evidence-backed additional voice" assert "voice.effective_from <= $2" in conn.calls[0][0] - assert conn.calls[0][1] == (POST_ID, T0) + assert conn.calls[0][1] == ([POST_ID], T0) + + +def test_load_voice_assignments_skips_database_for_no_visible_posts() -> None: + """A non-post-only neighborhood does not issue an empty-array query.""" + conn = ScriptedConn({}) + + assert asyncio.run( + _load_voice_assignments(conn, [], knowledge_cutoff=None) + ) == () + assert conn.calls == [] def test_payload_serializes_optional_validity() -> None: From 52bb01b9d9022b2132f733eecc464854a6d0d8a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:07:56 +0900 Subject: [PATCH 16/53] docs(adr): reserve Voice combinations as 0251 --- CHANGELOG.md | 2 +- ...nations.md => 0251-evidence-bearing-voice-combinations.md} | 2 +- docs/adr/README.md | 2 +- docs/ontology/lineageweave-kg.ttl | 2 +- docs/product-requirements.md | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- docs/storybook-inventory.md | 2 +- migrations/0237_source_post_voice_combination.sql | 2 +- tests/test_ontology.py | 2 +- tests/test_source_post_voice_schema.py | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) rename docs/adr/{0247-evidence-bearing-voice-combinations.md => 0251-evidence-bearing-voice-combinations.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4e2c1a9..995e8de13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project are documented here. Format follows ### Added -- Normalized Voice-of-X composition persistence (ADR 0247): every imported +- Normalized Voice-of-X composition persistence (ADR 0251): every imported primary voice is mirrored into `source_post_voice`; each additional voice requires its own PROV-O assertion and truth status. Compound lookup codes, keyword inference, confidence thresholds, and invented weights remain out of diff --git a/docs/adr/0247-evidence-bearing-voice-combinations.md b/docs/adr/0251-evidence-bearing-voice-combinations.md similarity index 99% rename from docs/adr/0247-evidence-bearing-voice-combinations.md rename to docs/adr/0251-evidence-bearing-voice-combinations.md index 8c2021a52..394d4065d 100644 --- a/docs/adr/0247-evidence-bearing-voice-combinations.md +++ b/docs/adr/0251-evidence-bearing-voice-combinations.md @@ -1,4 +1,4 @@ -# ADR 0247: Evidence-bearing Voice-of-X combinations +# ADR 0251: Evidence-bearing Voice-of-X combinations ## Status diff --git a/docs/adr/README.md b/docs/adr/README.md index 83e56345c..bbd8f6a6e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,7 +16,7 @@ decision from them. | [`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) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | -| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md) | +| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0251](0251-evidence-bearing-voice-combinations.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 9e6e89582..647e24571 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -361,7 +361,7 @@ rdfs:comment "A process- or system-generated source record." ; :lookupCode "vops" . -# ADR 0247 -- qualified, evidence-bearing combinations. A post links to one +# ADR 0251 -- qualified, evidence-bearing combinations. A post links to one # assignment per atomic voice instead of minting a term for each Cartesian # combination. Additional assignments use prov:wasDerivedFrom to retain their # evidence lineage. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 3be61b623..51ae81b6b 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -220,7 +220,7 @@ A release claim requires one exact protected-main head that proves: - Product/data boundary: ADR 0001, ADR 0089. - Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213. - Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065, - ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0247. + ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0251. - Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217. - LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079. - Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 446be6189..8ac45edbc 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -26,7 +26,7 @@ Accordingly, ADR 0246 keeps the controlled vocabulary extensible and refuses keyword inference, defaults, invented weights, or an asserted exhaustive cross-product. -ADR 0247 and migration 0237 now define the persistence contract for +ADR 0251 and migration 0237 now define the persistence contract for evidence-bearing composition. A post keeps one source-provided `voc_type_code`, mirrored as its sole primary association, while every additional voice requires a normalized PROV-O assertion and explicit truth @@ -64,7 +64,7 @@ queued; and an independent approval was still required. Auto-merge remains enabled. No self-approval, admin bypass, or stale-head check transfer is permitted. -Stacked PR #717 at `771a8edf` carries ADR 0247, migration 0237, qualified +Stacked PR #717 at `771a8edf` carries ADR 0251, migration 0237, qualified ontology terms, persistence/API/UI tests, and the category-validation review repairs. CodeRabbit and Devin were successful on that exact remote head before this local projection slice. It targets #713's branch, not protected `main`; diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 2c6175440..647d0e967 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -9,7 +9,7 @@ operator-facing control you can click before changing product CSS. | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | -| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses their source-post evidence action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0247 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | +| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses their source-post evidence action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index 1e7bbd52f..f1b904bf3 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -1,4 +1,4 @@ --- ADR 0247: normalized, evidence-bearing Voice-of-X combinations. +-- ADR 0251: normalized, evidence-bearing Voice-of-X combinations. -- source_post.voc_type_code remains the imported primary voice. Additional -- voices require a normalized PROV-O assertion instead of keyword inference. diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 72de60b77..501bb91e5 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -452,7 +452,7 @@ def test_post_voice_additions_do_not_invent_counterparty_relationships() -> None def test_voice_combinations_use_qualified_assignments() -> None: - """ADR 0247 composes atomic voices without Cartesian-product terms.""" + """ADR 0251 composes atomic voices without Cartesian-product terms.""" graph = load_ontology() assert (LW.VoiceAssignment, RDF.type, OWL.Class) in graph diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 0e9318f6e..2993b0480 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -1,4 +1,4 @@ -"""Static contract tests for ADR 0247's normalized Voice-of-X associations.""" +"""Static contract tests for ADR 0251's normalized Voice-of-X associations.""" from __future__ import annotations From e3ecb3b2dd6b500b602f755a306d32bba68e023e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:36:32 +0900 Subject: [PATCH 17/53] feat(voice): add governed evidence assignments --- ARCHITECTURE.md | 6 + CHANGELOG.md | 5 +- backend/app/main.py | 62 +++++++ .../app/ontology_neighborhood_ingestion.py | 9 +- backend/app/source_post_voice_ingestion.py | 166 ++++++++++++++++++ ...251-evidence-bearing-voice-combinations.md | 27 ++- docs/product-requirements.md | 7 +- docs/product-technical-gap-baseline.md | 10 +- frontend/src/App.test.tsx | 26 ++- frontend/src/App.tsx | 17 +- tests/test_ontology_neighborhood_ingestion.py | 7 +- tests/test_source_post_voice_ingestion.py | 112 ++++++++++++ 12 files changed, 429 insertions(+), 25 deletions(-) create mode 100644 backend/app/source_post_voice_ingestion.py create mode 100644 tests/test_source_post_voice_ingestion.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 399894584..90f9631d3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -361,6 +361,12 @@ than exposing the generic PROV-O `Person` class as business context. the list badge and popup meta show `Voice of Customer` / `Public` instead of raw codes. +`POST /api/posts/{post_id}/voice-assignments` lets a `post_admin` add one +governed atomic Voice with an explicit truth state and an ABAC-visible evidence +Post. The server creates the normalized PROV-O derivation and assignment in one +transaction; clients never submit an internal assertion id, and this route +cannot replace the imported primary Voice. + `GET /api/posts/{post_id}/voc-evidence` returns the `common_lookup_value` label for the post's `voc_type_code` plus the sentences in the post body that name a counterparty or affiliated diff --git a/CHANGELOG.md b/CHANGELOG.md index 995e8de13..92ec942da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,10 @@ All notable changes to this project are documented here. Format follows migration recording time. All twelve governed atomic Voice labels are translated across the five supported product locales. Ontology neighborhoods load assignments for every authorized visible Post in one bounded query, - including Person-, Organization-, Team-, and Project-focused exploration. + including Person-, Organization-, Team-, and Project-focused exploration. A + governed `post_admin` API creates each additional assignment and its + `prov:wasDerivedFrom` assertion atomically from an ABAC-visible evidence Post; + callers cannot replace the imported primary or supply an assertion UUID. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/main.py b/backend/app/main.py index a9dc2bfb8..c4a0b99ac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -164,6 +164,10 @@ rebuild_period_reports, ) from backend.app.source_post_revision import fetch_known_at_revision, parse_as_of_clock +from backend.app.source_post_voice_ingestion import ( + PrimaryVoiceAssignmentError, + persist_additional_voice_assignment, +) from lineageweave.adjudication_client import ( ContextualOrchestratorAdjudicationClient, NullAdjudicationClient, @@ -1727,6 +1731,64 @@ async def read_post( return payload +class CreatePostVoiceAssignmentRequest(BaseModel): + """Evidence and governed truth state for one additional Voice assignment.""" + + voice_type_code: str + truth_status_code: str + evidence_post_id: UUID + + +@app.post( + "/api/posts/{post_id}/voice-assignments", + status_code=status.HTTP_201_CREATED, +) +async def create_post_voice_assignment( + post_id: str, + request: CreatePostVoiceAssignmentRequest, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), + valkey: redis.Redis = Depends(get_valkey), +) -> dict[str, Any]: + """Attach one additional Voice using an authorized evidence Post.""" + _require_post_admin(account) + await _load_visible_post(post_id, account, pool) + evidence_post_id = str(request.evidence_post_id) + if evidence_post_id != post_id: + await _load_visible_post(evidence_post_id, account, pool) + async with pool.acquire() as conn: + try: + await persist_additional_voice_assignment( + conn, + post_id=post_id, + voice_type_code=request.voice_type_code, + truth_status_code=request.truth_status_code, + evidence_post_id=evidence_post_id, + ) + except PrimaryVoiceAssignmentError as exc: + raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc + except ( + asyncpg.CheckViolationError, + asyncpg.ForeignKeyViolationError, + ) as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "voice_type_code and truth_status_code must use governed lookup values", + ) from exc + assignments = await _load_post_voice_types(conn, post_id) + assignment = next( + item for item in assignments if item["code"] == request.voice_type_code + ) + await publish_activity_event( + valkey, + post_id, + "voice_assignment_added", + account.user_account_id, + "Additional Voice evidence connected", + ) + return assignment + + @app.get("/api/posts/{post_id}/content") async def read_post_content( post_id: str, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 9b3509c1e..1ff8e1fe4 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -783,6 +783,7 @@ async def _load_voice_assignments( post_ids: Sequence[str], *, knowledge_cutoff: datetime | None, + snapshot_at: datetime, ) -> tuple[OntologyVoiceAssignment, ...]: """Load qualified voices only for posts admitted to the visible neighborhood.""" if not post_ids: @@ -790,19 +791,20 @@ async def _load_voice_assignments( rows = await conn.fetch( """ select voice.post_id, voice.voice_type_code, lookup.lookup_label, voice.is_primary, - voice.truth_status_code, voice.recorded_at, - voice.provenance_assertion_id is not null as has_assertion + voice.truth_status_code, voice.recorded_at from source_post_voice voice join common_lookup_value lookup on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = any($1::uuid[]) and ($2::timestamptz is null or voice.effective_from <= $2) + and voice.recorded_at <= $3::timestamptz order by voice.post_id, voice.is_primary desc, lookup.display_order, voice.voice_type_code """, list(post_ids), knowledge_cutoff, + snapshot_at, ) assignments: list[OntologyVoiceAssignment] = [] for row in rows: @@ -822,7 +824,7 @@ async def _load_voice_assignments( recorded_at=row["recorded_at"], provenance_reference=( "Evidence-backed additional voice" - if row["has_assertion"] + if not row["is_primary"] else "Imported primary voice" ), ) @@ -1153,6 +1155,7 @@ async def visible_ontology_neighborhood( conn, visible_post_ids, knowledge_cutoff=knowledge_cutoff, + snapshot_at=snapshot_at, ), ) last_source_key = None diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py new file mode 100644 index 000000000..abb28e199 --- /dev/null +++ b/backend/app/source_post_voice_ingestion.py @@ -0,0 +1,166 @@ +"""Persist evidence-bearing additional Voice assignments (ADR 0251).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import asyncpg + +from lineageweave.knowledge_graph import NODE_POST +from lineageweave.ontology import LW, ontology_node_iri + + +class PrimaryVoiceAssignmentError(ValueError): + """Raised when the additional-voice path targets the imported primary.""" + + +async def _post_resource_id(conn: asyncpg.Connection, post_id: str) -> str: + """Return the bound PROV Entity resource for one evidence post.""" + existing = await conn.fetchval( + """ + select resource_id + from provenance_resource_binding + where node_type_code = 'node_post' + and node_id = $1::uuid + """, + post_id, + ) + if existing is not None: + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + existing, + ) + return str(existing) + resource_id = await conn.fetchval( + """ + insert into provenance_resource (resource_iri, resource_label) + values ($1, 'Authorized Voice evidence post') + on conflict (resource_iri) do update + set resource_label = coalesce( + provenance_resource.resource_label, + excluded.resource_label + ) + returning resource_id + """, + ontology_node_iri(NODE_POST, post_id), + ) + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + resource_id, + ) + await conn.execute( + """ + insert into provenance_resource_binding (resource_id, node_type_code, node_id) + values ($1::uuid, 'node_post', $2::uuid) + on conflict do nothing + """, + resource_id, + post_id, + ) + bound = await conn.fetchval( + """ + select resource_id + from provenance_resource_binding + where node_type_code = 'node_post' + and node_id = $1::uuid + """, + post_id, + ) + if bound is None: + raise RuntimeError("evidence post provenance binding was not persisted") + return str(bound) + + +async def persist_additional_voice_assignment( + conn: asyncpg.Connection, + *, + post_id: str, + voice_type_code: str, + 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}"]) + async with conn.transaction(): + evidence_resource_id = await _post_resource_id(conn, evidence_post_id) + assignment_resource_id = await conn.fetchval( + """ + insert into provenance_resource (resource_iri, resource_label) + values ($1, 'Qualified Voice assignment') + on conflict (resource_iri) do update + set resource_label = coalesce( + provenance_resource.resource_label, + excluded.resource_label + ) + returning resource_id + """, + assignment_iri, + ) + await conn.execute( + """ + insert into provenance_resource_type (resource_id, class_code) + values ($1::uuid, 'prov_entity') + on conflict do nothing + """, + assignment_resource_id, + ) + assertion_id = await conn.fetchval( + """ + insert into provenance_assertion + (subject_resource_id, relation_code, object_resource_id) + values ($1::uuid, 'prov_was_derived_from', $2::uuid) + on conflict do nothing + returning assertion_id + """, + assignment_resource_id, + evidence_resource_id, + ) + if assertion_id is None: + assertion_id = await conn.fetchval( + """ + select assertion_id + from provenance_assertion + where subject_resource_id = $1::uuid + and relation_code = 'prov_was_derived_from' + and object_resource_id = $2::uuid + and bundle_resource_id is null + """, + assignment_resource_id, + evidence_resource_id, + ) + if assertion_id is None: + raise RuntimeError("Voice evidence derivation was not persisted") + stored = await conn.fetchrow( + """ + insert into source_post_voice + (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) 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 + """, + post_id, + voice_type_code, + truth_status_code, + assertion_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"] diff --git a/docs/adr/0251-evidence-bearing-voice-combinations.md b/docs/adr/0251-evidence-bearing-voice-combinations.md index 394d4065d..259e1d048 100644 --- a/docs/adr/0251-evidence-bearing-voice-combinations.md +++ b/docs/adr/0251-evidence-bearing-voice-combinations.md @@ -57,6 +57,11 @@ compound lookup codes. labels, truth state, and evidence availability but never internal assertion identifiers. Filters match any associated voice, and repeated post cards show the combined labels. +- A `post_admin` may add an additional assignment by naming an ABAC-visible + evidence Post, an atomic Voice code, and a governed truth state. The API does + not accept a caller-supplied assertion identifier: one transaction binds the + evidence Post as a PROV Entity, records `prov:wasDerivedFrom`, and upserts the + assignment. It cannot replace or demote the imported primary Voice. - The authorized ontology neighborhood projects each association as a qualified assignment in JSON-LD and the exact-value CSV. SHACL requires its atomic voice concept, primary flag, and source-post evidence. The exact-value @@ -94,6 +99,19 @@ classDiagram ProvenanceAssertion "0..1" --> "0..*" SourcePostVoice ``` +```mermaid +sequenceDiagram + actor Admin + participant API + participant ABAC + participant PostgreSQL + Admin->>API: Add atomic Voice + truth + evidence Post + API->>ABAC: Authorize target and evidence Posts + ABAC-->>API: Both visible + API->>PostgreSQL: Atomic PROV derivation + assignment upsert + PostgreSQL-->>API: Evidence-bearing assignment +``` + ## Consequences Migration 0237 is replay-safe, backfills one primary association per existing @@ -103,10 +121,11 @@ category and stores no source content or identifying evidence in repository artifacts. The repository candidate projects authorized combinations through JSON-LD, -SHACL, CSV, and evidence navigation. Its synthetic Storybook desktop/mobile -scene verifies a focused evidence action and a contained horizontally -scrollable exact-value table. Authenticated runtime and an authorized write -workflow for additional assignments remain required before a release claim. +SHACL, CSV, and evidence navigation and includes the governed admin API write +path above. Its synthetic Storybook desktop/mobile scene verifies a focused +evidence action and a contained horizontally scrollable exact-value table. +Authenticated PostgreSQL runtime acceptance and a customer-facing authoring +surface remain required before a release claim. ## References diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 51ae81b6b..953d5794d 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -49,12 +49,17 @@ edge exposes the same authorized endpoints and evidence through API and UI. governed catalog resolution supplies a stable cross-record identity. - Preserve truth status, valid/system time, provenance, and evidence references. +- Preserve one imported primary Voice and allow a `post_admin` to add any + governed atomic Voice only with an ABAC-visible evidence Post and explicit + truth state; create the normalized PROV-O derivation server-side and never + accept an opaque provenance assertion identifier from the caller. - Validate DB-to-RDF projections with SHACL, including complete reified ProjectMention subject/predicate/object chains. - Keep SKOS broader/narrower distinct from OWL subclass semantics. Acceptance: Turtle, JSON-LD, N-Triples, SHACL, API payloads, persisted IRIs, -and rendered labels agree on term kind, direction, namespace, and provenance. +and rendered labels agree on term kind, direction, namespace, and provenance; +an additional Voice cannot demote the imported primary or cite hidden evidence. ### PRD-FR-3 — Bounded ontology exploration diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8ac45edbc..9b55d2a79 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -38,7 +38,8 @@ Cartesian-product codes. The remaining acceptance boundary is: 1. preserve the imported primary voice without reclassification (implemented in the candidate migration; runtime replay pending); 2. record each additional voice with its own source/evidence and truth state - (schema-enforced; no authorized write workflow shipped yet); + (schema-enforced and candidate `post_admin` API implemented; authenticated + PostgreSQL integration and customer-facing authoring UI remain pending); 3. keeps post voice distinct from named-counterparty relationship, actor role, topic, channel, lifecycle, and stakeholder-salience attributes; 4. return only authorized associations through API, JSON-LD, CSV, filters, @@ -64,10 +65,11 @@ queued; and an independent approval was still required. Auto-merge remains enabled. No self-approval, admin bypass, or stale-head check transfer is permitted. -Stacked PR #717 at `771a8edf` carries ADR 0251, migration 0237, qualified +Stacked PR #717 carries ADR 0251, migration 0237, qualified ontology terms, persistence/API/UI tests, and the category-validation review -repairs. CodeRabbit and Devin were successful on that exact remote head before -this local projection slice. It targets #713's branch, not protected `main`; +repairs plus a local candidate admin write path that creates its PROV-O +derivation from an authorized evidence Post. The remote exact head must be +refetched after this local slice is pushed. It targets #713's branch, not protected `main`; its checks and review are candidate evidence only. After #713 reaches protected main, #717 must be synchronized, retargeted to `main`, and revalidated on its then-current head. Creating #717 raised the live queue diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 40d0084c9..82755ef2d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -118,6 +118,7 @@ describe("App, authenticated", () => { contentAfterSummary?: boolean; organizationAliases?: boolean; combinedVoices?: boolean; + omitVoiceOptions?: boolean; askLineageGraph?: boolean; askImageCitation?: boolean; askDelivery?: boolean; @@ -1172,13 +1173,17 @@ describe("App, authenticated", () => { total_count: 1, limit: 50, offset: 0, - voc_type_options: [ - { code: "voc", label: "Voice of Customer" }, - { code: "vop", label: "Voice of Partner" }, - ...(options?.combinedVoices - ? [{ code: "vops", label: "Voice of Process" }] - : []), - ], + ...(options?.omitVoiceOptions + ? {} + : { + voc_type_options: [ + { code: "voc", label: "Voice of Customer" }, + { code: "vop", label: "Voice of Partner" }, + ...(options?.combinedVoices + ? [{ code: "vops", label: "Voice of Process" }] + : []), + ], + }), visibility_options: [{ code: "public", label: "Public" }], }, ), @@ -1988,6 +1993,13 @@ describe("App, authenticated", () => { expect(screen.getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); }); + it("offers additional voices when filter options are omitted", async () => { + stubBackend({ combinedVoices: true, omitVoiceOptions: true }); + render(); + + expect(await screen.findByRole("checkbox", { name: "Voice of Process" })).toBeInTheDocument(); + }); + it("renders safe Ask Agent evidence under each cited post", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a9d76145..2bbee7042 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4077,11 +4077,24 @@ function PostList({ })); const vocTypeOptions = vocTypeFilterOptions.length ? vocTypeFilterOptions - : Array.from(new Set(loadedPosts.map((post) => post.voc_type_code))) + : Array.from( + new Set( + loadedPosts.flatMap((post) => + post.voice_types?.length + ? post.voice_types.map((voice) => voice.code) + : [post.voc_type_code], + ), + ), + ) .sort() .map((code) => ({ code, - label: loadedPosts.find((post) => post.voc_type_code === code)?.voc_type_label ?? code, + label: + loadedPosts + .flatMap((post) => post.voice_types ?? []) + .find((voice) => voice.code === code)?.label ?? + loadedPosts.find((post) => post.voc_type_code === code)?.voc_type_label ?? + code, })); const filteredPosts = loadedPosts .filter((post) => { diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index f0b89f891..ed1ba143d 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -920,14 +920,15 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - ) assignments = asyncio.run( - _load_voice_assignments(conn, [POST_ID], knowledge_cutoff=T0) + _load_voice_assignments(conn, [POST_ID], knowledge_cutoff=T0, snapshot_at=T0) ) assert [assignment.voice_type_code for assignment in assignments] == ["voc", "vops"] assert assignments[0].provenance_reference == "Imported primary voice" assert assignments[1].provenance_reference == "Evidence-backed additional voice" assert "voice.effective_from <= $2" in conn.calls[0][0] - assert conn.calls[0][1] == ([POST_ID], T0) + assert "voice.recorded_at <= $3" in conn.calls[0][0] + assert conn.calls[0][1] == ([POST_ID], T0, T0) def test_load_voice_assignments_skips_database_for_no_visible_posts() -> None: @@ -935,7 +936,7 @@ def test_load_voice_assignments_skips_database_for_no_visible_posts() -> None: conn = ScriptedConn({}) assert asyncio.run( - _load_voice_assignments(conn, [], knowledge_cutoff=None) + _load_voice_assignments(conn, [], knowledge_cutoff=None, snapshot_at=T0) ) == () assert conn.calls == [] diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py new file mode 100644 index 000000000..0b1713f4f --- /dev/null +++ b/tests/test_source_post_voice_ingestion.py @@ -0,0 +1,112 @@ +"""Evidence-bearing additional Voice persistence tests (ADR 0251).""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from backend.app.source_post_voice_ingestion import ( + PrimaryVoiceAssignmentError, + persist_additional_voice_assignment, +) + + +class _Connection: + """Record the ordered SQL contract without requiring a live database.""" + + def __init__( + self, *, primary_conflict: bool = False, existing_evidence: bool = False + ) -> None: + self.primary_conflict = primary_conflict + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.fetchvals = iter( + ["evidence-resource", "assignment-resource", "assertion"] + if existing_evidence + else [ + None, + "evidence-resource", + "evidence-resource", + "assignment-resource", + "assertion", + ] + ) + + @asynccontextmanager + async def transaction(self): + """Expose the async transaction protocol used by asyncpg.""" + yield + + async def execute(self, query: str, *args: object) -> None: + """Record an execute call.""" + self.calls.append((query, args)) + + async def fetchval(self, query: str, *args: object) -> Any: + """Record and return the next scripted scalar.""" + self.calls.append((query, args)) + return next(self.fetchvals) + + async def fetchrow(self, query: str, *args: object) -> dict[str, str] | None: + """Return no row only when the imported primary blocks the write.""" + self.calls.append((query, args)) + return None if self.primary_conflict else {"voice_type_code": str(args[1])} + + +def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> None: + """The write derives an assignment from a bound evidence Post resource.""" + conn = _Connection() + + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + sql = "\n".join(query for query, _args in conn.calls) + assert "prov_was_derived_from" in sql + assert "where not source_post_voice.is_primary" in sql + assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( + conn.calls + ) + + +def test_additional_voice_cannot_demote_imported_primary() -> None: + """The current primary remains owned by source_post.voc_type_code.""" + conn = _Connection(primary_conflict=True) + + with pytest.raises(PrimaryVoiceAssignmentError): + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="voc", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + +def test_existing_evidence_binding_is_typed_as_a_prov_entity() -> None: + """A legacy Post binding gains the type required by PROV range checks.""" + conn = _Connection(existing_evidence=True) + + asyncio.run( + persist_additional_voice_assignment( + conn, + post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + voice_type_code="vops", + truth_status_code="truth_observed", + evidence_post_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + ) + ) + + assert any( + "provenance_resource_type" in query and args == ("evidence-resource",) + for query, args in conn.calls + ) From edc0ab97ee3803b92a7ccd3cfd461e8ef43d6532 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:52:08 +0900 Subject: [PATCH 18/53] feat(ui): show recorded Voice perspectives --- CHANGELOG.md | 2 + backend/app/main.py | 8 +++- ...251-evidence-bearing-voice-combinations.md | 4 +- docs/product-technical-gap-baseline.md | 9 +++- docs/storybook-inventory.md | 1 + frontend/src/App.tsx | 21 +++++++++ .../VoicePerspectiveList.stories.tsx | 43 +++++++++++++++++++ .../components/VoicePerspectiveList.test.tsx | 35 +++++++++++++++ frontend/src/i18n.ts | 12 ++++++ tests/test_source_state_serialization.py | 15 +++++-- 10 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/VoicePerspectiveList.stories.tsx create mode 100644 frontend/src/components/VoicePerspectiveList.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ec942da..65251d321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to this project are documented here. Format follows governed `post_admin` API creates each additional assignment and its `prov:wasDerivedFrom` assertion atomically from an ABAC-visible evidence Post; callers cannot replace the imported primary or supply an assertion UUID. + Post detail lists the primary and evidence-connected perspectives separately, + with localized provenance cues and knowledge-cutoff filtering. - Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type` scheme adds Voice of Supplier, Employee, Business, Regulator, Investor, Society, and Process as source-post categories. Ontology SKOS concepts and diff --git a/backend/app/main.py b/backend/app/main.py index c4a0b99ac..6d690e1d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -700,7 +700,9 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor async def _load_post_voice_types( - conn: asyncpg.Connection, post_id: str + conn: asyncpg.Connection, + post_id: str, + effective_cutoff: datetime | None = None, ) -> list[dict[str, Any]]: """Return qualified Voice-of-X associations without exposing assertion ids.""" rows = await conn.fetch( @@ -713,9 +715,11 @@ async def _load_post_voice_types( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = $1 + and ($2::timestamptz is null or voice.effective_from <= $2) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, + effective_cutoff, ) return [ { @@ -1716,7 +1720,7 @@ async def read_post( project_evidence = await _load_project_evidence( conn, post_id, row["source_project_code"], row["source_project_name"] ) - voice_types = await _load_post_voice_types(conn, post_id) + voice_types = await _load_post_voice_types(conn, post_id, as_of_clock) known_at = None if as_of_clock is not None: known_at = await fetch_known_at_revision(conn, post_id, as_of_clock) diff --git a/docs/adr/0251-evidence-bearing-voice-combinations.md b/docs/adr/0251-evidence-bearing-voice-combinations.md index 259e1d048..418ff2317 100644 --- a/docs/adr/0251-evidence-bearing-voice-combinations.md +++ b/docs/adr/0251-evidence-bearing-voice-combinations.md @@ -56,7 +56,9 @@ compound lookup codes. - Authorized post list/detail responses expose ordered voice assignments with labels, truth state, and evidence availability but never internal assertion identifiers. Filters match any associated voice, and repeated post cards show - the combined labels. + the combined labels. A knowledge-cutoff detail read includes only assignments + effective by that cutoff; the popup lists the imported and evidence-connected + perspectives separately instead of flattening them into a compound label. - A `post_admin` may add an additional assignment by naming an ABAC-visible evidence Post, an atomic Voice code, and a governed truth state. The API does not accept a caller-supplied assertion identifier: one transaction binds the diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9b55d2a79..b2b52e7f3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -48,14 +48,19 @@ Cartesian-product codes. The remaining acceptance boundary is: navigation implemented; the board re-filter matches every associated voice and all twelve governed atomic labels are localized across English, Korean, Chinese, Japanese, and Vietnamese; one bounded query projects assignments - for every authorized Post even when another node type is the focus); and + for every authorized Post even when another node type is the focus; post + detail lists primary and evidence-connected perspectives separately and + honors its knowledge cutoff); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. The candidate `CombinedVoiceEvidence` scene covers primary-plus-additional assignments; desktop and mobile screenshots were inspected on 2026-08-27. At 390 CSS pixels the document did not overflow, the named exact-value region remained horizontally scrollable, - and the source-post evidence action remained visible and labeled. + and the source-post evidence action remained visible and labeled. The + `Post/Recorded perspectives` desktop and 390-pixel scenes were also inspected + on 2026-08-27; both kept each complete Voice label paired with its imported + or evidence-connected state without clipping or horizontal overflow. At this snapshot the repository had 23 open PRs and 10 open issues. PR #713 was policy-blocked: exact-head frontend, CodeQL, ontology-publication, diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 647d0e967..22d32f221 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -7,6 +7,7 @@ operator-facing control you can click before changing product CSS. |---|---|---| | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | +| `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice without flattening them into one compound category. `CombinedEvidence` and `NarrowViewport` cover desktop and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | | `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses their source-post evidence action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bbee7042..f8756d553 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,6 +77,7 @@ import { type PeriodReportIndex, type PeriodReports, type PostLineage, + type PostVoiceType, type PostSummary, type PostSortOrder, type RankingList, @@ -1776,6 +1777,25 @@ function ActivityPanel({ postId, accessToken }: { postId: string; accessToken: s ); } +export function VoicePerspectiveList({ voices }: { voices: PostVoiceType[] }) { + return ( +
+

{t("Recorded perspectives")}

+
    + {voices.map((voice) => ( +
  • + {t(voice.label)} + + {t(voice.is_primary ? "Imported from source" : "Evidence connected")} + +
  • + ))} +
+
+ ); +} + + function PostDetailPopup({ postId, accessToken, @@ -2105,6 +2125,7 @@ function PostDetailPopup({ {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

+ {post.voice_types?.length ? : null}
+ {saved ?

{t("Perspective connected.")}

: null} + {error ?

{error}

: null} + + + ); +} + function PostDetailPopup({ postId, @@ -1826,6 +1916,7 @@ function PostDetailPopup({ onClose, onSelectPost, onSearch, + voiceOptions = [], }: { postId: string; accessToken: string; @@ -1838,6 +1929,7 @@ function PostDetailPopup({ onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; + voiceOptions?: PostFilterOption[]; }) { const [post, setPost] = useState(null); const [imageContent, setImageContent] = useState([]); @@ -2139,11 +2231,32 @@ function PostDetailPopup({ <>

{post.post_title}

- {post.voc_type_label ?? post.voc_type_code} ·{" "} + {t(postPrimaryVoiceLabel(post, knowledgeCutoff))} ·{" "} {post.visibility_label ?? post.visibility_code} ·{" "} {new Date(post.created_at).toLocaleString()}

{post.voice_types?.length ? : null} + {canAuthorVoice(canExtract, knowledgeCutoff) ? ( + { + const assignment = await createPostVoiceAssignment( + accessToken, + postId, + voiceTypeCode, + truthStatusCode, + ); + setPost((current) => current ? { + ...current, + voice_types: [ + ...(current.voice_types ?? []).filter((voice) => voice.code !== assignment.code), + assignment, + ], + } : current); + }} + /> + ) : null}
+ ) : row.evidence_count} + {row.recorded_at.slice(0, 10)} ))} diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index b16653e4e..fd054b30a 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -126,6 +126,7 @@ describe("ontologyLayout", () => { truth_status_code: "truth_observed", recorded_at: "2026-01-10T12:00:00+00:00", provenance_reference: "Evidence-backed additional voice", + evidence_post_id: POST_ID, }; const row = { ...source.exact_value_rows[0], @@ -135,6 +136,7 @@ describe("ontologyLayout", () => { target_node_id: assignment.voice_type_code, target_label: assignment.voice_type_label, target_type_code: "node_voice_type", + evidence_post_id: POST_ID, }; const withVoice = { ...source, @@ -148,7 +150,10 @@ describe("ontologyLayout", () => { }, } satisfies OntologyNeighborhoodPayload; - expect(neighborhoodCsv(withVoice)).toContain("Voice of Customer"); + const csv = neighborhoodCsv(withVoice); + expect(csv).toContain("Voice of Customer"); + expect(csv.split("\n")[0]).toContain("evidence_post_id"); + expect(csv).toContain(POST_ID); expect(filterNeighborhood(withVoice, "customer")!.voice_assignments).toEqual([assignment]); expect(filterNeighborhood(withVoice, "missing")!.voice_assignments).toEqual([assignment]); expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index d3a76dab4..8d3978267 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -144,6 +144,7 @@ export function neighborhoodCsv(payload: OntologyNeighborhoodPayload): string { "truth_status_code", "recorded_at", "ontology_property_iri", + "evidence_post_id", ]; const lines = [header.join(",")]; for (const row of payload.exact_value_rows) { diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index fcecf5eff..53afb5b2d 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -312,7 +312,10 @@ def exact_value_rows(self) -> tuple[dict[str, str], ...]: "recorded_at": assignment.recorded_at.isoformat(), "valid_from": "", "valid_to": "", - "evidence_count": "1", + "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 "" + ), } ) return tuple(rows) diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index 04f2e00e4..dd5f4e7ac 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -874,13 +874,15 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: truth_status_code=TRUTH_OBSERVED, recorded_at=T0, provenance_reference="Evidence-backed additional voice", - evidence_post_id=PERSON_ID, + evidence_post_id=POST_ID, ) neighborhood = replace(neighborhood, voice_assignments=(assignment,)) row = neighborhood.exact_value_rows()[0] assert row["property_code"] == "hasVoiceAssignment" assert row["target_label"] == "Voice of Process" + assert row["evidence_post_id"] == POST_ID + assert row["evidence_count"] == "1" graph = neighborhood.jsonld_document()["@graph"] assignment_iri = str(LW[f"voice-assignment/{POST_ID}/vops"]) projected = next(item for item in graph if item.get("@id") == assignment_iri) @@ -893,13 +895,16 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: assert post_projection[str(LW.hasVoiceAssignment)] == [{"@id": assignment_iri}] assert projected[str(LW.assignedVoiceType)] == {"@id": str(LW.voiceOfProcessType)} assert projected[str(LW.voiceAssignmentEvidence)] == { - "@id": ontology_node_iri(NODE_POST, PERSON_ID) + "@id": ontology_node_iri(NODE_POST, POST_ID) } assert projected["prov:wasDerivedFrom"] == { - "@id": ontology_node_iri(NODE_POST, PERSON_ID) + "@id": ontology_node_iri(NODE_POST, POST_ID) } hidden_evidence = replace(assignment, evidence_post_id=None) + hidden_row = replace(neighborhood, voice_assignments=(hidden_evidence,)).exact_value_rows()[0] + assert hidden_row["evidence_post_id"] == "" + assert hidden_row["evidence_count"] == "0" hidden_projection = next( item for item in replace(neighborhood, voice_assignments=(hidden_evidence,)) From 3db9c44d97640afe4b7f84b197a1d72adace5f4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:24:54 +0900 Subject: [PATCH 40/53] fix: preserve paged Voice JSON-LD evidence --- backend/tests/test_api.py | 4 ++++ ...251-evidence-bearing-voice-combinations.md | 5 ++++- docs/product-technical-gap-baseline.md | 4 +++- frontend/src/ontologyLayout.test.ts | 22 +++++++++++++++++++ frontend/src/ontologyLayout.ts | 21 +++++++++++++++++- tests/test_ontology_neighborhood.py | 4 +++- 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fe87f2325..6a5988830 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -92,6 +92,9 @@ _SOURCE_ORG_NAMED_HINTS_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0039_source_org_named_hints.sql" ) +_VOC_VOCABULARY_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0042_voc_type_vocabulary.sql" +) _MEMBER_LOCALE_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0044_member_locale_preference.sql" ) @@ -359,6 +362,7 @@ def seeded_db(demo_analyst_token): (Path(__file__).resolve().parents[2] / "migrations" / "0040_post_summary_contract.sql") .read_text() ) + cur.execute(_VOC_VOCABULARY_MIGRATION.read_text()) cur.execute(_MEMBER_LOCALE_MIGRATION.read_text()) cur.execute(_IMAGE_REGION_MIGRATION.read_text()) cur.execute(_POST_CONTENT_STRUCTURE_MIGRATION.read_text()) diff --git a/docs/adr/0251-evidence-bearing-voice-combinations.md b/docs/adr/0251-evidence-bearing-voice-combinations.md index 20985962f..eab0e21e2 100644 --- a/docs/adr/0251-evidence-bearing-voice-combinations.md +++ b/docs/adr/0251-evidence-bearing-voice-combinations.md @@ -79,7 +79,10 @@ compound lookup codes. whether the focus is a Post, Person, Organization, Team, or Project. An additional assignment whose evidence Post is outside that authorized node set is omitted as a whole, keeping the JSON-LD conformant with the SHACL - evidence minimum without disclosing or substituting hidden evidence. + evidence minimum without disclosing or substituting hidden evidence. When + bounded pages are accumulated, properties for the same JSON-LD subject are + merged and multi-value Voice relations are unioned instead of one page + replacing another. ## Data model diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6c5bfafae..b81d038b4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -61,7 +61,9 @@ Cartesian-product codes. The remaining acceptance boundary is: exact canonical repository-case node and Voice-assignment IRIs rather than accepting cross-origin suffix matches; the exact-value row exposes distinct carrying-Post and authorized derivation-evidence actions, while hidden - evidence emits neither an identifier nor a fabricated evidence count); and + evidence emits neither an identifier nor a fabricated evidence count; + paged JSON-LD merges properties for one subject and unions its multi-Voice + relation rather than overwriting an earlier page); and 5. proves zero-, one-, and multi-voice states with synthetic fixtures, migration replay, ontology/SHACL, API, accessibility, and Storybook edge tests before any release claim. The candidate `CombinedVoiceEvidence` scene diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index fd054b30a..b609e7e2b 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -159,6 +159,28 @@ describe("ontologyLayout", () => { expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]); }); + it("merges JSON-LD properties and multi-value relations for one paged subject", () => { + const source = payload(); + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const propertyIri = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; + const first = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, "rdfs:label": "Demo public post", [propertyIri]: [{ "@id": "voice:one" }] }] }, + }; + const second = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, [propertyIri]: [{ "@id": "voice:two" }] }] }, + }; + + expect(accumulateNeighborhoodPages(first, second).jsonld["@graph"]).toEqual([ + { + "@id": postIri, + "rdfs:label": "Demo public post", + [propertyIri]: [{ "@id": "voice:one" }, { "@id": "voice:two" }], + }, + ]); + }); + it("keeps only exact canonical JSON-LD node ids when filtering", () => { const source = payload(); const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index 8d3978267..47e2fdfa7 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -284,7 +284,26 @@ export function accumulateNeighborhoodPages( if (!Array.isArray(graph)) continue; for (const item of graph) { if (typeof item === "object" && item !== null && typeof item["@id"] === "string") { - graphItems.set(item["@id"], item as Record); + const incoming = item as Record; + const existing = graphItems.get(item["@id"]); + if (!existing) { + graphItems.set(item["@id"], incoming); + continue; + } + const merged = { ...existing, ...incoming }; + for (const key of Object.keys(incoming)) { + if (Array.isArray(existing[key]) && Array.isArray(incoming[key])) { + const values = [...existing[key], ...incoming[key]]; + const seen = new Set(); + merged[key] = values.filter((value) => { + const serialized = JSON.stringify(value); + if (seen.has(serialized)) return false; + seen.add(serialized); + return true; + }); + } + } + graphItems.set(item["@id"], merged); } } } diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index dd5f4e7ac..2ce86944c 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -902,7 +902,9 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: } hidden_evidence = replace(assignment, evidence_post_id=None) - hidden_row = replace(neighborhood, voice_assignments=(hidden_evidence,)).exact_value_rows()[0] + hidden_row = replace( + neighborhood, voice_assignments=(hidden_evidence,) + ).exact_value_rows()[0] assert hidden_row["evidence_post_id"] == "" assert hidden_row["evidence_count"] == "0" hidden_projection = next( From ebb4ef1d425e87d9725048f336489cf99940c24a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:25:54 +0900 Subject: [PATCH 41/53] docs: record Voice evidence paging head --- docs/product-technical-gap-baseline.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b81d038b4..f5d0b9bc4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -4,7 +4,7 @@ > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was > `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was > audited at implementation head -> `dde0a83a7da5c099bc52d5c447790e42e63a42b6`. This candidate and the +> `3db9c44d97640afe4b7f84b197a1d72adace5f4f`. This candidate and the > historical evidence below are not protected-main release evidence. ## Voice-of-X product and technical gap @@ -89,17 +89,21 @@ reached end of life, and the OpenAI fallback reported exhausted credits. This is provider/control-plane unavailability, not a vulnerability result or permission to transfer an older success. Auto-merge remains enabled, while an independent approval is still required. PR #717 implementation head -`dde0a83a` merges that +`3db9c44d` merges that parent change without force-pushing and separates the complete governed Voice catalog used for authoring from usage-derived Board filters, so an authorized administrator can attach a Voice that no visible Post carries yet. It also labels Voice exact-value navigation as opening the carrying Post rather than misrepresenting that Post as the separately recorded derivation evidence. Its -CodeRabbit and hosted Frontend/Storybook checks passed at exact head -`dde0a83a`; Devin and the hosted full suite were still pending. Focused local +CodeRabbit and hosted Frontend/Storybook checks passed at predecessor head +`dde0a83a`; refreshed checks for exact head `3db9c44d` were queued. Focused local backend tests, frontend type checking/lint, and the new unused-Voice authoring regression passed, and the exact-value navigation tests, lint, and type check -passed after the label repair. The wider local frontend run had +passed after the label repair. The paged JSON-LD union regression and Voice +evidence navigation suite passed 23 focused frontend tests; 48 focused backend +ontology/docstring tests also passed. The real-integration fixture now applies +the existing migration 0042 before the expanded taxonomy migrations instead +of seeding an incomplete legacy catalog. The wider local frontend run had 397 passes and eight five-second timeouts under concurrent machine load; a focused rerun of the new App path passed in 5.57 seconds with a 20-second test limit, so the timeout run is not promoted to full-suite success. An initial From d5fe4828287e63c2f37ac5cf7e82534625099e18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:48:56 +0900 Subject: [PATCH 42/53] test: reuse migrated Voice vocabulary --- backend/tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6a5988830..fa1c0d634 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -437,7 +437,6 @@ def seeded_db(demo_analyst_token): "('corporate_entity_level', 'plant', 'Plant'), " "('post_visibility', 'public', 'Public'), " "('post_visibility', 'private', 'Private'), " - "('voc_type', 'voc', 'Voice of Customer'), " "('permission', 'post_read', 'Read posts'), " "('person_side', 'our_side', 'Our side'), " "('person_side', 'counterparty', 'Counterparty'), " From b2e0c9634110f98ef75bd8e4d9185e869215b14e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:49:37 +0900 Subject: [PATCH 43/53] docs: refresh Voice gate evidence --- docs/product-technical-gap-baseline.md | 27 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f5d0b9bc4..8e70a9dec 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -4,7 +4,7 @@ > `ff7431bd1851c03e737808d22c6a2d43968582f9`; PR #713 was > `850494c3861703862a76cfe564381a41243c6c2d`; stacked PR #717 was > audited at implementation head -> `3db9c44d97640afe4b7f84b197a1d72adace5f4f`. This candidate and the +> `d5fe4828e9005f0157c308e8ea3c3a590cdf465b`. This candidate and the > historical evidence below are not protected-main release evidence. ## Voice-of-X product and technical gap @@ -79,34 +79,41 @@ Cartesian-product codes. The remaining acceptance boundary is: a single column, controls meet the 44-pixel touch target, and no horizontal overflow was visible. -At this snapshot the repository had 41 open PRs and 11 open issues. PR #713 +At this snapshot the repository had 42 open PRs and 11 open issues. PR #713 head `850494c3` includes the review-driven localization of all twelve governed Voice labels. Its frontend, ontology publication, static-analysis, dependency, coverage, full-suite, CodeRabbit, Devin, and OpenCode checks passed. Strix failed closed before producing a vulnerability report: the primary NVIDIA NIM model returned HTTP 429, one configured fallback had -reached end of life, and the OpenAI fallback reported exhausted credits. This +reached end of life, and the OpenAI fallback reported exhausted credits. A +same-head retry completed on 2026-08-27 with the explicit +`STRIX_PROVIDER_UNAVAILABLE` annotation and again produced no vulnerability +report. This is provider/control-plane unavailability, not a vulnerability result or permission to transfer an older success. Auto-merge remains enabled, while an independent approval is still required. PR #717 implementation head -`3db9c44d` merges that +`d5fe4828` merges that parent change without force-pushing and separates the complete governed Voice catalog used for authoring from usage-derived Board filters, so an authorized administrator can attach a Voice that no visible Post carries yet. It also labels Voice exact-value navigation as opening the carrying Post rather than misrepresenting that Post as the separately recorded derivation evidence. Its CodeRabbit and hosted Frontend/Storybook checks passed at predecessor head -`dde0a83a`; refreshed checks for exact head `3db9c44d` were queued. Focused local +`ebb4ef1d`; refreshed checks for exact head `d5fe4828` were queued. Focused local backend tests, frontend type checking/lint, and the new unused-Voice authoring regression passed, and the exact-value navigation tests, lint, and type check passed after the label repair. The paged JSON-LD union regression and Voice evidence navigation suite passed 23 focused frontend tests; 48 focused backend -ontology/docstring tests also passed. The real-integration fixture now applies +ontology/docstring tests also passed. The full backend suite at predecessor +head `ebb4ef1d` passed 1,366 tests with 148 environment-dependent skips. The +real-integration fixture now applies the existing migration 0042 before the expanded taxonomy migrations instead -of seeding an incomplete legacy catalog. The wider local frontend run had -397 passes and eight five-second timeouts under concurrent machine load; a -focused rerun of the new App path passed in 5.57 seconds with a 20-second test -limit, so the timeout run is not promoted to full-suite success. An initial +of seeding an incomplete or duplicate legacy catalog; the exact +`d5fe4828` authenticated post-list integration passed in 91.54 seconds. The +wider local frontend run had 400 passes and eight five-second timeouts under +concurrent backend-suite load; a later App-only run had 94 passes and five +five-second timeouts, while the hosted Frontend/Storybook job passed on +`ebb4ef1d`. Neither local timeout run is promoted to full-suite success. An initial authenticated integration attempt was unavailable while Keycloak initialized; a later retry against the shared synthetic stack succeeded in 56.18 seconds and proved the permission, API, PostgreSQL, From 3e332fdb99fa341537d7cd66430b828eb6873eb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:54:49 +0900 Subject: [PATCH 44/53] docs: distinguish Voice cutoff from history --- docs/product-technical-gap-baseline.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8e70a9dec..2b7015380 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -33,9 +33,12 @@ evidence-bearing composition. A post keeps one source-provided `voc_type_code`, mirrored as its sole primary association, while every additional voice requires a normalized PROV-O assertion and explicit truth status. An explicit assignment-effective instant preserves a backfilled -primary at historical cutoffs without antedating later primary changes. This -implements the first two requirements below without adding -Cartesian-product codes. The remaining acceptance boundary is: +primary at historical cutoffs and does not antedate a later primary change. +It does not preserve the former primary after that change: migration 0237 +deletes the old row, and the current key cannot represent an A → B → A +history. Issue #748 owns that separate temporal-history gap. This implements +the first two requirements below without adding Cartesian-product codes. The +remaining acceptance boundary is: 1. preserve the imported primary voice without reclassification (implemented in the candidate migration; migration 0237 replayed twice successfully on @@ -518,6 +521,7 @@ this file per §3.5 of the prior snapshot). | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | +| Voice primary history | ADR 0251 and migration 0237 prevent a changed imported primary Voice from appearing before its effective instant, but the former primary row is deleted and cannot be recovered at an older cutoff; #748 owns this gap | Define the temporal schema in an ADR, then prove atomic A → B → A changes, non-overlap, replay, concurrent UPSERT, ABAC, API, ontology, CSV, and UI cutoff behavior with synthetic PostgreSQL evidence | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | From 201ac4e807a0d473ccf7aba557bb36eb3f91418f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 07:20:16 +0900 Subject: [PATCH 45/53] fix: retain primary Voice assignment history --- backend/app/main.py | 10 ++- .../app/ontology_neighborhood_ingestion.py | 5 +- backend/app/source_post_voice_ingestion.py | 2 +- backend/tests/test_api.py | 62 +++++++++++++ ...251-evidence-bearing-voice-combinations.md | 21 +++-- docs/product-technical-gap-baseline.md | 15 ++-- .../0237_source_post_voice_combination.sql | 88 ++++++++++++++++--- tests/test_source_post_voice_ingestion.py | 1 + tests/test_source_post_voice_schema.py | 15 +++- tests/test_source_state_serialization.py | 1 + 10 files changed, 184 insertions(+), 36 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b8fe88081..94a5475cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -715,7 +715,10 @@ async def _load_post_voice_types( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = $1 - and ($2::timestamptz is null or voice.effective_from <= $2) + 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))) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, @@ -744,7 +747,8 @@ async def _post_filter_options( coalesce(lookup.lookup_label, option.code) as label, coalesce(lookup.display_order, 2147483647) as display_order from source_post post - left join source_post_voice voice on voice.post_id = post.post_id + left join source_post_voice voice + on voice.post_id = post.post_id and voice.effective_to is null cross join lateral ( values ('post_visibility', post.visibility_code), ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code)) @@ -1573,6 +1577,7 @@ async def list_posts( and ($3::text[] is null or exists ( select 1 from source_post_voice voice_filter where voice_filter.post_id = post.post_id + and voice_filter.effective_to is null and voice_filter.voice_type_code = any($3::text[]) )) and ($4::text is null or post.visibility_code = $4) @@ -1648,6 +1653,7 @@ async def list_posts( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = page.post_id + and voice.effective_to is null ) voices on true order by case when $1::text is not null then page.search_priority end asc, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 908d5218a..83088ca40 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -806,7 +806,10 @@ 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 or voice.effective_from <= $2) + 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.recorded_at <= $3::timestamptz order by voice.post_id, voice.is_primary desc, lookup.display_order, voice.voice_type_code diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py index abb28e199..7bbb015a4 100644 --- a/backend/app/source_post_voice_ingestion.py +++ b/backend/app/source_post_voice_ingestion.py @@ -145,7 +145,7 @@ async def persist_additional_voice_assignment( (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) do update + 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() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fa1c0d634..975fcda37 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -16,8 +16,10 @@ import math import os import uuid +from concurrent.futures import ThreadPoolExecutor from contextlib import closing from pathlib import Path +from threading import Barrier from types import SimpleNamespace import asyncpg @@ -4766,6 +4768,66 @@ def test_create_voice_assignment_persists_authorized_prov_o_evidence( assert cur.fetchone() == ("voc",) +def test_primary_voice_history_survives_concurrent_changes_and_cutoff_reads( + client, demo_analyst_token, seeded_db +) -> None: + """Concurrent A→B→C→A changes retain one non-overlapping primary timeline.""" + post_id = seeded_db["own_private_post_id"] + barrier = Barrier(2) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + + def update_voice(code: str) -> None: + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + barrier.wait() + cur.execute( + "update source_post set voc_type_code = %s where post_id = %s", + (code, post_id), + ) + conn.commit() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(update_voice, code) for code in ("vop", "voe")] + for future in futures: + future.result() + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "update source_post set voc_type_code = 'voc' where post_id = %s", + (post_id,), + ) + conn.commit() + cur.execute( + "select voice_type_code, effective_from, effective_to " + "from source_post_voice where post_id = %s and is_primary " + "order by effective_from, voice_assignment_id", + (post_id,), + ) + history = cur.fetchall() + + assert len(history) == 4 + assert history[0][0] == "voc" + assert history[-1][0] == "voc" + assert sum(effective_to is None for _, _, effective_to in history) == 1 + assert all( + history[index][2] == history[index + 1][1] + for index in range(len(history) - 1) + ) + for voice_type_code, effective_from, _effective_to in history: + response = client.get( + f"/api/posts/{post_id}", + params={"as_of": effective_from.isoformat()}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200, response.text + primaries = [ + voice for voice in response.json()["voice_types"] if voice["is_primary"] + ] + assert [voice["code"] for voice in primaries] == [voice_type_code] + + def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/tickets", diff --git a/docs/adr/0251-evidence-bearing-voice-combinations.md b/docs/adr/0251-evidence-bearing-voice-combinations.md index eab0e21e2..722895fdc 100644 --- a/docs/adr/0251-evidence-bearing-voice-combinations.md +++ b/docs/adr/0251-evidence-bearing-voice-combinations.md @@ -31,14 +31,15 @@ compound lookup codes. - An additional voice uses another existing `voc_type` code and must reference a normalized `provenance_assertion`. Missing evidence therefore cannot be persisted as a positive association. -- The pair `(post_id, voice_type_code)` is unique. One partial unique index - permits only one primary voice while allowing any evidence-backed subset of - the governed vocabulary as additional voices. +- Each association interval has an immutable assignment identifier. Partial + unique indexes permit only one current row for `(post_id, voice_type_code)` + and one current primary while allowing closed historical intervals. - `effective_from` records when an assignment became applicable. The initial imported primary starts at the source post's `created_at`; a later imported primary change and every added evidence-bearing voice start when recorded. - Knowledge-cutoff reads use this effective instant, so migration time does not - erase the primary voice from an older authorized view. + `effective_to` closes a replaced primary as a half-open interval. Knowledge- + cutoff reads select the interval containing the cutoff, so A → B → A changes + retain all three states without presenting two primaries at one instant. - A database trigger verifies that every association code belongs to the `voc_type` lookup category and every truth code belongs to `ontology_truth_status`; @@ -93,12 +94,14 @@ classDiagram text voc_type_code } class SourcePostVoice { + uuid voice_assignment_id uuid post_id text voice_type_code boolean is_primary text truth_status_code uuid provenance_assertion_id timestamptz effective_from + timestamptz effective_to timestamptz recorded_at } class LookupValue { @@ -129,10 +132,10 @@ sequenceDiagram ## Consequences Migration 0237 is replay-safe, backfills one primary association per existing -post, synchronizes later inserts and primary-voice changes, and adds a -voice-first index for bounded filtering. It introduces no new Voice-of-X -category and stores no source content or identifying evidence in repository -artifacts. +post, closes rather than deletes a replaced primary, synchronizes later +inserts and primary-voice changes, and adds a voice-first index for bounded +filtering. It introduces no new Voice-of-X category and stores no source +content or identifying evidence in repository artifacts. The repository candidate projects authorized combinations through JSON-LD, SHACL, CSV, and separate carrying-Post/evidence navigation and includes the governed admin API and diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2b7015380..383821bee 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -32,13 +32,12 @@ ADR 0251 and migration 0237 now define the persistence contract for evidence-bearing composition. A post keeps one source-provided `voc_type_code`, mirrored as its sole primary association, while every additional voice requires a normalized PROV-O assertion and explicit truth -status. An explicit assignment-effective instant preserves a backfilled -primary at historical cutoffs and does not antedate a later primary change. -It does not preserve the former primary after that change: migration 0237 -deletes the old row, and the current key cannot represent an A → B → A -history. Issue #748 owns that separate temporal-history gap. This implements -the first two requirements below without adding Cartesian-product codes. The -remaining acceptance boundary is: +status. Half-open assignment intervals preserve a backfilled primary at +historical cutoffs, close a replaced primary without deleting it, and permit a +later return to the same Voice. The #717 candidate therefore addresses #748's +A → B → A storage root cause without adding Cartesian-product codes. Protected +delivery and synthetic PostgreSQL concurrency/cutoff evidence remain required. +The remaining acceptance boundary is: 1. preserve the imported primary voice without reclassification (implemented in the candidate migration; migration 0237 replayed twice successfully on @@ -521,7 +520,7 @@ this file per §3.5 of the prior snapshot). | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | -| Voice primary history | ADR 0251 and migration 0237 prevent a changed imported primary Voice from appearing before its effective instant, but the former primary row is deleted and cannot be recovered at an older cutoff; #748 owns this gap | Define the temporal schema in an ADR, then prove atomic A → B → A changes, non-overlap, replay, concurrent UPSERT, ABAC, API, ontology, CSV, and UI cutoff behavior with synthetic PostgreSQL evidence | +| Voice primary history | The #717 candidate updates ADR 0251 and migration 0237 with immutable assignment ids and half-open intervals, closing rather than deleting a replaced primary so A → B → A is representable; this is not protected-main evidence | Prove migration replay, concurrent primary changes, non-overlap, and API/ontology cutoff reads against synthetic PostgreSQL at the current exact head, then close #748 only after protected delivery | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index 44f0d7f44..dd5532a86 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -5,22 +5,67 @@ begin; create table if not exists source_post_voice ( + voice_assignment_id uuid primary key default gen_random_uuid(), post_id uuid not null references source_post (post_id) on delete cascade, voice_type_code text not null references common_lookup_value (lookup_code), is_primary boolean not null default false, truth_status_code text not null references common_lookup_value (lookup_code), provenance_assertion_id uuid references provenance_assertion (assertion_id), effective_from timestamptz not null default now(), + effective_to timestamptz, recorded_at timestamptz not null default now(), - primary key (post_id, voice_type_code), - check (is_primary or provenance_assertion_id is not null) + check (is_primary or provenance_assertion_id is not null), + constraint source_post_voice_effective_interval_check + check (effective_to is null or effective_to >= effective_from) ); +alter table source_post_voice + add column if not exists voice_assignment_id uuid not null default gen_random_uuid(); alter table source_post_voice add column if not exists effective_from timestamptz not null default now(); +alter table source_post_voice + add column if not exists effective_to timestamptz; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass + and conname = 'source_post_voice_effective_interval_check' + ) then + alter table source_post_voice + add constraint source_post_voice_effective_interval_check + check (effective_to is null or effective_to >= effective_from); + end if; +end; +$$; + +do $$ +begin + if exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass + and contype = 'p' + and pg_get_constraintdef(oid) <> 'PRIMARY KEY (voice_assignment_id)' + ) then + alter table source_post_voice drop constraint source_post_voice_pkey; + end if; + if not exists ( + select 1 from pg_constraint + where conrelid = 'source_post_voice'::regclass and contype = 'p' + ) then + alter table source_post_voice + add constraint source_post_voice_pkey primary key (voice_assignment_id); + end if; +end; +$$; -create unique index if not exists source_post_voice_primary_idx - on source_post_voice (post_id) where is_primary; +drop index if exists source_post_voice_primary_idx; +create unique index if not exists source_post_voice_current_primary_idx + on source_post_voice (post_id) where is_primary and effective_to is null; + +create unique index if not exists source_post_voice_current_type_idx + on source_post_voice (post_id, voice_type_code) where effective_to is null; create index if not exists source_post_voice_type_idx on source_post_voice (voice_type_code, post_id); @@ -48,20 +93,35 @@ begin raise exception 'source_post_voice requires an ontology_truth_status lookup code' using errcode = '23514'; end if; + if new.is_primary then + perform 1 from source_post where post_id = new.post_id for update; + if exists ( + select 1 + from source_post_voice existing + where existing.post_id = new.post_id + and existing.is_primary + and existing.voice_assignment_id <> new.voice_assignment_id + and tstzrange(existing.effective_from, existing.effective_to, '[)') + && tstzrange(new.effective_from, new.effective_to, '[)') + ) then + raise exception 'source_post_voice primary intervals must not overlap' + using errcode = '23P01'; + end if; + end if; return new; end; $$; drop trigger if exists source_post_voice_type_guard on source_post_voice; create trigger source_post_voice_type_guard -before insert or update of voice_type_code, truth_status_code on source_post_voice +before insert or update on source_post_voice for each row execute function validate_source_post_voice_codes(); insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code, effective_from) select post_id, voc_type_code, true, 'truth_observed', created_at from source_post -on conflict (post_id, voice_type_code) do update +on conflict (post_id, voice_type_code) where effective_to is null do update set is_primary = true, truth_status_code = 'truth_observed', provenance_assertion_id = null @@ -71,11 +131,15 @@ create or replace function synchronize_source_post_primary_voice() returns trigger language plpgsql as $$ +declare + change_at timestamptz := clock_timestamp(); begin - delete from source_post_voice - where post_id = new.post_id - and is_primary - and voice_type_code <> new.voc_type_code; + update source_post_voice + set effective_to = change_at + where post_id = new.post_id + and is_primary + and effective_to is null + and voice_type_code <> new.voc_type_code; insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code, effective_from) @@ -84,9 +148,9 @@ begin new.voc_type_code, true, 'truth_observed', - case when tg_op = 'INSERT' then new.created_at else now() end + case when tg_op = 'INSERT' then new.created_at else change_at end ) - on conflict (post_id, voice_type_code) do update + on conflict (post_id, voice_type_code) where effective_to is null do update set is_primary = true, truth_status_code = 'truth_observed', provenance_assertion_id = null, diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py index 0b1713f4f..ecbd1952a 100644 --- a/tests/test_source_post_voice_ingestion.py +++ b/tests/test_source_post_voice_ingestion.py @@ -70,6 +70,7 @@ def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> sql = "\n".join(query for query, _args in conn.calls) assert "prov_was_derived_from" in sql + assert "where effective_to is null" in sql assert "where not source_post_voice.is_primary" in sql assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( conn.calls diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index e0449780e..ca28eef75 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -16,19 +16,28 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: sql = MIGRATION.read_text(encoding="utf-8").lower() assert "create table if not exists source_post_voice" in sql - assert "primary key (post_id, voice_type_code)" in sql + assert "voice_assignment_id uuid primary key" in sql + assert "effective_to timestamptz" in sql + assert "where is_primary and effective_to is null" in sql + assert "where effective_to is null" in sql assert "check (is_primary or provenance_assertion_id is not null)" in sql assert "truth_status_code text not null" in sql assert "effective_from timestamptz not null" in sql assert "true, 'truth_observed'" in sql assert "where is_primary" in sql assert "select post_id, voc_type_code, true, 'truth_observed', created_at" in sql - assert "case when tg_op = 'insert' then new.created_at else now() end" in sql + assert "change_at timestamptz := clock_timestamp()" in sql + assert "case when tg_op = 'insert' then new.created_at else change_at end" in sql assert "after insert on source_post" in sql assert "after update of voc_type_code on source_post" in sql assert "when (old.voc_type_code is distinct from new.voc_type_code)" in sql - assert "on conflict (post_id, voice_type_code) do update" in sql + assert "on conflict (post_id, voice_type_code) where effective_to is null do update" in sql assert "where not source_post_voice.is_primary" in sql + assert "set effective_to = change_at" in sql + assert "delete from source_post_voice" not in sql + assert "primary intervals must not overlap" in sql + assert "using errcode = '23p01'" in sql + assert "before insert or update on source_post_voice" in sql assert "where lookup_category = 'voc_type'" in sql assert "where lookup_category = 'ontology_truth_status'" in sql assert "errcode = '23514'" in sql diff --git a/tests/test_source_state_serialization.py b/tests/test_source_state_serialization.py index d668d9cc6..661c4e2c5 100644 --- a/tests/test_source_state_serialization.py +++ b/tests/test_source_state_serialization.py @@ -79,6 +79,7 @@ async def fetch( ) -> list[dict[str, object]]: assert "provenance_assertion_id is not null as evidence_available" in query assert "voice.effective_from <= $2" in query + assert "$2 < voice.effective_to" in query assert post_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" assert effective_cutoff == datetime(2026, 1, 1, tzinfo=UTC) return [ From 7a259a422232bad9718f1efb2430419931c17c94 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 07:27:12 +0900 Subject: [PATCH 46/53] fix: keep Voice migration replay conflict-free --- .../0237_source_post_voice_combination.sql | 25 +++++++++++++------ tests/test_source_post_voice_schema.py | 4 +-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index dd5532a86..ad8bb67c1 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -117,15 +117,26 @@ create trigger source_post_voice_type_guard before insert or update on source_post_voice for each row execute function validate_source_post_voice_codes(); +update source_post_voice voice + set is_primary = true, + truth_status_code = 'truth_observed', + provenance_assertion_id = null + from source_post post + where voice.post_id = post.post_id + and voice.voice_type_code = post.voc_type_code + and voice.effective_to is null + and not voice.is_primary; + insert into source_post_voice (post_id, voice_type_code, is_primary, truth_status_code, effective_from) -select post_id, voc_type_code, true, 'truth_observed', created_at -from source_post -on conflict (post_id, voice_type_code) where effective_to is null do update -set is_primary = true, - truth_status_code = 'truth_observed', - provenance_assertion_id = null -where not source_post_voice.is_primary; +select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at + from source_post post + where not exists ( + select 1 from source_post_voice voice + where voice.post_id = post.post_id + and voice.voice_type_code = post.voc_type_code + and voice.effective_to is null + ); create or replace function synchronize_source_post_primary_voice() returns trigger diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index ca28eef75..8cc5f3ef7 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -25,14 +25,14 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "effective_from timestamptz not null" in sql assert "true, 'truth_observed'" in sql assert "where is_primary" in sql - assert "select post_id, voc_type_code, true, 'truth_observed', created_at" in sql + assert "select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at" in sql assert "change_at timestamptz := clock_timestamp()" in sql assert "case when tg_op = 'insert' then new.created_at else change_at end" in sql assert "after insert on source_post" in sql assert "after update of voc_type_code on source_post" in sql assert "when (old.voc_type_code is distinct from new.voc_type_code)" in sql assert "on conflict (post_id, voice_type_code) where effective_to is null do update" in sql - assert "where not source_post_voice.is_primary" in sql + assert "and not voice.is_primary" in sql assert "set effective_to = change_at" in sql assert "delete from source_post_voice" not in sql assert "primary intervals must not overlap" in sql From c293323e79069ee6a7969533c4daf22b6ae0a8c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:00:30 +0900 Subject: [PATCH 47/53] feat(ontology): preserve temporal imported primary Voice intervals (ADR 0252) --- ARCHITECTURE.md | 5 + CHANGELOG.md | 5 + backend/app/main.py | 15 ++- .../app/ontology_neighborhood_ingestion.py | 13 +- backend/app/source_post_voice_ingestion.py | 2 +- backend/tests/test_api.py | 126 ++++++++++++++++++ .../0252-temporal-primary-voice-history.md | 97 ++++++++++++++ docs/adr/README.md | 3 +- docs/product-requirements.md | 6 + docs/product-technical-gap-baseline.md | 2 +- ...oice-combination-technical-requirements.md | 60 +++++++++ lineageweave/ontology_neighborhood.py | 51 ++++--- migrations/0238_source_post_voice_history.sql | 64 +++++++++ tests/test_ontology_neighborhood.py | 8 ++ tests/test_ontology_neighborhood_ingestion.py | 15 ++- tests/test_post_filter_options.py | 1 + .../test_source_post_voice_history_schema.py | 35 +++++ tests/test_source_post_voice_ingestion.py | 1 + tests/test_source_state_serialization.py | 1 + 19 files changed, 485 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0252-temporal-primary-voice-history.md create mode 100644 docs/voice-combination-technical-requirements.md create mode 100644 migrations/0238_source_post_voice_history.sql create mode 100644 tests/test_source_post_voice_history_schema.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d786771c..0ba86c8ea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -366,6 +366,11 @@ governed atomic Voice with an explicit truth state and an ABAC-visible evidence Post. The server creates the normalized PROV-O derivation and assignment in one transaction; clients never submit an internal assertion id, and this route cannot replace the imported primary Voice. +Imported-primary changes are retained in `source_post_voice` as non-overlapping +half-open effective intervals (ADR 0252). The database closes the current row +and opens the new observed primary at one statement instant; live reads select +the open interval, cutoff reads select the containing interval, and an ontology +continuation without an explicit cutoff uses its frozen snapshot instant. The bounded ontology response carries a visible Voice assignment's evidence Post id alongside its exact-value row. The exact-value table therefore offers separate carrying-Post and derivation-evidence actions; hidden evidence removes diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b956a3c..ae7062157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to this project are documented here. Format follows ### Added +- Imported primary Voice changes now retain non-overlapping effective intervals + (ADR 0252), including recurring A → B → A values. Live, knowledge-cutoff, + and ontology-snapshot reads select the same period contract; PostgreSQL + rejects overlapping primary intervals instead of relying on application + ordering. - Normalized Voice-of-X composition persistence (ADR 0251): every imported primary voice is mirrored into `source_post_voice`; each additional voice requires its own PROV-O assertion and truth status. Compound lookup codes, diff --git a/backend/app/main.py b/backend/app/main.py index b8fe88081..bdf8bee49 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -715,7 +715,14 @@ async def _load_post_voice_types( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = $1 - and ($2::timestamptz is null or voice.effective_from <= $2) + 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) + ) + ) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, @@ -744,7 +751,9 @@ async def _post_filter_options( coalesce(lookup.lookup_label, option.code) as label, coalesce(lookup.display_order, 2147483647) as display_order from source_post post - left join source_post_voice voice on voice.post_id = post.post_id + left join source_post_voice voice + on voice.post_id = post.post_id + and voice.effective_to is null cross join lateral ( values ('post_visibility', post.visibility_code), ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code)) @@ -1574,6 +1583,7 @@ async def list_posts( select 1 from source_post_voice voice_filter where voice_filter.post_id = post.post_id and voice_filter.voice_type_code = any($3::text[]) + and voice_filter.effective_to is null )) and ($4::text is null or post.visibility_code = $4) order by @@ -1648,6 +1658,7 @@ async def list_posts( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = voice.voice_type_code where voice.post_id = page.post_id + and voice.effective_to is null ) voices on true order by case when $1::text is not null then page.search_priority end asc, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 908d5218a..88fe85d91 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -769,6 +769,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, } @@ -793,6 +797,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 @@ -806,7 +811,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 or voice.effective_from <= $2) + and voice.effective_from <= coalesce($2::timestamptz, $3::timestamptz) + and ( + voice.effective_to is null + or coalesce($2::timestamptz, $3::timestamptz) < voice.effective_to + ) and voice.recorded_at <= $3::timestamptz order by voice.post_id, voice.is_primary desc, lookup.display_order, voice.voice_type_code @@ -831,6 +840,8 @@ async def _load_voice_assignments( is_primary=row["is_primary"], truth_status_code=row["truth_status_code"], recorded_at=row["recorded_at"], + effective_from=row["effective_from"], + effective_to=row["effective_to"], provenance_reference=( "Evidence-backed additional voice" if not row["is_primary"] diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py index abb28e199..7bbb015a4 100644 --- a/backend/app/source_post_voice_ingestion.py +++ b/backend/app/source_post_voice_ingestion.py @@ -145,7 +145,7 @@ async def persist_additional_voice_assignment( (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) do update + 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() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fa1c0d634..14cdd8424 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -16,6 +16,7 @@ import math import os import uuid +from concurrent.futures import ThreadPoolExecutor from contextlib import closing from pathlib import Path from types import SimpleNamespace @@ -254,6 +255,11 @@ / "migrations" / "0237_source_post_voice_combination.sql" ) +_VOICE_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0238_source_post_voice_history.sql" +) def _postgres_available() -> bool: @@ -430,6 +436,7 @@ def seeded_db(demo_analyst_token): cur.execute(_ONTOLOGY_TRUTH_STATUS_MIGRATION.read_text()) cur.execute(_VOICE_TAXONOMY_MIGRATION.read_text()) cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + cur.execute(_VOICE_HISTORY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4766,6 +4773,125 @@ def test_create_voice_assignment_persists_authorized_prov_o_evidence( assert cur.fetchone() == ("voc",) +def test_primary_voice_history_survives_a_b_a_cutoffs( + client, demo_analyst_token, seeded_db +) -> None: + """Real PostgreSQL and API reads preserve each imported primary interval.""" + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, created_at, updated_at) + select author_account_id, corporate_entity_id, + 'Synthetic Voice history', 'Synthetic interval evidence', + 'voc', 'public', created_at, updated_at + from source_post + where post_id = %s + returning post_id + """, + (seeded_db["own_private_post_id"],), + ) + post_id = str(cur.fetchone()[0]) + conn.commit() + + cur.execute( + "update source_post set voc_type_code = 'vob' where post_id = %s", + (post_id,), + ) + conn.commit() + cur.execute( + "update source_post set voc_type_code = 'voc' where post_id = %s", + (post_id,), + ) + conn.commit() + + cur.execute( + """ + select voice_type_code, effective_from, effective_to + from source_post_voice + where post_id = %s and is_primary + order by effective_from + """, + (post_id,), + ) + periods = cur.fetchall() + + assert [row[0] for row in periods] == ["voc", "vob", "voc"] + assert periods[0][2] == periods[1][1] + assert periods[1][2] == periods[2][1] + assert periods[2][2] is None + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + cutoffs = ( + periods[0][1] + (periods[0][2] - periods[0][1]) / 2, + periods[1][1] + (periods[1][2] - periods[1][1]) / 2, + periods[2][1], + ) + for cutoff, expected in zip(cutoffs, ("voc", "vob", "voc"), strict=True): + response = client.get( + f"/api/posts/{post_id}", + params={"as_of": cutoff.isoformat()}, + headers=headers, + ) + assert response.status_code == 200, response.text + primary = [ + voice for voice in response.json()["voice_types"] if voice["is_primary"] + ] + assert [voice["code"] for voice in primary] == [expected] + + +def test_concurrent_primary_voice_updates_keep_non_overlapping_history(seeded_db) -> None: + """Waiting source updates use lock-time clocks and leave one current primary.""" + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, created_at, updated_at) + select author_account_id, corporate_entity_id, + 'Synthetic concurrent Voice history', 'Synthetic lock evidence', + 'voc', 'public', created_at, updated_at + from source_post + where post_id = %s + returning post_id + """, + (seeded_db["own_private_post_id"],), + ) + post_id = str(cur.fetchone()[0]) + conn.commit() + + def update_primary(code: str) -> None: + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "update source_post set voc_type_code = %s where post_id = %s", + (code, post_id), + ) + conn.commit() + + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(update_primary, ("vob", "vop"))) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + select voice_type_code, effective_from, effective_to + from source_post_voice + where post_id = %s and is_primary + order by effective_from + """, + (post_id,), + ) + periods = cur.fetchall() + + assert len(periods) == 3 + assert periods[0][0] == "voc" + assert {periods[1][0], periods[2][0]} == {"vob", "vop"} + assert periods[0][2] == periods[1][1] + assert periods[1][2] == periods[2][1] + assert periods[2][2] is None + + def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/tickets", diff --git a/docs/adr/0252-temporal-primary-voice-history.md b/docs/adr/0252-temporal-primary-voice-history.md new file mode 100644 index 000000000..2cf3f5685 --- /dev/null +++ b/docs/adr/0252-temporal-primary-voice-history.md @@ -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 diff --git a/docs/adr/README.md b/docs/adr/README.md index bbd8f6a6e..2f76db767 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,8 +9,9 @@ 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 | +| [`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) | | [`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) | diff --git a/docs/product-requirements.md b/docs/product-requirements.md index d7c8e9091..4c0029b51 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -53,6 +53,10 @@ edge exposes the same authorized endpoints and evidence through API and UI. governed atomic Voice only with an ABAC-visible evidence Post and explicit truth state; create the normalized PROV-O derivation server-side and never accept an opaque provenance assertion identifier from the caller. +- Preserve recurring imported-primary history as non-overlapping half-open + intervals; live reads return the current primary, while authorized cutoff + reads return the primary effective at that instant without substituting the + current value. - Let a `post_admin` connect another perspective from the live Post popup by choosing an unassigned atomic Voice and an explicit truth state; use the open authorized Post as evidence and hide the write action on cutoff views. @@ -63,6 +67,8 @@ edge exposes the same authorized endpoints and evidence through API and UI. Acceptance: Turtle, JSON-LD, N-Triples, SHACL, API payloads, persisted IRIs, and rendered labels agree on term kind, direction, namespace, and provenance; an additional Voice cannot demote the imported primary or cite hidden evidence; +an A → B → A imported-primary sequence returns A, B, and A at its three +respective authorized cutoff intervals; the exact-value table opens the carrying Post and its authorized derivation evidence as distinct actions; the authoring form has explicit selections, permission/cutoff gating, retryable diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2b7015380..f2e3351da 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -521,7 +521,7 @@ this file per §3.5 of the prior snapshot). | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | -| Voice primary history | ADR 0251 and migration 0237 prevent a changed imported primary Voice from appearing before its effective instant, but the former primary row is deleted and cannot be recovered at an older cutoff; #748 owns this gap | Define the temporal schema in an ADR, then prove atomic A → B → A changes, non-overlap, replay, concurrent UPSERT, ABAC, API, ontology, CSV, and UI cutoff behavior with synthetic PostgreSQL evidence | +| Voice primary history | ADR 0252 and candidate migration 0238 retain half-open imported-primary intervals, allow recurring A → B → A values, and align live, cutoff, and ontology-snapshot reads. Static/unit contracts pass; this branch is not protected-main or authenticated runtime evidence | Prove migration replay and atomic A → B → A cutoff reads against the isolated PostgreSQL/OIDC stack, then pass exact-head hosted checks and protected review before closing #748 | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | diff --git a/docs/voice-combination-technical-requirements.md b/docs/voice-combination-technical-requirements.md new file mode 100644 index 000000000..f075f7e2e --- /dev/null +++ b/docs/voice-combination-technical-requirements.md @@ -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. diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index 53afb5b2d..387c61f17 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -220,7 +220,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: @@ -246,6 +248,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) @@ -310,8 +322,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 "" @@ -400,23 +414,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, diff --git a/migrations/0238_source_post_voice_history.sql b/migrations/0238_source_post_voice_history.sql new file mode 100644 index 000000000..3fde74a97 --- /dev/null +++ b/migrations/0238_source_post_voice_history.sql @@ -0,0 +1,64 @@ +-- 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_pkey; + +alter table source_post_voice + add primary key (post_id, voice_type_code, effective_from); + +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); + +drop index if exists source_post_voice_primary_idx; +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; + +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 new.created_at else change_at end, + change_at + ); + return new; +end; +$$; + +commit; diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index 2ce86944c..2232ced9e 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -873,6 +873,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, ) @@ -883,6 +884,7 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: assert row["target_label"] == "Voice of Process" assert row["evidence_post_id"] == POST_ID assert row["evidence_count"] == "1" + assert row["valid_from"] == T0.isoformat() graph = neighborhood.jsonld_document()["@graph"] assignment_iri = str(LW[f"voice-assignment/{POST_ID}/vops"]) projected = next(item for item in graph if item.get("@id") == assignment_iri) @@ -900,6 +902,10 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: assert projected["prov:wasDerivedFrom"] == { "@id": ontology_node_iri(NODE_POST, POST_ID) } + assert ( + projected["time:hasBeginning"]["time:inXSDDateTimeStamp"]["@value"] + == T0.isoformat() + ) hidden_evidence = replace(assignment, evidence_post_id=None) hidden_row = replace( @@ -918,6 +924,8 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): replace(assignment, recorded_at=T0.replace(tzinfo=None)) + with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): + replace(assignment, effective_from=T0.replace(tzinfo=None)) def test_node_bound_truncation_keeps_nearer_hop_over_farther_alphabetically_earlier_type() -> None: diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 8e2abe285..4b98864f6 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -832,6 +832,8 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: "is_primary": True, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": False, "evidence_post_id": None, } @@ -905,6 +907,8 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - "is_primary": True, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": False, "evidence_post_id": None, }, @@ -915,6 +919,8 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - "is_primary": False, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": True, "evidence_post_id": POST_ID, }, @@ -932,7 +938,14 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - assert assignments[1].evidence_post_id == POST_ID assert "evidence.node_id = any($1::uuid[])" in conn.calls[0][0] assert "voice.is_primary or evidence.node_id = any($1::uuid[])" in conn.calls[0][0] - assert "voice.effective_from <= $2" in conn.calls[0][0] + assert ( + "voice.effective_from <= coalesce($2::timestamptz, $3::timestamptz)" + in conn.calls[0][0] + ) + assert ( + "coalesce($2::timestamptz, $3::timestamptz) < voice.effective_to" + in conn.calls[0][0] + ) assert "voice.recorded_at <= $3" in conn.calls[0][0] assert conn.calls[0][1] == ([POST_ID], T0, T0) diff --git a/tests/test_post_filter_options.py b/tests/test_post_filter_options.py index 6ac4dc570..09ea8613e 100644 --- a/tests/test_post_filter_options.py +++ b/tests/test_post_filter_options.py @@ -57,6 +57,7 @@ def test_post_filter_options_use_one_authorized_source_scan() -> None: assert "cross join lateral" in query assert "('post_visibility', post.visibility_code)" in query assert "left join source_post_voice voice" in query + assert "voice.effective_to is null" in query assert "('voc_type', coalesce(voice.voice_type_code, post.voc_type_code))" in query assert "post.corporate_entity_id::text = any($1::text[])" in query assert "post.process_unit_id::text = any($2::text[])" in query diff --git a/tests/test_source_post_voice_history_schema.py b/tests/test_source_post_voice_history_schema.py new file mode 100644 index 000000000..eb11467ca --- /dev/null +++ b/tests/test_source_post_voice_history_schema.py @@ -0,0 +1,35 @@ +"""Static contract tests for ADR 0252 temporal primary Voice history.""" + +from __future__ import annotations + +from pathlib import Path + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0238_source_post_voice_history.sql" +) + + +def test_primary_voice_history_uses_non_overlapping_half_open_intervals() -> None: + """The database preserves recurring Voices and rejects overlapping primaries.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "add column if not exists effective_to timestamptz" in sql + assert "primary key (post_id, voice_type_code, effective_from)" in sql + assert "tstzrange(effective_from, effective_to, '[)') with &&" in sql + assert "where (is_primary)" in sql + assert "effective_from < effective_to" in sql + + +def test_primary_voice_change_closes_current_rows_before_insert() -> None: + """One transaction instant closes the prior state and opens the new primary.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "change_at timestamptz := clock_timestamp()" in sql + assert "set effective_to = change_at" in sql + assert "and (is_primary or voice_type_code = new.voc_type_code)" in sql + assert sql.index("set effective_to = change_at") < sql.index( + "insert into source_post_voice" + ) + assert "on conflict" not in sql diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py index 0b1713f4f..72ca97126 100644 --- a/tests/test_source_post_voice_ingestion.py +++ b/tests/test_source_post_voice_ingestion.py @@ -71,6 +71,7 @@ def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> sql = "\n".join(query for query, _args in conn.calls) assert "prov_was_derived_from" in sql assert "where not source_post_voice.is_primary" in sql + assert "where effective_to is null" in sql assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( conn.calls ) diff --git a/tests/test_source_state_serialization.py b/tests/test_source_state_serialization.py index d668d9cc6..e70262c12 100644 --- a/tests/test_source_state_serialization.py +++ b/tests/test_source_state_serialization.py @@ -79,6 +79,7 @@ async def fetch( ) -> list[dict[str, object]]: assert "provenance_assertion_id is not null as evidence_available" in query assert "voice.effective_from <= $2" in query + assert "voice.effective_to is null or $2 < voice.effective_to" in query assert post_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" assert effective_cutoff == datetime(2026, 1, 1, tzinfo=UTC) return [ From 11fc2fa593c7c4e9a4de76388524161fe2cb5551 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 18:54:25 +0900 Subject: [PATCH 48/53] docs(adr): renumber Voice combinations after collision --- CHANGELOG.md | 2 +- backend/app/source_post_voice_ingestion.py | 2 +- ...tions.md => 0256-evidence-bearing-voice-combinations.md} | 2 +- docs/adr/README.md | 2 +- docs/ontology/lineageweave-kg.ttl | 2 +- docs/product-requirements.md | 2 +- docs/product-technical-gap-baseline.md | 6 +++--- migrations/0237_source_post_voice_combination.sql | 2 +- tests/test_ontology.py | 2 +- tests/test_source_post_voice_ingestion.py | 2 +- tests/test_source_post_voice_schema.py | 2 +- 11 files changed, 13 insertions(+), 13 deletions(-) rename docs/adr/{0251-evidence-bearing-voice-combinations.md => 0256-evidence-bearing-voice-combinations.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b956a3c..9653d1d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project are documented here. Format follows ### Added -- Normalized Voice-of-X composition persistence (ADR 0251): every imported +- Normalized Voice-of-X composition persistence (ADR 0256): every imported primary voice is mirrored into `source_post_voice`; each additional voice requires its own PROV-O assertion and truth status. Compound lookup codes, keyword inference, confidence thresholds, and invented weights remain out of diff --git a/backend/app/source_post_voice_ingestion.py b/backend/app/source_post_voice_ingestion.py index 7bbb015a4..609a71f4f 100644 --- a/backend/app/source_post_voice_ingestion.py +++ b/backend/app/source_post_voice_ingestion.py @@ -1,4 +1,4 @@ -"""Persist evidence-bearing additional Voice assignments (ADR 0251).""" +"""Persist evidence-bearing additional Voice assignments (ADR 0256).""" from __future__ import annotations diff --git a/docs/adr/0251-evidence-bearing-voice-combinations.md b/docs/adr/0256-evidence-bearing-voice-combinations.md similarity index 99% rename from docs/adr/0251-evidence-bearing-voice-combinations.md rename to docs/adr/0256-evidence-bearing-voice-combinations.md index 722895fdc..79279130d 100644 --- a/docs/adr/0251-evidence-bearing-voice-combinations.md +++ b/docs/adr/0256-evidence-bearing-voice-combinations.md @@ -1,4 +1,4 @@ -# ADR 0251: Evidence-bearing Voice-of-X combinations +# ADR 0256: Evidence-bearing Voice-of-X combinations ## Status diff --git a/docs/adr/README.md b/docs/adr/README.md index bbd8f6a6e..989d83890 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,7 +16,7 @@ decision from them. | [`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) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | -| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0251](0251-evidence-bearing-voice-combinations.md) | +| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0184](0184-ontology-provenance-explorer.md), [0222](0222-project-nodes-in-ontology-neighborhood.md), [0256](0256-evidence-bearing-voice-combinations.md) | | [`POSTGRESQL_CONCURRENCY_REFERENCES.md`](../doctoring/POSTGRESQL_CONCURRENCY_REFERENCES.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 647e24571..ba2bccfd3 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -361,7 +361,7 @@ rdfs:comment "A process- or system-generated source record." ; :lookupCode "vops" . -# ADR 0251 -- qualified, evidence-bearing combinations. A post links to one +# ADR 0256 -- qualified, evidence-bearing combinations. A post links to one # assignment per atomic voice instead of minting a term for each Cartesian # combination. Additional assignments use prov:wasDerivedFrom to retain their # evidence lineage. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index d7c8e9091..0c4f90c6a 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -232,7 +232,7 @@ A release claim requires one exact protected-main head that proves: - Product/data boundary: ADR 0001, ADR 0089. - Asynchronous delivery and database-pool isolation: ADR 0204, ADR 0213. - Knowledge Graph, ontology, and provenance: ADR 0004, ADR 0011, ADR 0065, - ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0251. + ADR 0184, ADR 0207, ADR 0222, ADR 0246, ADR 0256. - Semantic units and retrieval: ADR 0047, ADR 0062, ADR 0102, ADR 0217. - LLM/model boundary: ADR 0070, ADR 0072, ADR 0076, ADR 0079. - Measurement: ADR 0003, ADR 0145, ADR 0200, ADR 0205. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 383821bee..e86ff6856 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -28,7 +28,7 @@ Accordingly, ADR 0246 keeps the controlled vocabulary extensible and refuses keyword inference, defaults, invented weights, or an asserted exhaustive cross-product. -ADR 0251 and migration 0237 now define the persistence contract for +ADR 0256 and migration 0237 now define the persistence contract for evidence-bearing composition. A post keeps one source-provided `voc_type_code`, mirrored as its sole primary association, while every additional voice requires a normalized PROV-O assertion and explicit truth @@ -123,7 +123,7 @@ PROV-O, and primary-preservation assertions; no identifying source data was used or retained. No self-approval, admin bypass, or stale-head check transfer is permitted. -Stacked PR #717 carries ADR 0251, migration 0237, qualified +Stacked PR #717 carries ADR 0256, migration 0237, qualified ontology terms, persistence/API/UI tests, and the category-validation review repairs plus a local candidate admin write path that creates its PROV-O derivation from an authorized evidence Post. Its JSON-LD projection names that @@ -520,7 +520,7 @@ this file per §3.5 of the prior snapshot). | Image understanding | Region, OCR, and description work exists across active heads (#405, #419), but current runtime acceptance has not yet proved table-image structure, complete region coverage, or summary/image readiness together | Orchestrator-backed rendered workflow, original/derived asset provenance, region-before-OCR processing, and honest unsupported states; reconcile ADR 0052's image-bearing summary readiness with ADR 0098 before changing sequencing | | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | -| Voice primary history | The #717 candidate updates ADR 0251 and migration 0237 with immutable assignment ids and half-open intervals, closing rather than deleting a replaced primary so A → B → A is representable; this is not protected-main evidence | Prove migration replay, concurrent primary changes, non-overlap, and API/ontology cutoff reads against synthetic PostgreSQL at the current exact head, then close #748 only after protected delivery | +| Voice primary history | The #717 candidate updates ADR 0256 and migration 0237 with immutable assignment ids and half-open intervals, closing rather than deleting a replaced primary so A → B → A is representable; this is not protected-main evidence | Prove migration replay, concurrent primary changes, non-overlap, and API/ontology cutoff reads against synthetic PostgreSQL at the current exact head, then close #748 only after protected delivery | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index ad8bb67c1..a010a830c 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -1,4 +1,4 @@ --- ADR 0251: normalized, evidence-bearing Voice-of-X combinations. +-- ADR 0256: normalized, evidence-bearing Voice-of-X combinations. -- source_post.voc_type_code remains the imported primary voice. Additional -- voices require a normalized PROV-O assertion instead of keyword inference. diff --git a/tests/test_ontology.py b/tests/test_ontology.py index 501bb91e5..f24745f87 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -452,7 +452,7 @@ def test_post_voice_additions_do_not_invent_counterparty_relationships() -> None def test_voice_combinations_use_qualified_assignments() -> None: - """ADR 0251 composes atomic voices without Cartesian-product terms.""" + """ADR 0256 composes atomic voices without Cartesian-product terms.""" graph = load_ontology() assert (LW.VoiceAssignment, RDF.type, OWL.Class) in graph diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py index ecbd1952a..75a0c48d0 100644 --- a/tests/test_source_post_voice_ingestion.py +++ b/tests/test_source_post_voice_ingestion.py @@ -1,4 +1,4 @@ -"""Evidence-bearing additional Voice persistence tests (ADR 0251).""" +"""Evidence-bearing additional Voice persistence tests (ADR 0256).""" from __future__ import annotations diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 8cc5f3ef7..05265bf10 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -1,4 +1,4 @@ -"""Static contract tests for ADR 0251's normalized Voice-of-X associations.""" +"""Static contract tests for ADR 0256's normalized Voice-of-X associations.""" from __future__ import annotations From 474dfdf73ec412ee6a4824300bbdca2e1f188082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 16:00:30 +0900 Subject: [PATCH 49/53] feat(ontology): preserve temporal imported primary Voice intervals (ADR 0252) --- ARCHITECTURE.md | 5 + CHANGELOG.md | 6 + backend/app/main.py | 11 ++ .../app/ontology_neighborhood_ingestion.py | 12 ++ backend/tests/test_api.py | 119 +++++++++++++++++- .../0252-temporal-primary-voice-history.md | 97 ++++++++++++++ docs/adr/README.md | 4 +- docs/product-requirements.md | 6 + docs/product-technical-gap-baseline.md | 1 + ...oice-combination-technical-requirements.md | 60 +++++++++ lineageweave/ontology_neighborhood.py | 51 +++++--- .../0237_source_post_voice_combination.sql | 5 +- migrations/0238_source_post_voice_history.sql | 57 +++++++++ tests/test_ontology_neighborhood.py | 8 ++ tests/test_ontology_neighborhood_ingestion.py | 15 ++- tests/test_post_filter_options.py | 1 + .../test_source_post_voice_history_schema.py | 49 ++++++++ tests/test_source_post_voice_ingestion.py | 1 + tests/test_source_state_serialization.py | 1 + 19 files changed, 486 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0252-temporal-primary-voice-history.md create mode 100644 docs/voice-combination-technical-requirements.md create mode 100644 migrations/0238_source_post_voice_history.sql create mode 100644 tests/test_source_post_voice_history_schema.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7cbe3697f..9b7f53ed7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -375,6 +375,11 @@ governed atomic Voice with an explicit truth state and an ABAC-visible evidence Post. The server creates the normalized PROV-O derivation and assignment in one transaction; clients never submit an internal assertion id, and this route cannot replace the imported primary Voice. +Imported-primary changes are retained in `source_post_voice` as non-overlapping +half-open effective intervals (ADR 0252). The database closes the current row +and opens the new observed primary at one statement instant; live reads select +the open interval, cutoff reads select the containing interval, and an ontology +continuation without an explicit cutoff uses its frozen snapshot instant. The bounded ontology response carries a visible Voice assignment's evidence Post id alongside its exact-value row. The exact-value table therefore offers separate carrying-Post and derivation-evidence actions; hidden evidence removes diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e57455bb..7953f47b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to this project are documented here. Format follows ### Added - Normalized Voice-of-X composition persistence (ADR 0256): every imported +- Imported primary Voice changes now retain non-overlapping effective intervals + (ADR 0252), including recurring A → B → A values. Live, knowledge-cutoff, + and ontology-snapshot reads select the same period contract; PostgreSQL + rejects overlapping primary intervals instead of relying on application + ordering. +- Normalized Voice-of-X composition persistence (ADR 0251): every imported primary voice is mirrored into `source_post_voice`; each additional voice requires its own PROV-O assertion and truth status. Compound lookup codes, keyword inference, confidence thresholds, and invented weights remain out of diff --git a/backend/app/main.py b/backend/app/main.py index 08a8391f5..d149bedcf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -723,6 +723,14 @@ async def _load_post_voice_types( or ($2::timestamptz is not null and voice.effective_from <= $2 and (voice.effective_to is null or $2 < voice.effective_to))) + 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) + ) + ) order by voice.is_primary desc, lookup.display_order, voice.voice_type_code """, post_id, @@ -753,6 +761,8 @@ async def _post_filter_options( from source_post post left join source_post_voice voice on voice.post_id = post.post_id and voice.effective_to is null + on voice.post_id = post.post_id + and voice.effective_to is null cross join lateral ( values ('post_visibility', post.visibility_code), ('voc_type', coalesce(voice.voice_type_code, post.voc_type_code)) @@ -1583,6 +1593,7 @@ async def list_posts( where voice_filter.post_id = post.post_id and voice_filter.effective_to is null and voice_filter.voice_type_code = any($3::text[]) + and voice_filter.effective_to is null )) and ($4::text is null or post.visibility_code = $4) order by diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index f64987ec4..96e643c67 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -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, } @@ -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 @@ -907,6 +912,11 @@ async def _load_voice_assignments( 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 + ) and voice.recorded_at <= $3::timestamptz order by voice.post_id, voice.is_primary desc, lookup.display_order, voice.voice_type_code @@ -931,6 +941,8 @@ async def _load_voice_assignments( is_primary=row["is_primary"], truth_status_code=row["truth_status_code"], recorded_at=row["recorded_at"], + effective_from=row["effective_from"], + effective_to=row["effective_to"], provenance_reference=( "Evidence-backed additional voice" if not row["is_primary"] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6dbc00936..c3a0e896a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -260,7 +260,6 @@ Path(__file__).resolve().parents[2] / "migrations" / "0237_source_post_voice_combination.sql" - Path(__file__).resolve().parents[2] / "migrations" / "0175_ontology_truth_status.sql" ) _OCCUPATIONAL_CONSTRUCT_MIGRATION = ( Path(__file__).resolve().parents[2] @@ -272,6 +271,11 @@ / "migrations" / "0239_occupational_construct_catalog.sql" ) +_VOICE_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0238_source_post_voice_history.sql" +) def _postgres_available() -> bool: @@ -455,6 +459,7 @@ def seeded_db(demo_analyst_token): cur.execute(_VOICE_TAXONOMY_MIGRATION.read_text()) cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) + cur.execute(_VOICE_HISTORY_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4911,6 +4916,33 @@ def update_voice(code: str) -> None: future.result() with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: +def test_primary_voice_history_survives_a_b_a_cutoffs( + client, demo_analyst_token, seeded_db +) -> None: + """Real PostgreSQL and API reads preserve each imported primary interval.""" + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, created_at, updated_at) + select author_account_id, corporate_entity_id, + 'Synthetic Voice history', 'Synthetic interval evidence', + 'voc', 'public', created_at, updated_at + from source_post + where post_id = %s + returning post_id + """, + (seeded_db["own_private_post_id"],), + ) + post_id = str(cur.fetchone()[0]) + conn.commit() + + cur.execute( + "update source_post set voc_type_code = 'vob' where post_id = %s", + (post_id,), + ) + conn.commit() cur.execute( "update source_post set voc_type_code = 'voc' where post_id = %s", (post_id,), @@ -4944,6 +4976,91 @@ def update_voice(code: str) -> None: ] assert [voice["code"] for voice in primaries] == [voice_type_code] + cur.execute( + """ + select voice_type_code, effective_from, effective_to + from source_post_voice + where post_id = %s and is_primary + order by effective_from + """, + (post_id,), + ) + periods = cur.fetchall() + + assert [row[0] for row in periods] == ["voc", "vob", "voc"] + assert periods[0][2] == periods[1][1] + assert periods[1][2] == periods[2][1] + assert periods[2][2] is None + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + cutoffs = ( + periods[0][1] + (periods[0][2] - periods[0][1]) / 2, + periods[1][1] + (periods[1][2] - periods[1][1]) / 2, + periods[2][1], + ) + for cutoff, expected in zip(cutoffs, ("voc", "vob", "voc"), strict=True): + response = client.get( + f"/api/posts/{post_id}", + params={"as_of": cutoff.isoformat()}, + headers=headers, + ) + assert response.status_code == 200, response.text + primary = [ + voice for voice in response.json()["voice_types"] if voice["is_primary"] + ] + assert [voice["code"] for voice in primary] == [expected] + + +def test_concurrent_primary_voice_updates_keep_non_overlapping_history(seeded_db) -> None: + """Waiting source updates use lock-time clocks and leave one current primary.""" + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + insert into source_post + (author_account_id, corporate_entity_id, post_title, post_body, + voc_type_code, visibility_code, created_at, updated_at) + select author_account_id, corporate_entity_id, + 'Synthetic concurrent Voice history', 'Synthetic lock evidence', + 'voc', 'public', created_at, updated_at + from source_post + where post_id = %s + returning post_id + """, + (seeded_db["own_private_post_id"],), + ) + post_id = str(cur.fetchone()[0]) + conn.commit() + + def update_primary(code: str) -> None: + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "update source_post set voc_type_code = %s where post_id = %s", + (code, post_id), + ) + conn.commit() + + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(update_primary, ("vob", "vop"))) + + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + """ + select voice_type_code, effective_from, effective_to + from source_post_voice + where post_id = %s and is_primary + order by effective_from + """, + (post_id,), + ) + periods = cur.fetchall() + + assert len(periods) == 3 + assert periods[0][0] == "voc" + assert {periods[1][0], periods[2][0]} == {"vob", "vop"} + assert periods[0][2] == periods[1][1] + assert periods[1][2] == periods[2][1] + assert periods[2][2] is None + def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( diff --git a/docs/adr/0252-temporal-primary-voice-history.md b/docs/adr/0252-temporal-primary-voice-history.md new file mode 100644 index 000000000..2cf3f5685 --- /dev/null +++ b/docs/adr/0252-temporal-primary-voice-history.md @@ -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 diff --git a/docs/adr/README.md b/docs/adr/README.md index de21b1028..1a3441119 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | | [`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) | diff --git a/docs/product-requirements.md b/docs/product-requirements.md index df2d15bce..763a116b9 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -53,6 +53,10 @@ edge exposes the same authorized endpoints and evidence through API and UI. governed atomic Voice only with an ABAC-visible evidence Post and explicit truth state; create the normalized PROV-O derivation server-side and never accept an opaque provenance assertion identifier from the caller. +- Preserve recurring imported-primary history as non-overlapping half-open + intervals; live reads return the current primary, while authorized cutoff + reads return the primary effective at that instant without substituting the + current value. - Let a `post_admin` connect another perspective from the live Post popup by choosing an unassigned atomic Voice and an explicit truth state; use the open authorized Post as evidence and hide the write action on cutoff views. @@ -63,6 +67,8 @@ edge exposes the same authorized endpoints and evidence through API and UI. Acceptance: Turtle, JSON-LD, N-Triples, SHACL, API payloads, persisted IRIs, and rendered labels agree on term kind, direction, namespace, and provenance; an additional Voice cannot demote the imported primary or cite hidden evidence; +an A → B → A imported-primary sequence returns A, B, and A at its three +respective authorized cutoff intervals; the exact-value table opens the carrying Post and its authorized derivation evidence as distinct actions; the authoring form has explicit selections, permission/cutoff gating, retryable diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e09efd117..5cf9f8c2d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -620,6 +620,7 @@ this file per §3.5 of the prior snapshot). | Semantic source rendering | Paragraph, table, list, formula, and indentation work exists across stacks (#394, #427, #448–#450); #515 adds synthetic backend/frontend parity for deterministic rows/cells, footnote boundaries, and encoded scripts | Land the #427 → #515 stack, then gather authenticated browser evidence that list nesting, continuation alignment, and formula units render without authoring-layout artifacts | | Event and project semantics | #663 is the largest current user-visible gap slice: evidence-backed Project nodes, bounded traversal, cutoff/snapshot fencing, exact-value table parity, and localized graph labels. Focus visibility, label-bound, and temporal test-double regressions are repaired. #666's heuristic removal is composed into this parent but is not separately protected-main evidence. #640 separately adds project journeys without claiming authoritative lifecycle status | Combined #663 must pass exact-head checks and independent approval before protected merge. Aggregate authenticated evidence must still prove distinct projects/events and handover intervals without promoting co-occurrence | | Voice primary history | The #717 candidate updates ADR 0256 and migration 0237 with immutable assignment ids and half-open intervals, closing rather than deleting a replaced primary so A → B → A is representable; this is not protected-main evidence | Prove migration replay, concurrent primary changes, non-overlap, and API/ontology cutoff reads against synthetic PostgreSQL at the current exact head, then close #748 only after protected delivery | +| Voice primary history | ADR 0252 and candidate migration 0238 retain half-open imported-primary intervals, allow recurring A → B → A values, and align live, cutoff, and ontology-snapshot reads. Static/unit contracts pass; this branch is not protected-main or authenticated runtime evidence | Prove migration replay and atomic A → B → A cutoff reads against the isolated PostgreSQL/OIDC stack, then pass exact-head hosted checks and protected review before closing #748 | | Knowledge Graph readability | #659 recreates the token-backed node-type repair on current `main`, including regression coverage; it is open and therefore not protected-main evidence | Merge #659 normally, then verify light/dark contrast, keyboard graph navigation, full labels, and evidence tables in the authenticated rendered surface | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | | Calendar / Naruon | #355 delivered the projection contract; v2.17.0 wires operator consumption without forwarding the end-user token. Naruon producer, provider/consumer fixtures, and protected merge remain open (#336) | Verify observed events against the published schema without invented events; keep commitments available when the channel is unwired | diff --git a/docs/voice-combination-technical-requirements.md b/docs/voice-combination-technical-requirements.md new file mode 100644 index 000000000..f075f7e2e --- /dev/null +++ b/docs/voice-combination-technical-requirements.md @@ -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. diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index cb07daae0..991b81f10 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -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: @@ -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) @@ -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 "" @@ -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, diff --git a/migrations/0237_source_post_voice_combination.sql b/migrations/0237_source_post_voice_combination.sql index a010a830c..5011dc1b3 100644 --- a/migrations/0237_source_post_voice_combination.sql +++ b/migrations/0237_source_post_voice_combination.sql @@ -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 @@ -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, diff --git a/migrations/0238_source_post_voice_history.sql b/migrations/0238_source_post_voice_history.sql new file mode 100644 index 000000000..ad4637754 --- /dev/null +++ b/migrations/0238_source_post_voice_history.sql @@ -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; + +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 + ); + return new; +end; +$$; + +commit; diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index 3f6165745..764bceaec 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -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, ) @@ -922,6 +923,7 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: assert row["target_label"] == "Voice of Process" assert row["evidence_post_id"] == POST_ID assert row["evidence_count"] == "1" + assert row["valid_from"] == T0.isoformat() graph = neighborhood.jsonld_document()["@graph"] assignment_iri = str(LW[f"voice-assignment/{POST_ID}/vops"]) projected = next(item for item in graph if item.get("@id") == assignment_iri) @@ -939,6 +941,10 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: assert projected["prov:wasDerivedFrom"] == { "@id": ontology_node_iri(NODE_POST, POST_ID) } + assert ( + projected["time:hasBeginning"]["time:inXSDDateTimeStamp"]["@value"] + == T0.isoformat() + ) hidden_evidence = replace(assignment, evidence_post_id=None) hidden_row = replace( @@ -957,6 +963,8 @@ def test_voice_assignments_join_exact_csv_rows_and_jsonld() -> None: with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): replace(assignment, recorded_at=T0.replace(tzinfo=None)) + with pytest.raises(OntologyNeighborhoodError, match="offset-aware"): + replace(assignment, effective_from=T0.replace(tzinfo=None)) def test_node_bound_truncation_keeps_nearer_hop_over_farther_alphabetically_earlier_type() -> None: diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 333d91df9..093fe8906 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -930,6 +930,8 @@ def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: "is_primary": True, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": False, "evidence_post_id": None, } @@ -1003,6 +1005,8 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - "is_primary": True, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": False, "evidence_post_id": None, }, @@ -1013,6 +1017,8 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - "is_primary": False, "truth_status_code": "truth_observed", "recorded_at": T0, + "effective_from": T0, + "effective_to": None, "has_assertion": True, "evidence_post_id": POST_ID, }, @@ -1030,7 +1036,14 @@ def test_load_voice_assignments_preserves_truth_and_customer_safe_provenance() - assert assignments[1].evidence_post_id == POST_ID assert "evidence.node_id = any($1::uuid[])" in conn.calls[0][0] assert "voice.is_primary or evidence.node_id = any($1::uuid[])" in conn.calls[0][0] - assert "voice.effective_from <= $2" in conn.calls[0][0] + assert ( + "voice.effective_from <= coalesce($2::timestamptz, $3::timestamptz)" + in conn.calls[0][0] + ) + assert ( + "coalesce($2::timestamptz, $3::timestamptz) < voice.effective_to" + in conn.calls[0][0] + ) assert "voice.recorded_at <= $3" in conn.calls[0][0] assert conn.calls[0][1] == ([POST_ID], T0, T0) diff --git a/tests/test_post_filter_options.py b/tests/test_post_filter_options.py index 6ac4dc570..09ea8613e 100644 --- a/tests/test_post_filter_options.py +++ b/tests/test_post_filter_options.py @@ -57,6 +57,7 @@ def test_post_filter_options_use_one_authorized_source_scan() -> None: assert "cross join lateral" in query assert "('post_visibility', post.visibility_code)" in query assert "left join source_post_voice voice" in query + assert "voice.effective_to is null" in query assert "('voc_type', coalesce(voice.voice_type_code, post.voc_type_code))" in query assert "post.corporate_entity_id::text = any($1::text[])" in query assert "post.process_unit_id::text = any($2::text[])" in query diff --git a/tests/test_source_post_voice_history_schema.py b/tests/test_source_post_voice_history_schema.py new file mode 100644 index 000000000..ded3ed49d --- /dev/null +++ b/tests/test_source_post_voice_history_schema.py @@ -0,0 +1,49 @@ +"""Static contract tests for ADR 0252 temporal primary Voice history.""" + +from __future__ import annotations + +from pathlib import Path + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0238_source_post_voice_history.sql" +) + + +def test_primary_voice_history_uses_non_overlapping_half_open_intervals() -> None: + """The database preserves recurring Voices and rejects overlapping primaries.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "add column if not exists effective_to timestamptz" in sql + assert "drop constraint if exists source_post_voice_pkey" not in sql + assert "primary key (post_id, voice_type_code, effective_from)" not in sql + assert "voice_assignment_id" not in sql + assert "tstzrange(effective_from, effective_to, '[)') with &&" in sql + assert "where (is_primary)" in sql + assert "effective_from < effective_to" in sql + + +def test_primary_voice_change_closes_current_rows_before_insert() -> None: + """One transaction instant closes the prior state and opens the new primary.""" + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "change_at timestamptz := clock_timestamp()" in sql + assert "set effective_to = change_at" in sql + assert "and (is_primary or voice_type_code = new.voc_type_code)" in sql + assert sql.index("set effective_to = change_at") < sql.index( + "insert into source_post_voice" + ) + assert "on conflict" not in sql + + +def test_future_source_clock_is_bounded_by_the_recording_clock() -> None: + """A future source timestamp cannot create an interval that closes backwards.""" + assignment_sql = ( + MIGRATION.parent / "0237_source_post_voice_combination.sql" + ).read_text(encoding="utf-8").lower() + history_sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "least(post.created_at, clock_timestamp())" in assignment_sql + assert "least(new.created_at, change_at)" in assignment_sql + assert "least(new.created_at, change_at)" in history_sql diff --git a/tests/test_source_post_voice_ingestion.py b/tests/test_source_post_voice_ingestion.py index 75a0c48d0..52fdf0b1f 100644 --- a/tests/test_source_post_voice_ingestion.py +++ b/tests/test_source_post_voice_ingestion.py @@ -72,6 +72,7 @@ def test_additional_voice_creates_prov_derivation_and_assignment_atomically() -> assert "prov_was_derived_from" in sql assert "where effective_to is null" in sql assert "where not source_post_voice.is_primary" in sql + assert "where effective_to is null" in sql assert "voice-assignment/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1/vops" in str( conn.calls ) diff --git a/tests/test_source_state_serialization.py b/tests/test_source_state_serialization.py index 661c4e2c5..1153ac6e4 100644 --- a/tests/test_source_state_serialization.py +++ b/tests/test_source_state_serialization.py @@ -80,6 +80,7 @@ async def fetch( assert "provenance_assertion_id is not null as evidence_available" in query assert "voice.effective_from <= $2" in query assert "$2 < voice.effective_to" in query + assert "voice.effective_to is null or $2 < voice.effective_to" in query assert post_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" assert effective_cutoff == datetime(2026, 1, 1, tzinfo=UTC) return [ From 4b48c5594b116d88aea0ef006ce6705d2ece180b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 19:02:10 +0900 Subject: [PATCH 50/53] test(db): restore Voice migration fixture syntax --- backend/tests/test_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6dbc00936..32680f24c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -260,7 +260,6 @@ Path(__file__).resolve().parents[2] / "migrations" / "0237_source_post_voice_combination.sql" - Path(__file__).resolve().parents[2] / "migrations" / "0175_ontology_truth_status.sql" ) _OCCUPATIONAL_CONSTRUCT_MIGRATION = ( Path(__file__).resolve().parents[2] From 0ff51f862f1add80797f084d90c298dc0355e236 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 19:03:00 +0900 Subject: [PATCH 51/53] fix(db): keep Voice history replay-safe --- backend/tests/test_api.py | 124 ++----------------------- tests/test_source_post_voice_schema.py | 5 +- 2 files changed, 9 insertions(+), 120 deletions(-) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c3a0e896a..e8764af6f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -261,6 +261,11 @@ / "migrations" / "0237_source_post_voice_combination.sql" ) +_VOICE_HISTORY_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0238_source_post_voice_history.sql" +) _OCCUPATIONAL_CONSTRUCT_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -271,11 +276,6 @@ / "migrations" / "0239_occupational_construct_catalog.sql" ) -_VOICE_HISTORY_MIGRATION = ( - Path(__file__).resolve().parents[2] - / "migrations" - / "0238_source_post_voice_history.sql" -) def _postgres_available() -> bool: @@ -458,8 +458,8 @@ def seeded_db(demo_analyst_token): cur.execute(_ONTOLOGY_TRUTH_STATUS_MIGRATION.read_text()) cur.execute(_VOICE_TAXONOMY_MIGRATION.read_text()) cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) - cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute(_VOICE_HISTORY_MIGRATION.read_text()) + cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -4916,33 +4916,6 @@ def update_voice(code: str) -> None: future.result() with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: -def test_primary_voice_history_survives_a_b_a_cutoffs( - client, demo_analyst_token, seeded_db -) -> None: - """Real PostgreSQL and API reads preserve each imported primary interval.""" - with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: - cur.execute( - """ - insert into source_post - (author_account_id, corporate_entity_id, post_title, post_body, - voc_type_code, visibility_code, created_at, updated_at) - select author_account_id, corporate_entity_id, - 'Synthetic Voice history', 'Synthetic interval evidence', - 'voc', 'public', created_at, updated_at - from source_post - where post_id = %s - returning post_id - """, - (seeded_db["own_private_post_id"],), - ) - post_id = str(cur.fetchone()[0]) - conn.commit() - - cur.execute( - "update source_post set voc_type_code = 'vob' where post_id = %s", - (post_id,), - ) - conn.commit() cur.execute( "update source_post set voc_type_code = 'voc' where post_id = %s", (post_id,), @@ -4976,91 +4949,6 @@ def test_primary_voice_history_survives_a_b_a_cutoffs( ] assert [voice["code"] for voice in primaries] == [voice_type_code] - cur.execute( - """ - select voice_type_code, effective_from, effective_to - from source_post_voice - where post_id = %s and is_primary - order by effective_from - """, - (post_id,), - ) - periods = cur.fetchall() - - assert [row[0] for row in periods] == ["voc", "vob", "voc"] - assert periods[0][2] == periods[1][1] - assert periods[1][2] == periods[2][1] - assert periods[2][2] is None - - headers = {"Authorization": f"Bearer {demo_analyst_token}"} - cutoffs = ( - periods[0][1] + (periods[0][2] - periods[0][1]) / 2, - periods[1][1] + (periods[1][2] - periods[1][1]) / 2, - periods[2][1], - ) - for cutoff, expected in zip(cutoffs, ("voc", "vob", "voc"), strict=True): - response = client.get( - f"/api/posts/{post_id}", - params={"as_of": cutoff.isoformat()}, - headers=headers, - ) - assert response.status_code == 200, response.text - primary = [ - voice for voice in response.json()["voice_types"] if voice["is_primary"] - ] - assert [voice["code"] for voice in primary] == [expected] - - -def test_concurrent_primary_voice_updates_keep_non_overlapping_history(seeded_db) -> None: - """Waiting source updates use lock-time clocks and leave one current primary.""" - with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: - cur.execute( - """ - insert into source_post - (author_account_id, corporate_entity_id, post_title, post_body, - voc_type_code, visibility_code, created_at, updated_at) - select author_account_id, corporate_entity_id, - 'Synthetic concurrent Voice history', 'Synthetic lock evidence', - 'voc', 'public', created_at, updated_at - from source_post - where post_id = %s - returning post_id - """, - (seeded_db["own_private_post_id"],), - ) - post_id = str(cur.fetchone()[0]) - conn.commit() - - def update_primary(code: str) -> None: - with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: - cur.execute( - "update source_post set voc_type_code = %s where post_id = %s", - (code, post_id), - ) - conn.commit() - - with ThreadPoolExecutor(max_workers=2) as executor: - list(executor.map(update_primary, ("vob", "vop"))) - - with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: - cur.execute( - """ - select voice_type_code, effective_from, effective_to - from source_post_voice - where post_id = %s and is_primary - order by effective_from - """, - (post_id,), - ) - periods = cur.fetchall() - - assert len(periods) == 3 - assert periods[0][0] == "voc" - assert {periods[1][0], periods[2][0]} == {"vob", "vop"} - assert periods[0][2] == periods[1][1] - assert periods[1][2] == periods[2][1] - assert periods[2][2] is None - def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( diff --git a/tests/test_source_post_voice_schema.py b/tests/test_source_post_voice_schema.py index 05265bf10..b78135b6b 100644 --- a/tests/test_source_post_voice_schema.py +++ b/tests/test_source_post_voice_schema.py @@ -25,9 +25,10 @@ def test_voice_combination_schema_is_normalized_and_evidence_bearing() -> None: assert "effective_from timestamptz not null" in sql assert "true, 'truth_observed'" in sql assert "where is_primary" in sql - assert "select post.post_id, post.voc_type_code, true, 'truth_observed', post.created_at" in sql + assert "select post.post_id, post.voc_type_code, true, 'truth_observed'" in sql + assert "least(post.created_at, clock_timestamp())" in sql assert "change_at timestamptz := clock_timestamp()" in sql - assert "case when tg_op = 'insert' then new.created_at else change_at end" in sql + assert "case when tg_op = 'insert' then least(new.created_at, change_at) else change_at end" in sql assert "after insert on source_post" in sql assert "after update of voc_type_code on source_post" in sql assert "when (old.voc_type_code is distinct from new.voc_type_code)" in sql From b0eedabdc13d9ebbc37d7d1957713f885b8927f6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 19:04:28 +0900 Subject: [PATCH 52/53] test(db): prove replay after Voice history change --- backend/tests/test_api.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e8764af6f..7a433d4fe 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4950,6 +4950,33 @@ def update_voice(code: str) -> None: assert [voice["code"] for voice in primaries] == [voice_type_code] +def test_voice_migration_family_replays_after_a_primary_change(seeded_db) -> None: + """Every-start replay remains valid after a post accumulates Voice history.""" + post_id = seeded_db["own_private_post_id"] + with closing(psycopg2.connect(seeded_db["dsn"])) as conn, conn.cursor() as cur: + cur.execute( + "update source_post set voc_type_code = 'vob' where post_id = %s", + (post_id,), + ) + conn.commit() + + for _ in range(2): + cur.execute(_VOICE_ASSIGNMENT_MIGRATION.read_text()) + cur.execute(_VOICE_HISTORY_MIGRATION.read_text()) + conn.commit() + + cur.execute( + "select count(*) filter (where effective_to is null), " + "count(*) from source_post_voice " + "where post_id = %s and is_primary", + (post_id,), + ) + current_count, history_count = cur.fetchone() + + assert current_count == 1 + assert history_count >= 2 + + def test_tickets_list_is_empty_before_any_created(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/posts/{seeded_db['own_private_post_id']}/tickets", From 123fdb7744d6a13ddd804975fef4e3fc7f9ce86c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 19:10:03 +0900 Subject: [PATCH 53/53] fix(db): assign Voice history a unique migration number --- backend/tests/test_api.py | 2 +- ...ost_voice_history.sql => 0243_source_post_voice_history.sql} | 0 tests/test_source_post_voice_history_schema.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename migrations/{0238_source_post_voice_history.sql => 0243_source_post_voice_history.sql} (100%) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 7a433d4fe..a7bcc9f26 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -264,7 +264,7 @@ _VOICE_HISTORY_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" - / "0238_source_post_voice_history.sql" + / "0243_source_post_voice_history.sql" ) _OCCUPATIONAL_CONSTRUCT_MIGRATION = ( Path(__file__).resolve().parents[2] diff --git a/migrations/0238_source_post_voice_history.sql b/migrations/0243_source_post_voice_history.sql similarity index 100% rename from migrations/0238_source_post_voice_history.sql rename to migrations/0243_source_post_voice_history.sql diff --git a/tests/test_source_post_voice_history_schema.py b/tests/test_source_post_voice_history_schema.py index ded3ed49d..66b7866b9 100644 --- a/tests/test_source_post_voice_history_schema.py +++ b/tests/test_source_post_voice_history_schema.py @@ -7,7 +7,7 @@ MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" - / "0238_source_post_voice_history.sql" + / "0243_source_post_voice_history.sql" )