Metadata-Version: 2.4
Name: osp-provider-contracts
Version: 0.9.0
Summary: Shared contracts for OSP providers and orchestrator.
Author: OSP Team
License: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.13
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == 'dev'
Requires-Dist: hatch<2,>=1.14; extra == 'dev'
Requires-Dist: pydantic<3,>=2.11.7; extra == 'dev'
Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
Requires-Dist: ruff>=0.15; extra == 'dev'
Requires-Dist: twine<7,>=6; extra == 'dev'
Requires-Dist: ty>=0.0.18; extra == 'dev'
Requires-Dist: uv<0.13,>=0.12.5; extra == 'dev'
Description-Content-Type: text/markdown

# osp-provider-contracts

Shared Python contract package for OSP providers and orchestrator:
typed interfaces, canonical errors, capabilities schema,
and a reusable provider conformance kit.

For maintainer-facing internals and invariants, see `src/README.md`.

## Scope

- Small, explicit provider protocol
- Shared request/result/context types
- One executable-mode vocabulary (`check` or `apply`)
- Canonical error taxonomy with retry metadata
- Capabilities and manifest v2 schema validation
- Optional read-only provider assessment and planner-result validation
- Conformance assertions and a reusable pytest suite for provider CI
- Canonical gate reason enum for approval-required flows

No pytest plugin is included. Providers opt in by subclassing the conformance
suite from a local test module.

## Coordinated contract changes

Changing a strict RPC or update shape requires a coordinated cutover of the
orchestrator and every provider image containing the runtime. Publishing a
contracts or runtime wheel does not update already-built containers. Old queued
messages can survive a restart, so deployment order alone is insufficient.
Build all matching consumers before activation, stop new admissions and scheduled
submissions, and let the old consumers drain published work and provider updates.
Account for broker ready/unacknowledged messages, unpublished outbox rows and
provider work that can still emit updates before switching. Do not purge queues
or discard task evidence to make the cutover appear complete. Waiting approvals
and delayed retries need an explicit resume decision with the new consumers.

The `ref` to `target_ref` cutover also requires migration
`0039_produced_resource_target_ref` before the new orchestrator reads stored
produced resources. Upgrade the portal and CLI before reopening access. Verify
RPC reads and a task update round trip before resuming submissions. An image-only
rollback does not undo the data migration or convert messages written by the new
code; treat rollback as another coordinated transition.

The [deployment release guide](https://github.uio.no/IT-OPS/osp-deployment/blob/main/docs/dev/ARTIFACT_PROMOTION.md)
owns the environment-specific publication and activation procedure. All container
builds run on OSP-BUILDER.

## Execution

Execution intent is a shared request field: `ExecutionMode.CHECK` validates
without applying changes; `ExecutionMode.APPLY` executes the action. The public
API supplies `mode`, and the runtime exposes it as `request.execution_mode`.
Provider-owned `request.payload` preserves the caller's action options.
Separately hydrated provider inputs are available as `request.resolved_inputs`.

```python
from osp_provider_contracts import ExecutionMode

if request.execution_mode is ExecutionMode.APPLY:
    create_vm()
```

Provider entrypoints use `execute(request, context, *, progress=None)`.
`request.name`, `request.target_ref` and `request.comment` describe the task
subject separately from its options. `context.task_ref` is the persisted global
execution identity. Human database identifiers do not cross the provider
boundary.

Return actual outputs as `ProviderResult.produced_resources`, using typed
`ProducedResource(kind=..., target_ref=..., name=...)` entries. Existing-resource
operations retain their input target even when they produce other resources.
A partial failure reports surviving outputs through
`ProviderError.produced_resources`. Checks and simulations report no created
resources.

## Secrets

Payloads, resolved inputs, results, produced resources and progress events
are durable data. The orchestrator stores them with the task, its events, its
outbox row and schedule templates, and anyone who may read the task can see
them. Never put a secret in any of them.

- A provider authenticates to its own systems with credentials from its
  deployment, never with credentials from a task.
- A secret the provider creates, such as an initial password, goes to Vault;
  the result carries at most the Vault path.
- A secret the caller owns is stored in Vault first; the payload carries its
  path as an ordinary provider field, and the provider reads it with its own
  Vault identity.
- Short-lived access for a person, such as a console URL, returns through its
  control RPC and must not be logged.

## Read-only assessment

Providers may implement `AssessmentProvider.assess(request, context)` to expose
the same request-time planner used before execution. The assessment control RPC
uses the shared `ProviderRequest` execution mode, including its caller `payload`
and separately hydrated `resolved_inputs`, and `RequestContext` identity fields.
Its wire `context.task_ref` is the transient `rpc_id` and
`cleared_gates` is always empty. It creates no task, outbox entry, or
execution call. Providers without `assess()` return `supported: false`; planner
results use the shared `PlannerResult` validator and provider failures use the
canonical error shape.

An assessment may include an optional `plan.warnings` list of `{code, message}`
objects. These are provider advisories only: they do not change `decision`,
`gates`, or `violations`. Providers omit the list when no advisory is known.

## Action fields and inputs

An action's `fields[]` is the only place for per-field facts: `required`,
`choices`, `choices_exhaustive`, `default` and `group`. `inputs` holds only
cross-field facts: `soft_limits`, `placement`, `planning_tables`, `os_catalog`
and `aliases`. Providers whose data repos declare compact
`required`/`optional`/`choices`/`defaults`/`tiers` blocks project them with
`osp_provider_contracts.action_fields`.

## Optional soft-limit facts

An action may publish `inputs.soft_limits` keyed by its declared input fields.
Each rule contains exactly one boundary:

- `max_without_force` is a finite numeric threshold. The orchestrator compares
  an explicitly supplied scalar after numeric conversion.
- `allowed_without_force` is the ordinary set for an explicitly supplied
  scalar. Presence is meaningful: an empty list means every explicit value is
  outside that set; an absent list declares no allow-list boundary.

Optional `label` and `unit` values are presentation facts. Soft limits predict
advice only; they do not authorize a request or bypass provider policy. The
orchestrator assessment is the sole generic evaluator. Portal and CLI clients
render its result rather than interpreting these rules independently.

## Optional choice capacity hints

A choice may carry `ipv4_capacity: {free, as_of, likely_full}` from a cached
network snapshot. `free` is an estimated available IPv4 count, `as_of` identifies
the snapshot time, and `likely_full` is the provider's advisory classification.
A VLAN spanning several subnets is not classified as full while a relevant
subnet reports available addresses. Missing data omits the object entirely;
clients keep the choice and add no capacity decoration or warning. Cached counts
are not a reservation and do not replace the provider's execution checks.

The orchestrator assessment may ask for confirmation when an explicitly
selected choice is likely full. CLI `--force` records client consent to that
server advice; it is not a provider input or an authorization bypass. Optional
metadata that a client cannot display falls back to the unannotated choice.

## Optional input planning tables

Providers may publish `inputs.planning_tables` as advisory data for local input
suggestions. Each table has `columns` (provider input names),
`minimum_columns` (numeric columns compared as requested value <= row value),
`rows` (objects keyed by those names), and `exhaustive` (default false). Exact
columns match the supplied value; missing cells or missing declarations remain
unknown. A non-exhaustive table cannot establish incompatibility. Tables are
scoped to the runtime manifest that supplied them: facts from different runtimes
must not be combined into a synthetic execution target.

For example, a flavor table can relate `flavor`, `cpu_cores`, `memory_mib` and
`disk_gb`, marking the three sizes as minimum columns. Providers generate it
from their own planner data, restricted to advertised choices. Existing placement
`points` and `inputs.placement.field_aliases` remain provider-owned facts;
clients map those column names to input names before offering suggestions.

For assessment, explicit placement constraints match points from the one
runtime manifest that supplied them. A point advertises an approval gate only
when `acl.allowed` is false and `acl.approval_kind` is present. A gate on some
matching points means approval is possible; gates on every matching point mean
approval is unavoidable. No constraints, no matches, and missing ACL facts
remain unknown. Clients may show and collect placement choices locally, but the
orchestrator owns this aggregate interpretation.

These declarations never authorize or reject task admission. Clients send only
explicit inputs, apart from unambiguous ownership selection. A provider's
read-only assessment describes the likely plan now; execution plans again.
Displaying or confirming a plan does not copy its normalized defaults into the
request, select a runtime, or reserve capacity.

## Optional task cancellation

A provider may implement `TaskCancellationProvider.task_cancellation(task_ref)`
and `cancel_task(task_ref)`. Both return `TaskCancellation(cancellable, requested,
reason)` based on the actual backend operation. The runtime invokes these hooks
concurrently with execution on its control queue, so implementations must
synchronize access to their operation handles. An absent implementation means
active cancellation is unsupported; no manifest field is required.

`cancel_task` must recheck backend capability and be idempotent. `requested=True`
means the stop was accepted, not that it finished. Once the backend confirms it
has stopped, execution raises `TaskCancelled(ProviderResult(...))`, including any
surviving `produced_resources`. The runtime then emits CANCELLED with that result.
The orchestrator retains the resource claim until the terminal update arrives.
Never stop a Python worker thread or replace cancellation with deletion of its
partially created resources.

## Optional result declarations

An action may declare
`outputs: [{"kind": "image_artifact", "path": "resolved.image_ref"}]`.
The path is relative to the full provider result, so a client reads
`result.resolved.image_ref` directly from that path. A field may declare
`accepts: "image_artifact"`. These declarations let clients match a producer
kind to a consumer field name; they do not require output metadata or enforce
admission.

An action may also declare `resource_usage: ["vm", "disk"]` for provider-owned
cleanup planning. Names must be unique and must match resource kinds declared by
the same provider. The declaration is optional and does not affect other actions.

A successful `ProviderResult` that leaves follow-up work may carry
`pending_retry` with the scalar `inputs_patch` to apply on resubmission and a
user-facing `label` and optional `description`. Providers emit this receipt only
when the work is actually pending. The orchestrator persists and projects it
into task actionability; clients do not interpret provider-specific result data.

## Resource field semantics

Resource-list field names remain provider-owned. A field may declare one
semantic from the closed vocabulary `manifest.FIELD_SEMANTICS`; validation
rejects other names, and `validate_list_response` checks each declared value
against the shape below (`null` means unknown, absent facts are omitted).
Semantics are optional: clients place declared semantics in shared columns and
rows and show every other field generically under its manifest label.

| Semantic | Value shape |
| --- | --- |
| `name` | String resource name (field type `string`) |
| `state.name` | String from the field's declared choices (field type `enum`) |
| `state.detail` | String provider-native status shown beside `state.name` |
| `compute.cpu_cores` | Non-negative integer |
| `compute.memory_mib` | Non-negative integer MiB |
| `compute.flavor` | String provider size preset |
| `storage.disk_gib` | Non-negative integer GiB |
| `placement.category` | String security category (KAT) |
| `placement.location` | String naming where it runs, such as a region or vCenter |
| `guest.os` | String operating system |
| `guest.image` | String image the guest was installed from |
| `network.name` | String attached network |
| `network.vlan` | Non-negative integer VLAN id |
| `network.addresses` | List of `{"address": canonical IP string, "family": 4 or 6, "network"?: string}` |
| `artifact.digest` | String content digest (field type `string`) |
| `artifact.size_bytes` | Non-negative integer byte count (field type `integer`, `unit: bytes`) |

A provider may group `state.name` choices as `ready`, `busy`, `failed`, or
`retired`. When any state choice is grouped, every choice must use one of these
groups. The raw state value remains provider-owned; clients use the group only
for shared presentation and workflow behavior.

## Image inventory fields

`ResourceListItem.fields` may expose the optional `ImageResourceFields` Python
typing convenience:

| Field | Type |
| --- | --- |
| `os_family`, `os_release`, `storage_layout`, `image_variant` | `str` |
| `artifact_sha256`, `artifact_format`, `architecture` | `str` |
| `cfengine`, `qualified` | `bool` |
| `channel`, `status` | `str` |

Every field is optional. An absent key means the provider did not report that
fact. Providers normalize facts they know; clients must not add aliases or
guess from an image name. Manifest semantics above own cross-provider identity,
size, and state behavior when provider field names differ.

Resource-list rows keep provider identity flat: `target_ref`, `display`,
`ownership`, and `fields`. Optional root `created_at` and `updated_at` values
are UTC RFC 3339 timestamps describing the resource. Optional root `downloads`
maps asset names such as `artifact` or `checksum` to a `ResourceDownload` with
an internal root, relative POSIX path, filename, and simple media type. The
resource-list RPC supplies the provider family and resource kind at the
envelope level; this package does not construct a composite resource reference
for clients.

`ResourceListQuery.freshness` is `cached` (default) or `live`. Providers may
answer `cached` reads from long-lived unscoped upstream snapshots; `live`
re-reads them first and refreshes the shared snapshot. Every resource-list
response carries a required `observed_at`: the UTC RFC 3339 time of the oldest
upstream snapshot behind it (the build time when nothing upstream was read), so
clients can show the age of what they render.

## Approval-Required Contract

Providers that need human approval raise `ApprovalRequired(message, gates=[...])`.
Each Gate carries:

- `gate_key`: provider-stable identity for this policy hold
- `approval_kind`: `peer`, `maintainer`, or `admin`
- `required_approvals`: explicit positive quorum
- `reason` and `message`: policy explanation and user-facing consequence
- `violations` and `tags`: structured provider evidence
- optional `decision_payload`: provider-authored decision context. Create
  planners set `source: provider` and `effective_inputs` to the exact
  execution-ready values that produced the Gate; providers must not include
  environment credentials or unrelated configuration.

The provider states the complete decision requirement. The orchestrator stores
and evaluates it without deriving authority from reason strings.

Requests that can never succeed as submitted raise
`RequestRejected(message, violations=[...])`; the task ends REJECTED. Every
input-level failure (rejection, Gate, or plain `ValidationError`) names its
inputs through `violations`, each with a `field`. `ProviderError.state_uncertain`
marks failures after which backend state is unknown. `extra` is diagnostics
only; no consumer branches on it.

## Install

```bash
pip install osp-provider-contracts
```

## Development

```bash
env -u VIRTUAL_ENV uv sync --extra dev
hatch shell
hatch run dev:check
hatch run dev:build
hatch run dev:verify
```

`hatch run dev:schema` regenerates the packaged provider manifest JSON Schema.
`hatch run dev:schema-check` fails when that artifact is stale. The generated
schema describes the wire structure; `validate_manifest_v2` remains the runtime
owner for cross-field references and other semantic invariants.

Forge publication consumers import `canonical_digest` from
`osp_provider_contracts.canonical_digest` and the final image naming helpers
from `osp_provider_contracts.forge_image`. These modules own the persisted
digest encoding and the human-readable Forge image name shared by providers.

## Provider manifest fixtures

The real-provider fixtures in
`tests/fixtures/provider_manifests/{nrec,vmware}.json` are generated from the
provider source and sibling data repositories. From this repository, run:

```bash
bash scripts/regenerate-manifest-fixtures.sh
```

The script calls `nrec_provider.domain.capabilities.build_capabilities` with
`../osp-provider-nrec-data` and
`vmware_provider.domain.capabilities.build_capabilities` with
`../osp-provider-vmware-data/policy.yaml`, using each provider's `.venv`.
Output is formatted with sorted keys and written to the two fixture paths
above. The builders run with explicit source/data paths and a clean environment
so ambient credentials and secret files do not affect the result. The venvs
provide third-party dependencies only: inside `env -i`, `PYTHONPATH` explicitly
selects this contracts checkout, `../osp-provider-runtime/src`, and the
corresponding provider `src` tree. Installed copies of those three packages
are not used. The script requires the sibling provider checkouts, their
virtualenvs, and the matching data checkouts; missing inputs fail the command.
Each provider builder validates its manifest before returning, and the script
replaces the tracked files only after both builders succeed.

After provider contract or data changes, regenerate the fixtures and run
the existing fixture contract check. To verify deterministic output, generate
twice and compare the first result before running the check:

```bash
(
  set -euo pipefail
  tmp_dir="$(mktemp -d)"
  trap 'rm -rf "${tmp_dir}"' EXIT
  bash scripts/regenerate-manifest-fixtures.sh
  cp tests/fixtures/provider_manifests/nrec.json "${tmp_dir}/nrec.json"
  cp tests/fixtures/provider_manifests/vmware.json "${tmp_dir}/vmware.json"
  bash scripts/regenerate-manifest-fixtures.sh
  cmp --silent "${tmp_dir}/nrec.json" tests/fixtures/provider_manifests/nrec.json
  cmp --silent "${tmp_dir}/vmware.json" tests/fixtures/provider_manifests/vmware.json
)
./.venv/bin/pytest -q tests/unit/contracts/test_provider_manifest_fixtures.py
```

That test validates the saved JSON against the shared manifest contract; it
does not establish that a fixture is fresh against a provider, so run both the
regeneration command and the check.

## Release

See `docs/release.md` for the manual/gated publish flow.

Tag and push:

```bash
git tag -s vX.Y.Z -m "osp-provider-contracts vX.Y.Z"
git push origin vX.Y.Z
```

## Plan-bound approvals

`Plan`, `Gate` and `Violation` in `osp_provider_contracts.plan` are pure provider
values. Combine shared policy requirements with a read-only effect plan, then
call `plan_outcome(plan, action, context)` before any external mutation. A gated
plan must supply a normalized, public change summary; private `Plan.data` never
enters the wire. `Plan.to_planner_result(action)` projects the same evidence for
assessment. The action argument ensures both paths produce the same Gate keys.

Gate keys name logical requirements. `gate_fingerprint` hashes all their frozen
evidence (including summary, wording and prepared inputs), excluding only the
key. `uncleared` requires an exact match in `RequestContext.cleared_gates`.
Wire version 7 carries that mapping as a JSON object; there is no array form.
Unmigrated providers may omit summaries and continue checking key membership.
The orchestrator stores fingerprints itself, supersedes stale cleared Gates and
returns predecessor evidence to authorized readers. Deploy the orchestrator
first, then release contracts/runtime, then providers, then CLI/portal. Rebuild
release lockfiles after the new packages have been published.

## Optional authoring helpers

`ResourceAccessContext.allows(contacts)` checks resource contact overlap against
the filtered authorized view, or accepts explicit `access_mode="all"`.

The pure `request_policy` module validates the `request_policy` section against
a manifest and evaluates exact selectors, scalar input checks, netgroup checks,
requirements and warnings into a `Plan`. It performs no YAML loading or I/O.
`delete_approval: {kind: admin, required_approvals: 1}` under `request_policy`
overrides the shared default Maintainer delete requirement. Approval kinds are
`peer`, `maintainer` and `admin`; quorums are explicit positive integers.
SDK providers reject ownership checks until they have a native fact adapter.
Optional action `stages` are unique stable stage keys for progress presentation.
