Metadata-Version: 2.4
Name: solcis-sdk
Version: 0.8.0
Summary: Reference Policy Enforcement Point (PEP) client for Solcis — decide one action against the `policy` engine and enforce it, fail-closed.
Author: Solcis
License: MIT
Keywords: policy,enforcement,pep,authorization,agent-safety
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: THIRD_PARTY_NOTICES.md
Requires-Dist: blake3==1.0.10
Requires-Dist: pynacl==1.6.2
Dynamic: license-file

# solcis-sdk

The reference **Policy Enforcement Point (PEP)** client for [Solcis](https://solcis.ai), published
on PyPI as `solcis-sdk`. The Node (`@solcis/sdk` on npm) and .NET (`Solcis.Sdk` on NuGet.org)
clients carry the same API.

A PEP is the point in *your* application where — right before an agent does something risky (send
data, call a tool, spend money) — you ask the policy evaluator *"is this allowed?"* and obey. This
client runs the installed `policy` binary as a subprocess, asks it to decide **one** action, reads
the JSON answer, and **enforces** it. It is all-local and **fails
closed**: any problem is treated as `BLOCK`.

## Install

```sh
pip install solcis-sdk
```

That installs the newest release. The package carries your Solcis release's major.minor:
`solcis-sdk` `0.8.x` goes with Solcis `0.8`, and the patch is the package's own, so a client fix ships
without a Solcis release. To stay on your release's minor, give it as a range:
`pip install "solcis-sdk~=0.8.0"` for Solcis 0.8. Before 1.0, a new minor can change the client's API.
The package runs no install-time scripts, and its only runtime additions are the crypto pins the receipt
verifier needs (`blake3` + `PyNaCl`).

If you were given a wheel file instead, `pip install ./solcis_sdk-<version>-py3-none-any.whl` installs
the same package.

The client and the engine also have to agree on the **decision contract**, which this client checks on
every call and answers with a **BLOCK** where an engine was built to a different one.

> **Note:** the package is branded **Solcis** and its environment variables use the `SOLCIS_` prefix.
> The credentials it handles are minted under the `solcis_` prefix too: a channel pre-shared key (PSK)
> reads `solcis_pep_…` and an operating credential reads `solcis_ac_…`. A credential issued earlier may
> carry the product's former prefix, which is still accepted; the SDK passes a credential through
> without checking its prefix.

## Use

```python
import solcis_pep

result = solcis_pep.decide_and_enforce(
    {
        "action": "export",
        "enforcement_point_id": "agent-1",
        "user_id": "user-1",
        "source": "secret-db",
        "destination": "public-bucket",
        "payload": {"rows": 3},
    },
    policy_bin="/path/to/policy",  # or set SOLCIS_POLICY_BIN
    bundle="/path/to/bundle", trust="/path/to/trust",  # or SOLCIS_BUNDLE / SOLCIS_TRUST
)

if result["outcome"] == solcis_pep.PROCEED:
    ...  # let the action happen
elif result["outcome"] == solcis_pep.HOLD:
    ...  # pause; a human must decide first
else:  # solcis_pep.BLOCK
    ...  # stop the action
```

`policy_bin` defaults to the `SOLCIS_POLICY_BIN` environment variable (an explicit path — never a
bare name found on `PATH`). **Prefer `decide_and_enforce`** — it decides and maps in one call, so the
two steps can't be mis-wired.

With `policy_bin` set but **neither `bundle` nor `trust` configured**, `decide()` fails closed
(`no_bundle_configured`) rather than silently falling back to the engine's built-in demo policy — see
[the reason-code table](../README.md#the-process-exec-variables). To try the SDK with no bundle to
hand, opt into the demo engine explicitly: pass `demo_engine=True`, or set
`SOLCIS_PEP_DEMO_ENGINE=true`. **Not for production or a shared environment** — the demo engine
decides from a permissive sample policy, not one you authored. `demo_engine` is **tri-state**: the
default `None` defers to the env; `True` turns it on; anything else (including `False`) turns it off,
even with the env set — precedence is **per-call > env > off**.

The same opt-in also covers the demo engine's **receipt anchor** on the serve transport. `policy serve
--demo` signs receipts with a key anyone can reproduce, so without the opt-in the client refuses that key.
Configured as `SOLCIS_PDP_TRUST_ANCHOR`, a serve decide fails closed with `receipt_untrusted` and
`report_outcome` returns `outcome_receipt_unverifiable`, both before any connection; as a decide's
trust-on-first-use pin, the decide fails closed before any connection; and first seen on first contact, it
is never pinned and that decide fails closed once the response is read. Pass `demo_engine=True` to
`decide_via_selected_transport`, `enforce_action` or `report_outcome`, or set
`SOLCIS_PEP_DEMO_ENGINE=true`, only when rehearsing against the demo engine: the same setting turns on the
process-exec demo policy, so set it only in a rehearsal shell — see
[the demo receipt anchor](../README.md#the-serve-transport-variables).

### `enforce()` accepts a string **or** a result mapping

`enforce()` maps the engine's 5-outcome decision to `PROCEED` / `BLOCK` / `HOLD`. It accepts **either**
the decision string **or** a whole `decide()` result mapping (it reads `result["decision"]`), so passing
the whole result enforces the decision it carries:

```python
result = solcis_pep.decide(action, policy_bin="/path/to/policy")
solcis_pep.enforce(result["decision"])  # the decision string
solcis_pep.enforce(result)  # the whole result mapping — same outcome
solcis_pep.result_to_outcome(result)  # the self-documenting named form
```

A valid but unrecognised token still maps to `BLOCK` (fail closed). Genuine misuse — `None`, a number,
a list, or a mapping with no string `"decision"` — **raises** a descriptive, secret-free `TypeError` in
development and **fails closed to `BLOCK`** in production (`SOLCIS_PEP_ENV=production` or `python -O`);
pass `strict=` to force either mode. `decide_and_enforce` only ever passes the decision string, so it
never raises.

### Per-call enforcement mode

Set the enforcement mode for a single call with the `enforcement_mode=` keyword — threaded verbatim into
the process-exec argv as `--enforcement-mode <value>`:

```python
solcis_pep.decide_and_enforce(action, policy_bin="/path/to/policy", enforcement_mode="shadow")
```

**Precedence:** the per-call `enforcement_mode` (when non-empty) **overrides** `SOLCIS_ENFORCEMENT_MODE`,
which overrides the engine default. It writes **no** `os.environ`, is **never** sourced from the
request/action/payload, and governs the **process-exec transport only** — on serve the daemon owns the
mode (no effect, no error). It never softens a real `deny`: routing keys solely on `decision`.

### Non-blocking async API

`decide_async` and `decide_and_enforce_async` are **non-blocking** async siblings of `decide` /
`decide_and_enforce`. Each thread-offloads the audited sync core **verbatim** via `asyncio.to_thread`, so
it frees the event loop while the `policy` child is in flight and **resolves** to the same result mapping
the sync path returns for the same input:

```python
import asyncio
import solcis_pep


async def main():
    r = await solcis_pep.decide_async(action, policy_bin="/path/to/policy")
    result = await solcis_pep.decide_and_enforce_async(action, policy_bin="/path/to/policy")
    if result["outcome"] == solcis_pep.PROCEED:
        ...  # allowed — carry on


asyncio.run(main())
```

They are **additive** — the sync `decide` / `decide_and_enforce` are byte-for-byte unchanged — and govern
the **process-exec transport only** (the serve transport is already async network I/O; for the
config-selected front door use the sync `enforce_action` / `decide_via_selected_transport`). Because they
reuse the sync core verbatim, they inherit its **exact** fail-closed semantics (every failure resolves to
the identical coded `reason_code`) and the same decided-request / `bound_fields` binding, so
`assert_executed_request` behaves identically on an async result. They **never raise** — every failure,
including an async-plumbing fault, resolves to a fail-closed `BLOCK` (an unobserved raise could be
swallowed upstream and fail **open**).

**Threading — thread-safety matters.** The offload runs on the event loop's **default
`ThreadPoolExecutor`**, so the optional `on_decision` observer fires on a **worker thread** — it **must be
thread-safe** — and many concurrent `decide_async` calls **queue** on that bounded pool (each still spawns
its own `policy` child; the async path lifts the sync path's implicit spawn serialisation, so the host is
responsible for bounding in-flight decides).

When a bundle is configured and no skip-verify handle is passed, the offloaded core runs `policy verify`
then `policy decide` as **two sequential spawns**, so an un-skipped call costs up to **~2× the per-spawn
timeout budget**. Verify once at boot with `verify_at_boot` (below) and pass its handle as `verified=` to
collapse this to a single spawn.

### Bundle identity, skip-verify, provenance, and the executed→decided binding

Every decide result carries the engine's **native-identity block** — four additive, tolerant-parse
string fields (absent ⇒ `""`, never fabricated), surfaced on **both** transports:

- **`policy_id`** — the `id:` of the **policy that decided**: the policy whose rule decided, or whose own
  policy-level check did (such as its large-input refusal). For a deployment the console publishes, it is
  the policy id the console shows. It is `""` when no single policy decided — an allow traced to no
  rule, an empty set of policies, or an engine-wide check such as intent alignment (on a deny, the rule id
  says which). When several policies objected, it names the one whose outcome was enforced; the decision
  log names them all. **Identity is not an authz anchor**: it is a non-authenticated author label for
  observability only; never grant or route on `policy_id` alone.
- **`version_hash`** — `blake3(policy.yaml)`, the revision of the **combined set of policies enforced** — not
  the version of the policy `policy_id` names (distinct from the skip-verify `bundle_hash` and the serve
  Health `bundle_manifest_hash`).
- **`action`** — the decided action-subject the engine evaluated (e.g. `bank_transfer_external`).
- **`verification`** — `"verified"` (the signature was checked this call) or `"assumed"` (skip-verify).

```python
r = solcis_pep.decide(action, policy_bin="/path/to/policy", bundle=bundle, trust=trust)
# policy_id is "" when no single policy decided.
print("decided by", r["policy_id"] or "(no single policy)", r["action"], r["verification"])
```

Read the block in this order: if `ok` is false, ignore it; an empty `version_hash` means the demo engine or no
bundle; an empty `policy_id` with a `version_hash` means no single policy decided.

#### Rules the engine could not evaluate

When the policy engine cannot evaluate a rule (an AI model it could not reach, a fact the request did
not supply, a policy module that failed), it fails closed, usually to a hold. `reason_code` alone cannot
tell that hold from one the policy asked for. **`engine_exceptions`** can: one record for each rule, or eval
within a rule, that could not be evaluated, on both transports. It is `[]` when the engine
evaluated every rule, when the engine predates the field, and when `ok` is false.
**`engine_exceptions_omitted`** counts any further records the engine left out: an integer from 0 to
2147483647, and 0 when there are none or when the engine sent anything else.

| key | meaning |
| --- | --- |
| `rule_id` | the rule that could not be evaluated |
| `eval_id` | the eval within the rule, or `None` |
| `cause` | why it could not be evaluated (below) |
| `refusal` | when model routing refused, which check refused it: a `reason` token from the serve Health report's `escalating_rules`; otherwise `None` |
| `capability` | the capability name the policy's author gave the rule's AI model, never an endpoint; otherwise `None` |
| `policy_id` | the `id:` of the policy the rule belongs to; `""` when no single policy owns it; `None` on the demo engine |
| `deciding` | `True` on the one record, if any, whose failure is the outcome this decision took |
| `observe_only` | `True` when the rule is observe-only, so its failure never decides |

`cause` is currently one of `model_disabled`, `model_not_configured`, `model_unavailable`, `model_no_capacity`,
`decision_deadline_passed`, `model_timed_out`, `model_error`, `model_input_missing`,
`judge_misconfigured`, `predicate_unevaluable`, `wasm_module_failed`, `rule_has_no_evaluator` or
`required_registry_empty`. The list can grow: treat a value you do not recognise as a rule
that could not be evaluated, for a reason this version cannot name. A malformed record is skipped
rather than failing the decision.

```python
r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
for e in r["engine_exceptions"]:
    if e["deciding"]:
        logging.warning("%s could not be evaluated (%s)", e["rule_id"], e["cause"])
```

These records explain an outcome and never change it: route on `outcome` (or `decision`) as always. The
`on_decision` record does not carry them, because an unbounded list suits a result and not a log line, so
read them from the result.

#### The decision's severity

The policy engine can rate a decision: how serious it judged what it decided. **`result["severity"]`**
carries that rating on both transports, and is `None` when the decision has none. `None` means not rated and never means
"low". It is also `None` when `ok` is false, and when the engine sent a rating this version cannot read. It
is the decision's **highest** rating, which may come from a rule other than `rule_id`: a deny from one rule
can carry a `high` rating from another rule that only observes. On a shadowed decision (`shadow` is `True`)
it is the rating the decision would have carried, so check `shadow` before acting on it.

When present it is a dict with all five keys:

| key | meaning |
| --- | --- |
| `label` | the rating's word: `low`, `medium`, `high` or `critical`. Open: the engine's cut-offs between labels are provisional, so an unknown label is passed through as sent. Rank by this, and do not recompute it from `score` |
| `score` | a whole number from 0 to 100, always present. For sorting and display, not for thresholds |
| `confidence` | how sure the model was, from 0 to 1, normally only on an `inference` rating; `None` when the engine sends none |
| `source` | what produced it: `wasm` (a WebAssembly policy module), `inference` (an AI model) or `deterministic` (a built-in check, or a score the policy's author wrote down). Open, like `label` |
| `deterministic_check` | the built-in check that produced a `deterministic` rating, such as `pii.credit-card`; `None` on a `deterministic` rating the author wrote down, and on every other source |

A worked example: a policy denies a message with `on_match: deny` in a rule named `FLAGGED-DENY`, while an
observe-only rule named `PII-OBSERVE` rates the same message `high` with the `pii.credit-card` check. The
result reads `decision == "deny"`, `rule_id == "FLAGGED-DENY"` and `severity == {"label": "high", "score": 50,
"confidence": None, "source": "deterministic", "deterministic_check": "pii.credit-card"}`. The rating
describes the decision, not the rule that decided it.

Rank labels through an explicit map, and treat a label you do not recognise as at least `high`, so a label
the engine adds later is never quietly ignored:

```python
RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3}

r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
sev = r["severity"]
if r["outcome"] == solcis_pep.PROCEED and not r["shadow"] and sev is not None:
    if RANK.get(sev["label"], RANK["high"]) >= RANK["high"]:
        logging.warning(
            "allowed with a high rating: %s",
            json.dumps(
                {
                    "label": sev["label"],
                    "score": sev["score"],
                    "source": sev["source"],
                    "rule_id": r["rule_id"],
                }
            ),
        )
```

`label` and `source` are strings the engine supplies, so escape them before writing them into a log line; serialising them with `json.dumps`, as above, does that.

A rating never changes the outcome: route on `outcome` (or `decision`) as always. Use `severity` to allow and
log, to feed telemetry, or to send a request to a person. If a request with a rating should be blocked or
held, give the rule that decides it an `on_match` of `deny` or `escalate` in the policy, where the decision
is governed and recorded. A policy cannot gate a check on another rule's severity band.

On a serve `allow` whose receipt verified, `severity` is the copy the engine signed into the receipt. Under trust-on-first-use (TOFU), where the client pins the receipt-signing key the first time it sees one instead of being given it, the very first receipt is checked against a key that is not pinned yet.
Everywhere else (process-exec, and a serve deny or hold) it is the engine's reply, which is not signed, so
treat it as a signal and not as evidence. The decision log holds the signed record.

`solcis_pep.parse_severity(raw)` is public for a caller that holds a raw rating of its own, such as a
receipt's `content_severity`. It applies the same rules as the result and returns the rating, or `None` when
the value is missing or malformed.

The `on_decision` record carries `severity` too, as a read-only copy, because a rating is fixed-size and
suits a log line.

#### The mandatory executed→decided binding

`decide` reports the `action` it evaluated; a PEP **must** bind the tool call it is about to execute to
that decided action. `assert_executed_action(result, executed_action)` is that binding, and it is the
**mandatory** PEP pattern: decide the action you are about to execute, then assert the executed action
equals `result["action"]` **before** acting. It returns `PROCEED` **only** when the decision routed to
PROCEED, the result is a real engine decision, and the decided action equals the executed one — every
other case (including a matched-action `deny`) returns `BLOCK`. A caller without this binding is outside
the trust envelope.

```python
r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
if (
    r["outcome"] == solcis_pep.PROCEED
    and solcis_pep.assert_executed_action(r, action["action"]) == solcis_pep.PROCEED
):
    ...  # safe to execute exactly action["action"]
```

When a signed policy denies on a **request field** (not just the action) — e.g. `destination` or
`payload.amount` — the decide result lists those fields in `result["bound_fields"]`, and the PEP must
bind **every** one. `assert_executed_request(result, executed_request)` is the generalisation of
`assert_executed_action`: it binds the action **and** every `bound_fields` entry, comparing each
executed value against the decided one **byte-for-byte** (no trim / case-fold / unicode / numeric
normalisation), so a decide-benign / execute-malicious dodge (decide `destination="internal"`, execute
`"external"`) fails closed to `BLOCK`. Prefer it whenever your policies branch on request parameters.
(It needs the decided request the SDK captured, so it applies to the process-exec `decide` result.)

```python
r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
if (
    r["outcome"] == solcis_pep.PROCEED
    and solcis_pep.assert_executed_request(r, action) == solcis_pep.PROCEED
):
    ...  # safe to execute action: its action AND every bound field matched the decided request
```

#### Skip per-call re-verify (`verify_at_boot`)

Verify a bundle **once at boot** and skip the per-call signature re-verify (two `policy` spawns → one):

```python
handle = solcis_pep.verify_at_boot(
    policy_bin, bundle, trust
)  # runs the real anchor verify, captures bundle_hash
r = solcis_pep.decide(action, policy_bin=policy_bin, bundle=bundle, trust=trust, verified=handle)
```

`verify_at_boot` runs the real Ed25519/anchor verify and, on success, captures the engine-reported
`bundle_hash` (`h`) into an opaque handle; `decide(verified=…)` then appends `--assume-verified
--expect-bundle-hash <h>`. **The exact security trade:** it **skips the Ed25519 signature check** but the
engine still recomputes every file's blake3 **and** the full-signed-set `h`, refusing fail-closed on any
mismatch. It **needs the operator-pinned hash** — a skip requested without a successfully-minted handle
**fails closed** (`assume_verified_requires_verify`), and with no bundle configured
(`assume_verified_requires_bundle`). A hand-built handle never enables skip; on the serve transport it is
a no-op. **Two-hash discipline:** `version_hash` (`blake3(policy.yaml)`, identity) ≠ `h` (the signed-set
`bundle_hash`, integrity) — never pin `version_hash` as the skip hash.

#### The decision-provenance hook

Pass `on_decision=callable` to observe the finalized decision — `outcome`, `decision`, `reason_code`,
`rule_id`, `policy_id`, `version_hash`, `action`, `verification`, `shadow`, `severity`, `transport`, `ok` — without a
bespoke telemetry ring buffer. It fires exactly once, **after** the decision is finalized, **never**
influences routing, and a raise is swallowed (the observer must be non-blocking):

```python
solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, on_decision=lambda record: log(record))
```

## The four security rules

1. **argv, never a shell string** — `policy` runs from an argument list with no shell, and the
   request goes to its stdin (`policy decide --json -`), never into argv, where every local account
   could read it. Action data is never evaluated by a shell. No command injection.
2. **decision from the JSON, never the exit code** — the engine exits `0` even when it denies. The
   outcome is read from `decision.decision`; the exit status is never trusted.
3. **fail closed** — any problem (non-zero exit, empty/garbled output, missing decision, timeout,
   an exception) maps to `BLOCK`. A broken check must never accidentally allow.
4. **no signing key** — the client verifies signatures and never produces them; it needs only the
   **public** trust anchor and the bundle. It does hold two secrets you must protect if you use those
   features: the serve channel's pre-shared key, and the agent credential you supply to enrolment.

The full frozen decide contract ships with the SDK sources rather than inside this package. Ask your
Solcis contact for it if you are writing a client of your own.

## The two transports (one API)

The distribution ships three top-level modules — `solcis_pep` (the process-exec transport + the unified
front door), `serve` (the framed-TCP serve transport), and `receipt` (the signed-receipt verifier). The
example above uses the process-exec entry point (`decide_and_enforce`). For the **unified front door**
that auto-selects serve-vs-process-exec by config, use `solcis_pep.enforce_action(action)`: it enforces
over the **serve** transport when `SOLCIS_PDP_ADDR` is set (a loopback `host:port`), else over
process-exec. `SOLCIS_PDP_ADDR` is the only selector — **not** `SOLCIS_PDP` (the testbed `stub|api` mode
enum) or `SOLCIS_PDP_URL` (an HTTP address). The serve transport verifies a signed receipt before
honouring any proceed-able decision.

> **Required — `SOLCIS_CHANNEL_AGENT_ID`.** The daemon channel-overwrites `request.enforcement_point_id` with the
> channel id its PSK maps to, so the signed receipt is bound to that channel id. Set
> `SOLCIS_CHANNEL_AGENT_ID` to that channel id: the agent's id from the console, shown under the
> environment's name on the agent's page. **There is no default.** Unset or empty, every decide
> BLOCKs with `channel_agent_id_missing` before any connection is opened, and `report_outcome` is refused
> with the same code. A **wrong** value fails closed too: a genuine allow fails the content-bind
> (`receipt_tampered`), and a legitimate outcome report surfaces as `outcome_receipt_unverifiable`
> (recorded, but not provably the reporter's).

### Serve env vocabulary (canonical names)

The serve transport is configured **from the environment only** (never from a request field). Each value
resolves with the precedence **explicit call option > canonical `SOLCIS_PDP_*` env**, read presence-based
(*not* truthy: the resolver tests `is not None`, never `or`, so a set-but-**empty** canonical stays empty
and still fails closed).

| Concept | Canonical |
|---|---|
| Pre-shared key (channel credential) | `SOLCIS_PDP_PSK` |
| Receipt-signing trust anchor | `SOLCIS_PDP_TRUST_ANCHOR` |
| Connect+read timeout (ms) | `SOLCIS_PDP_TIMEOUT_MS` |
| Daemon loopback `host:port` (selects serve) | `SOLCIS_PDP_ADDR` |

`SOLCIS_PDP_TRUST_ANCHOR` is the **receipt-signing** anchor (it verifies the signed serve receipt) — a
**distinct** name from `SOLCIS_TRUST`, the bundle-verify (`policy verify --trust`) anchor on the
process-exec path. No env name or value ever appears in a fail-closed diagnostic line (coded reason
tokens only).

The full env surface and the serve wire are in
[`../README.md`](../README.md#environment-configuration-surface) and
[`../SERVE_PROTOCOL.md`](../SERVE_PROTOCOL.md).

### What is this daemon enforcing? (`serve.health`)

To render "this engine is currently enforcing X" without making a decision, read the enforced-identity
block off a serve **Health** report. `serve.health()` returns a parsed `HealthReport` — or `None` on any
fault — and, on a bundle-serving daemon, that report carries `policy_set_id` — the id of the policy document enforced, `composed:` followed by your
enforcement point id for a policy set — and the same `version_hash` / `verification` a decision carries:

```python
from solcis_pep import serve

h = serve.health()  # None on any fault (unreachable / refused / malformed)
if h is None:
    ...  # could not reach the daemon — render "unknown"
elif h.policy_set_id is None:
    ...  # no bundle yet (demo engine, or awaiting first pull)
else:
    print(f"enforcing {h.policy_set_id} @ {h.version_hash} ({h.verification})")
    # on serve, h.verification is always serve.VERIFICATION_VERIFIED today
```

Three rules keep this honest:

- **Provenance, not permission.** `policy_set_id` / `version_hash` / `verification` ride **outside** the
  signed receipt, so they are labels to display and log — never an authorization anchor, and never a
  substitute for the pinned receipt trust anchor the serve decide path verifies.
- **Identity present is not fresh, unstale, or non-empty.** A present identity is what the engine decides
  with now; it does not say the bundle is current, that a reload did not fail, or that anything is
  enforced (a signed zero-member bundle has an identity and enforces nothing). Freshness is the
  operator-only `bundle_manifest_hash` / `bundle_stale`, absent (`None`) from a Pep report.
- **`None` means unknown.** `serve.health()` collapses every fault to `None`. Render "unknown"; never
  fall back to a negative claim about enforcement.

## Enrol — bind your agent's identity at startup (`enrol`)

A **cold-path** helper, separate from the decide/enforce core, wraps the control-plane
`POST /api/agent-environments/enrol`: a running agent presents its `solcis_ac_…` operating credential and
its runtime `did`, and its declared instance flips `declared -> active`.

```python
from solcis_pep.enrol import enrol, EnrolError

try:
    # Each keyword falls back to its env var, so a fully-configured agent can call enrol() with no args.
    instance = enrol(
        base_url=base_url, credential=credential, did=did
    )  # EnrolResult(id, did, first_seen_at, status="active")
except EnrolError as e:
    if e.retryable:
        ...  # transient (control-plane down / rate-limited / 5xx) — safe to retry: a repeat enrol is idempotent
    else:
        raise  # a config error (wrong credential, did already claimed) — fail startup loudly
```

| Env var | Meaning |
|---|---|
| `SOLCIS_CONTROL_PLANE_URL` | The control-plane **origin** (management plane — **not** the PDP; unrelated to `SOLCIS_PDP_ADDR`/`SOLCIS_PDP_URL`). Must be `https` unless loopback. |
| `SOLCIS_AGENT_CREDENTIAL` | The `solcis_ac_…` operating credential (a bearer secret; never logged). |
| `SOLCIS_AGENT_DID` | The runtime did (**not** `SOLCIS_CHANNEL_AGENT_ID`, the serve channel id). |

**Idempotent** on the same `did` (a 200 no-op, `first_seen_at` preserved) — call it every boot; retry when
`e.retryable`. It **raises** (it does not fail closed to BLOCK): `EnrolUsageError` on misuse (missing config
or a non-https base), `EnrolError` (with a closed `.code`, a `.retryable` flag, and the HTTP `.status`) on an
HTTP/transport fault. It uses only the stdlib (`urllib`) — no new dependency — and **transits** your
credential to the control-plane, storing none. TLS trust uses the system OpenSSL trust store.

## Migration: vendored file → installed package

If your tree still vendors this client **by path** — e.g.
`sys.path.insert(0, ".../oss/pep-sdk/python")` then `import solcis_pep`, or a `SOLCIS_PEP_PYTHON`
directory override — depend on the installed package instead, as described in [Install](#install)
above:

```sh
pip install solcis-sdk
```

Everything installs under the one name `solcis_pep`: `import solcis_pep` is the main entry point, and
the helpers are its submodules — `from solcis_pep import serve, receipt` and
`from solcis_pep.enrol import enrol`. Replace the `sys.path` insertion (or the `SOLCIS_PEP_PYTHON`
override) with a normal dependency on `solcis-sdk`; the call sites (`solcis_pep.decide_and_enforce(...)`,
or `solcis_pep.enforce_action(...)` for transport auto-selection) are unchanged.

**Breaking change from the alpha releases `1.0.0a3` and earlier** (which came before the SDK took the
Solcis release's version; every `0.8` release and later has the change): those releases installed `serve`, `receipt`, `tofu`,
`enrol` and `env_names` as separate top-level modules, so code wrote `import serve` or
`from enrol import enrol`. Those names were generic enough to clash with any other installed package
that ships one, so they now live inside `solcis_pep`. Change `import serve` to
`from solcis_pep import serve`, `import receipt` to `from solcis_pep import receipt`, and
`from enrol import …` to `from solcis_pep.enrol import …`. `import solcis_pep` and the enforcement
result are unchanged. The only other additions are the
minimal crypto runtime dependencies (`blake3` + `PyNaCl`, pulled transitively, used only by the
serve/receipt path — the process-exec path is unaffected) and the new serve transport + unified front
door. The `SOLCIS_PEP_PYTHON` override remains only as an escape hatch for containers that mount the
client at a fixed path.

## Licence

MIT.
