# Zamburak user's guide ## Policy loader contract The runtime policy loader enforces canonical policy schema v1 and supports an explicit legacy migration path. - accepted without migration: `schema_version: 1`, - accepted with explicit migration: `schema_version: 0` (migrated to v1), - rejected fail-closed: unknown schema versions and unknown schema families. Unknown schema versions are never defaulted, partially loaded, or heuristically migrated. ## Runtime API Use `PolicyEngine` or `PolicyDefinition` from `zamburak-policy`: - `PolicyEngine::from_yaml_str(...)`, - `PolicyEngine::from_json_str(...)`, - `PolicyDefinition::from_yaml_str(...)`, - `PolicyDefinition::from_json_str(...)`. These entrypoints return canonical policy objects and hide migration evidence. For auditable migration evidence, use: - `PolicyEngine::from_yaml_str_with_migration_audit(...)`, - `PolicyEngine::from_json_str_with_migration_audit(...)`, - `PolicyDefinition::from_yaml_str_with_migration_audit(...)`, - `PolicyDefinition::from_json_str_with_migration_audit(...)`. Audit outcomes include: - source and target schema versions, - source and target canonicalized SHA-256 document hashes, - ordered per-step transform records (`policy_schema_v0_to_v1`). ## Source checkout requirement for `full-monty` This repository vendors the Track A runtime substrate as a Git submodule at `third_party/full-monty/`. Consumers building Zamburak from source must initialize and update submodules before building: ```sh git submodule update --init --recursive ``` Maintainers syncing Track A fork state can use the repository-local workflow: ```sh make monty-sync ``` Track A changes are constrained by [Monty fork policy](monty-fork-policy.md). Zamburak semantics are prohibited in fork API surface, and pull requests that violate that policy are rejected by automated review checks. ## Track A runtime IDs `full-monty` exposes stable, host-only runtime IDs for suspendable execution payloads. - `RunProgress::FunctionCall` and `RunProgress::OsCall` include `arg_runtime_ids: Vec` and `kwarg_runtime_ids: Vec<(RuntimeValueId, RuntimeValueId)>`. - `ReplProgress::FunctionCall` and `ReplProgress::OsCall` include the same runtime-ID field types. - Both progress enums expose `runtime_ids()` for read-only access to the ID slices without destructuring the enum payload, returning `(&[RuntimeValueId], &[(RuntimeValueId, RuntimeValueId)])`. Runtime IDs are opaque host metadata and carry no policy meaning. They remain stable across `start()` or `resume()` boundaries and survive `dump()` or `load()` round trips for run-progress payloads. ## Track A snapshot extension bytes `full-monty` snapshots can carry opaque, embedder-owned extension bytes. Monty stores these bytes alongside snapshot state but never interprets them. - `Snapshot`, `FutureSnapshot`, `ReplSnapshot`, and `ReplFutureSnapshot` expose `with_snapshot_extension(...)` for attaching bytes (accepting either `Vec` or `SnapshotExtension`) and `snapshot_extension()` for read-only byte access. Use `snapshot_extension_raw()` to access the `SnapshotExtension` wrapper for future metadata expansion. - Extension bytes are optional and default to `None` when not provided. - `RunProgress::dump()`/`load()` and `ReplProgress::dump()`/`load()` preserve extension bytes across round trips. ## Track A runtime observer events `full-monty` exposes a generic runtime observer surface for host-side instrumentation. - install an observer with `RuntimeObserverHandle::new(...)`, - start run execution with `MontyRun::start_with_observer(...)`, - start REPL snippet execution with `MontyRepl::start_with_observer(...)` or `MontyRepl::start_no_print_with_observer(...)`, - inspect canonical event classes: - `ValueCreated`, - `OpResult`, - `ExternalCallRequested`, - `ExternalCallReturned`, - `ControlCondition`. Observer payloads are runtime-generic and ID-centric. They intentionally do not carry Zamburak policy decisions or governance semantics. Baseline semantics are preserved in both of these modes: - no observer installed (`start(...)` and existing entrypoints), - observer-aware entrypoints with `RuntimeObserverHandle::disabled()`, - explicit no-op observer (`RuntimeObserverHandle::new(NoopRuntimeObserver)`). This allows hosts to adopt instrumentation incrementally without changing execution outcomes. This contract is enforced by compatibility tests covering run execution, error propagation, OS-call suspension, REPL completion, and REPL snapshot dump or load round trips. Track A also enforces a representative local overhead envelope for observer-aware entrypoints: - disabled handle mode must remain within `1.20x` of baseline median runtime, - no-op observer mode must remain within `2.15x` of baseline median runtime. These limits apply only to the generic Track A substrate and do not describe Track B policy-layer costs. ## IFC core types and dependency graph The `zamburak-core` crate provides the information-flow control (IFC) substrate used by the governance engine. It is decoupled from interpreter internals and can be used independently for any system that needs label propagation over a directed acyclic graph (DAG) of dependencies. ### Value identity `ValueId` is a stable, opaque identifier for runtime values. It wraps a `u64` and is the primary key for the dependency graph. ### Labels Three label dimensions track security properties of runtime values: - **Integrity** (`IntegrityLabel`): three-level lattice `Untrusted < Trusted < Verified`. Join (meet) returns the minimum—a derived value's integrity cannot exceed its weakest input. - **Confidentiality** (`DataLabels`): additive set of classification tags (`Pii`, `AuthSecret`, `PrivateEmailBody`, `PaymentInstrument`, `InternalPolicyNote`). Join is set union—data flowing into a derived value accumulates all source classifications. - **Authority** (`AuthoritySet`): narrowing set of `AuthorityCapability` values. Join is set intersection—a derived value can only exercise capabilities that all its sources possess. ### Dependency graph `DependencyGraph` stores `ValueNode` entries keyed by `ValueId`. Each node records integrity, confidentiality, and authority labels plus a list of parent `ValueId` edges (direct dependencies). The graph enforces budgets: - `max_values`: maximum number of value nodes, - `max_parents_per_value`: maximum parent edges per node, - `max_closure_steps`: maximum breadth-first search (BFS) steps during transitive summary computation. Budget overflow is fail-closed: the graph marks itself as truncated and `compute_summary` returns `DependencySummary::unknown_top()` (conservative worst-case labels). ### Transitive dependency summary `compute_summary(&graph, &id, &budgets)` performs a bounded breadth-first search (BFS) walk through parent edges, joining all reachable node labels into a `DependencySummary`. The summary captures integrity (greatest lower bound), confidentiality (union), authority (intersection), origin count, and a truncation flag. ### Propagation modes - `Normal`: join operand summaries only (direct data dependencies). - `Strict`: join operand summaries plus the active control-context summary. Strict mode ensures that policy evaluation accounts for implicit information flows through control dependence (e.g. branching on untrusted data before making an effectful call). ### Control context `ExecutionContextSummary` tracks the program-counter integrity and confidentiality labels accumulated from control-flow conditions. In strict mode, `as_summary()` converts this into a `DependencySummary` that is joined into every effectful call's dependency summary. ## Governed execution with `zamburak-monty` The `zamburak-monty` crate provides a governed execution path around the vendored `full-monty` interpreter. A `GovernedRunner` wraps a compiled `MontyRun` with a Zamburak observer and mediates every external-function call through a deterministic `ExternalCallMediator` hook. ### Constructing a `GovernedRunner` ```rust use std::sync::{Arc, Mutex}; use zamburak_monty::{ AllowAllMediator, ExternalCallMediator, GovernedRunner, }; let monty_run = monty::MontyRun::new( "x = 1 + 2\nx".to_owned(), "test.py", vec![], ).expect("parse failed"); let mediator: Arc> = Arc::new(Mutex::new(AllowAllMediator)); let runner = GovernedRunner::new(monty_run, mediator); ``` ### Selecting an `ExternalCallMediator` The mediator trait defines the deterministic hook invoked at each external-call boundary. Implementations receive a `CallContext` and return a `MediationDecision`: - `MediationDecision::Allow` — proceed with the external call, - `MediationDecision::Deny { reason }` — block the call with an explanation, - `MediationDecision::RequireConfirmation { request }` — yield to the host for interactive approval. Built-in mediators: - `AllowAllMediator` — unconditionally allows every call (testing and permissive mode), - `DenyAllMediator` — unconditionally denies every call (deny-path testing), - `PolicyMediator` — evaluates calls against loaded policy rules (production use; see [Policy-backed mediation](#policy-backed-mediation) below). Each `CallContext` now includes an `ifc` payload describing the observer-driven information-flow state at that boundary: - `propagation_mode` — `Normal` or `Strict`, - `aggregate_summary` — dependency summary for the whole call, - `control_context` — the active program-counter summary, - `arg_summaries` — per-positional-argument provenance, - `kwarg_summaries` — per-keyword `(key, value)` provenance in the same order as `CallContext::kwarg_names`, - `kwarg_names` — resolved keyword identifiers used to align policy rules with the corresponding keyword value summary. For tests or embedder-controlled execution, `GovernedRunner::with_ifc_config` can override the default IFC configuration before execution starts. This is useful when strict mode must be forced or value seed labels must be customized. `GovernedIfcConfig::strict_with_boundary_seeds` and `IfcValueSeedConfig::boundary_defaults` provide the shared strict/boundary defaults used by the governed IFC tests. ### `GovernedRunProgress` yield states After execution, the governed runner returns a `GovernedRunProgress` enum: - `Complete(MontyObject)` — execution finished with a final value, - `ExternalCallPending { context, suspended }` — an external call was allowed and execution paused so the host can provide the actual result via the `SuspendedCall`, - `Denied { reason, function_name, call_id }` — an external call was denied by the mediator, - `AwaitConfirmation { context, suspended }` — execution paused pending host confirmation; the `SuspendedCall` can be resumed after approval, - `NameLookup { name, inner }` — execution paused for an unresolved name lookup, - `ResolveFutures(...)` — execution paused waiting for async futures. ### Minimal governed run example ```rust use std::sync::{Arc, Mutex}; use monty::{MontyObject, MontyRun, NoLimitTracker, PrintWriter}; use zamburak_monty::{ AllowAllMediator, ExternalCallMediator, GovernedRunProgress, GovernedRunner, }; let monty_run = MontyRun::new( "x = 1 + 2\nx".to_owned(), "test.py", vec![], ).expect("parse failed"); let mediator: Arc> = Arc::new(Mutex::new(AllowAllMediator)); let runner = GovernedRunner::new(monty_run, mediator); match runner.run_no_limits(vec![]) { Ok(GovernedRunProgress::Complete(value)) => { assert_eq!(value, MontyObject::Int(3)); } other => panic!("unexpected result: {other:?}"), } ``` ### Inspecting IFC at an external-call boundary ```rust use std::sync::{Arc, Mutex}; use monty::{MontyRun, NoLimitTracker, PrintWriter}; use zamburak_monty::{ AllowAllMediator, ExternalCallMediator, GovernedRunProgress, GovernedRunner, }; let monty_run = MontyRun::new("effect(\"x\")".to_owned(), "test.py", vec![]) .expect("parse failed"); let mediator: Arc> = Arc::new(Mutex::new(AllowAllMediator)); let runner = GovernedRunner::new(monty_run, mediator); match runner.run_no_limits(vec![]) { Ok(GovernedRunProgress::ExternalCallPending { context, .. }) => { assert_eq!(context.function_name, "effect"); assert!(!context.ifc.arg_summaries.is_empty()); } other => panic!("unexpected result: {other:?}"), } ``` ## Policy-backed mediation `PolicyMediator` connects the `zamburak-policy` evaluation engine to the governed runner's external-call boundary. Every call that reaches the mediator is evaluated against the loaded policy rules and fails closed when tool or summary information is unavailable. ### Constructing a `PolicyMediator` Load a policy into a `PolicyEngine`, then pass it to `PolicyMediator::new`: ```rust use zamburak_policy::PolicyEngine; use zamburak_monty::PolicyMediator; let policy_yaml = r#" schema_version: 1 policy_name: production_policy default_action: Deny strict_mode: true budgets: max_values: 100000 max_parents_per_value: 64 max_closure_steps: 10000 max_witness_depth: 32 tools: - tool: get_last_email side_effect_class: ExternalRead default_decision: Allow - tool: send_email side_effect_class: ExternalWrite required_authority: [EmailSendCap] arg_rules: - arg: body forbids_confidentiality: [AUTH_SECRET] context_rules: deny_if_pc_integrity_contains: [Untrusted] default_decision: RequireConfirmation "#; let engine = PolicyEngine::from_yaml_str(policy_yaml) .expect("valid policy"); let mediator = PolicyMediator::new(engine); ``` ### Integrating `PolicyMediator` with `GovernedRunner` Pass the mediator to `GovernedRunner::new` as the production `ExternalCallMediator` implementation: ```rust use std::sync::{Arc, Mutex}; use monty::MontyRun; use zamburak_monty::{ ExternalCallMediator, GovernedRunner, PolicyMediator, }; use zamburak_policy::PolicyEngine; let policy_yaml = "..."; // omitted for brevity let engine = PolicyEngine::from_yaml_str(policy_yaml) .expect("valid policy"); let mediator: Arc> = Arc::new(Mutex::new(PolicyMediator::new(engine))); let monty_run = MontyRun::new( "effect(\"x\")".to_owned(), "test.py", vec![], ).expect("parse failed"); let runner = GovernedRunner::new(monty_run, mediator); ``` The governed runner then evaluates every `FunctionCall` and `OsCall` yield through the policy engine before returning a `GovernedRunProgress` state. ### Policy evaluation flow When `PolicyMediator::mediate` is called, the mediator translates the `CallContext` into a policy-layer-owned `ExternalCallPolicyInput` and calls `PolicyEngine::evaluate_external_call`. The evaluation follows a deterministic decision order: 1. **Tool lookup** — the tool name (from `CallContext.function_name`) is matched against `ToolPolicy.tool` in the loaded policy. A missing tool entry fails closed with a deny decision. 2. **Context rules** — `deny_if_pc_integrity_contains` rules are checked against the active control-context integrity. Unrecognized integrity label strings in the policy also fail closed. 3. **Authority requirements** — each `required_authority` capability must be present in the caller's authority set. Missing capabilities are denied. 4. **Positional argument rules** — `requires_integrity` and `forbids_confidentiality` rules are checked against each positional argument's dependency summary. 5. **Keyword argument rules** — the same argument rules are checked against keyword argument value summaries, preventing bypass by passing guarded parameters as keyword arguments. 6. **Default decision** — when no earlier rule fires, the tool's `default_decision` is returned. `RequireDraft` is conservatively mapped to `RequireConfirmation` for Task 1.6.4. ### Policy evaluation types The `zamburak-policy` crate exposes the following public runtime evaluation types at the crate root, for example: ```rust use zamburak_policy::{ ExternalCallKind, ExternalCallPolicyDecision, ExternalCallPolicyInput, KeywordArgumentSummary, PolicyDecisionExplanation, PolicyDecisionReason, }; ``` These public crate-root exports are: - `ExternalCallKind` — external-call classification used for policy diagnostics: `Function`, `Os`, or `Method`. This enum allows the policy layer to distinguish between different call types without depending on Monty runtime internals. - `ExternalCallPolicyInput` — input data for external-call evaluation (tool name, call kind, dependency summaries, caller authority, and control context). - `KeywordArgumentSummary` — per-keyword policy input entry carrying the keyword name plus the key and value dependency summaries, so `arg_rules` match the correct keyword argument. - `ExternalCallPolicyDecision` — decision outcome: `Allow`, `Deny`, or `RequireConfirmation`, each carrying a `PolicyDecisionExplanation`. - `PolicyDecisionExplanation` — metadata attached to a decision with both a machine-parseable `reason` code (`PolicyDecisionReason`) and a human-readable `summary` field. - `PolicyDecisionReason` — machine-parseable reason code for policy decisions, enabling structured audit pipelines and programmatic handling of policy outcomes. Variants include `MissingToolPolicy`, `ContextRuleDeny`, `MissingAuthority`, `InvalidAuthorityInPolicy`, `ArgumentIntegrityRequirement`, `ArgumentConfidentialityForbidden`, `DefaultAllow`, `DefaultDeny`, `DefaultRequireConfirmation`, and `RequireDraftMappedToConfirmation`. ### Fail-closed behaviours Library consumers should be aware of the following fail-closed behaviours: - **Missing tool policy** — any external call to a tool name not present in the loaded policy is denied. - **Unrecognized label strings** — if the policy YAML contains a misspelt integrity or confidentiality label, the evaluation treats it as a deny condition rather than silently skipping the rule. - **Missing caller authority** — if the caller's `AuthoritySet` does not contain a required capability, the call is denied. - **Budget overflow** — when the dependency graph's transitive summary computation exceeds configured budgets, the summary becomes `DependencySummary::unknown_top()`, which produces conservative policy decisions. ## Example: canonical policy (schema v1) ```yaml schema_version: 1 policy_name: personal_assistant_default default_action: Deny strict_mode: true budgets: max_values: 100000 max_parents_per_value: 64 max_closure_steps: 10000 max_witness_depth: 32 tools: [] ``` ## Example: migrated legacy policy (schema v0) ```yaml schema_version: 0 policy_name: personal_assistant_default default_action: Deny strict_mode: true budgets: max_values: 100000 max_parents_per_value: 64 max_closure_steps: 10000 max_witness_depth: 32 tools: - name: send_email side_effect: ExternalWrite authority: [EmailSendCap] args: - name: body forbid_confidentiality: [AUTH_SECRET] context: deny_if_pc_integrity_contains: [Untrusted] default_decision: RequireConfirmation ``` The loader migrates this policy to canonical v1 and exposes migration audit metadata through the audit-bearing API variants. ## Example: rejected policy A policy document using an unsupported schema version such as `schema_version: 2` is rejected with `PolicyLoadError::UnsupportedSchemaVersion`. This fail-closed behaviour is intentional and required by the security contracts. ## Authority token lifecycle Authority tokens are stateful security objects managed through `zamburak-core`. Lifecycle operations are: ### Minting Only host-trusted issuers may mint tokens. Each minted token encodes a subject, capability, scope, and expiry. Minting from untrusted issuers is rejected with `AuthorityLifecycleError::UntrustedMinter`. ```rust use zamburak_core::{ AuthorityToken, AuthorityIssuer, IssuerTrust, MintRequest, AuthorityTokenId, AuthoritySubject, AuthorityCapability, AuthorityScope, ScopeResource, TokenTimestamp, }; let token = AuthorityToken::mint(MintRequest { token_id: AuthorityTokenId::try_from("tok-1")?, issuer: AuthorityIssuer::try_from("policy-host")?, issuer_trust: IssuerTrust::HostTrusted, subject: AuthoritySubject::try_from("assistant")?, capability: AuthorityCapability::try_from("EmailSendCap")?, scope: AuthorityScope::new(vec![ ScopeResource::try_from("send_email")?, ])?, issued_at: TokenTimestamp::new(100), expires_at: TokenTimestamp::new(500), })?; # Ok::<(), zamburak_core::AuthorityLifecycleError>(()) ``` ### Delegation Delegated tokens must narrow both scope (strict subset) and lifetime (strict subset). Parent lineage is retained for audit. Delegation from revoked or expired parents is rejected before scope checks run. The delegation start time must also be on or after the parent issuance time. ```rust use zamburak_core::{ AuthorityToken, AuthorityIssuer, AuthorityTokenId, AuthoritySubject, AuthorityCapability, AuthorityScope, ScopeResource, IssuerTrust, MintRequest, DelegationRequest, RevocationIndex, TokenTimestamp, }; // Mint a parent token with two scope resources. let parent_token = AuthorityToken::mint(MintRequest { token_id: AuthorityTokenId::try_from("tok-parent")?, issuer: AuthorityIssuer::try_from("policy-host")?, issuer_trust: IssuerTrust::HostTrusted, subject: AuthoritySubject::try_from("assistant")?, capability: AuthorityCapability::try_from("EmailSendCap")?, scope: AuthorityScope::new(vec![ ScopeResource::try_from("send_email")?, ScopeResource::try_from("draft_email")?, ])?, issued_at: TokenTimestamp::new(100), expires_at: TokenTimestamp::new(500), })?; // Delegate with strictly narrowed scope and lifetime. let revocation_index = RevocationIndex::default(); let child = AuthorityToken::delegate( &parent_token, DelegationRequest { token_id: AuthorityTokenId::try_from("tok-child")?, delegated_by: AuthorityIssuer::try_from("policy-host")?, subject: AuthoritySubject::try_from("assistant")?, scope: AuthorityScope::new(vec![ ScopeResource::try_from("send_email")?, ])?, delegated_at: TokenTimestamp::new(200), expires_at: TokenTimestamp::new(400), }, &revocation_index, )?; # Ok::<(), zamburak_core::AuthorityLifecycleError>(()) ``` ### Revocation The host manages a `RevocationIndex`. Revoked tokens are stripped at policy-evaluation boundaries. ```rust let mut revocation_index = RevocationIndex::default(); revocation_index.revoke(token.token_id().clone()); ``` ### Policy boundary validation `PolicyEngine::validate_authority_tokens` partitions tokens into effective and invalid sets at a given evaluation time. Revoked, expired, and pre-issuance tokens (evaluation time before `issued_at`) are stripped from the effective set. ```rust let validation = engine.validate_authority_tokens( &tokens, &revocation_index, TokenTimestamp::new(now), ); let effective = validation.effective_tokens(); let invalid = validation.invalid_tokens(); ``` ### Snapshot restore `revalidate_tokens_on_restore` applies the same validation as policy-boundary checks. On restore, any previously valid tokens that have since been revoked or expired are conservatively stripped. ### Error handling All lifecycle operations return `Result<_, AuthorityLifecycleError>`: - `EmptyField` — a required text field was empty, - `InvalidTokenLifetime` — issued_at is not before expires_at, - `UntrustedMinter` — issuer trust level is not `HostTrusted`, - `DelegationScopeNotStrictSubset` — delegated scope is not a proper subset, - `DelegationLifetimeNotStrictSubset` — delegated expiry is not before parent expiry, - `InvalidParentToken` — parent is revoked or expired at delegation time, - `DelegationBeforeParentIssuance` — delegation start is before parent issuance. All timestamps are injected via `TokenTimestamp` to ensure deterministic evaluation without wall-clock dependencies. ## Consumer integration: localized diagnostics Zamburak uses injection-first localization. The host application owns locale negotiation and loader lifecycle; Zamburak never reads process locale environment variables or maintains mutable global state. ### Host-owned loader setup Create a `FluentLanguageLoader` in the host application and pass it through a `FluentLocalizerAdapter` that implements the `Localizer` trait: ```rust use zamburak_core::i18n::{FluentLocalizerAdapter, Localizer}; use i18n_embed::fluent::FluentLanguageLoader; let loader: FluentLanguageLoader = /* host-configured loader */; let localizer: Box = Box::new( FluentLocalizerAdapter::new(loader), ); ``` When no localization backend is configured, use the deterministic fallback: ```rust use zamburak_core::i18n::NoOpLocalizer; let localizer = NoOpLocalizer; ``` ### Loading Zamburak embedded assets Zamburak publishes embedded `.ftl` translation assets via `Localizations`. Load them into the host-owned loader so Zamburak messages are available: ```rust use zamburak_core::i18n::Localizations; loader.load_assets(&Localizations, &requested_locales); ``` Resolution order is: 1. host application catalogue entries, 2. Zamburak bundled entries for the requested locale, 3. Zamburak bundled `en-US` entries, 4. caller-provided fallback text. ### Rendering localized diagnostics Zamburak diagnostics expose a `render_localized` method that accepts an injected `&dyn Localizer` plus caller fallback copy: ```rust let message = diagnostic.render_localized(&localizer, "fallback text"); ``` Formatting failures and missing translations fall through the resolution chain and always produce deterministic output. See `adr-002-localization-and-internationalization-with-fluent.md` for the full design rationale.