Skip to content

Exposure Plan

The original backend contract remains unchanged. An opt-in strict OCI subprocess-deny profile adds a separate worker protocol; v1 static adapters retain their original guarantees.

Available in published stable 0.3.0. This feature is not part of the published 0.2.1 package.

An exposure plan records an explicit publication decision over static evidence; it is not an authorization grant or runtime safety proof.

Catalog → Graph → Repository Readiness
                          ↓
                   Exposure Policy
                          ↓
                    Exposure Plan
                          ↓
           Direct repository REST/MCP bundle

Independent contracts and pure boundary

apizr.exposure-policy/v1 and apizr.exposure-plan/v1 are new independent contracts. Breaking selection/planning semantics require new contract versions. Existing Catalog, Graph, Repository Readiness, IR, Inspection, interface and execution contracts retain their existing semantics and bytes.

from apizr.exposure import ExposurePolicy, plan_exposure, plan_bytes, plan_digest

policy = ExposurePolicy.model_validate(
    {
        "selection": {"include": ["python:shop:calculate"]},
        "interfaces": ["rest", "mcp"],
        "execution": {"allowed": ["local-process", "oci-container"]},
    }
)
plan = plan_exposure(catalog, graph, readiness, policy=policy)
canonical = plan_bytes(plan)
identity = plan_digest(plan)

The API takes already validated Catalog, Graph, RepositoryReadinessReport and ExposurePolicy models. It revalidates nested models, including unchecked copies, and verifies the readiness report against its original evidence using the existing pure validate_report boundary. There is no scan, source parsing, filesystem access, project import/execution, network, Git, installation, build backend, Docker invocation, environment probe or interface generator call.

Selection and eligibility

The policy requires nonempty interfaces and execution.allowed sets. There are no implicit interface targets or backend choices. selection defaults to empty: an empty plan is valid and the human report warns that nothing is planned.

  • selection.include: exact python:module:symbol IDs only.
  • selection.include_all_ready: defaults to false; when explicitly true, unions all repository READY IDs with the explicit includes. It never adds CONDITIONAL IDs, even when conditional exposure is enabled.
  • selection.exclude: removes IDs after all inclusions. Exclusion always wins.
  • Unknown IDs in either include or exclude produce blocking diagnostics, even when the same unknown ID occurs in both sets. Bare names and malformed IDs are policy errors. No fuzzy matching or module selectors exist in v1.
  • eligibility.allow_conditional: defaults to false. Explicitly selected CONDITIONAL records require true. AMBIGUOUS and UNSUPPORTED never pass.

REST and MCP compatibility is represented separately. Both v1 adapters consume the shared repository-aware eligibility decision over the validated assessment. It retains local interface blockers except exactly proven local import reasons; see Repository Readiness. Exposure never lowers or reinterprets Python types. A conditional repository assessment can pass only if repository interface eligibility still holds (for example, unknown required effect evidence). The opt-in cannot bypass an unresolved local callable/type contract. All requested targets must pass; there is no partial interface selection.

Execution composition

execution.allowed is an order-independent set of direct, local-process, and/or oci-container. execution.require reuses the Repository Readiness control vocabulary, including time/input/output bounds, environment, working directory, network/filesystem restrictions and OCI resource limits.

For every selected capability, the plan records the sorted intersection of:

  1. compatible modes in the bound Repository Readiness report;
  2. explicitly allowed exposure modes satisfying exposure controls through the existing readiness execution_compatibility adapter.

There is no duplicated backend capability table or ranking. Readiness restrictions cannot be widened. The default readiness policy considers local-process and OCI; planning direct mode also requires a readiness policy that includes direct. An empty compatible intersection blocks the whole requested plan. Requiring subprocess_deny always blocks affected selections; PID limits do not satisfy that requirement (#49). An empty selection has no affected capability and remains valid.

“Contract-compatible” does not mean runtime-available. The plan has no worker image, environment values, secrets, user identities, permissions or runtime configuration. Runtime binding belongs to the bundle/runtime stage.

Complete evidence and fail-closed behavior

Published 0.4.4 requires graph_complete = true and catalog_exit_code = 0. In Apizr 0.4.5, exposure.scope.required_evidence owns the selected execution proof. It consumes validated, bound Catalog, Graph and Repository Readiness without source parsing, host lookup or execution.

Roots are the exact IDs left after include/include-all-ready/exclude resolution. A bounded, iterative closure follows calls_capability, references_capability, imports_capability and imports_module. References remain conservative because the callable can be invoked indirectly. Imports are distinct from calls and require module initialization. Each capability requires its owning module and every present package ancestor. Missing package parents use the loader's existing synthetic namespace contract. contains does not select every function in a module. Private dependencies need stable bindings and execution evidence, not public JSON input/output contracts. They never become public roots. No effects are propagated.

Graph diagnostics join canonical source identities and retained declaration spans: an exact unique span owns a function-body diagnostic; other locations belong to module initialization. Known-path Catalog parse/encoding/analysis/size/read errors can be unrelated. Input, aggregate-limit, collision, unknown-path, invalid module identity and unexplained incompleteness are global. Contradictory import trees also block the entire copied source manifest. Digest/manifest inconsistencies fail canonical validation. Required diagnostics block even with conditional opt-in. Unrelated diagnostics stay in the full audit and all full-repository digests.

Shared #252 initialization facts are required for modules and package ancestors. A nonempty module without callable Readiness evidence remains unproven. Exact retained import locations must match Graph edges; external imports never discharge local APIZR-READY-015. Valid module-handle/function cycles terminate. A cyclic module-scope callable import blocks when the target definition has not yet been reached in retained lexical initialization order. No runtime behavior is guessed.

An empty selection tolerates unrelated path diagnostics, but global corruption still blocks it. Multiple roots remain atomic. Breadth-first paths use capability neighbors before module neighbors, then canonical IDs, yielding deterministic shortest dependency paths with relative source location and typed evidence code.

ExposureRefused.diagnostics contains deterministic typed reasons for incomplete evidence, unknown IDs, ineligible state, incompatible interface or execution. The API returns no plan if any blocking diagnostic exists. Invalid models or mismatched evidence raise ValueError. No partial planning policy exists in v1. validate_plan recomputes the expected plan from bound evidence/policy and checks all snapshots; structural schema validation alone is not evidence verification.

Snapshot and dependency boundary

Each record retains its capability ID, module, repository-relative POSIX source path, selected-scope readiness state/reasons, requested and compatible interfaces, compatible execution modes, effect snapshot and relationship evidence. Graph diagnostics, import declarations, module import context and direct relationships retain uncertainty without repair or effect propagation. Full input documents and callee readiness/effect documents are not embedded. In 0.4.5, the plan's readiness projection may discharge interface/relationship reasons proven by the required closure; the separately bound full Repository Readiness report is unchanged. Effect, execution-policy and identity reasons retain their existing precedence. The successful v1 schema and full-repository identity semantics are unchanged.

calls_capability, references_capability and imports_capability are evidence only: selecting A that calls B does not expose B. observed_support_modules contains the selected source modules and known direct target modules. It is not recursive or a complete runtime dependency closure. external_modules contains lexical external module names from the selected evidence; no standard-library, third-party, installed or vulnerability classification is attempted.

Queries: plan.capability_ids(), plan.for_interface("mcp"), and plan.compatible_with("oci-container") return deterministic tuples.

Identity and serialization

Validation checks graph.catalog_digest == catalog_digest(catalog), graph.repository_digest == catalog.repository_digest, and the readiness catalog/graph/repository digests and conclusions. The plan binds:

  • repository_digest;
  • catalog_digest;
  • graph_digest;
  • repository_readiness_digest (which also binds readiness policy);
  • exposure_policy_digest.

policy_bytes/policy_digest and plan_bytes/plan_digest use sorted JSON keys, compact separators, UTF-8, finite values and exactly one trailing LF. Set-like selectors, exclusions, interfaces, modes and controls are sorted and deduplicated. There is no timestamp, checkout path, Git identity or self-digest. Relocation preserves bytes. Unselected source changes in the scanned universe change bound evidence and thus the plan digest; selected capability IDs remain stable.

Schemas are generated from typed models: policy and plan. The golden under tests/fixtures/exposure/v1 selects two capabilities, retains a call to an unexposed helper and lexical typing evidence, and leaves conditional, unsupported and unrelated declarations unexposed. Tests compare committed bytes and schemas on every supported Python version, and exercise selection order, relocation, strictness, digest mutations, tampering and audit-hook non-execution.

CLI and scope

See the exposure guide. The CLI uses one analyze_repository discovery for Catalog and Graph, assesses readiness once, then calls the pure planner. Verification recomputes readiness conclusions from those artifacts without rediscovery or source parsing.

#87 covers planning. #88 adds the direct repository bundle stage through expose build. Governed repository execution is implemented by #90. There is no expose serve, RBAC, approval service or dependency-stack split. Existing issues #28, #49 and #63 remain open.