Metadata-Version: 2.4
Name: osp-provider-contracts
Version: 0.17.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.14,>=0.13.4; 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 v3 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`.

## Resource actions and authority

Manifest v3 nests action specifications in each resource kind's `actions` map.
Repeated verbs on different kinds have independent fields and metadata; there
is no provider-global action map. `ManifestV3` and `validate_manifest_v3` own
the shape and semantic checks, and the packaged schema is
`schemas/provider-manifest-v3.schema.json`.

Kinds declare optional `min_role` (default `reader`), actions declare optional
`min_role` (default `writer`). The effective floor is the higher of the two,
using cumulative Reader, Writer, Maintainer and Admin authority in the trusted
provider family. Explicit Reader actions are supported. These binding floors
never grant ownership, sudo or permission outside the family. Runtime and SDK
enforce them before provider work; the orchestrator also applies them to
admission and discovery. `RequestContext.role` carries trusted family authority
on wire 11; it is independent of audited `sudo`. In-process contexts default to
Reader and callers must explicitly supply any greater authority. Request policy
selectors may match `role` by exact value or a list of exact values.

Field labels, ranges, patterns and choices are advisory presentation facts.
Clients may validate scalar syntax and types, while physical constraints remain
provider model/planner checks and organisational policy remains provider-owned.

The accepted [ownership-facts contract](docs/adrs/ADR-ownership-facts.md) defines
per-resource optional `netgroups` and `contacts` and links to orchestrator policy.
Every resource kind must return ownership. `ResourceOwnership` stores optional
`frozenset[str]` values, requires at least one reported field, and serializes each
reported set as a sorted array. Empty arrays are valid. Netgroup names are
trimmed and case-sensitive; contacts are trimmed and casefolded. Duplicate
members collapse. `ResourceOwnership.from_mapping` and `.as_dict()` own parsing
and serialization.

Every birth (`target: none`) is contact-owned. Wire 11 may carry
`identity.primary_owner_netgroup`, the orchestrator-selected contact owner used
only for VM folder placement, never authority. It carries `identity.netgroups` as the caller's
current membership union within the token ceiling, and `identity.contacts` as
requested contact facts, never an ownership grant. The envelope's `role` is the
caller's union provider Role; `sudo` retains its existing meaning.
`owning_netgroups` maps each provider-native mutation target reference to the
orchestrator's current resolved owners; `ownership_fingerprint` maps the same
references to the digests of the provider facts used. Both maps are required,
even when empty for CREATE or untargeted work. Secondary mutation targets need
their own entries, including both sides of identity swap; read-only references
such as clone sources do not acquire mutation grants. References are local to
the envelope's provider instance; runtime lookup must also check resource kind.
Assessments carry the same facts in `context`. In Python, `RequestContext` and
`TaskRequestEnvelope` expose these sets as frozensets and maps as read-only
mappings. Runtime enforcement must check every mutation target, the Role floor,
current ownership fingerprint and caller-owner membership intersection (Admin
and sudo retain the ADR's overrides). Structural parsing is not authorization.

Import `ownership_fingerprint` from the package root. Its exact encoding is
SHA-256 over UTF-8 JSON containing **both** `contacts` and `netgroups` keys,
lexically sorted keys, compact `,`/`:` separators, unescaped Unicode, no trailing
newline, and sorted normalized set arrays. Omitted sets become empty arrays.
The result is 64 lowercase hexadecimal characters. Resolved siteadmin owners
and caller memberships are excluded. For empty facts the hashed bytes are
`{"contacts":[],"netgroups":[]}`. Changed facts require fresh authorization,
never an ownership approval Gate.

An action may declare `approval_kind: peer|maintainer|admin` directly in its
`ActionSpec`. This declares which authority can clear that action's policy
Gates; it does not itself require a Gate or supply quorum. Each emitted Gate
still carries its complete approval evidence and explicit positive quorum.
Providers must keep emitted Gate kinds consistent with their action declaration;
when omitted, the individual Gate remains the declaration. There is no second
team-approval registry.


## 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 availability hints

Choices and resource-local actions may carry `availability` with required
`state` (`ok`, `low`, `out`) and RFC 3339 `as_of`, optional non-negative
`remaining`, and an optional short human `note` (1–160 characters). This is
advisory snapshot data, never a reservation or admission rule. Providers enforce
supply in planning. Omitted availability means unknown.

The orchestrator assessment may ask for confirmation when an explicitly
selected choice reports low or exhausted supply. 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_v3` 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 10 carries that mapping as a JSON object; there is no array form.
The orchestrator stores fingerprints, supersedes stale cleared Gates and returns
predecessor evidence to authorized readers. Publish reviewed shared packages
first, refresh consumer release lockfiles, then coordinate the wire drain and
matching provider, data, orchestrator, CLI and portal upgrade.

## Optional authoring helpers

`ResourceAccessContext(subject, role)` authenticates an inventory reader.
Contact search is an ordinary `ResourceListQuery.filters` entry declared in the
manifest. The orchestrator applies resolved-owner “mine” filtering; neither
contact overlap nor inventory visibility grants write authority.

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.
Resource ownership enforcement belongs to the runtime, using trusted grants and
current provider facts. `member_of: [teams]` requires ANY current trusted caller
membership in the list, for every action. There is no selected birth team or
acting-team fallback.

The bundled `schemas/request-policy-v1.schema.json` describes this closed rule
vocabulary. Regenerate it together with the manifest schema using
`python scripts/export-manifest-schema.py`; manifest-dependent target and input
checks remain in `validate_request_policy`.
Optional action `stages` are unique stable stage keys for progress presentation.

## Trusted sudo context

Wire 11 requires `sudo: true|false` beside task envelope `identity` and
`cleared_gates`, and inside assessment `context`. Builders read the single
boolean from `RequestContext.sudo`; parsers reject a missing or non-boolean
value. Direct Python contexts default to ordinary (`False`) and validate the
same boolean contract. `sudo` is reserved as an action field name.

The orchestrator authorizes the existing scoped Admin sudo session before
setting this fact. Providers receive no password or JWT and must never derive
it from action inputs. Runtime/SDK policy hooks override policy judgements;
missing resources, malformed execution inputs and backend failures remain real
errors. Pure Plan and request-policy evaluation keep those facts explicit.
Version 0.14.0 requires a coordinated wire-9 drain and consumer upgrade.
