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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `persistence_postgres` typed `text_segment` SQL: insert/lookup of exact UTF-8 half-open byte spans on the existing `0006` table, cutoff-eligible document reads (`available_time <= knowledge_cutoff`), and live recovery of a known `hello` span. No new migration number (`#45` still owns `0007`).
- Hourly contextual-orchestrator discovery records all provider models but routes OpenCode only through general-chat candidates, excluding embedding, image, reranker, transcription, moderation, safety, and other endpoint-only identifiers before price selection.
- Live `docs/product-technical-gap-baseline.md` mapping operator-visible gaps to
protected-main maturity, exact current PR/issue state, stacked delivery order,
Expand Down
7 changes: 7 additions & 0 deletions crates/persistence_postgres/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub enum PersistenceError {
ConcurrentWriteConflict,
/// A restored snapshot failed integrity revalidation and is not usable.
RestoreIntegrityFailed,
/// A text segment had a negative or inverted UTF-8 byte span.
InvalidTextSegment,
/// A retention, hold, deletion, or tombstone record failed closed validation.
InvalidRetentionLifecycle,
/// An active legal hold blocked completed deletion.
Expand Down Expand Up @@ -71,6 +73,7 @@ impl fmt::Display for PersistenceError {
Self::InvalidAuditEvent => "invalid audit event",
Self::ConcurrentWriteConflict => "concurrent write conflict",
Self::RestoreIntegrityFailed => "restore integrity failed",
Self::InvalidTextSegment => "invalid text segment",
Self::InvalidRetentionLifecycle => "invalid retention lifecycle",
Self::LegalHoldBlocksDeletion => "legal hold blocks deletion",
Self::UngovernedEvidenceRestore => "ungoverned evidence restore",
Expand Down Expand Up @@ -209,6 +212,10 @@ mod tests {
PersistenceError::RestoreIntegrityFailed.to_string(),
"restore integrity failed"
);
assert_eq!(
PersistenceError::InvalidTextSegment.to_string(),
"invalid text segment"
);
assert_eq!(
PersistenceError::InvalidRetentionLifecycle.to_string(),
"invalid retention lifecycle"
Expand Down
11 changes: 11 additions & 0 deletions crates/persistence_postgres/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
//! contracts chain immutable run identities to those manifests. Typed
//! membership-assignment SQL (migration `0006`) replaces the polymorphic 0001 stub so documents
//! can belong to multiple entities and projects without atomistic collapse.
//! Typed `text_segment` SQL persists exact UTF-8 byte spans and cutoff-eligible
//! document lookups so segment-level membership is not raw SQL.
//! Concurrent document revises use one transactional `DO` block that requires
//! exactly one open row to close, and live `SQLx` maps racing SQLSTATEs onto
//! typed conflict errors. Restore integrity probes refuse to mark analytical
Expand Down Expand Up @@ -42,6 +44,7 @@ mod naming;
mod relation_sql;
mod restore_integrity;
mod retention_sql;
mod segment_sql;
mod sql_session;
mod sqlx_gate;
#[cfg(feature = "live-sqlx")]
Expand Down Expand Up @@ -194,6 +197,14 @@ pub use retention_sql::release_legal_hold_sql;
pub use retention_sql::select_active_analysis_document_sql;
/// Render supersede SQL for a successive retention policy.
pub use retention_sql::supersede_retention_policy_sql;
/// Exact-span text segment row.
pub use segment_sql::TextSegmentRecord;
/// Render insert SQL for a validated text segment.
pub use segment_sql::insert_text_segment_sql;
/// Render selection SQL for a text segment by primary key.
pub use segment_sql::select_text_segment_by_id_sql;
/// Render cutoff-eligible text-segment selection for one document.
pub use segment_sql::select_text_segments_for_document_as_of_sql;
/// Recording SQL transport for offline contract tests.
pub use sql_session::RecordingSqlSession;
/// Live SQL transport contract.
Expand Down
87 changes: 85 additions & 2 deletions crates/persistence_postgres/src/live_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ use crate::retention_sql::{
insert_evidence_tombstone_sql, insert_legal_hold_sql, insert_retention_policy_sql,
select_active_analysis_document_sql,
};
use crate::segment_sql::{
TextSegmentRecord, insert_text_segment_sql, select_text_segment_by_id_sql,
select_text_segments_for_document_as_of_sql,
};
use crate::sql_session::{SqlSession, apply_sql_batch};
use crate::tenant_session::set_session_tenant_sql;
use crate::{MigrationContractError, PersistenceError};
use temporal_core::{EventTime, SystemTime};
use temporal_core::{EventTime, KnowledgeCutoff, SystemTime};
use uuid::Uuid;

/// Fail-closed live document/audit repository backed by [`SqlSession`].
Expand Down Expand Up @@ -320,6 +324,47 @@ impl<S: SqlSession> LiveDocumentRepository<S> {
self.session.execute(&sql)
}

/// Insert an exact-span text segment under the active tenant.
///
/// # Errors
///
/// Returns inverted/negative span validation or transport failures.
pub fn insert_text_segment(
&mut self,
record: &TextSegmentRecord,
) -> Result<(), PersistenceError> {
self.bind_session_tenant(record.tenant_record_id)?;
let sql = insert_text_segment_sql(record)?;
self.session.execute(&sql)
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

/// Look up one text segment by primary key.
///
/// # Errors
///
/// Returns transport failures.
pub fn submit_text_segment_by_id(
&mut self,
text_segment_id: Uuid,
) -> Result<(), PersistenceError> {
let sql = select_text_segment_by_id_sql(text_segment_id);
self.session.execute(&sql)
}

/// Look up cutoff-eligible text segments for one document identity.
///
/// # Errors
///
/// Returns transport failures.
pub fn submit_text_segments_for_document_as_of(
&mut self,
document_record_id: Uuid,
knowledge_cutoff: &KnowledgeCutoff,
) -> Result<(), PersistenceError> {
let sql = select_text_segments_for_document_as_of_sql(document_record_id, knowledge_cutoff);
self.session.execute(&sql)
Comment on lines +345 to +365

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: Text-segment lookups also skip tenant binding, but this matches sibling read methods

submit_text_segment_by_id (live_repository.rs) and submit_text_segments_for_document_as_of (:358-365) do not bind the session tenant GUC. Under FORCE RLS a read with an unset GUC fails closed (returns zero rows), so a standalone lookup would silently return nothing. However this matches the established pattern for other read methods (submit_membership_assignments_for_document at :290, submit_source_artifact_by_id at :376, submit_active_analysis_document at :486), which also rely on a previously bound GUC. So it is not a new inconsistency introduced by this PR, only the insert path is anomalous.

Open in Devin Review

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

}

/// Insert a bitemporal event-instance version.
///
/// # Errors
Expand Down Expand Up @@ -523,9 +568,10 @@ mod tests {
use crate::migration::MigrationCatalog;
use crate::model_run_sql::{CorpusSplitManifestRecord, ModelArtifactRecord, ModelRunRecord};
use crate::relation_sql::EventRelationRecord;
use crate::segment_sql::TextSegmentRecord;
use crate::sql_session::RecordingSqlSession;
use crate::{MigrationContractError, PersistenceError};
use temporal_core::{AvailableTime, EventTime, SystemTime};
use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff, SystemTime};

fn sample_record() -> DocumentRecord {
DocumentRecord {
Expand Down Expand Up @@ -670,6 +716,42 @@ mod tests {
);
}

fn exercise_text_segment(repo: &mut LiveDocumentRepository<RecordingSqlSession>) {
let segment = TextSegmentRecord {
text_segment_id: uuid::Uuid::from_u128(7),
tenant_record_id: uuid::Uuid::nil(),
document_record_id: uuid::Uuid::from_u128(11),
start_byte: 0,
end_byte: 5,
system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"),
available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"),
};
repo.insert_text_segment(&segment).expect("segment insert");
let executed = repo.session().executed();
let segment_bind = executed
.iter()
.rposition(|sql| sql.contains("tepp.current_tenant_record_id"))
.expect("text segment insert must bind tenant session");
assert!(executed[segment_bind + 1].contains("INSERT INTO text_segment"));
repo.submit_text_segment_by_id(segment.text_segment_id)
.expect("segment by id");
let cutoff = KnowledgeCutoff::parse_rfc3339("2026-02-01T00:00:00Z").expect("cutoff");
repo.submit_text_segments_for_document_as_of(segment.document_record_id, &cutoff)
.expect("segments as of");
let mut inverted = segment.clone();
inverted.end_byte = 0;
assert_eq!(
repo.insert_text_segment(&inverted),
Err(PersistenceError::InvalidTextSegment)
);
assert!(
repo.session()
.executed()
.iter()
.any(|sql| sql.contains("INSERT INTO text_segment"))
);
}

fn exercise_event_mention(repo: &mut LiveDocumentRepository<RecordingSqlSession>) {
let mention = EventMentionRecord {
event_mention_id: uuid::Uuid::from_u128(2),
Expand Down Expand Up @@ -987,6 +1069,7 @@ mod tests {
exercise_membership_assignment(&mut repo);
exercise_event_relation(&mut repo);
exercise_event_mention(&mut repo);
exercise_text_segment(&mut repo);
exercise_event_instance(&mut repo);
exercise_source_artifact(&mut repo);
exercise_retention_legal_hold(&mut repo);
Expand Down
145 changes: 145 additions & 0 deletions crates/persistence_postgres/src/segment_sql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! SQL contracts for exact-span `text_segment` rows (ADR 0008 / ADR 0013).

use crate::PersistenceError;
use temporal_core::{AvailableTime, KnowledgeCutoff, SystemTime};
use uuid::Uuid;

/// One append-only exact-span observation on a document.
///
/// Maps to physical `text_segment` from migration `0006`. Byte offsets are
/// half-open `[start_byte, end_byte)` over the document UTF-8 bytes.
/// `document_record_id` is required; a foreign key remains a later migration
/// (`#45` owns `0007`).
Comment on lines +7 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 text_segment lacks an append-only mutation trigger

text_segment is created in migration 0006 and granted only SELECT, INSERT to tepp_app_runtime, but unlike the append-only identity tables in migration 0004 it has no reject_append_only_mutation statement-level trigger. The PR only adds a SQL adapter and does not touch migrations, so this is out of scope, but if segment spans are intended to be append-only observations (as the doc comment implies: "One append-only exact-span observation"), the absence of a mutation-rejecting trigger means a superuser or future grant change could silently update/delete spans. Worth confirming against the accepted ERD append-only intent.

Open in Devin Review

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

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TextSegmentRecord {
/// Segment identity used by membership and mention observed units.
pub text_segment_id: Uuid,
/// Owning tenant boundary.
pub tenant_record_id: Uuid,
/// Document whose UTF-8 bytes this span indexes.
pub document_record_id: Uuid,
/// Inclusive start offset in UTF-8 bytes; must be `>= 0`.
pub start_byte: i64,
/// Exclusive end offset in UTF-8 bytes; must be `> start_byte`.
pub end_byte: i64,
/// System/record time when the span was asserted.
pub system_time: SystemTime,
/// Availability time of the span evidence.
pub available_time: AvailableTime,
}

impl TextSegmentRecord {
/// Fail-closed half-open byte-span validation.
///
/// # Errors
///
/// Returns [`PersistenceError::InvalidTextSegment`] when `start_byte` is
/// negative or `end_byte` is not strictly greater than `start_byte`.
pub fn validate(&self) -> Result<(), PersistenceError> {
if self.start_byte < 0 || self.end_byte <= self.start_byte {
return Err(PersistenceError::InvalidTextSegment);
}
Ok(())
}
Comment on lines +38 to +43

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: Adapter validation duplicates but does not weaken the DB CHECK constraint

TextSegmentRecord::validate (segment_sql.rs) enforces start_byte >= 0 && end_byte > start_byte, which exactly mirrors the physical text_segment_byte_span CHECK (start_byte >= 0 AND end_byte > start_byte) in migrations/0006_typed_membership_assignment.up.sql. The half-open span semantics and fail-closed behavior are consistent between the adapter and the database, so there is no divergence risk here.

Open in Devin Review

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

}

/// Render insert SQL for a validated text segment.
///
/// # Errors
///
/// Returns [`PersistenceError::InvalidTextSegment`] before any SQL is produced.
pub fn insert_text_segment_sql(record: &TextSegmentRecord) -> Result<String, PersistenceError> {
record.validate()?;
Ok(format!(
"INSERT INTO text_segment (\
text_segment_id, tenant_record_id, document_record_id, \
start_byte, end_byte, system_time, available_time\
) VALUES (\
'{segment}'::uuid, '{tenant}'::uuid, '{document}'::uuid, \
{start_byte}, {end_byte}, '{system}'::timestamptz, '{available}'::timestamptz\
)",
segment = record.text_segment_id,
tenant = record.tenant_record_id,
document = record.document_record_id,
start_byte = record.start_byte,
end_byte = record.end_byte,
system = record.system_time.to_rfc3339(),
available = record.available_time.to_rfc3339(),
))
}

/// Render selection of one text segment by primary key.
#[must_use]
pub fn select_text_segment_by_id_sql(text_segment_id: Uuid) -> String {
format!(
"SELECT text_segment_id, tenant_record_id, document_record_id, \
start_byte, end_byte, system_time, available_time \
FROM text_segment \
WHERE text_segment_id = '{text_segment_id}'::uuid \
LIMIT 1"
)
}

/// Render cutoff-eligible segments for one document identity.
///
/// Enforces `available_time <= knowledge_cutoff` so a historical fit cannot
/// consume a span that was unavailable at the declared cutoff.
#[must_use]
pub fn select_text_segments_for_document_as_of_sql(
document_record_id: Uuid,
knowledge_cutoff: &KnowledgeCutoff,
) -> String {
format!(
"SELECT text_segment_id, tenant_record_id, document_record_id, \
start_byte, end_byte, system_time, available_time \
FROM text_segment \
WHERE document_record_id = '{document_record_id}'::uuid \
AND available_time <= '{cutoff}'::timestamptz \
ORDER BY start_byte, text_segment_id",
cutoff = knowledge_cutoff.to_rfc3339(),
)
}

#[cfg(test)]
mod tests {
use super::{
TextSegmentRecord, insert_text_segment_sql, select_text_segment_by_id_sql,
select_text_segments_for_document_as_of_sql,
};
use crate::PersistenceError;
use temporal_core::{AvailableTime, KnowledgeCutoff, SystemTime};
use uuid::Uuid;

fn sample() -> TextSegmentRecord {
TextSegmentRecord {
text_segment_id: Uuid::from_u128(1),
tenant_record_id: Uuid::from_u128(2),
document_record_id: Uuid::from_u128(3),
start_byte: 0,
end_byte: 5,
system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"),
available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"),
}
}

#[test]
fn validate_and_select_helpers_cover_local_branches() {
let record = sample();
record.validate().expect("valid");
let insert = insert_text_segment_sql(&record).expect("insert");
assert!(insert.contains("0, 5"));
assert_eq!(
insert_text_segment_sql(&TextSegmentRecord {
start_byte: 0,
end_byte: 0,
..record.clone()
}),
Err(PersistenceError::InvalidTextSegment)
);
let by_id = select_text_segment_by_id_sql(record.text_segment_id);
assert!(by_id.contains("LIMIT 1"));
let cutoff = KnowledgeCutoff::parse_rfc3339("2026-01-01T00:00:00Z").expect("c");
let as_of = select_text_segments_for_document_as_of_sql(record.document_record_id, &cutoff);
assert!(as_of.contains("available_time <="));
}
}
Loading
Loading