Exposure Plan v1
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: exactpython:module:symbolIDs only.selection.include_all_ready: defaults tofalse; when explicitlytrue, unions all repositoryREADYIDs with the explicit includes. It never addsCONDITIONALIDs, 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 tofalse. Explicitly selectedCONDITIONALrecords requiretrue.AMBIGUOUSandUNSUPPORTEDnever pass.
REST and MCP compatibility is represented separately. Both v1 adapters consume
local_readiness.can_generate_interface, the authoritative shared Interface
Contract eligibility fact. Exposure never lowers or reinterprets Python types.
A conditional repository assessment can pass only if local 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:
- compatible modes in the bound Repository Readiness report;
- explicitly allowed exposure modes satisfying exposure controls through the
existing readiness
execution_compatibilityadapter.
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
Readiness v1 expresses completeness through graph_complete and
catalog_exit_code, rather than a new complete field. Exposure requires
graph_complete = true and catalog_exit_code = 0, even for empty selections.
It does not require the report's aggregate exit code to be zero: unrelated
conditional/unsupported capabilities need not be selected.
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, 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.
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.