Chio/Docs
LOGIN · JOIN

Persistent taint

See which later work inherits the restrictions an operation’s reads leave behind, and what a verified isolation epoch resets.

Concept chio@5f63e6e

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.

Retained taintChoose a change.
principal⊥ℓlineage⊥ℓsession⊥ℓtrread termshost restartnew sessionpublic read joinsnew epochs₁s₂s₃a2delegateinherits lineageInvalidDataunverified epochverified epochprincipal⊥ℓlineage⊥ℓsession⊥ℓtrreadrestartsessions₁s₂s₃a2delegateunverifiedepoch

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.

One principal’s retained labels on a time axis: a restricted read, a host restart, a new session, a delegate, and an isolation epoch. Times are illustrative and labels are drawn at two points, ⊥ and the terms’ label; the tenant, each request’s own label, and operator floors are left out.

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.

crates/security/chio-security-types/src/ports_parts/part_01.rs783–789rust
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:

  • load takes a FlowStateKey.
  • join carries one inside its FlowJoinRequest.
  • open_isolation_epoch takes an IsolationEpochTransition naming 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.

crates/security/chio-security-types/src/ports_parts/part_01.rs813–823rust
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.

Terminalbash
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
Output
{"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.

security-host/persistent_research.rsrust
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(())
}
Output
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.

RefusalSourceCondition
Signed Deny, "guard denied the request: flow state exceeded label limits"
FlowDenial::StateOverflow
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"
FlowDenial::StateOverflow or FlowDenial::StateChanged
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.

RefusalSourceCondition
Before invocation: guard denied the request: guard "chio-flow-pre-invocation" denied the request, evidence "flow denied: flow state exceeded label limits"
FlowDenial::StateOverflow
Source label exceeds its bound
Before invocation: the same reason, evidence "flow denied: flow state changed and requires reevaluation"
FlowDenial::StateChanged
Retained state changed, or failed to read or write, during evaluation
At dispatch: "security pre-dispatch hook rejected dispatch"
FlowDenial::StateChanged
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(KernelError::DurableAdmission("durable post-invocation pipeline returned an unsupported blocking verdict")) with it
FlowDenial::StateChanged
Retained state unreadable, or output join not persisted
PortErrorKind::InvalidData, 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.

Next: release a result under its retained label