Classification
Turn detected content into categories, and categories into labels the kernel enforces, without treating a failed classifier as a clean result.
On this page
Classification labels a tool result by what it contains, even when its source declared nothing sensitive. A classifier finds a category in the returned bytes, the operator's map turns that category into readers and compartments, and the kernel joins the result with every restriction the operation already carries.
The running example is the pricing lab's host from Delegate sensitive research. Its read_unmarked tool declares no restriction, yet the document it returns carries the contract marker CONTRACT-HARBOR-GOODS-17, and the retained label comes back restricted to the larkfield-pricing owner and the harbor-goods-terms compartment.
A finding becomes a label only through the map
The byte regex reports internal-report at the matched bytes. The operator's CategoryLabelMap turns that category into the research owner and the internal compartment, and the kernel joins it with the floors. Labels.
A detector reports what the bytes contain, and the operator decides what it means
Classification has two owners. The detector inspects the exact payload presented to it and reports findings; the operator's category map says which readers and compartments each category requires.
pub trait StructuredClassifier: Send + Sync {
fn classify(
&self,
payload: &[u8],
) -> Result<StructuredClassificationResult, StructuredClassificationError>;
}Each finding names its classifier, its category, a configured confidence, and a location, either a byte range or a field path. A finding such as "customer contract marker at these bytes" carries no label. Changing what the detector matches and changing who may read a category are therefore separate administrative decisions, made in separate objects.
Source knowledge and content inspection complement each other. A source declaration keeps its obligation when a summary no longer contains a detectable marker, and a detector can add a restriction to material whose source declaration was less specific. The join keeps both.
The native host loads the rules and the map from one configuration section
On the native process host, this fragment is the value of native_broker.classifier. The byte regex finds a customer contract marker, and the map sends that category to the pricing team's readers and the customer-terms compartment.
{
"id": "contract-rules",
"version": "1",
"rules": [
{
"category": "customer-terms",
"expression": "CONTRACT-[A-Z-]+-[0-9]+",
"confidence_basis_points": 10000
}
],
"category_labels": {
"customer-terms": {
"kind": "known",
"owners": {
"larkfield-pricing": [
"larkfield-pricing"
]
},
"compartments": [
"customer-terms"
]
}
}
}A customers row containing CONTRACT-HARBOR-GOODS-17 receives the customer-terms category, and its label cannot flow to a public clearance. The confidence is configured rule metadata from 0 to 10,000 basis points; it states nothing about measured detection accuracy. The host refuses a configuration with no rules, or with a rule whose category has no label.
The pricing lab's host builds the same pair in code: classifier pricing-documents version 1 matches CONTRACT-HARBOR-GOODS-[0-9]+ as the contract-terms category at 10,000 basis points, and its map sends that category to the larkfield-pricing owner and the harbor-goods-terms compartment. The same resolver configuration sets a bottom output floor, trusts your pricing lead's pricing-approver key for disclosure, and sets a 60-second flow-fence lifetime. Neither the document text nor the tool arguments can replace the map.
How the host assembles the classifier and the map
The process host reads ClassifierConfig (chio-cli/src/cli/process_host/native_broker.rs:63), a serde structure in the native_broker section of the host configuration, never a chio command-line flag. It names the classifier's id and version, the regex rules with their categories and confidence, and the category-to-label table. The host turns it into a RegexStructuredClassifier and a CategoryLabelMap, wraps the classifier in the broker's body classifier, which also classifies each request's decoded HTTP body, then wraps that in StructuredClassificationAdapter and wires both into the flow resolver a native tool call runs against.
pub struct CategoryLabelMap {
classifier_id: ClassifierId,
classifier_version: ClassifierVersion,
labels: BTreeMap<RecordId, InformationLabel>,
}The map binds one classifier identity and version to its category labels. CategoryLabelMap::classify checks that the findings belong to the requested tenant, request, and payload, resolves every reported category, and joins the mapped labels into one verified classification, starting from bottom. The port is ClassificationPort (ports_parts/part_03.rs:574), its adapter is StructuredClassificationAdapter (chio-control-plane/src/security/adapters_parts/part_01.inc:49), and the finding-limit test is finding_limit_fails_closed (structured_classification.rs:647).
A clean scan never lowers a label
A verified classification is one input to the output label. The kernel joins it with three others, so the effective label is at least as restrictive as each:
- The operator's output floor. The least any output of the tool is labeled, set by the operator.
- The declared
output_label. Present when the tool's tool flow declaration names one. - The retained labels. The principal, lineage, and session restrictions the operation already carries.
A successful no-match contributes bottom to the join, so the floor, the declaration, and the retained history decide. The kernel reaches the classifier through one port, ClassificationPort, which StructuredClassificationAdapter implements over any configured StructuredClassifier; the join code reads findings the same way whichever classifier produced them.
Classification only reads. RegexStructuredClassifier borrows the payload, reports categories, and returns findings or an error, never rewritten bytes. Rewriting, redacting, or refusing content belongs to the guards in the data layer and sanitization, which share the classifier's guard crate. A response can pass every guard unmodified and still classify as restricted, and a redacted response is classified on its redacted bytes while its label still carries the floor, the declared label, and the retained restrictions.
A classifier that cannot answer stops the call
A no-match is evidence: the configured rules ran over the payload and found no category. A failed classifier has produced no evidence, and the kernel keeps that distinction as ClassificationMappingError::ClassifierFailure, so a broken detector never turns a payload into public information.
The same rule holds one layer down. A payload that would produce more than RegexStructuredClassifier's finding limit of 256 returns Err(StructuredClassificationError::TooManyFindings) in place of the first 256 matches, and the test finding_limit_fails_closed pins that behavior.
Each classifier supports the facts its rules can detect. A byte regex suits a controlled marker whose pattern is known, and it makes the detection-to-label path reproducible; a paraphrase of the same terms ("Harbor Goods pays 18 percent under list") can pass it unmatched. Known source restrictions belong in floors, which hold whatever a detector misses, so a false negative costs only what the floors leave open and a false positive costs the work it blocks.
The pricing lab keeps the terms restricted after their marker is gone
Run these from the host/ directory of the security-research project after building it. The first read returns a document whose tool declares no restriction:
research init --state .detected
research read unmarked --as fixer --operation detected-v1 --state .detected --json > detected.json
jq '{document: .output.document, label: .flow.snapshot.session_label}' detected.json{
"document": "unmarked.txt",
"label": {
"compartments": [
"harbor-goods-terms"
],
"kind": "known",
"owners": {
"larkfield-pricing": [
"larkfield-pricing"
]
}
}
}You should see the larkfield-pricing owner and the harbor-goods-terms compartment. The read_unmarked declaration and the operator floor are both bottom; the classifier found CONTRACT-HARBOR-GOODS-17 in the document and supplied the restriction. "Unmarked" names the tool's declared policy; the bytes it returned carried the marker.
research init --state .floor
printf '%s\n' 'Harbor Goods pays 18 percent below list on sku-204 through June.' > .floor/terms.txt
research read terms --as fixer --operation floor-v1 --state .floor --json > floor.json
jq .flow.snapshot.session_label floor.json{
"compartments": [
"harbor-goods-terms"
],
"kind": "known",
"owners": {
"larkfield-pricing": [
"larkfield-pricing"
]
}
}This edit removes the marker before the read, and the label stays restricted: the read_terms declaration and its operator floor require the contract label on their own. The example runs no sanitizer; it supplies different bytes to isolate the policy decision. In a fresh state, research read issue returns a bottom label, while the same read after the terms keeps their restriction through retained history.
The same comparison through the classification API
Classify one customer row and keep the floor
This program uses the production classifier, adapter, and mapper. A customers row returns CONTRACT-HARBOR-GOODS-17; the map turns the match into the larkfield-pricing and customer-terms label, and egress permits the internal clearance while refusing public. It then classifies redacted bytes it constructed itself, with their own payload commitment.
use chio_control_plane::security::adapters::StructuredClassificationAdapter;
use chio_core_types::sha256;
use chio_data_guards::{RegexClassificationRule, RegexStructuredClassifier};
use chio_flow::{authorize_egress, CategoryLabelMap, ClassificationMappingError, EgressDenial};
use chio_security_types::{Compartment, InformationLabel, PrincipalId};
use chio_security_types::ports::{CanonicalBody, ClassificationRequest, ClassifierId, ClassifierVersion, Digest32, RecordId, RequestId, TenantId};
use std::{collections::{BTreeMap, BTreeSet}, sync::Arc};
fn request(id: &str, bytes: &[u8]) -> ClassificationRequest {
ClassificationRequest {
tenant_id: TenantId::new("larkfield").unwrap(),
request_id: RequestId::new(id).unwrap(),
payload: CanonicalBody::new(bytes.to_vec()).unwrap(),
payload_digest: Digest32::new(*sha256(bytes).as_bytes()),
}
}
pub fn compare_classification() -> Result<(), Box<dyn std::error::Error>> {
let classifier = RegexStructuredClassifier::new("contract-rules", "1", vec![
RegexClassificationRule::new("customer-terms", "CONTRACT-[A-Z-]+-[0-9]+", 10_000)?,
])?;
let adapter = StructuredClassificationAdapter::new(Arc::new(classifier));
let owner = PrincipalId::new("larkfield-pricing")?;
let floor = InformationLabel::try_known(
BTreeMap::from([(owner.clone(), BTreeSet::from([owner]))]),
BTreeSet::from([Compartment::new("customer-terms")?]),
)?;
let mappings = BTreeMap::from([(RecordId::new("customer-terms")?, floor.clone())]);
let map = CategoryLabelMap::new(ClassifierId::new("contract-rules")?, ClassifierVersion::new("1")?, mappings)?;
let marked = request("marked-response", br#"{"row":"CONTRACT-HARBOR-GOODS-17"}"#);
let found = map.classify(&adapter, &marked)?;
assert_eq!(found.finding_count(), 1);
assert_eq!(found.label(), &floor);
assert_eq!(found.payload_digest(), marked.payload_digest);
assert_eq!(authorize_egress(found.label(), Some(&floor)), Ok(()));
assert_eq!(authorize_egress(found.label(), Some(&InformationLabel::bottom())), Err(EgressDenial::FlowViolation));
// Classify the actual effective bytes again; an operator floor still applies.
let effective = request("sanitized-response", br#"{"row":"[redacted]"}"#);
let clean = map.classify(&adapter, &effective)?;
assert_eq!(clean.finding_count(), 0);
assert!(clean.label().is_bottom());
assert_eq!(clean.label().join_restrictions(&floor)?, floor);
let absent_map = CategoryLabelMap::new(ClassifierId::new("contract-rules")?, ClassifierVersion::new("1")?, BTreeMap::new())?;
assert!(matches!(absent_map.classify(&adapter, &marked), Err(ClassificationMappingError::UnknownCategory(_))));
let mut substituted = marked.clone();
substituted.payload_digest = Digest32::new([0; 32]);
assert!(map.classify(&adapter, &substituted).is_err());
println!("CLASSIFY CONTRACT-HARBOR-GOODS-17: one mapped larkfield-pricing/customer-terms finding");
println!("ALLOW internal clearance; REFUSE public clearance");
println!("CLASSIFY effective redacted bytes: no match; operator floor retained");
println!("REFUSE unmapped category and substituted payload commitment");
Ok(())
}CLASSIFY CONTRACT-HARBOR-GOODS-17: one mapped larkfield-pricing/customer-terms finding ALLOW internal clearance; REFUSE public clearance CLASSIFY effective redacted bytes: no match; operator floor retained REFUSE unmapped category and substituted payload commitment
The no-match contributes bottom, and joining the operator's floor keeps the internal restriction. An unmapped category and a substituted payload digest both refuse in place of a guessed public label. A serving native broker also resolves its classification contract and retained context under the original operation.
Input failures refuse before the tool runs, and output failures go to recovery
Three places call classification: FlowPolicyView::classify_arguments, the input classification PersistentFlowResolver and NativeFlowResolver run before a tool runs; the post-invocation join in PersistentFlowResolver; and native output classification. Each folds whatever ClassificationMappingError it receives into FlowDenial::ClassifierFailure, displayed as "classifier evaluation failed".
| Caller receives | When | Raised by |
|---|---|---|
Signed Deny, reason "guard denied the request: classifier evaluation failed" | Before the tool runs | Input classification |
Err(Kernel | After the tool runs | Output classification |
| Caller receives | When | Raised by |
|---|---|---|
Signed Deny, reason guard denied the request: guard "chio- | Before the tool runs | Flow |
Signed Deny, reason "flow blocked: classifier evaluation failed" | After the tool runs, without durable admission | Flow |
Err(Kernel | After the tool runs, with durable admission | Flow |
Every row has the same causes: the classifier failed or hit its finding limit, returned a result bound to another tenant, request, or payload, or reported a category with no entry in the map. StructuredClassificationAdapter turns every StructuredClassificationError into a port error, which CategoryLabelMap::classify folds into ClassifierFailure; UnknownCategory stays distinct until the caller folds it. On an active-defense kernel, built with build_kernel_with_active_defense, the receipt's guard evidence reads "flow denied: classifier evaluation failed" while the response reason names only the guard.
For a refusal before invocation, inspect rule compilation, the classifier identity and version, every category mapping, and the request and payload binding. For a failure after invocation, retain the original operation and follow outcome recovery: the tool ran, and correcting a rule leaves that effect in place.