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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

- Global Ask public verification now admits only bounded, provenance-bearing
persisted claims attached to exact cited public posts; missing admission
fails closed without token-overlap egress.

### Added

- Evidence Operations now presents cited claim, rebid, handover, external,
Expand Down
96 changes: 85 additions & 11 deletions backend/app/global_ask_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
cited_post_summaries,
historical_body_limitations,
)
from lineageweave.public_claim_envelope import (
PersistedPublicClaimEnvelope,
envelope_from_authorized_row,
)
from lineageweave.semantic_query import NullSemanticQueryClient, SemanticQueryClient
from lineageweave.temporal_expressions import resolve_korean_relative_time

Expand Down Expand Up @@ -103,6 +107,30 @@

_logger = logging.getLogger(__name__)

_AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL = """
select envelope.public_claim_envelope_id,
envelope.source_post_id,
envelope.claim_kind_code,
envelope.claim_text
from public_claim_envelope envelope
join source_post post on post.post_id = envelope.source_post_id
join provenance_assertion assertion
on assertion.assertion_id = envelope.provenance_assertion_id
and assertion.relation_code = 'prov_was_derived_from'
join provenance_resource_binding evidence
on evidence.resource_id = assertion.object_resource_id
and evidence.node_type_code = 'node_post'
and evidence.node_id = envelope.source_post_id
where envelope.egress_eligible
and post.visibility_code = 'public'
and envelope.source_post_id = any($1::uuid[])
and ($2::timestamptz is null or (
envelope.created_at <= $2 and post.created_at <= $2
))
order by envelope.created_at, envelope.public_claim_envelope_id
limit 4
Comment on lines +120 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Duplicate evidence bindings can crowd the four-envelope cap

The admission query joins provenance_resource_binding on the assertion's object resource. If that resource has multiple node_post bindings to the same source post, the join emits the envelope more than once, and the limit 4 is applied to the joined rows, so duplicates can consume the budget and crowd out distinct envelopes. The insert trigger checks only that one binding matches, not uniqueness. Consider exists/distinct if duplicate bindings are possible.

Devin Review

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

"""


class _SafeJobError(Exception):
"""Failure whose bounded message is safe to persist for the requester."""
Expand Down Expand Up @@ -188,16 +216,29 @@ async def _verify_public_claims(
*,
verify_external: bool,
client: ClaimVerificationClient,
persisted_envelopes: tuple[PersistedPublicClaimEnvelope, ...] | None = None,
) -> tuple[str, tuple[ClaimVerificationResult, ...]]:
"""Verify only cited claims explicitly marked safe for public egress."""
"""Verify only cited claims explicitly marked safe for public egress.

Production passes persisted envelopes. The legacy source projection is
retained only as an explicit compatibility seam for callers that have not
adopted the admission store; Global Ask never uses that heuristic path.
"""

if not verify_external:
return VERIFICATION_SKIPPED, ()
cited_ids = frozenset(cited_post_ids)
claims = (
tuple(envelope.verification_candidate() for envelope in persisted_envelopes)
if persisted_envelopes is not None
else tuple(
claim
for claim in public_claim_candidates(sources, question)
if set(claim.source_post_ids).issubset(cited_ids)
)
)
claims = tuple(
claim
for claim in public_claim_candidates(sources, question)
if set(claim.source_post_ids).issubset(cited_ids)
claim for claim in claims if set(claim.source_post_ids).issubset(cited_ids)
)
Comment on lines 230 to 242

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Production verification now requires a persisted envelope

In the production path, compute_global_ask_answer always passes persisted_envelopes, so the legacy token-overlap nomination in public_claim_candidates is unreachable outside tests. Any opted-in question whose cited public posts have no stored egress-eligible envelope now reports no public claims and issues no external request. This is the intended fail-closed change, but it hard-gates all public verification on producers first persisting governed envelopes.

Devin Review

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

if not claims:
return VERIFICATION_NO_PUBLIC_CLAIMS, ()
Expand All @@ -219,6 +260,28 @@ async def _verify_public_claims(
)


async def load_authorized_public_claim_envelopes(
conn: asyncpg.Connection,
cited_post_ids: list[str],
*,
knowledge_cutoff: datetime | None,
) -> tuple[PersistedPublicClaimEnvelope, ...]:
"""Load bounded persisted claims for exact cited public evidence posts."""

if not cited_post_ids:
return ()
rows = await conn.fetch(
_AUTHORIZED_PUBLIC_CLAIM_ENVELOPES_SQL,
cited_post_ids,
knowledge_cutoff,
)
return tuple(
envelope
for row in rows
if (envelope := envelope_from_authorized_row(row)) is not None
)


async def load_job_visibility(
conn: asyncpg.Connection, job_id: str, account_id: str
) -> tuple[set[str], set[str], bool, bool]:
Expand Down Expand Up @@ -372,6 +435,7 @@ def can_see(row: asyncpg.Record) -> bool:
[],
verify_external=verify_external,
client=verification_client,
persisted_envelopes=(),
)
delivery = build_ask_delivery("", (), ())
return {
Expand Down Expand Up @@ -433,14 +497,16 @@ def can_see(row: asyncpg.Record) -> bool:
_ASK_RETRY_MESSAGE,
) from exc
cited_ids = list(answer.cited_post_ids)
verification_status, external_claims = await _verify_public_claims(
question_text,
usable_sources,
cited_ids,
verify_external=verify_external,
client=verification_client,
)
async with pool.acquire() as conn:
persisted_envelopes = (
await load_authorized_public_claim_envelopes(
conn,
cited_ids,
knowledge_cutoff=knowledge_cutoff,
)
if verify_external
else ()
)
if knowledge_cutoff is None:
lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
images = await cited_post_images(conn, cited_ids)
Expand All @@ -452,6 +518,14 @@ def can_see(row: asyncpg.Record) -> bool:
cited_ids,
checked_by=knowledge_cutoff,
)
verification_status, external_claims = await _verify_public_claims(
question_text,
usable_sources,
cited_ids,
verify_external=verify_external,
client=verification_client,
persisted_envelopes=persisted_envelopes,
)
cited_posts = cited_post_summaries(usable_sources, cited_ids)
cited_events = cited_post_events(usable_sources, cited_ids)
cited_evidence = cited_post_evidence(usable_sources, cited_ids)
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@
"0250_operations_case_analysis_input.sql",
"0251_product_semantic_catalog.sql",
"0253_voice_semantic_taxonomy.sql",
"0257_public_claim_envelope.sql",
)
)

Expand Down Expand Up @@ -5678,6 +5679,47 @@ def verify(self, claim):
""",
(seeded_db["public_post_id"],),
)
cur.execute(
"insert into provenance_resource (resource_iri, resource_label) "
"values ('urn:lineageweave:test:public-claim', 'Synthetic public claim') "
"returning resource_id"
)
claim_resource_id = cur.fetchone()[0]
cur.execute(
"insert into provenance_resource_type (resource_id, class_code) "
"values (%s, 'prov_entity')",
(claim_resource_id,),
)
cur.execute(
"insert into provenance_resource (resource_iri, resource_label) "
"values ('urn:lineageweave:test:public-post-evidence', 'Synthetic source post') "
"returning resource_id"
)
post_resource_id = cur.fetchone()[0]
cur.execute(
"insert into provenance_resource_type (resource_id, class_code) "
"values (%s, 'prov_entity')",
(post_resource_id,),
)
cur.execute(
"insert into provenance_resource_binding (resource_id, node_type_code, node_id) "
"values (%s, 'node_post', %s)",
(post_resource_id, seeded_db["public_post_id"]),
)
cur.execute(
"insert into provenance_assertion "
"(subject_resource_id, relation_code, object_resource_id) "
"values (%s, 'prov_was_derived_from', %s) returning assertion_id",
(claim_resource_id, post_resource_id),
)
assertion_id = cur.fetchone()[0]
cur.execute(
"insert into public_claim_envelope "
"(source_post_id, provenance_assertion_id, claim_kind_code, claim_text, egress_eligible) "
"values (%s, %s, 'claim_public_event', "
"'Synthetic Apollo event was published.', true)",
(seeded_db["public_post_id"], assertion_id),
)
conn.commit()

monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FakeChatClient())
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0215-global-ask-public-claim-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ carried by a cited public source. Private sources, Keyman/person facts, raw
source hints, source bodies, TEPP artifacts, fast-mlsirm artifacts, prompts,
credentials, and uncited facts never form a public query.

ADR 0269 strengthens admission: the production queue now requires a persisted,
PROV-O-bound public-claim envelope for an exact cited post. Question-token
overlap is retained only as legacy library compatibility and is not a runtime
egress decision.

SearXNG retrieves at most five bounded snippets for at most four claims. Result
URLs must be HTTP(S), must not be search pages, localhost, `.local`, or literal
non-global addresses, and are never fetched by LineageWeave. The untrusted
Expand Down
55 changes: 55 additions & 0 deletions docs/adr/0269-persisted-public-claim-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# ADR 0269: Persist public-claim admission before external verification

## Status

Accepted

## Context

ADR 0215 defines opt-in public verification and keeps external evidence
separate from internal authority. Its first implementation nominated semantic
facts by token overlap with the question. Token overlap is neither provenance
nor a governed claim-admission decision, and it can change when wording changes.

The abandoned draft PR #679 proposed replacing that implementation wholesale.
The current Global Ask queue, cutoff behavior, authorization scope, SearXNG
validation, and contextual-orchestrator verifier have since evolved and remain
authoritative. Only the persisted admission boundary is still missing.

## Decision

`public_claim_envelope` stores one bounded claim kind, exact claim text, source
post, PROV-O `prov:wasDerivedFrom` assertion, and egress decision. The evidence
resource must bind to that same source post. Only organization presence, public
event, and public relationship kinds are admitted; person, Keyman, measurement,
prompt, and source-body payloads have no storage code.

Production Global Ask loads at most four envelopes whose source post is both
public and cited in the completed answer. The per-question opt-in remains the
durable consent boundary. A cutoff excludes envelopes or source posts created
after that cutoff. Changing a post from public revokes egress eligibility.

The persisted envelope supplies candidates to the existing ADR 0215 verifier.
It does not replace SearXNG URL validation, contextual-orchestrator adjudication,
or the distinction between external URLs and internal post citations. No claim
is inferred from question-token overlap in the production path. When no current,
authorized envelope exists, verification reports no public claims and performs
no external request.

## Consequences

- Public egress admission is stable, reviewable, and provenance-bearing.
- Existing verification transport and outcome contracts remain unchanged.
- A producer must persist a governed envelope before a claim becomes eligible;
absence stays unavailable rather than being repaired heuristically.
- Draft PR #679 remains historical evidence for the missing boundary and is not
merged wholesale over the current semantic stack.

## References

Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV
ontology*. World Wide Web Consortium. https://www.w3.org/TR/prov-o/

Thorne, J., Vlachos, A., Christodoulopoulos, C., & Mittal, A. (2018). FEVER: A
large-scale dataset for fact extraction and verification. In *Proceedings of
NAACL-HLT 2018* (pp. 809–819). https://doi.org/10.18653/v1/N18-1074
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ decision from them.
| [`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) |
| Persisted public-claim admission | [0269](0269-persisted-public-claim-admission.md) |
| [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) |
| [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) |
| [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.md) |
Expand Down
7 changes: 5 additions & 2 deletions docs/product-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,9 @@ stale evidence from a previously opened post.
### PRD-FR-5A — Opt-in public claim verification

- Persist an explicit per-question opt-in before any external search begins.
- Nominate only cited, public semantic/KG facts; source bodies, private facts,
personal facts, and measurement outputs never become external queries.
- Admit only persisted, provenance-bearing claims for exact cited public posts;
source bodies, private facts, personal facts, measurement outputs, and claims
nominated from question-token overlap never become external queries (ADR 0269).
- Retrieve bounded public evidence through SearXNG and adjudicate through
contextual-orchestrator's verification mode.
- Report supported, refuted, and not-enough-information outcomes without
Expand All @@ -398,6 +399,8 @@ stale evidence from a previously opened post.
Acceptance: leaving the control off causes no public request; hidden or
uncited facts cause no public request; unavailable services fail closed; and
each displayed public judgment retains its originating internal evidence IDs.
An absent or unauthorized persisted envelope performs no external request and
reports that no public claim is available rather than fabricating admission.

### PRD-FR-5B — Knowledge-cutoff Global Ask

Expand Down
64 changes: 64 additions & 0 deletions lineageweave/public_claim_envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Persisted admission envelopes for public Global Ask verification.

The envelope decides which already-cited public assertion may leave the
workspace boundary. Retrieval and adjudication remain owned by the existing
claim-verification clients; this module never derives a claim from question
tokens or source text.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from .claim_verification import PublicClaimCandidate

ADMITTED_PUBLIC_CLAIM_KINDS = frozenset(
{
"claim_organization_presence",
"claim_public_event",
"claim_public_relationship",
}
)


@dataclass(frozen=True)
class PersistedPublicClaimEnvelope:
"""One governed claim and its exact authorized source-post provenance."""

public_claim_envelope_id: str
source_post_id: str
claim_kind_code: str
claim_text: str

def verification_candidate(self) -> PublicClaimCandidate:
"""Project the persisted envelope into the existing verifier contract."""

return PublicClaimCandidate(
claim_text=self.claim_text,
claim_kind=self.claim_kind_code,
source_post_ids=(self.source_post_id,),
)


def envelope_from_authorized_row(row: Any) -> PersistedPublicClaimEnvelope | None:
"""Validate a database row already filtered by ABAC and PROV-O binding."""

kind = str(row["claim_kind_code"] or "").strip()
envelope_id = str(row["public_claim_envelope_id"] or "").strip()
source_post_id = str(row["source_post_id"] or "").strip()
claim_text = str(row["claim_text"] or "").strip()
if (
kind not in ADMITTED_PUBLIC_CLAIM_KINDS
or not envelope_id
or not source_post_id
or not claim_text
or len(claim_text) > 800
):
return None
return PersistedPublicClaimEnvelope(
public_claim_envelope_id=envelope_id,
source_post_id=source_post_id,
claim_kind_code=kind,
claim_text=claim_text,
)
Loading