Chio/Docs
LOGIN · JOIN

Information flow

Carry a sensitive read’s restrictions through later calls and delegated work, so the kernel refuses release to a destination that lacks clearance.

Concept chio@5f63e6e

On this page

The fixer can read Harbor Goods’ negotiated price sheet to fix issue #417, keep working on the quote code, and hand parts of the work to the reviewer. What the sheet taught the agent still reaches only the readers your pricing team allows, never the public pull request.

The host labels each result before the agent sees it. The kernel joins that label into state it retains for the agent’s principal, its delegation lineage, and its session, and checks the retained label against the destination’s clearance at every call that sends data out. The figure follows that state through five calls of the PR #421 review.

The retained label rises at the internal read; public posts are refused while it exceeds the public clearanceℓtinternalpublic ⊥c(d) = ⊥public review
ℓ4⋢c(d)\htmlClass{role-deny}{\ell_4 \not\sqsubseteq c(d)}
ℓ5⋢c(d)\htmlClass{role-deny}{\ell_5 \not\sqsubseteq c(d)}
x1readREADME.mdx2readdeploy/settings.yamlx3writesummaryx4postsummaryx5postcommentno grantclick an underlined label to change the run
ℓt+1=ℓt⊔ℓ(xt)\htmlClass{role-call}{\ell_{t+1}} = \htmlClass{role-call}{\ell_t} \sqcup \ell(x_t)
allow(xt)  ⟺  ℓt⊑c(d)\text{allow}(x_t) \iff \htmlClass{role-call}{\ell_t} \sqsubseteq \htmlClass{cor-relation}{c(d)}
The retained label rises at the internal read; public posts are refused while it exceeds the public clearancetℓpublic ⊥internalc(d) = ⊥public review
ℓ4⋢c(d)\htmlClass{role-deny}{\ell_4 \not\sqsubseteq c(d)}
ℓ5⋢c(d)\htmlClass{role-deny}{\ell_5 \not\sqsubseteq c(d)}
x1readREADME.mdx2readdeploy/settings.yamlx3writesummaryx4postsummaryx5postcommentno grantclick an underlined label to change the run

After the read of deploy/settings.yaml the retained label is internal. Reading and writing continue; both public posts are refused because internal does not flow to the public clearance.

ℓt+1=ℓt⊔ℓ(xt)allow(xt)  ⟺  ℓt⊑c(d)\begin{aligned} \htmlClass{role-call}{\ell_{t+1}} &= \htmlClass{role-call}{\ell_t} \sqcup \ell(x_t) \\ \text{allow}(x_t) &\iff \htmlClass{role-call}{\ell_t} \sqsubseteq \htmlClass{cor-relation}{c(d)} \end{aligned}
In Rust · lattice.rs:65
pub fn authorize_egress(
    source: &InformationLabel,
    clearance: Option<&InformationLabel>,
) -> Result<(), EgressDenial> {
    // … Top source, Top or missing clearance: Err
    if !source.flows_to(clearance) {
        return Err(EgressDenial::FlowViolation);
    }
    Ok(())
}
Calls from the pull request #421 review, drawn on two points of the lattice; real labels carry owners, readers, and compartments. ℓ is the label retained for the principal, lineage, and session, and only calls that egress meet a clearance.

Permission to read a price sheet is separate from permission to publish what it taught

The fixer can hold a grant to query the customers table and a grant to comment on pull requests. Whether a comment may carry what the table taught depends on the relationship between the read, the later action, and the audience that receives the result.

Information survives transformation. A commit message can reveal a customer’s price floor without quoting a number, and a test fixture can rest on terms it never names. Even the next public question an agent asks can reflect restricted material, so searching an output for copied source text says little about whether the work depends on the source.

Information-flow control makes the relationship part of policy. Releasing restricted data adds obligations to retained state, and every later operation either preserves them at its destination or carries an authorized disclosure. Flow control tracks what the workflow was allowed to observe and makes no claim about an agent’s internal reasoning, so a harmless-looking output can still need review. Four parties own the state on this path:

  • The application owns the source, the destination policy, and the caller’s identity.
  • The kernel owns the admitted operation and the order in which its flow updates become durable.
  • The authority store keeps the principal, lineage, and session labels after the host process exits.
  • The destination adapter performs the external effect, only after the operation reaches dispatch.

Because labels accumulate under identities, the application’s identity structure decides which work shares restrictions. Unrelated work in one long-lived context becomes subject to both sources’ policies: an agent that read Harbor Goods’ sheet and then fixes Ostrander’s quote carries both customers’ restrictions. Separate contexts suit work whose tasks and information are separate; a new conversation opened after the same restricted material was shared carries that material’s restrictions with it.

Plan handoffs as operations. The restricted fixer produces an internal review, and your pricing lead approves one public summary for the pull request, so the disclosure becomes an identifiable decision by a named approver. The fixer never needs a general power to declare its own outputs public.

Jif brought information-flow analysis and controlled declassification to a programming language. Chio applies the model at runtime boundaries (classified tool results, authenticated retained state, delegated work, destinations) and covers what passes through them.

Read more in Information Flow Across Calls, which walks the PR #421 review call by call.

Each read joins the retained label, and a join only adds restrictions

An information label holds owner policies and compartments: each owner names the readers it permits, and each compartment names a separation the data requires. A more restrictive destination has fewer allowed readers. Labels and clearances defines the ordering that permits or refuses a transfer.

The source label at admission joins the retained principal, lineage, and session labels with the request payload and any operator floor. For an owner present on both sides, the join intersects the allowed readers. It keeps every owner from either side and unions the compartments. In the pricing lab the host has already classified the customers read as internal and PR #421’s fixture and issue #417 as public, and each read joins into the state the next call uses:

security-research/examples/retained_flow.rs · in mainrust
let mut retained = public.clone();

// The customers table, PR #421's fixture, then issue #417. The host supplies
// each classification; content cannot choose its own label.
for input_label in [&internal, &public, &public] {
    retained = retained.join_restrictions(input_label)?;
}

The result is monotonic: an ordinary call keeps or raises the retained restrictions. Delegating a task or closing a session leaves principal and lineage knowledge in place. A verified isolation-epoch transition is the flow store’s one mechanism for starting an isolated successor.

Two customer price sheets owned by your pricing team show the intersection. Harbor Goods’ sheet permits your pricing lead and Harbor Goods’ contact, Ostrander’s permits the pricing lead and Ostrander’s contact; after both reads, the combined state permits the pricing team and the pricing lead, and neither customer’s contact may receive the result. The portable programs in the worked run assert that result.

A result’s restrictions are durable before the reader sees the result

Output handling belongs to the same boundary as admission. The configured return path runs in this order:

  1. Inspect. The host reads the raw output.
  2. Sanitize. Configured sanitization runs.
  3. Classify. The effective response gets its label, including any operator floor.
  4. Join. The resulting restrictions are durably joined into retained state.
  5. Release. The response becomes observable.

Releasing first and storing labels later would open a crash window in which the reader holds data the system has forgotten. A persisted raw response is releasable only with the operation-owned release authority, the current revocation and emergency-stop checks where release requires them, and any required release checkpoint. That checkpoint records the exact guarded response that can be returned again without invoking the tool; recovery never mints a new release owner from a stored receipt.

Classification comes from the data source and the workflow. A signed tool declaration sets a required output label; runtime classification and operator floors add to it, and the floors apply even when a detector finds nothing to sanitize. An agent-controlled argument calling a response public carries no weight against the operator-owned declaration, and policy and manifest changes follow their update procedure so a continuation cannot swap the evaluated contract after admission.

Recovery from an interrupted return identifies the original operation, authenticates the retained result, rebuilds its classification context, and re-establishes release authority. When verified evidence cannot rebuild the state, the host keeps restrictive uncertainty, the Top label, because an empty label would make recovery more permissive than the original run. Shadow migration applies the same rule to sessions that ran before flow tracking was enabled.

Integrity runs the other way: a consequential call checks what influenced it

Confidentiality asks where data may go. Integrity asks what shaped the context that now requests a consequential action, and the kernel answers it with the same join discipline.

Integrity gateChoose a context.
fresh isolation epochd₁tool outputExternalmmailbox messageExternalptyped return≤ n bitsread at the intent committrusted≤ n bitsexternalcontext influenceκ requiresinfluencerequestdispatchInsufficientIntegrityτendorsementagKd₂ρcontextearly guardintent committooldeny receiptintegrity_faultfresh isolation epochd₁toolExternalmmailboxExternalpreturn≤ n bitsread at committrusted≤ n bitsexternalκ requiresInsufficientIntegrityτendorsementagKd₂ρcontextearly guardintentcommittooldeny receiptintegrity_fault

Delivered output raises the influence, and the intent commit refuses

The kernel joins the influence of every output it delivers into the context, in the same writer transaction that delivers it. Once a tool output or an unattested mailbox message has joined, the context is externally influenced, and the check inside the intent commit refuses with InsufficientIntegrity. The early guard only saves latency; a join that commits first is always seen. Information flow.

One consequential call from a mediated context: the outputs delivered into it, its influence on a three-point order, and the check at the intent commit. The bound n is symbolic; provider-only requirements, action-selection contracts, and the commitment algorithm are left out.Prompt injection

The kernel joins the influence of every output it delivers into a mediated context (tool results, mailbox payloads, artifact reads, model-context restores, and typed returns) in the writer transaction that delivers it, so influence only grows. A grant declares the integrity it requires. An early guard refuses obvious mismatches to save latency; the authoritative check runs inside the intent commit and refuses with InsufficientIntegrity. Allow receipts record which condition admitted the call in satisfied_by.

Two remedies exist. An approver on the deployment’s integrity roster can endorse one exact action against the context’s current influence commitment and one continuation; the commit consumes the endorsement in the same transaction and the history stays. Or a verified fresh isolation epoch starts a quarantined successor that receives only typed returns, each bounded by its return contract.

Read more in Prompt injection, which applies the integrity check to adversary-written content.

A public read in a new host process leaves the contract restriction in place

The sequence separates the lifetime of the host program from the lifetime of the restrictions. Pull the security-research project, build its host, and run these commands from host/ against a fresh state directory. Each command opens the original stores in a new host process.

Terminalbash
research init --state .flow
research read terms --as fixer --operation terms-v1 --state .flow --json > terms.json
research inspect --as fixer --state .flow --json > reopened.json
research read issue --as fixer --operation issue-v1 --state .flow --json > issue.json
jq '{output: .output.document, label: .flow.snapshot.session_label}' issue.json
Output
{
  "output": "issue-417.txt",
  "label": {
    "compartments": ["harbor-goods-terms"],
    "kind": "known",
    "owners": {"larkfield-pricing": ["larkfield-pricing"]}
  }
}

You should see issue #417 returned and the session label still carrying the larkfield-pricing owner and the harbor-goods-terms compartment. reopened.json holds the same restrictions before the public read. Both come from the native operation store’s retained state, written before the terms were released.

Before startup reconciliation, the host reopens its security-participant binding, loads the signed declaration registry, and installs a NativeFlowResolver, built from the binding, registry, classifier, clock, and resolver configuration with its captured lifecycle, as the kernel’s pre-dispatch hook under SecurityPreDispatchPolicy::Enforce. The principal comes from the retained signed capability and the lineage from the parent grant. The actor and session selectors are operator controls; a worker API derives the same context from authenticated runtime state.

Portable calculations: retained reads and reader intersection

Run a sequence of reads against retained state

A customers table read is followed by two public inputs, PR #421’s fixture and issue #417. The program uses Chio’s label and egress functions to decide which destinations remain usable. Run it from the security-research project root.

security-research/examples/retained_flow.rsrust
use chio_flow::{authorize_egress, EgressDenial};
use chio_security_types::{InformationLabel, PrincipalId};
use std::collections::{BTreeMap, BTreeSet};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let owner = PrincipalId::new("larkfield-pricing")?;
    let internal = InformationLabel::try_known(
        BTreeMap::from([(owner.clone(), BTreeSet::from([owner]))]),
        BTreeSet::new(),
    )?;
    let public = InformationLabel::bottom();
    let mut retained = public.clone();

    // The customers table, PR #421's fixture, then issue #417. The host supplies
    // each classification; content cannot choose its own label.
    for input_label in [&internal, &public, &public] {
        retained = retained.join_restrictions(input_label)?;
    }

    assert_eq!(retained, internal);
    assert_eq!(authorize_egress(&retained, Some(&internal)), Ok(()));
    assert_eq!(
        authorize_egress(&retained, Some(&public)),
        Err(EgressDenial::FlowViolation),
    );
    println!("Three inputs processed; the customers-table read still constrains egress");
    Ok(())
}
Terminalbash
cargo run --locked --quiet --example retained_flow
Output
Three inputs processed; the customers-table read still constrains egress

The internal destination succeeds because its clearance keeps the pricing team’s restriction, and the public destination fails with FlowViolation. The assertion compares the complete retained label with the internal label, so neither later public input erased the customer data. The program keeps state in memory; a serving host authenticates the context, persists principal and lineage state, and commits each learned restriction before the read becomes observable.

Change which readers the two inputs permit

Two price sheets share one owner and permit different customer contacts. An agent that read both must satisfy both, so the common readers are the pricing team and the pricing lead, and sending to either customer would disclose the other customer’s terms.

security-research/examples/reader_intersection.rsrust
use chio_flow::{authorize_egress, EgressDenial};
use chio_security_types::{InformationLabel, PrincipalId};
use std::collections::{BTreeMap, BTreeSet};

fn readers(names: &[&str]) -> InformationLabel {
    let owner = PrincipalId::new("larkfield-pricing").unwrap();
    let readers = names
        .iter()
        .map(|name| PrincipalId::new(*name).unwrap())
        .collect();
    InformationLabel::try_known(BTreeMap::from([(owner, readers)]), BTreeSet::new()).unwrap()
}

fn main() {
    let harbor_goods = readers(&["larkfield-pricing", "mara-okafor", "priya-raman"]);
    let ostrander = readers(&["larkfield-pricing", "mara-okafor", "tomas-ibarra"]);
    let joined = harbor_goods.join_restrictions(&ostrander).unwrap();
    let expected = readers(&["larkfield-pricing", "mara-okafor"]);

    assert_eq!(joined, expected);
    assert_eq!(authorize_egress(&joined, Some(&expected)), Ok(()));
    assert_eq!(
        authorize_egress(&joined, Some(&harbor_goods)),
        Err(EgressDenial::FlowViolation)
    );
    assert_eq!(
        authorize_egress(&joined, Some(&ostrander)),
        Err(EgressDenial::FlowViolation)
    );
    println!("Shared-owner readers intersect; neither customer's audience may receive the result");
}
Terminalbash
cargo run --locked --quiet --example reader_intersection
Output
Shared-owner readers intersect; neither customer's audience may receive the result

To let Harbor Goods’ contact, priya-raman, read the joint analysis, both sheets must permit that reader: replace main with this variation. Harbor Goods’ destination now succeeds; Ostrander’s still includes its own contact, tomas-ibarra, whom Harbor Goods’ sheet excludes. Removing the pricing lead from either sheet removes that reader from the common readers instead. Each variation changes the owners’ input policies for a new calculation, and the model has no instruction that reclassifies what it already learned.

security-research/examples/reader_intersection.rs · replaces mainrust
fn main() {
    let harbor_goods = readers(&["larkfield-pricing", "mara-okafor", "priya-raman"]);
    let ostrander = readers(&["larkfield-pricing", "mara-okafor", "priya-raman", "tomas-ibarra"]);
    let joined = harbor_goods.join_restrictions(&ostrander).unwrap();
    let expected = readers(&["larkfield-pricing", "mara-okafor", "priya-raman"]);

    assert_eq!(joined, expected);
    assert_eq!(authorize_egress(&joined, Some(&harbor_goods)), Ok(()));
    assert_eq!(
        authorize_egress(&joined, Some(&ostrander)),
        Err(EgressDenial::FlowViolation),
    );
    println!("Priya is permitted by both sheets; Tomás is still excluded by Harbor Goods' sheet");
}
Output
Priya is permitted by both sheets; Tomás is still excluded by Harbor Goods' sheet

A public send of restricted work stops at egress, before the effect

An egress operation needs a destination clearance that policy owns. A request cannot promote its desired destination into trusted clearance, and a tool with external effects stays an egress tool when it omits an egress declaration. The fixer’s workflow on issue #417 shows where each step leaves the next call:

StepState that mattersNext call
Public context, before the terms
Established unrestricted state
Public flow is evaluated normally
Harbor Goods’ terms released
Classified output joined durably
Retains the terms’ restrictions
Review delegated
Child authority plus retained lineage
Cannot widen publication permission
PR #421’s fixture read
Joined with existing state
The terms’ restrictions remain
Summary posted to the public pull request
Source label against destination clearance
Refused by ordinary flow

The next operation accounts for the terms even when its arguments quote nothing. The delegated review carries principal and lineage state plus the authenticated child context, so the child finishes useful work at an internal destination. A classifier labels content; the durable flow authority connects what the workflow learned to each later admission and release.

Declassification, an embedder API, is the one path for an intended disclosure. A grant binds the exact request, destination, purpose, subject, source label, and time window, authorizes that one disclosure in a host that embeds and accepts it, and leaves the retained taint in place.

In Rust: the egress decision function
crates/security/chio-flow/src/lattice.rs65–80rust
pub fn authorize_egress(
    source: &InformationLabel,
    clearance: Option<&InformationLabel>,
) -> Result<(), EgressDenial> {
    if matches!(source, InformationLabel::Top) {
        return Err(EgressDenial::TopSource);
    }
    let clearance = clearance.ok_or(EgressDenial::MissingClearance)?;
    if matches!(clearance, InformationLabel::Top) {
        return Err(EgressDenial::TopClearance);
    }
    if !source.flows_to(clearance) {
        return Err(EgressDenial::FlowViolation);
    }
    Ok(())
}

Read more in Control egress, which compares two receivers and records each refusal.

Each Data Protection page owns one decision on this path

Next: run the whole delegated fix