Persistent taint
See which later work inherits the restrictions an operation’s reads leave behind, and what a verified isolation epoch resets.
On this page
A workflow’s restrictions outlive the worker that learned them. A replacement worker, a new conversation, or a delegate continues under the obligations of everything the work has already read.
The kernel joins each read’s restrictions into durable state before releasing what was read, then uses that state in later admission and release decisions. The figure tracks the fixer, after it read Harbor Goods' terms, through a restart, a new session, a delegate, and an isolation epoch.
A restart reopens the labels the store retained
The host process that performed the read exits, and a new process reopens the original stores with a fresh serving fence. All three labels continue from the retained state. A missing key, store, or binding refuses startup instead of opening empty. The restart comparison.
The flow record keeps what a read means after the worker is gone
A model context holds material for reasoning, an application checkpoint records progress, and a flow record holds the restrictions later decisions must respect. Deleting a message from the visible conversation leaves its information wherever it already reached: summaries, checkpoints, and delegated work.
Agent applications replace workers and resume tasks routinely. Restrictions held in a worker’s memory would vanish at every restart, so the same durable task would appear to have learned nothing. Retaining them under authenticated identities lets recovery continue the original work without turning an interruption into a wider disclosure.
Sharing one principal across unrelated work accumulates restrictions at that scope. Choose task identities and isolation epochs deliberately and change them through the supported transitions; a new session identifier is a lifecycle choice and carries no isolation evidence.
Five fields locate retained taint, and the host fills them from authority it holds
The FlowStateKey combines tenant, principal, lineage, session, and isolation epoch. Those fields locate the state the host carries into the next call.
pub struct FlowStateKey {
pub tenant_id: TenantId,
pub principal_id: crate::PrincipalId,
pub lineage_id: LineageId,
pub session_id: SessionId,
pub isolation_epoch_id: IsolationEpochId,
}The principal carries what its holder learned across separate sessions. The lineage carries it through delegated work, so a child spawned to review or continue a task inherits what its parent accumulated. The session carries what belongs to one conversation. A new value in any field selects a distinct lookup context, and principal and lineage aggregates still carry their restrictions into the applicable sessions and child contexts.
Every store operation is scoped by the same five fields:
loadtakes aFlowStateKey.joincarries one inside itsFlowJoinRequest.open_isolation_epochtakes anIsolationEpochTransitionnaming the tenant, principal, and lineage plus the previous and new session and epoch.
The native process host, the Linux chio process host with a native_broker configuration, keeps the labels in the security-participant state of its admission operation store, SqliteAdmissionOperationStore, and its kernel writes them under the operation’s own lease. On a kernel built with build_kernel_with_active_defense, the flow guard and hooks sit in front of PersistentFlowResolver, which retains labels through the FlowStateStore port; SqliteSecurityStateStore is its SQLite implementation.
The pricing lab's host builds the key from authority it already holds: tenant larkfield, the principal from the signed capability’s subject key, the lineage from the retained parent grant, the session the operator chooses, and epoch pricing-epoch-1. A new host process takes a fresh serving fence while reopening the original participant binding, and the context generation comes from the store’s observation. A worker that could supply its own lineage or claimed generation would choose which history the host sees, so neither comes from the request.
Newly learned taint is durable before any decision depends on it
On the native process host, the kernel commits classified input through the operation-owned flow join before it checks destination policy at dispatch. On an active-defense embedding, the flow resolver persists the input join before the following egress or declassification decision. A refused disclosure leaves the learned restrictions intact, and the join itself grants no disclosure authority.
The egress fence enforces the same order from the other side. At dispatch the resolver persists the admitted join and only then prepares the fence against the persisted snapshot. The test fence_is_prepared_only_after_taint_persistence prepares a fence against state that has not absorbed the join, and the preparation is refused with FlowDenial::StateChanged; a state change after preparation fails the same way. The caller sees a signed Deny with the reason “security pre-dispatch hook rejected dispatch”.
Taint is written once classification has run. When a classifier fails, or returns a result bound to a different payload, the native process host refuses the operation at admission, before the join, and writes no taint for that call. The next call is evaluated against the taint the last joined operation left.
Only a verified isolation epoch gives a principal a clean start
An isolation epoch starts a principal again while keeping the restrictions its delegated work accumulated. IsolationEpochTransition names the previous and new epoch ids, the session the epoch opens into, and the evidence behind the switch.
pub struct IsolationEpochTransition {
pub tenant_id: TenantId,
pub principal_id: crate::PrincipalId,
pub lineage_id: LineageId,
pub previous_isolation_epoch_id: IsolationEpochId,
pub new_isolation_epoch_id: IsolationEpochId,
pub new_session_id: SessionId,
pub verification_evidence_hash: Digest32,
pub transition_id: RecordId,
pub effective_at_unix_ms: u64,
}open_isolation_epoch on SqliteSecurityStateStore opens the epoch after the store’s IsolationEpochEvidenceVerifierPort::verify succeeds. A store opened with SqliteSecurityStateStore::open carries a verifier that refuses every transition; open_with_isolation_epoch_verifier installs the verifier it is given. Once a principal has an epoch, the store refuses a join under an epoch that was never opened, and an unverified switch through either path returns PortErrorKind::InvalidData.
After a verified transition the new epoch’s principal label starts at ⊥. Its lineage label and the fresh session’s label keep the compartment the previous epoch accumulated through delegated work.
A restart, a new session, and a delegate all keep the terms’ restriction
Run the comparison from host/ in the security-research project after building its host, against a fresh state directory. The process that performs the read exits before inspect runs.
research init --state .taint
research read terms --as fixer --operation terms-v1 --state .taint --json > original.json
research inspect --as fixer --state .taint --json > reopened.json
RESEARCH_SESSION=next-conversation research read issue --as fixer --operation issue-v1 --state .taint --json > next.json
research read issue --as reviewer --operation review-v1 --state .taint --json > child.json
jq -c .snapshot.principal_label reopened.json
jq -c .flow.snapshot.session_label next.json
jq -c .flow.snapshot.lineage_label child.json{"compartments":["harbor-goods-terms"],"kind":"known","owners":{"larkfield-pricing":["larkfield-pricing"]}}
{"compartments":["harbor-goods-terms"],"kind":"known","owners":{"larkfield-pricing":["larkfield-pricing"]}}
{"compartments":["harbor-goods-terms"],"kind":"known","owners":{"larkfield-pricing":["larkfield-pricing"]}}You should see all three projections carry the larkfield-pricing owner and the harbor-goods-terms compartment. The reviewer holds a signed attenuated capability: it can invoke the selected tools under lower per-tool limits and a shorter expiry, and it cannot delegate further. Its read of issue #417 succeeds, and the output joins the inherited restricted context.
The run reopens the native operation store. Each start loads the original key, store, and binding, and a missing one refuses startup instead of manufacturing a new flow binding or opening an empty database.
Flow-store comparison and the unverified epoch
Reopen the read and test the next disclosure
The reviewer reads Harbor Goods' terms and its host restarts. In this disposable SQLite comparison the read survives reopening, a new conversation allows the internal result and refuses public egress, and a child with the same lineage inherits the obligation. Call the function once with a fresh path you own from your Rust embedding.
use chio_flow::{authorize_egress, EgressDenial};
use chio_security_types::{Compartment, InformationLabel, PrincipalId};
use chio_security_types::ports::{
FlowJoinRequest, FlowStateKey, FlowStateStore, IsolationEpochId,
LineageId, PortErrorKind, RecordId, SessionId, TenantId,
};
use chio_store_sqlite::SqliteSecurityStateStore;
use std::{collections::{BTreeMap, BTreeSet}, path::Path};
/// Use a fresh, caller-owned disposable database. This tests the FlowStateStore port,
/// not native admission's separate operation-owned security participant journal.
pub fn compare_persistent_research(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
assert!(!path.exists(), "choose a fresh example database");
let owner = PrincipalId::new("larkfield-pricing")?;
let restricted = InformationLabel::try_known(
BTreeMap::from([(owner.clone(), BTreeSet::from([owner]))]),
BTreeSet::from([Compartment::new("harbor-goods-terms")?]),
)?;
let key = FlowStateKey {
tenant_id: TenantId::new("larkfield")?,
principal_id: PrincipalId::new("review-worker")?,
lineage_id: LineageId::new("review-family")?,
session_id: SessionId::new("review-before-restart")?,
isolation_epoch_id: IsolationEpochId::new("review-epoch")?,
};
let store = SqliteSecurityStateStore::open(path)?;
store.join(&FlowJoinRequest {
key: key.clone(), principal_join: restricted.clone(),
lineage_join: restricted.clone(), session_join: restricted.clone(),
transition_id: RecordId::new("read-terms")?,
})?;
drop(store);
let reopened = SqliteSecurityStateStore::open(path)?;
let retained = reopened.load(&key)?.expect("original read remains recorded");
assert_eq!(retained.session_label, restricted);
let next = FlowStateKey { session_id: SessionId::new("review-after-restart")?, ..key.clone() };
let later = reopened.join(&FlowJoinRequest {
key: next, principal_join: InformationLabel::bottom(),
lineage_join: InformationLabel::bottom(), session_join: InformationLabel::bottom(),
transition_id: RecordId::new("later-public-input")?,
})?;
assert_eq!(authorize_egress(&later.session_label, Some(&restricted)), Ok(()));
assert_eq!(authorize_egress(&later.session_label, Some(&InformationLabel::bottom())), Err(EgressDenial::FlowViolation));
let child = FlowStateKey {
principal_id: PrincipalId::new("reviewer")?,
session_id: SessionId::new("child-review")?, ..key.clone()
};
// Establish the fixture child's epoch before reading its inherited context.
// A serving host must bind this lineage to the authenticated child grant.
reopened.join(&FlowJoinRequest {
key: child.clone(), principal_join: InformationLabel::bottom(),
lineage_join: InformationLabel::bottom(), session_join: InformationLabel::bottom(),
transition_id: RecordId::new("open-authenticated-child-context")?,
})?;
let inherited = reopened.load(&child)?.expect("shared lineage remains restricted");
assert_eq!(inherited.lineage_label, restricted);
let invented_epoch = FlowStateKey { isolation_epoch_id: IsolationEpochId::new("invented-clean-epoch")?, ..key };
let refusal = reopened.join(&FlowJoinRequest {
key: invented_epoch, principal_join: InformationLabel::bottom(),
lineage_join: InformationLabel::bottom(), session_join: InformationLabel::bottom(),
transition_id: RecordId::new("unverified-clean-start")?,
}).err().expect("an epoch name is not isolation evidence");
assert_eq!(refusal.kind(), PortErrorKind::InvalidData);
println!("REOPEN original read: contract restriction retained");
println!("ALLOW internal result: new session keeps the source obligation");
println!("REFUSE public result: later public input did not clear history");
println!("INHERIT child lineage: the reviewer still carries the restriction");
println!("REFUSE invented epoch: verified isolation evidence required");
Ok(())
}REOPEN original read: contract restriction retained ALLOW internal result: new session keeps the source obligation REFUSE public result: later public input did not clear history INHERIT child lineage: delegated reviewer still carries the restriction REFUSE invented epoch: verified isolation evidence required
The comparison writes a database through SqliteSecurityStateStore’s FlowStateStore port, separate from the native admission store’s operation-owned participant journal, and performs no external publication. The identities are fixtures, and the example opens the child context before reading its label; a serving host selects the complete key and binds the lineage to the authenticated child grant. A changed session name is a new context under the same principal and lineage, and renaming the epoch fails until the configured verifier accepts isolation or destruction evidence.
Taint that cannot be joined or persisted refuses the call at admission or dispatch
Each refusal traces to FlowDenial in chio-flow or to the flow store’s port error. What the caller sees depends on the host and the stage. On the native process host, an admission refusal is a signed Deny whose reason carries the FlowDenial text after “guard denied the request: ”, and a dispatch refusal is a signed Deny with a fixed reconciliation reason.
| Refusal | Source | Condition |
|---|---|---|
Signed Deny, "guard denied the request: flow state exceeded label limits" | Flow | Admission: classified input and operator floor exceed the label bound |
Signed Deny, "security dispatch outcome requires reconciliation: security native dispatch capture callback failed; authoritative recovery required" | Flow or Flow | Dispatch: source label over its bound, observed state missing or changed, or flow clock unreadable |
An active-defense kernel runs the same checks through its flow guard, pre-dispatch hook, and post-invocation hook. A pre-invocation denial puts the FlowDenial text in the signed receipt as guard evidence prefixed “flow denied: ”, while the response reason names only the guard. A post-invocation block reaches the caller as “flow blocked: ” only when durable admission does not apply. evaluate_post_invocation raises an output join it cannot represent to Top, so this kernel has no output-side overflow refusal, and a direct FlowStateStore caller sees the store’s port error kind.
| Refusal | Source | Condition |
|---|---|---|
Before invocation: guard denied the request: guard "chio-, evidence "flow denied: flow state exceeded label limits" | Flow | Source label exceeds its bound |
Before invocation: the same reason, evidence "flow denied: flow state changed and requires reevaluation" | Flow | Retained state changed, or failed to read or write, during evaluation |
At dispatch: "security pre-dispatch hook rejected dispatch" | Flow | Admitted join not persisted, or egress fence not prepared or acquired against persisted state |
After invocation: "flow blocked: flow state changed and requires reevaluation" without durable admission; Err(Kernel with it | Flow | Retained state unreadable, or output join not persisted |
Port, to a direct FlowStateStore caller | Port error | Join under an unopened epoch, or unverified epoch switch; through the resolver, a StateChanged refusal above |
Inspect the retained label, lineage, and operator floor the operation observed, and repair a missing binding or an oversized label through the owning policy; deleting retained taint would erase the restriction it records. A dispatch-time reconciliation reason needs the original operation’s custody and outcome recovery before any new effect. The flow-security gate runs fence_is_prepared_only_after_taint_persistence and isolation_epoch_must_be_verified_and_preserves_lineage_taint in its exact test inventory, beside a formal model of the information-flow lattice. Every Chio release passes the flow-security gate before it is tagged. chio release verify --evidence qualification shows its result for the release you run.