osp-provider-sdk
A thin, optional authoring layer for OSP providers. Declare actions and lists, plan before effects, and report progress through the existing Provider protocol. The package layers are contracts → runtime → SDK → providers. The orchestrator never installs the SDK; existing providers can keep their own Provider implementations.
pip install osp-provider-sdk
One-file provider
This is examples/buckets.py. Replace backend with your
team's adapter. The included backend is in-memory and
makes no network calls; real backends must persist the task's client token for
recovery across processes. Do not adopt a bucket merely because its name matches.
"""S3-style bucket provider on the OSP SDK, with a replaceable backend adapter."""
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
from osp_provider_contracts.canonical_digest import canonical_digest
from pydantic import BaseModel, ConfigDict, Field
from osp_provider_sdk import App, Done, Page, Plan, Resource, ResourceOwnership, Violation
from . import backend
@asynccontextmanager
async def lifespan():
async with backend.Client.from_env() as client:
yield client
app = App("s3", policy=Path(__file__).with_name("policy.yaml"), lifespan=lifespan)
class Create(BaseModel):
model_config = ConfigDict(frozen=True)
name: str = Field(title="Name")
quota_gib: int = Field(10, ge=1, le=1000, title="Quota (GiB)")
region: Literal["osl", "bgo"] = "osl"
class Resize(BaseModel):
model_config = ConfigDict(frozen=True)
quota_gib: int = Field(ge=1, le=1000, title="Quota (GiB)")
class Row(BaseModel):
quota_gib: int = Field(title="Quota (GiB)")
region: str
def _row(b) -> Resource:
return Resource(
target_ref=b.id,
display=b.name,
ownership=ResourceOwnership(contacts=b.contacts),
fields=Row(quota_gib=b.quota_gib, region=b.region).model_dump(),
created_at=b.created,
)
@app.resource("bucket", fields=Row, filters=["region"])
async def buckets(q, access) -> Page:
page = await app.state.list(region=q.filters.get("region"), cursor=q.cursor, limit=q.limit)
return Page(
tuple(_row(b) for b in page.items if access.allows(b.contacts)),
page.cursor,
observed_at=page.observed_at,
)
@buckets.get
async def bucket(ref, access) -> Resource | None:
b = await app.state.get(ref)
return _row(b) if b and access.allows(b.contacts) else None
@app.action("create", inputs=Create, stages=["s3.create", "s3.tag"])
async def create(req, ctx) -> Done:
with ctx.step(
"s3.create",
f"Creating bucket {req.name}: {req.inputs.quota_gib} GiB in {req.inputs.region}",
):
b = await app.state.create(
req.name, req.inputs.region, req.inputs.quota_gib, client_token=ctx.task_ref
)
ctx.created(b.id, req.name)
with ctx.step("s3.tag", f"Recording owners {', '.join(ctx.contacts)} on {req.name}"):
await app.state.tag(b.id, contacts=ctx.contacts)
return Done(f"Created bucket {req.name} ({b.id})")
@create.plan
async def plan_create(req, ctx) -> Plan:
if not req.name:
return Plan(
"Missing bucket name",
violations=(Violation.missing_required("name", "A bucket name is required"),),
)
taken = await app.state.get_by_name(req.name)
if taken and taken.client_token != ctx.task_ref:
return Plan(
f"bucket {req.name}: name taken",
violations=(
Violation.invalid_value("name", f"Bucket {req.name} already exists ({taken.id})."),
),
)
return Plan(
f'create bucket "{req.name}" in {req.inputs.region}, quota {req.inputs.quota_gib} GiB, '
f"owners {', '.join(sorted(ctx.contacts))}"
)
@app.action("resize", target="resource", inputs=Resize)
async def resize(req, ctx) -> Done:
with ctx.step(
"s3.quota", f"Setting {req.target_ref} quota {ctx.plan.data} → {req.inputs.quota_gib} GiB"
):
await app.state.set_quota(req.target_ref, req.inputs.quota_gib)
return Done(f"Bucket {req.target_ref} quota is {req.inputs.quota_gib} GiB")
@resize.plan
async def plan_resize(req, ctx) -> Plan:
b = await app.state.get(req.target_ref)
if b is None:
return Plan(
"Bucket not found",
violations=(Violation.invalid_value("target_ref", "Bucket does not exist"),),
)
if b.quota_gib == req.inputs.quota_gib:
return Plan.nothing_to_do(f'bucket {b.id} "{b.name}": quota already {b.quota_gib} GiB')
return Plan(
f'bucket {b.id} "{b.name}": quota {b.quota_gib} → {req.inputs.quota_gib} GiB',
data=b.quota_gib,
)
@app.action(
"destroy",
target="resource",
destructive=True,
cancellable=True,
stages=["s3.empty", "s3.delete"],
)
async def destroy(req, ctx) -> Done:
with ctx.step("s3.empty", f"Emptying bucket {req.target_ref}"):
async for done, total in app.state.empty(req.target_ref, objects=ctx.plan.data):
ctx.progress(done, total, f"Emptied {done} of {total} prefixes in {req.target_ref}")
ctx.checkpoint() # The previous prefix delete has settled.
with ctx.step("s3.delete", f"Deleting bucket {req.target_ref}"):
await app.state.delete(req.target_ref)
return Done(f"Deleted bucket {req.target_ref}")
@destroy.plan
async def plan_destroy(req, ctx) -> Plan:
b = await app.state.get(req.target_ref)
if b is None:
return Plan.nothing_to_do(f"bucket {req.target_ref}: already absent")
objects = frozenset(b.objects)
scope = canonical_digest(sorted(objects))
return Plan(
f'delete bucket {b.id} "{b.name}" and {len(objects)} object versions (scope {scope})',
data=objects,
)
if __name__ == "__main__":
app.run()
For optional live external policy, see policy reload. Bundled policy remains the default simple mode.
The matching policy.yaml is loaded with yaml.safe_load
and validated against the generated manifest before entering lifespan:
request_policy:
version: 1
rules:
- id: team_access
match: {kind: bucket, actions: [create, resize, destroy], instance: dev}
check: {netgroup_in: [team-storage]}
level: gate
approval: {kind: maintainer, required_approvals: 1}
message: This team needs a storage operator's approval.
- id: quota_warning
match: {kind: bucket, actions: [create, resize]}
check: {input: quota_gib, max: 80}
level: warn
message: This is a large bucket quota.
- id: quota_approval
match: {kind: bucket, actions: [create, resize]}
check: {input: quota_gib, max: 100}
level: gate
approval: {kind: maintainer, required_approvals: 1}
message: Quotas above 100 GiB need storage operator approval.
The default destructive-action Gate requires one Maintainer approval for APPLY
(a CHECK deletes nothing; assessments still show the Gate). To change
it provider-wide, add delete_approval: {kind: admin, required_approvals: 1}
under request_policy. A destructive action that owners may
run after typing its phrase, such as deleting their own snapshot, declares
delete_approval=False; its planner then owns any Gate. peer is also supported. Rules belong to contracts;
backend-specific decisions stay in the custom read-only planner.
Invocation and lifetime
app.run() reads RABBITMQ_URL, PROVIDER_INSTANCE, PROVIDER_INSTANCE_ID
and CONCURRENCY (default 1); keyword arguments override any runtime option. Queue names come from ProviderIdentity.
For example, PROVIDER_INSTANCE=dev python -m examples.buckets starts the
example's RabbitMQ runner when RABBITMQ_URL is provided.
A provider's __main__.py is just app.run(). The SDK command covers the rest
without an orchestrator; it imports the app and enters lifespan only for an
online doctor and for plan:
python -m osp_provider_sdk manifest examples.buckets:app [--check [PATH]]
python -m osp_provider_sdk doctor examples.buckets:app [--offline]
python -m osp_provider_sdk plan examples.buckets:app create '{"quota_gib": 150}' --name logs
plan previews ordinary Writer policy for local provider authors. It performs
no effects and grants no live identity or elevation; orchestrator admission
still uses the authenticated caller's current Role.
doctor --offline checks declarations and policy. Online it also connects to
RABBITMQ_URL and runs the provider's own checks, which may use app.state
and return problem strings:
@app.doctor
def data_dir() -> list[str]:
return [] if DATA_DIR.is_dir() else [f"{DATA_DIR} is missing"]
It prints ok (exit 0) or one problem per line (exit 1). plan prints the
planner result an assessment would return, against the configured backend.
Offline invocation uses with app.running():. This validates declarations,
enters lifespan, and owns one event loop if any callback or lifespan is async.
All async backend clients are created and closed on that loop. Runtime workers
block for their handler's result, so runtime concurrency still bounds work.
Async handlers must move blocking backend calls into asyncio.to_thread.
Sync handlers returning awaitables fail with an instruction to use async def.
from osp_provider_contracts import ProviderRequest, RequestContext
from examples.buckets import app
with app.running():
result = app.execute(
ProviderRequest("create", name="logs"),
RequestContext("local-task", netgroup="team-storage", contacts=("storage@example.org",)),
)
print(result.produced_resources)
Planning and approval
Assessment, CHECK and APPLY use one planner: model validation → shared rules →
custom plan → combination. Assessment always describes APPLY intent and has no
cleared approvals. It returns structured rejection, incomplete or approval
facts without calling apply. CHECK and no-op plans stop before apply and return
{"plan": <planner result>}; an App subclass that must keep an existing CHECK
shape overrides plan_result(req, plan) and copies only public facts.
Gated actions must declare .plan and author a concrete summary. The Gate key
names a requirement; its fingerprint covers every frozen Gate field except the
key, including summary and effective inputs. Use native IDs, before/after values
and material destructive scope. Bind destructive work to a content revision or
immutable object-version scope, including its fingerprint in public evidence.
Apply must enforce that scope with conditional writes and refuse a nonempty
bucket delete. Leave out secrets, timestamps and unrelated volatile counts.
If the planned work changes, approval must be obtained again. Private Plan.data
never enters the public assessment; the checked frozen input model and the same
Plan reach apply.
Custom plans must be read-only. Apply cannot request approval: a late
ApprovalRequired becomes late_gate_contract_violation with uncertain state.
Keep live-state-dependent authorization wholly in the custom plan when shared
rules over model defaults would check different values from those executed.
Progress, cancellation and results
Stages use <system>.<verb> keys. Declared stages determine 1-based position;
repeat and skip them freely. Progress counts are monotonic within a stage. The
first and final counts are sent, intermediate updates are limited to one every
two seconds, and the latest suppressed count is flushed on stage exit. Evidence
is a JSON-safe mapping under 2 KiB without reserved progress keys. Offline calls
log progress. Delivery failures do not fail backend work.
cancellable=True is cooperative. cancel_task accepts a request; only
ctx.checkpoint() stops work. Call it only after prior backend operations have
settled and the current state is safe to leave. Stage entry and progress never
stop work. A request after the last checkpoint can finish normally.
Use Done for ordinary success or return a full ProviderResult for advanced
outcomes (pending_retry, provenance, mixed resources). Advanced results retain
all their fields. Record each survivor with ctx.created, specifying kind= for
mixed kinds, and call ctx.removed only after confirmed cleanup. Survivors are
merged into success, typed errors, unexpected failures and cancellation results.
There is no rollback or durable SDK journal. A retried task must rediscover its
resources through provider-owned backend identity.
Manifest
Fields come from frozen Pydantic models: str, int, float, bool, scalar
Literal (an exhaustive enum), list[...] and dict[...] (list/object),
and T | None = None for an optional input without a default. The model states
name (Field(alias=...) for dotted wire names), type, required, default, integer
ge/le, string pattern, title (label) and description (help).
json_schema_extra adds the other contract field keys: unit, group,
semantic, accepts and choices. Choices on a Literal relabel or annotate
exactly its values (meta, hidden, aliases, …). Choices on any other type
are suggestions: values must validate against the field, and
type: "enum" or choices_exhaustive: False may be stated, never True.
Annotate ResourceRef for references. Validators, serializers, nested models
and other constraints fail startup.
An input-model field whose wire name is name declares the action's operational
name. Clients send it as the top-level Task name, and direct callers use
ProviderRequest(name=...); payload.name is rejected for that declaration.
The field must be a string. Its required/default/pattern/label/help metadata
comes from the model like every other field. The SDK validates the envelope
value through that field and exposes the same defaulted value as req.name
and in req.inputs. An alias such as Field(alias="name") uses the same
binding. hostname is an ordinary separate input. Declare names for custom
verbs as well as create; an action without a name field does not ask clients
for one. The validated model also supplies policy facts and plan evidence
(normalized_request / effective_inputs). That evidence describes prepared
work; resubmit the original inputs and top-level name rather than copying the
normalized mapping into Task inputs.
Actions take description, target, destructive, stages, risk_hint and
confirmation_phrase. Destructive actions always advertise a phrase, by default
the upper-cased action name (snapshot_delete → SNAPSHOT DELETE); clients
offer destructive actions only with one. Resources take list fields and
filters; list fields carry no required, so views stay meaningful. Exact get
is optional; without one the SDK walks list cursors until the native reference
is found. Undeclared row fields are dropped while downloads, timestamps and other
native metadata are retained. Page is the contracts ResourceListResponse:
pass its required observed_at freshness timestamp.
The manifest version is the installed osp-provider-<family> distribution's
version (family smoke_stack → osp-provider-smoke-stack) unless
App(version=...) sets it; code that is not installed advertises 0.0.0.
Multiple resource kinds
Declare kind= on each action when an App offers multiple kinds. Handlers are
selected by (resource_kind, action), so both sandwich/create and
drink/create can have their own planner and effects. Manifest v3 keeps each action specification inside its resource kind, so
repeated names can declare independent fields, targets, stages and policy.
Kinds declare min_role (default reader); actions declare min_role
(default writer). The orchestrator enforces both cumulative Role floors
without granting ownership or policy overrides. An explicit reader action
is supported. The SDK also enforces these floors for direct execution and
assessment and checks kind floors for list/exact-reference reads. Trusted
ctx.role is available to both planners and effects, separate from policy
overrides; list callbacks receive access.role. Use explicit resource
kinds in Provider requests and plan --kind drink for an ambiguous action.
The local sandwich shop demonstrates create,
update and approved delete for sandwiches and drinks, ownership-filtered
inventory, three seconds of progress with safe cancellation, and persistent
SQLite orders. It never contacts an external service. The two kinds share
quantity and preparation instructions; their products and prices differ.
The sandwich kind also offers order: one task prepares both products with
the same quantity and name, returning two independent produced-resource links.
Each can then be updated or deleted through its own kind's actions. Both rows
are committed together, and task-derived references make redelivery idempotent.
quote on either kind is a Reader action that returns the price without
changing orders. The bundled shop policy
owns the ordinary quantity limit once: its maximum of five supplies the form
hint and requires one Maintainer approval above it for Writers and Maintainers.
An ordinary Admin is exempt from that quantity rule; ownership still applies,
and the physical model maximum of twenty applies to every Role. Delete keeps
its independent destructive-action approval.
SANDWICH_SHOP_DB selects the state file (default .local/sandwich-shop.sqlite3).
The canonical local stack registers this bundled example through
osp-local.json, outside shared release inventory; see
the stack instructions.
Everything else
extra= on App, @app.action and @app.resource (merged into its list)
adds any remaining key of the contracts ManifestV3, ActionSpec or ListSpec.
Unknown keys and keys owned by an SDK argument fail at startup, and the whole
document is then checked with validate_manifest_v3.
app = App("nrec", description="NREC provider", extra={"features": ["console.novnc"]})
@app.resource("vm", fields=VmRow, filters=["name"], extra={"page_size_max": 1000})
def vms(q, access) -> Page: ...
@app.action(
"resize",
target="resource",
inputs=Resize,
risk_hint="Resizing interrupts the VM.",
extra={"inputs": {"soft_limits": {"flavor": {"allowed_without_force": ["m1.small"]}}}},
)
def resize(req, ctx) -> Done: ...
app.run(**runtime) overrides any RuntimeConfig field, including the
environment and queue defaults: concurrency=5 replaces CONCURRENCY, and
prefetch_count, control_concurrency or max_attempts work the same way.
Protocol methods the SDK does not declare stay plain methods on an App
subclass: get_console, get_history and get_build_log are found by the
runtime, and self.call(fn, *args) runs sync or async backend code on the
lifespan's loop with self.state.
Hot-loaded data (reloaded choices, points, actions an operator switched off)
overrides effective_manifest(), starting from super().effective_manifest().
The SDK validates its result once per call and uses that one snapshot for
capabilities, action membership and list field/filter checks. It can hide
declared actions and lists or add list fields and filters, never offer an
undeclared handler; input models still validate the inputs themselves.
Ownership policy rules, native backend cancellation and structured inputs are
not part of the SDK.
Development and snapshots
Install the released contracts/runtime dependencies and SDK development tools:
uv venv --python 3.13
uv pip install -e '.[dev]'
.venv/bin/ruff check .
.venv/bin/ty check src tests
.venv/bin/pytest
.venv/bin/python -m osp_provider_sdk manifest examples.buckets:app --check examples/manifest.json
.venv/bin/python -m build
.venv/bin/twine check dist/*
Generate snapshots with python -m osp_provider_sdk manifest module:app.
--check [path] compares exact canonical JSON, defaulting to manifest.json.
Snapshot generation checks startup but never enters lifespan or connects a backend.
CI runs the SDK and shared provider conformance suite with lowest-direct and highest dependency resolution, checks the manifest, builds both artifacts, and imports the installed wheel outside the source directory. Tag CI attaches artifacts to a GitHub release; it does not publish to PyPI.
Releases
Publish reviewed contracts 0.12 first, then refresh and review the runtime lock and publish runtime 0.12. Refresh and review the SDK lock before publishing SDK 0.5. Consumer locks must use published registry packages, with no local source overrides. Verify candidate wheels together in a clean environment before publication. Coordinate wire 9 and manifest v3 activation separately from publishing these shared libraries.
The private repository's Actions policy must allow its configured actions.
Set up the new distribution name and PyPI credentials or trusted publishing.
After review, quality checks and signed release/tag verification, a maintainer
can run bash scripts/publish.sh (or -r testpypi). --tag additionally creates,
verifies and pushes a signed tag after upload; normal development never runs it.
No release or live environment is needed for the local tests.
Scoped sudo policy execution
PlanCtx.policy and Ctx.policy share the runtime's request-scoped Policy
owner. It consumes only the orchestrator-authorized RequestContext.sudo from
wire 8. Use ctx.policy.allows("stable-rule", condition, evidence=...) for a
provider policy judgement; raise the original error when a backend operation
cannot proceed. Do not put elevation switches in an input model.
The SDK applies that owner before shared reject-level policy can stop planning
and again to provider-authored Plan policy violations/Gates. Assessment reports
an allowed plan with override warnings; execution still prepares and applies
the real operation. Violation.invalid_value, Violation.missing_required and
incomplete execution plans remain failures. Use these only for essential
execution data/capability; express configurable business limits as policy.
Execution retains collected policy_overrides on both result data and error
extra, including planner rejections and cancellation results. The actual
outcome, error detail, retry semantics and surviving resources remain intact.
A scheduled future Writer request is ordinary unless the orchestrator separately
authorizes its sudo context; a prior management session is not such a grant.
Metadata
Release files for osp-provider-sdk 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| osp_provider_sdk-0.5.0.tar.gz | 47.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| osp_provider_sdk-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.8 kB
Release files / osp_provider_sdk-0.5.0.tar.gz
| Download URL | osp_provider_sdk-0.5.0.tar.gz |
|---|---|
| Size | 47.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
23a9c6fcf53753f6d61137571e90f0aa6f12d9a7a2c923e4eecebd7a89b0cfa1
|
|
BLAKE2b-256 checksum How to use checksums |
84e4ea5cbf351b34b9604b1e6543680ba099942de2b86348cab2183fe3ab89e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.15
|
Release files / osp_provider_sdk-0.5.0-py3-none-any.whl
| Download URL | osp_provider_sdk-0.5.0-py3-none-any.whl |
|---|---|
| Size | 33.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
44675033453a75eae2098a60028f28f608e1d68894a4646ca68842214fdc2422
|
|
BLAKE2b-256 checksum How to use checksums |
6c75315a116b868e7433f58d45ab6ed665bcef37b8b3c7feeb04eb5f543436d7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.15
|
Security for osp-provider-sdk 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.