From 8139aa392ff0d7baece98abaa313faa78dee2b2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:27:16 +0900 Subject: [PATCH 1/4] feat(semantics): persist occupational construct evidence --- CHANGELOG.md | 4 + backend/app/main.py | 7 + .../app/occupational_construct_ingestion.py | 207 ++++++++++++++++++ backend/tests/test_api.py | 84 ++++++- ...ational-construct-assertion-persistence.md | 66 ++++++ docs/product-requirements.md | 6 +- frontend/src/api.ts | 15 ++ .../0238_occupational_construct_assertion.sql | 84 +++++++ ...test_occupational_construct_persistence.py | 178 +++++++++++++++ tests/test_occupational_construct_schema.py | 42 ++++ 10 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 backend/app/occupational_construct_ingestion.py create mode 100644 docs/adr/0249-occupational-construct-assertion-persistence.md create mode 100644 migrations/0238_occupational_construct_assertion.sql create mode 100644 tests/test_occupational_construct_persistence.py create mode 100644 tests/test_occupational_construct_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a728048..e19d722fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ All notable changes to this project are documented here. Format follows behaviors, and FJA worker functions distinct. Record-to-construct links require a reified evidence span and PROV-O derivation/time; unsupported DPT-to-psychology crosswalks and local scores remain unavailable (ADR 0248). +- Versioned occupational construct vocabularies and semantic-unit assertions + now persist in normalized tables. Database and application validation require + same-Post verbatim evidence, and authorized Post detail exposes provenance + without internal identifiers or numerical scores (ADR 0249). - The DOT/FJA Data/People/Things worker-function taxonomy is now published in the canonical ontology: all 24 worker functions carry the official diff --git a/backend/app/main.py b/backend/app/main.py index 6457bbde1..e6e5f3fa5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -141,6 +141,9 @@ post_content_is_complete, publish_post_content_event, ) +from backend.app.occupational_construct_ingestion import ( + load_occupational_construct_assertions, +) from backend.app.post_content_worker import run_post_content_worker from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL, source_post_visible from backend.app.post_evaluation_ingestion import ( @@ -1654,6 +1657,9 @@ async def read_post( project_evidence = await _load_project_evidence( conn, post_id, row["source_project_code"], row["source_project_name"] ) + occupational_construct_assertions = await load_occupational_construct_assertions( + 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 +1667,7 @@ async def read_post( **_serialize_post(row, labels), "post_body": row["post_body"], "project_evidence": project_evidence, + "occupational_construct_assertions": occupational_construct_assertions, } if known_at is not None: payload["known_at"] = known_at diff --git a/backend/app/occupational_construct_ingestion.py b/backend/app/occupational_construct_ingestion.py new file mode 100644 index 000000000..dd446a09c --- /dev/null +++ b/backend/app/occupational_construct_ingestion.py @@ -0,0 +1,207 @@ +"""Persist evidence-bound occupational constructs under ADR 0249.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit + + +CONSTRUCT_FAMILIES = frozenset( + { + "cognitive_ability", + "work_style", + "work_activity", + "affective_reaction", + "performance_behavior", + } +) +TRUTH_STATUS_CODES = frozenset( + { + "truth_authoritative", + "truth_observed", + "truth_inferred", + "truth_proposed", + "truth_superseded", + "truth_rejected", + } +) + + +def _https_iri(value: str, field_name: str) -> str: + """Return one normalized HTTPS IRI or reject untrusted input.""" + normalized = value.strip() + parsed = urlsplit(normalized) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: + raise ValueError(f"{field_name} must be an absolute credential-free HTTPS IRI") + return normalized + + +@dataclass(frozen=True) +class ConstructVocabulary: + """One immutable external vocabulary release.""" + + vocabulary_iri: str + version_label: str + license_iri: str + attribution_text: str + + def __post_init__(self) -> None: + object.__setattr__(self, "vocabulary_iri", _https_iri(self.vocabulary_iri, "vocabulary_iri")) + object.__setattr__(self, "license_iri", _https_iri(self.license_iri, "license_iri")) + if not self.version_label.strip() or not self.attribution_text.strip(): + raise ValueError("vocabulary version and attribution must be non-empty") + + +@dataclass(frozen=True) +class OccupationalConstruct: + """One versioned external occupational construct.""" + + vocabulary: ConstructVocabulary + construct_iri: str + family_code: str + preferred_label: str + + def __post_init__(self) -> None: + object.__setattr__(self, "construct_iri", _https_iri(self.construct_iri, "construct_iri")) + if self.family_code not in CONSTRUCT_FAMILIES: + raise ValueError(f"unsupported occupational construct family {self.family_code!r}") + if not self.preferred_label.strip(): + raise ValueError("construct preferred label must be non-empty") + + +@dataclass(frozen=True) +class OccupationalConstructAssertion: + """One construct assertion bound to a verbatim semantic-unit span.""" + + post_content_unit_id: str + unit_text: str + construct: OccupationalConstruct + evidence_text: str + truth_status_code: str + extraction_method: str + + def __post_init__(self) -> None: + for name, value in ( + ("post_content_unit_id", self.post_content_unit_id), + ("unit_text", self.unit_text), + ("evidence_text", self.evidence_text), + ("truth_status_code", self.truth_status_code), + ("extraction_method", self.extraction_method), + ): + if not value.strip(): + raise ValueError(f"{name} must be non-empty") + if self.evidence_text not in self.unit_text: + raise ValueError("construct evidence must be a verbatim semantic-unit span") + if self.truth_status_code not in TRUTH_STATUS_CODES: + raise ValueError(f"unsupported ontology truth status {self.truth_status_code!r}") + + +async def persist_occupational_construct_assertions( + conn: Any, + post_id: str, + orchestrator_session_id: str, + assertions: tuple[OccupationalConstructAssertion, ...], +) -> None: + """Atomically replace one Post's construct assertions and shared registry rows.""" + if not post_id.strip() or not orchestrator_session_id.strip(): + raise ValueError("post and orchestrator session identifiers must be non-empty") + async with conn.transaction(): + await conn.execute( + "delete from post_occupational_construct_assertion where post_id = $1", + post_id, + ) + for assertion in assertions: + vocabulary = assertion.construct.vocabulary + vocabulary_id = await conn.fetchval( + """ + insert into occupational_construct_vocabulary + (vocabulary_iri, version_label, license_iri, attribution_text) + values ($1, $2, $3, $4) + on conflict (vocabulary_iri, version_label) do update set + vocabulary_iri = excluded.vocabulary_iri + where occupational_construct_vocabulary.license_iri = excluded.license_iri + and occupational_construct_vocabulary.attribution_text = excluded.attribution_text + returning vocabulary_id + """, + vocabulary.vocabulary_iri, + vocabulary.version_label, + vocabulary.license_iri, + vocabulary.attribution_text, + ) + if vocabulary_id is None: + raise ValueError("vocabulary metadata conflicts with its immutable version") + construct_id = await conn.fetchval( + """ + insert into occupational_construct + (vocabulary_id, construct_iri, construct_family_code, preferred_label) + values ($1, $2, $3, $4) + on conflict (vocabulary_id, construct_iri) do update set + construct_iri = excluded.construct_iri + where occupational_construct.construct_family_code = excluded.construct_family_code + and occupational_construct.preferred_label = excluded.preferred_label + returning construct_id + """, + vocabulary_id, + assertion.construct.construct_iri, + assertion.construct.family_code, + assertion.construct.preferred_label, + ) + if construct_id is None: + raise ValueError("construct metadata conflicts with its immutable vocabulary version") + await conn.execute( + """ + insert into post_occupational_construct_assertion + (post_id, post_content_unit_id, construct_id, evidence_text, + truth_status_code, extraction_method, orchestrator_session_id) + values ($1, $2, $3, $4, $5, $6, $7) + """, + post_id, + assertion.post_content_unit_id, + construct_id, + assertion.evidence_text, + assertion.truth_status_code, + assertion.extraction_method, + orchestrator_session_id, + ) + + +async def load_occupational_construct_assertions( + conn: Any, post_id: str +) -> list[dict[str, object]]: + """Load assertions only after the caller has authorized their Post.""" + rows = await conn.fetch( + """ + select construct.construct_iri, construct.construct_family_code, + construct.preferred_label, vocabulary.vocabulary_iri, + vocabulary.version_label, assertion.evidence_text, + assertion.truth_status_code, assertion.extraction_method, + assertion.generated_at, unit.unit_index + from post_occupational_construct_assertion assertion + join occupational_construct construct on construct.construct_id = assertion.construct_id + join occupational_construct_vocabulary vocabulary + on vocabulary.vocabulary_id = construct.vocabulary_id + join post_content_unit unit + on unit.post_content_unit_id = assertion.post_content_unit_id + where assertion.post_id = $1 + order by unit.unit_index, construct.construct_family_code, + construct.preferred_label, construct.construct_iri + """, + post_id, + ) + return [ + { + "construct_iri": row["construct_iri"], + "construct_family_code": row["construct_family_code"], + "preferred_label": row["preferred_label"], + "vocabulary_iri": row["vocabulary_iri"], + "vocabulary_version": row["version_label"], + "evidence_text": row["evidence_text"], + "truth_status_code": row["truth_status_code"], + "extraction_method": row["extraction_method"], + "generated_at": row["generated_at"], + "unit_index": row["unit_index"], + "provenance": "post_occupational_construct_assertion.evidence_text", + } + for row in rows + ] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 892c8231a..1dee1b294 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -231,6 +231,14 @@ / "migrations" / "0183_source_post_event_occurred_at.sql" ) +_ONTOLOGY_TRUTH_STATUS_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0175_ontology_truth_status.sql" +) +_OCCUPATIONAL_CONSTRUCT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0238_occupational_construct_assertion.sql" +) def _postgres_available() -> bool: @@ -383,12 +391,16 @@ def seeded_db(demo_analyst_token): cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_SCOPE_MIGRATION.read_text()) - cur.execute(_GLOBAL_ASK_EVIDENCE_SEARCH_MIGRATION.read_text()) + for statement in _GLOBAL_ASK_EVIDENCE_SEARCH_MIGRATION.read_text().split(";\n\n"): + if statement.strip(): + cur.execute(statement + ";") cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) + cur.execute(_ONTOLOGY_TRUTH_STATUS_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) + cur.execute(_OCCUPATIONAL_CONSTRUCT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) @@ -1945,6 +1957,76 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence( assert listed_post["project_evidence"][0]["provenance"] == "post_project_mention.evidence_text" +def test_post_detail_exposes_evidence_bound_occupational_construct( + client, demo_analyst_token, seeded_db +) -> None: + """The authorized detail projection preserves its exact synthetic evidence.""" + conn = psycopg2.connect(seeded_db["dsn"]) + try: + with conn.cursor() as cur: + cur.execute( + """ + insert into post_content_unit + (post_id, unit_index, unit_kind_code, unit_text) + values (%s, 90, 'plain_text', %s) + returning post_content_unit_id + """, + ( + seeded_db["public_post_id"], + "Synthetic record requires oral comprehension.", + ), + ) + unit_id = cur.fetchone()[0] + cur.execute( + """ + insert into occupational_construct_vocabulary + (vocabulary_iri, version_label, license_iri, attribution_text) + values (%s, '31.0', %s, 'Synthetic O*NET attribution') + returning vocabulary_id + """, + ( + "https://www.onetcenter.org/database.html", + "https://creativecommons.org/licenses/by/4.0/", + ), + ) + vocabulary_id = cur.fetchone()[0] + cur.execute( + """ + insert into occupational_construct + (vocabulary_id, construct_iri, construct_family_code, preferred_label) + values (%s, %s, 'cognitive_ability', 'Oral Comprehension') + returning construct_id + """, + (vocabulary_id, "https://data.onetcenter.org/element/1.A.1.a.1"), + ) + construct_id = cur.fetchone()[0] + cur.execute( + """ + insert into post_occupational_construct_assertion + (post_id, post_content_unit_id, construct_id, evidence_text, + truth_status_code, extraction_method, orchestrator_session_id) + values (%s, %s, %s, 'oral comprehension', 'truth_inferred', + 'contextual_orchestrator_structured', 'synthetic-session') + """, + (seeded_db["public_post_id"], unit_id, construct_id), + ) + conn.commit() + finally: + conn.close() + + response = client.get( + f"/api/posts/{seeded_db['public_post_id']}", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200 + assertions = response.json()["occupational_construct_assertions"] + assert assertions[0]["preferred_label"] == "Oral Comprehension" + assert assertions[0]["evidence_text"] == "oral comprehension" + assert assertions[0]["provenance"] == ( + "post_occupational_construct_assertion.evidence_text" + ) + + def test_post_detail_as_of_returns_the_cutoff_known_body( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docs/adr/0249-occupational-construct-assertion-persistence.md b/docs/adr/0249-occupational-construct-assertion-persistence.md new file mode 100644 index 000000000..bf5da06f3 --- /dev/null +++ b/docs/adr/0249-occupational-construct-assertion-persistence.md @@ -0,0 +1,66 @@ +# ADR 0249: Occupational construct assertion persistence + +**Status:** Accepted +**Date:** 2026-08-27 +**Extends:** [ADR 0062](0062-semantic-unit-embedding.md), [ADR 0184](0184-ontology-provenance-explorer.md), [ADR 0248](0248-occupational-construct-evidence-boundary.md) + +## Context + +ADR 0248 defines the semantic assertion but intentionally leaves persistence +unavailable. A bare Post-to-construct edge would lose the exact semantic unit, +source vocabulary version, extraction session, truth status, and verbatim +evidence that make the assertion reviewable. Reusing `knowledge_graph_edge` +would also mix a provenance-bearing analysis artifact into the navigation +projection forbidden by ADR 0065. + +## Decision + +1. Persist source vocabularies, their versioned external constructs, and Post + assertions in three normalized tables: `occupational_construct_vocabulary`, + `occupational_construct`, and `post_occupational_construct_assertion`. +2. Every assertion references one existing `post_content_unit`, one construct, + one ontology truth-status code, one extraction method, and the shared + post-scoped contextual-orchestrator session identifier. It stores no score, + weight, intensity, importance, causal flag, or person binding. +3. A database trigger rejects an assertion when the semantic unit belongs to a + different Post or its evidence is not a non-empty verbatim substring of the + stored unit text. Application validation mirrors this boundary before SQL. +4. Replacement is atomic per Post. Vocabulary and construct rows use natural + versioned uniqueness and UPSERT; post assertions are deleted and recreated + inside the same transaction so stale analysis cannot coexist with a new + source-derived set. +5. The already-authorized `GET /api/posts/{post_id}` response may include the + assertions after the Post ABAC decision succeeds. It exposes evidence, + construct label/IRI/family, vocabulary/version, truth status, method, and + generated time, but not internal database identifiers. +6. Search, ontology-neighborhood nodes, extraction prompts, UI presentation, + and person/job/task binding remain separate increments. Absence returns an + empty list. + +## Considered options + +| Option | Outcome | +|---|---| +| Store construct JSON on the Post | Rejected: duplicates vocabulary metadata and defeats 3NF/query integrity | +| Add bare knowledge-graph edges | Rejected: loses evidence-unit provenance and confuses navigation with assertion ownership | +| Versioned registry plus evidence-unit assertion | Accepted: normalized, replayable, provenance-bearing, and additive | + +## Consequences + +- Authorized clients can inspect persisted construct evidence without treating + it as a measured person attribute. +- Vocabulary updates create a new version row instead of silently changing the + meaning of historical assertions. +- The trigger adds one indexed unit lookup per written assertion; batch volume + is bounded by the semantic units of one Post. A future measured throughput + problem may replace it with a set-based staging validator. + +## Verification + +- `tests/test_occupational_construct_persistence.py` verifies trust-boundary + validation, atomic replacement SQL, versioned UPSERT, and empty replacement. +- `tests/test_occupational_construct_schema.py` verifies replay safety, 3NF + foreign keys, evidence trigger, prohibited numeric fields, and hot-path + indexes. +- The existing Post-detail ABAC path loads the projection only after its + visibility decision; focused tests verify the returned review model. diff --git a/docs/product-requirements.md b/docs/product-requirements.md index b39b89b2e..147c63c9f 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -84,8 +84,10 @@ continues to pass unchanged. score, causal effect, or job requirement. Acceptance: SHACL rejects incomplete assertions; ontology tests prohibit FJA -equivalence and require exact Post/evidence/PROV statement structure. Runtime -persistence and UI remain unavailable until their separate ADR acceptance. +equivalence and require exact Post/evidence/PROV statement structure. ADR 0249 +adds normalized, semantic-unit-bound persistence and an authorized Post-detail +projection. Search, graph navigation, extraction, and UI remain unavailable +until their separate ADR acceptance. ### PRD-FR-3 — Bounded ontology exploration diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fca5882d0..04470a5ad 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -29,6 +29,7 @@ export interface PostSummary { post_body_excerpt?: string | null; post_body_truncated?: boolean; project_evidence?: ProjectEvidence[]; + occupational_construct_assertions?: OccupationalConstructAssertion[]; created_at: string; } @@ -264,6 +265,20 @@ export interface ProjectEvidence { provenance: string; } +export interface OccupationalConstructAssertion { + construct_iri: string; + construct_family_code: string; + preferred_label: string; + vocabulary_iri: string; + vocabulary_version: string; + evidence_text: string; + truth_status_code: string; + extraction_method: string; + generated_at: string; + unit_index: number; + provenance: string; +} + export interface PostAiSummary { post_id: string; korean_summary: string; diff --git a/migrations/0238_occupational_construct_assertion.sql b/migrations/0238_occupational_construct_assertion.sql new file mode 100644 index 000000000..c78286ddf --- /dev/null +++ b/migrations/0238_occupational_construct_assertion.sql @@ -0,0 +1,84 @@ +-- ADR 0249: versioned occupational constructs and evidence-unit assertions. +-- Replay-safe; numerical measurement remains outside LineageWeave. + +create table if not exists occupational_construct_vocabulary ( + vocabulary_id uuid primary key default gen_random_uuid(), + vocabulary_iri text not null check (btrim(vocabulary_iri) <> ''), + version_label text not null check (btrim(version_label) <> ''), + license_iri text not null check (btrim(license_iri) <> ''), + attribution_text text not null check (btrim(attribution_text) <> ''), + created_at timestamptz not null default now(), + unique (vocabulary_iri, version_label) +); + +create table if not exists occupational_construct ( + construct_id uuid primary key default gen_random_uuid(), + vocabulary_id uuid not null references occupational_construct_vocabulary(vocabulary_id), + construct_iri text not null check (btrim(construct_iri) <> ''), + construct_family_code text not null check (construct_family_code in ( + 'cognitive_ability', + 'work_style', + 'work_activity', + 'affective_reaction', + 'performance_behavior' + )), + preferred_label text not null check (btrim(preferred_label) <> ''), + unique (vocabulary_id, construct_iri) +); + +create table if not exists post_occupational_construct_assertion ( + assertion_id uuid primary key default gen_random_uuid(), + post_id uuid not null references source_post(post_id) on delete cascade, + post_content_unit_id uuid not null references post_content_unit(post_content_unit_id) on delete cascade, + construct_id uuid not null references occupational_construct(construct_id), + evidence_text text not null check (btrim(evidence_text) <> ''), + truth_status_code text not null references common_lookup_value(lookup_code) check ( + truth_status_code in ( + 'truth_authoritative', + 'truth_observed', + 'truth_inferred', + 'truth_proposed', + 'truth_superseded', + 'truth_rejected' + ) + ), + extraction_method text not null check (btrim(extraction_method) <> ''), + orchestrator_session_id text not null check (btrim(orchestrator_session_id) <> ''), + generated_at timestamptz not null default now(), + unique (post_id, post_content_unit_id, construct_id, extraction_method) +); + +create or replace function validate_occupational_construct_evidence() +returns trigger +language plpgsql +as $$ +declare + unit_post_id uuid; + selected_unit_text text; +begin + select unit.post_id, unit.unit_text + into unit_post_id, selected_unit_text + from post_content_unit unit + where unit.post_content_unit_id = new.post_content_unit_id; + if unit_post_id is null or unit_post_id <> new.post_id then + raise exception 'occupational construct evidence unit must belong to the assertion post'; + end if; + if strpos(selected_unit_text, new.evidence_text) = 0 then + raise exception 'occupational construct evidence must be verbatim unit text'; + end if; + return new; +end; +$$; + +drop trigger if exists occupational_construct_evidence_trigger + on post_occupational_construct_assertion; +create trigger occupational_construct_evidence_trigger +before insert or update on post_occupational_construct_assertion +for each row execute function validate_occupational_construct_evidence(); + +create index if not exists occupational_construct_family_iri_idx + on occupational_construct (construct_family_code, construct_iri); +create index if not exists post_occupational_construct_post_time_idx + on post_occupational_construct_assertion (post_id, generated_at desc, assertion_id); +create index if not exists post_occupational_construct_construct_post_idx + on post_occupational_construct_assertion (construct_id, post_id); diff --git a/tests/test_occupational_construct_persistence.py b/tests/test_occupational_construct_persistence.py new file mode 100644 index 000000000..e8485a78a --- /dev/null +++ b/tests/test_occupational_construct_persistence.py @@ -0,0 +1,178 @@ +"""Persistence checks for evidence-bound occupational constructs (ADR 0249).""" + +from __future__ import annotations + +import asyncio +from contextlib import AbstractAsyncContextManager +from datetime import UTC, datetime + +import pytest + +from backend.app.occupational_construct_ingestion import ( + ConstructVocabulary, + OccupationalConstruct, + OccupationalConstructAssertion, + load_occupational_construct_assertions, + persist_occupational_construct_assertions, +) + + +class _Transaction(AbstractAsyncContextManager[None]): + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: object) -> None: + return None + + +class RecordingConnection: + """Record parameterized persistence calls without a database.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.values = iter(("vocabulary-id", "construct-id")) + self.rows: list[dict[str, object]] = [] + + def transaction(self) -> _Transaction: + return _Transaction() + + async def execute(self, query: str, *args: object) -> None: + self.calls.append((" ".join(query.split()), args)) + + async def fetchval(self, query: str, *args: object) -> str: + self.calls.append((" ".join(query.split()), args)) + return next(self.values) + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.calls.append((" ".join(query.split()), args)) + return self.rows + + +def _assertion() -> OccupationalConstructAssertion: + vocabulary = ConstructVocabulary( + "https://www.onetcenter.org/database.html", + "31.0", + "https://creativecommons.org/licenses/by/4.0/", + "O*NET 31.0 Database by USDOL/ETA.", + ) + construct = OccupationalConstruct( + vocabulary, + "https://data.onetcenter.org/element/1.A.1.a.1", + "cognitive_ability", + "Oral Comprehension", + ) + return OccupationalConstructAssertion( + "11111111-1111-1111-1111-111111111111", + "The record states a synthetic comprehension requirement.", + construct, + "synthetic comprehension requirement", + "truth_inferred", + "contextual_orchestrator_structured", + ) + + +def test_assertion_rejects_nonverbatim_evidence_and_unsafe_iris() -> None: + """Trust-boundary values fail before any SQL can run.""" + assertion = _assertion() + with pytest.raises(ValueError, match="verbatim"): + OccupationalConstructAssertion( + assertion.post_content_unit_id, + assertion.unit_text, + assertion.construct, + "not present", + assertion.truth_status_code, + assertion.extraction_method, + ) + with pytest.raises(ValueError, match="HTTPS"): + ConstructVocabulary( + "http://unsafe.example/vocabulary", + "1", + "https://example.test/license", + "Synthetic attribution", + ) + with pytest.raises(ValueError, match="truth status"): + OccupationalConstructAssertion( + assertion.post_content_unit_id, + assertion.unit_text, + assertion.construct, + assertion.evidence_text, + "truth_guessed", + assertion.extraction_method, + ) + + +def test_persistence_replaces_then_upserts_versioned_registry_and_assertion() -> None: + """One transaction performs delete, registry UPSERTs, then assertion insert.""" + conn = RecordingConnection() + asyncio.run( + persist_occupational_construct_assertions( + conn, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "post-session", + (_assertion(),), + ) + ) + statements = [query for query, _ in conn.calls] + assert statements[0].startswith("delete from post_occupational_construct_assertion") + assert "on conflict (vocabulary_iri, version_label) do update" in statements[1] + assert "where occupational_construct_vocabulary.license_iri" in statements[1] + assert "on conflict (vocabulary_id, construct_iri) do update" in statements[2] + assert "where occupational_construct.construct_family_code" in statements[2] + assert statements[3].startswith("insert into post_occupational_construct_assertion") + assert conn.calls[3][1][-1] == "post-session" + + +def test_conflicting_version_metadata_fails_closed() -> None: + """An existing release cannot be silently rewritten by an UPSERT.""" + conn = RecordingConnection() + conn.values = iter((None,)) + with pytest.raises(ValueError, match="immutable version"): + asyncio.run( + persist_occupational_construct_assertions( + conn, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "post-session", + (_assertion(),), + ) + ) + + +def test_empty_replacement_only_removes_stale_assertions() -> None: + """Unavailable analysis writes no placeholder registry or assertion rows.""" + conn = RecordingConnection() + asyncio.run( + persist_occupational_construct_assertions( + conn, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "post-session", () + ) + ) + assert len(conn.calls) == 1 + assert conn.calls[0][0].startswith("delete from post_occupational_construct_assertion") + + +def test_authorized_projection_omits_internal_ids_and_preserves_provenance() -> None: + """The read model returns review fields in semantic-unit order.""" + conn = RecordingConnection() + conn.rows = [ + { + "construct_iri": "https://data.onetcenter.org/element/1.A.1.a.1", + "construct_family_code": "cognitive_ability", + "preferred_label": "Oral Comprehension", + "vocabulary_iri": "https://www.onetcenter.org/database.html", + "version_label": "31.0", + "evidence_text": "synthetic evidence", + "truth_status_code": "truth_inferred", + "extraction_method": "contextual_orchestrator_structured", + "generated_at": datetime(2026, 8, 27, tzinfo=UTC), + "unit_index": 2, + } + ] + result = asyncio.run( + load_occupational_construct_assertions( + conn, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + ) + ) + assert result[0]["vocabulary_version"] == "31.0" + assert result[0]["provenance"] == ( + "post_occupational_construct_assertion.evidence_text" + ) + assert "construct_id" not in result[0] diff --git a/tests/test_occupational_construct_schema.py b/tests/test_occupational_construct_schema.py new file mode 100644 index 000000000..7de22a82c --- /dev/null +++ b/tests/test_occupational_construct_schema.py @@ -0,0 +1,42 @@ +"""Static schema contracts for ADR 0249 occupational assertions.""" + +from pathlib import Path + + +MIGRATION = Path(__file__).resolve().parents[1] / "migrations/0238_occupational_construct_assertion.sql" + + +def test_construct_migration_is_normalized_replay_safe_and_indexed() -> None: + """The migration owns three replay-safe tables and both query directions.""" + sql = MIGRATION.read_text(encoding="utf-8").casefold() + for table in ( + "occupational_construct_vocabulary", + "occupational_construct", + "post_occupational_construct_assertion", + ): + assert f"create table if not exists {table}" in sql + assert "references post_content_unit(post_content_unit_id)" in sql + assert "references occupational_construct(construct_id)" in sql + assert "references common_lookup_value(lookup_code)" in sql + for truth_status in ( + "truth_authoritative", + "truth_observed", + "truth_inferred", + "truth_proposed", + "truth_superseded", + "truth_rejected", + ): + assert truth_status in sql + assert "validate_occupational_construct_evidence" in sql + assert "strpos(selected_unit_text, new.evidence_text) = 0" in sql + assert "(post_id, generated_at desc, assertion_id)" in sql + assert "(construct_id, post_id)" in sql + + +def test_construct_assertion_schema_contains_no_local_measurement() -> None: + """Persistence cannot smuggle scores, weights, or person traits into the model.""" + assertion_sql = MIGRATION.read_text(encoding="utf-8").split( + "create table if not exists post_occupational_construct_assertion", 1 + )[1].split(");", 1)[0].casefold() + for prohibited in ("score", "weight", "intensity", "importance", "person_id"): + assert prohibited not in assertion_sql From ab810d1e7c6d3afa547a34166de5f83a9be7cfde Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 27 Aug 2026 01:49:05 +0900 Subject: [PATCH 2/4] fix: scope construct assertions to post detail --- frontend/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 04470a5ad..96e76f1ed 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -29,7 +29,6 @@ export interface PostSummary { post_body_excerpt?: string | null; post_body_truncated?: boolean; project_evidence?: ProjectEvidence[]; - occupational_construct_assertions?: OccupationalConstructAssertion[]; created_at: string; } @@ -102,6 +101,7 @@ export interface PostKnownAt { export interface PostDetail extends PostSummary { post_body: string; known_at?: PostKnownAt; + occupational_construct_assertions?: OccupationalConstructAssertion[]; } export interface PostImageContent { From f79378f50670fe6fbfb8c4295f526cea904d78d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:51:21 +0900 Subject: [PATCH 3/4] fix(api): scope construct evidence to post detail --- frontend/src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 04470a5ad..03b2cece4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -29,7 +29,6 @@ export interface PostSummary { post_body_excerpt?: string | null; post_body_truncated?: boolean; project_evidence?: ProjectEvidence[]; - occupational_construct_assertions?: OccupationalConstructAssertion[]; created_at: string; } @@ -101,6 +100,7 @@ export interface PostKnownAt { export interface PostDetail extends PostSummary { post_body: string; + occupational_construct_assertions: OccupationalConstructAssertion[]; known_at?: PostKnownAt; } From 9214c50f1fb14cdfc97377103509cd601cc76695 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 01:52:20 +0900 Subject: [PATCH 4/4] fix(api): remove duplicate construct detail field --- frontend/src/api.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ee4b0aa51..03b2cece4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -102,7 +102,6 @@ export interface PostDetail extends PostSummary { post_body: string; occupational_construct_assertions: OccupationalConstructAssertion[]; known_at?: PostKnownAt; - occupational_construct_assertions?: OccupationalConstructAssertion[]; } export interface PostImageContent {