Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1751f3f
test(evidence): define schema-bound extraction contract
seonghobae Aug 20, 2026
f088a84
test(evidence): canonicalize extraction schema regression formatting
seonghobae Aug 20, 2026
9bd08ae
feat(evidence): add bounded extraction schema contracts
seonghobae Aug 20, 2026
a76a3bc
style(evidence): apply canonical rustfmt
seonghobae Aug 20, 2026
2bf2166
test(evidence): satisfy fail-closed clippy contracts
seonghobae Aug 20, 2026
f7d0c04
style(evidence): apply canonical test formatting
seonghobae Aug 20, 2026
512bc3e
docs(changelog): record extraction schema contract
seonghobae Aug 20, 2026
d56090a
docs(changelog): preserve revocation evidence wording
seonghobae Aug 20, 2026
8fdb1b9
docs(changelog): keep TLS policy text unchanged
seonghobae Aug 20, 2026
b336a36
test(evidence): require explicit extraction normalization
seonghobae Aug 20, 2026
0325ad3
feat(evidence): bind extraction normalization rules
seonghobae Aug 20, 2026
04299d1
feat(evidence): export extraction normalization contract
seonghobae Aug 20, 2026
f6901af
style(evidence): apply normalization regression rustfmt
seonghobae Aug 20, 2026
a527c43
style(evidence): apply canonical normalization formatting
seonghobae Aug 20, 2026
69bc738
style(evidence): apply canonical extraction export formatting
seonghobae Aug 20, 2026
6e526fa
docs(adr): bind extraction schema version semantics
seonghobae Aug 21, 2026
fd34bb5
test(evidence): require extraction schema standard errors
seonghobae Aug 21, 2026
ef02ee8
fix(evidence): expose extraction schema standard errors
seonghobae Aug 21, 2026
40988c3
docs(changelog): record extraction schema error contract
seonghobae Aug 21, 2026
2fdc380
test(evidence): make identifier error contract scope-neutral
seonghobae Aug 21, 2026
4b1164c
test(evidence): require canonical source-channel set identity
seonghobae Aug 21, 2026
f483af9
fix(evidence): canonicalize extraction source-channel sets
seonghobae Aug 21, 2026
a1535d8
docs(evidence): bind ExtractionSchema doctoring authority
seonghobae Aug 22, 2026
36de84d
test(evidence): reject contradictory field cardinality
seonghobae Aug 23, 2026
c5a87a6
style(evidence): format cardinality regression
seonghobae Aug 23, 2026
212d48d
fix(evidence): reject contradictory field cardinality
seonghobae Aug 23, 2026
67545c6
test(evidence): bind cardinality failure type
seonghobae Aug 23, 2026
f2e6f2d
test(evidence): cover cardinality error contract
seonghobae Aug 23, 2026
c38b966
docs(evidence): bind cardinality presence semantics
seonghobae Aug 23, 2026
61a89d7
chore(stack): realign extraction schema with current main
seonghobae Aug 24, 2026
6a17e5f
test(docs): pin RFC 5280 author identity
seonghobae Aug 25, 2026
b35d739
fix(docs): correct RFC 5280 author identity
seonghobae Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding.
- Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits.
- Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state.
- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures.
- Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement.
- Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge.
- Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation.
Expand Down
297 changes: 297 additions & 0 deletions crates/originweave-evidence/src/extraction_schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
//! Versioned schema contracts for typed evidence extraction.
//!
//! These value objects describe what may be extracted and which reviewed
//! evidence channels may support each field. They do not read browser data,
//! disclose protected values, persist artifacts, execute models, or grant any
//! browser, network, secret, approval, or storage authority.

use std::{collections::BTreeSet, fmt};

/// Maximum encoded byte length for an extraction schema or field identifier.
pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128;
/// Maximum number of fields admitted by one extraction schema.
pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256;

/// The typed value contract for one extracted field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtractionValueType {
/// Bounded textual data.
Text,
/// A whole-number value.
Integer,
/// A decimal numeric value.
Decimal,
/// A boolean value.
Boolean,
/// A timestamp value whose concrete normalization is defined by the schema version.
Timestamp,
}

/// The number of values admitted for one extracted field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtractionCardinality {
/// Exactly one value is admitted.
One,
/// Zero or one value is admitted.
ZeroOrOne,
/// A bounded collection may be admitted by a later extraction runtime.
Many,
}

/// A reviewed evidence channel that may support an extracted value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtractionSourceChannel {
/// A semantic browser node with an independently validated identity.
SemanticNode,
/// Embedded structured metadata such as JSON-LD, RDFa, or Microdata.
StructuredData,
/// A bounded table-cell observation.
TableCell,
/// A bounded network response whose origin and response identity are independently verified.
NetworkResponse,
/// A separately approved model interpretation backed by explicit evidence identifiers.
ModelInterpretation,
}

/// A deterministic normalization rule declared for one extracted field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ExtractionNormalizationRule {
/// Preserve the typed source value without text normalization.
Verbatim,
/// Trim surrounding whitespace from a textual value.
TrimTextWhitespace,
/// Normalize a timestamp into an RFC 3339 UTC representation.
Rfc3339Utc,
}

/// A validation failure while constructing an extraction schema contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionSchemaError {
/// A schema or field identifier was empty or outside the accepted identifier grammar.
InvalidIdentifier,
/// An identifier or field collection exceeded its bounded limit.
LimitExceeded,
/// A field's required flag contradicted its declared cardinality.
InvalidCardinalityRequirement,
/// A field did not declare any reviewed source channel.
MissingSourceChannel,
/// A field declared the same source channel more than once.
DuplicateSourceChannel,
/// The declared normalization rule was incompatible with the field value type.
InvalidNormalizationRule,
/// A schema did not contain any field definitions.
MissingField,
/// A schema declared the same field identifier more than once.
DuplicateField,
}

impl fmt::Display for ExtractionSchemaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidIdentifier => "invalid extraction schema or field identifier",
Self::LimitExceeded => "extraction schema limit exceeded",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Self::InvalidCardinalityRequirement => {
"extraction field required flag is incompatible with the declared cardinality"
}
Self::MissingSourceChannel => "extraction field requires at least one source channel",
Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel",
Self::InvalidNormalizationRule => {
"extraction normalization rule is incompatible with the field value type"
}
Self::MissingField => "extraction schema requires at least one field",
Self::DuplicateField => "extraction schema contains a duplicate field identifier",
})
}
}

impl std::error::Error for ExtractionSchemaError {}

/// One typed field declared by a versioned extraction schema.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionField {
identifier: String,
value_type: ExtractionValueType,
cardinality: ExtractionCardinality,
required: bool,
normalization_rule: ExtractionNormalizationRule,
source_channels: Vec<ExtractionSourceChannel>,
}

impl ExtractionField {
/// Validate and construct one extraction field contract with verbatim normalization.
pub fn new(
identifier: &str,
value_type: ExtractionValueType,
cardinality: ExtractionCardinality,
required: bool,
source_channels: &[ExtractionSourceChannel],
) -> Result<Self, ExtractionSchemaError> {
Self::new_with_normalization(
identifier,
value_type,
cardinality,
required,
ExtractionNormalizationRule::Verbatim,
source_channels,
)
}

/// Validate and construct one extraction field with an explicit normalization rule.
pub fn new_with_normalization(
identifier: &str,
value_type: ExtractionValueType,
cardinality: ExtractionCardinality,
required: bool,
normalization_rule: ExtractionNormalizationRule,
source_channels: &[ExtractionSourceChannel],
) -> Result<Self, ExtractionSchemaError> {
validate_identifier(identifier)?;

let cardinality_requirement_is_compatible = match cardinality {
ExtractionCardinality::One => required,
ExtractionCardinality::ZeroOrOne => !required,
ExtractionCardinality::Many => true,
};
if !cardinality_requirement_is_compatible {
return Err(ExtractionSchemaError::InvalidCardinalityRequirement);
}
Comment thread
seonghobae marked this conversation as resolved.

if source_channels.is_empty() {
return Err(ExtractionSchemaError::MissingSourceChannel);
}

let normalization_is_compatible = match normalization_rule {
ExtractionNormalizationRule::Verbatim => true,
ExtractionNormalizationRule::TrimTextWhitespace => {
value_type == ExtractionValueType::Text
}
ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp,
};
if !normalization_is_compatible {
return Err(ExtractionSchemaError::InvalidNormalizationRule);
}

let mut seen_channels = BTreeSet::new();
for source_channel in source_channels {
if !seen_channels.insert(*source_channel) {
return Err(ExtractionSchemaError::DuplicateSourceChannel);
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

Ok(Self {
identifier: identifier.to_owned(),
value_type,
cardinality,
required,
normalization_rule,
source_channels: seen_channels.into_iter().collect(),
Comment thread
seonghobae marked this conversation as resolved.
})
}

/// Return the stable field identifier.
#[must_use]
pub fn identifier(&self) -> &str {
&self.identifier
}

/// Return the declared value type.
#[must_use]
pub const fn value_type(&self) -> ExtractionValueType {
self.value_type
}

/// Return the declared cardinality.
#[must_use]
pub const fn cardinality(&self) -> ExtractionCardinality {
self.cardinality
}

/// Return whether the field must be present in a conforming extraction result.
#[must_use]
pub const fn required(&self) -> bool {
self.required
}

/// Return the deterministic normalization rule declared for this field.
#[must_use]
pub const fn normalization_rule(&self) -> ExtractionNormalizationRule {
self.normalization_rule
}

/// Return the reviewed source channels that may support this field.
#[must_use]
pub fn source_channels(&self) -> &[ExtractionSourceChannel] {
&self.source_channels
}
}

/// A bounded versioned collection of typed extraction-field contracts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionSchema {
version: String,
fields: Vec<ExtractionField>,
}

impl ExtractionSchema {
/// Validate and construct one versioned extraction schema.
pub fn new(version: &str, fields: Vec<ExtractionField>) -> Result<Self, ExtractionSchemaError> {
validate_identifier(version)?;
if fields.is_empty() {
return Err(ExtractionSchemaError::MissingField);
}
if fields.len() > MAX_EXTRACTION_FIELD_COUNT {
return Err(ExtractionSchemaError::LimitExceeded);
}

let mut field_identifiers = BTreeSet::new();
for field in &fields {
if !field_identifiers.insert(field.identifier()) {
return Err(ExtractionSchemaError::DuplicateField);
}
}

Ok(Self {
version: version.to_owned(),
fields,
})
}

/// Return the immutable schema version identifier.
#[must_use]
pub fn version(&self) -> &str {
&self.version
}

/// Return the schema's ordered field definitions.
#[must_use]
pub fn fields(&self) -> &[ExtractionField] {
&self.fields
}

/// Find one field by its stable identifier.
#[must_use]
pub fn field(&self, identifier: &str) -> Option<&ExtractionField> {
self.fields
.iter()
.find(|field| field.identifier() == identifier)
}
}

Comment thread
seonghobae marked this conversation as resolved.
fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> {
if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES {
return Err(ExtractionSchemaError::LimitExceeded);
}

let mut bytes = identifier.bytes();
let Some(first_byte) = bytes.next() else {
return Err(ExtractionSchemaError::InvalidIdentifier);
};
if !first_byte.is_ascii_lowercase() {
return Err(ExtractionSchemaError::InvalidIdentifier);
}
if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) {
return Err(ExtractionSchemaError::InvalidIdentifier);
}

Ok(())
}
Comment thread
seonghobae marked this conversation as resolved.
6 changes: 6 additions & 0 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]

mod extraction_schema;
mod sensitive_access;

pub use extraction_schema::{
ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema,
ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType,
MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES,
};
pub use sensitive_access::{
MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass,
SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome,
Expand Down
77 changes: 77 additions & 0 deletions crates/originweave-evidence/tests/extraction_normalization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use originweave_evidence::{
ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError,
ExtractionSourceChannel, ExtractionValueType,
};

#[test]
fn extraction_fields_require_an_explicit_typed_normalization_rule()
-> Result<(), ExtractionSchemaError> {
let text = ExtractionField::new_with_normalization(
"product_name",
ExtractionValueType::Text,
ExtractionCardinality::One,
true,
ExtractionNormalizationRule::TrimTextWhitespace,
&[ExtractionSourceChannel::SemanticNode],
)?;
assert_eq!(
text.normalization_rule(),
ExtractionNormalizationRule::TrimTextWhitespace
);

let timestamp = ExtractionField::new_with_normalization(
"captured_at",
ExtractionValueType::Timestamp,
ExtractionCardinality::One,
true,
ExtractionNormalizationRule::Rfc3339Utc,
&[ExtractionSourceChannel::NetworkResponse],
)?;
assert_eq!(
timestamp.normalization_rule(),
ExtractionNormalizationRule::Rfc3339Utc
);
Ok(())
}

#[test]
fn extraction_fields_fail_closed_on_type_incompatible_normalization() {
assert_eq!(
ExtractionField::new_with_normalization(
"captured_at",
ExtractionValueType::Timestamp,
ExtractionCardinality::One,
true,
ExtractionNormalizationRule::TrimTextWhitespace,
&[ExtractionSourceChannel::NetworkResponse],
),
Err(ExtractionSchemaError::InvalidNormalizationRule)
);
assert_eq!(
ExtractionField::new_with_normalization(
"product_name",
ExtractionValueType::Text,
ExtractionCardinality::One,
true,
ExtractionNormalizationRule::Rfc3339Utc,
&[ExtractionSourceChannel::SemanticNode],
),
Err(ExtractionSchemaError::InvalidNormalizationRule)
);
}

#[test]
fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> {
let field = ExtractionField::new(
"unit_price",
ExtractionValueType::Decimal,
ExtractionCardinality::ZeroOrOne,
false,
&[ExtractionSourceChannel::StructuredData],
)?;
assert_eq!(
field.normalization_rule(),
ExtractionNormalizationRule::Verbatim
);
Ok(())
}
Loading
Loading