Solcis.Sdk 0.8.0

dotnet add package Solcis.Sdk --version 0.8.0
                    
NuGet\Install-Package Solcis.Sdk -Version 0.8.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Solcis.Sdk" Version="0.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Solcis.Sdk" Version="0.8.0" />
                    
Directory.Packages.props
<PackageReference Include="Solcis.Sdk" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Solcis.Sdk --version 0.8.0
                    
#r "nuget: Solcis.Sdk, 0.8.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Solcis.Sdk@0.8.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Solcis.Sdk&version=0.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Solcis.Sdk&version=0.8.0
                    
Install as a Cake Tool

.NET PEP client

A reference Policy Enforcement Point in C#/.NET. It implements the same decide contract and the same four security rules as the Python and Node clients. 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. SolcisPep.DecideAndEnforce(action, policyBin: …) execs policy decide --json - via ProcessStartInfo + ArgumentList (UseShellExecute = false), writes the request to its stdin, reads the nested decision.decision, and enforces fail-closed (PROCEED / BLOCK / HOLD). Decide() never throws. It holds no private key and imports no proprietary code.

Install

Solcis.Sdk is published to NuGet.org. No source configuration and no token — it resolves from the gallery your SDK already uses:

dotnet add package Solcis.Sdk

That adds the newest release. To stay on your Solcis release's minor, give it as a floating version: <PackageReference Include="Solcis.Sdk" Version="0.8.*" /> for Solcis 0.8 (see Which version to pin).

The same gallery supplies the transitive crypto dependencies (NSec.Cryptography, Blake3), so a cold restore needs nothing beyond the default source.

Which version to pin

The package carries your Solcis release's major.minor: Solcis.Sdk 0.8.x goes with Solcis 0.8. The patch is the package's own, so a client fix ships without a Solcis release. Use the newest patch for your release's minor, and move to the next minor when you upgrade Solcis; before 1.0, a new minor can change the client's API. The client and the engine also have to agree on the decision contract, which the client verifies on every call and enforces by BLOCKing against an engine 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.

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 or 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.

Build + test

Requires the .NET SDK 8.0+ to build and test:

cd oss/pep-sdk/dotnet
dotnet test tests/SolcisPep.Tests.csproj

The tests drive the shared ../test-support/stub-policy.sh (a bash stub — Linux/macOS, or WSL/bash on Windows) and cover: the verdict mapping, fail-closed on every error path (garble / empty / crash / missing / non-object JSON / timeout), argv-not-shell, decision-from- JSON-not-exit-code, verify-before-obey, and no-key — mirroring the Python/Node gates one-for-one.

Use

using Solcis.Pep;

var action = new Dictionary<string, object?>
{
    ["action"] = "export", ["agent_id"] = "agent-1", ["user_id"] = "user-1",
    ["source"] = "case-db", ["destination"] = "workspace-y",
};
var (outcome, decision) = SolcisPep.DecideAndEnforce(
    action, policyBin: "/path/to/policy", bundle: "/path/to/bundle", trust: "/path/to/trust");
if (outcome != SolcisPep.Proceed) { /* stop (BLOCK) or pause for a human (HOLD) */ }

Set the binary via the policyBin argument or the SOLCIS_POLICY_BIN env — an explicit path, never a bare name found on a writable PATH. For a signed bundle pass bundle: + trust: (the PUBLIC anchor); it is verified (policy verify) before any decision is trusted. Prefer DecideAndEnforce — it decides and maps in one call, so the two steps can't be mis-wired.

With policyBin 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. To try the SDK with no bundle to hand, opt into the demo engine explicitly: pass demoEngine: 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. demoEngine is tri-state (bool?): the default null defers to the env; true turns it on; false turns it off outright, 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 SolcisServe.ReportOutcome 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 demoEngine: true to DecideAndEnforceAuto or SolcisServe.ReportOutcome, 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.

Enforce — a Decision overload + ResultToOutcome

Enforce(string) maps a decision token to Proceed / Block / Hold. For call-site parity with the Node/Python object-accepting enforce, Enforce also has a Decision-record overload and a named ResultToOutcome(Decision) helper (both read decision.Verdict):

var decision = SolcisPep.Decide(action, policyBin: "/path/to/policy");
SolcisPep.Enforce(decision.Verdict);   // the decision string
SolcisPep.Enforce(decision);           // the whole Decision record — same outcome
SolcisPep.ResultToOutcome(decision);   // the self-documenting named form

A non-blank unknown token maps to Block (fail closed). A null or blank decision string is API misuse and throws ArgumentException (a development signal); DecideAndEnforce always passes a real, non-blank Verdict, so it never throws.

Per-call enforcement mode

Set the enforcement mode for a single call with the enforcementMode: parameter — threaded verbatim into the process-exec argv as --enforcement-mode <value>:

var (outcome, decision) = SolcisPep.DecideAndEnforce(
    action, policyBin: "/path/to/policy", enforcementMode: "shadow");

Precedence: the per-call enforcementMode (when non-empty) overrides SOLCIS_ENFORCEMENT_MODE, which overrides the engine default. It writes no environment variable, is never sourced from the request/action/payload, and governs the process-exec transport only — on serve (DecideAndEnforceAuto with SOLCIS_PDP_ADDR set) the daemon owns the mode (no effect, no error). It never softens a real deny: routing keys solely on the authoritative decision.

Non-blocking async API

DecideAsync and DecideAndEnforceAsync are non-blocking async siblings of Decide / DecideAndEnforce. Each runs the policy child on Process + WaitForExitAsync / ReadToEndAsync under a CancellationTokenSource timeout, so a single-threaded host is not frozen for the whole spawn, and the returned Task completes with the same Decision the sync path returns for the same input:

var d = await SolcisPep.DecideAsync(action, policyBin: "/path/to/policy");
var (outcome, decision) = await SolcisPep.DecideAndEnforceAsync(action, policyBin: "/path/to/policy");
if (outcome != SolcisPep.Proceed) { /* stop (BLOCK) or pause for a human (HOLD) */ }

They are additive — the sync Decide / DecideAndEnforce are 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 DecideAndEnforceAuto). They preserve the exact fail-closed semantics of the sync path — every fail path maps to the identical coded token, and a slow child is killed at the timeout ⇒ decide_timeout — and the same decided-request / BoundFields binding, so AssertExecutedRequest behaves identically on an async result.

The Task never faults. It always completes RanToCompletion with a fail-closed Block — even a fault in the SDK's own async plumbing resolves to Block, never faults the Task (an unobserved fault could be swallowed upstream and fail open). onDecision fires once, exactly like the sync twin.

When a bundle is configured and no skip-verify handle is presented, the async path runs policy verify then policy decide as two sequential async spawns, so an un-skipped call costs up to ~2× the per-spawn timeout budget (verify-timeout + decide-timeout). Verify once at boot with VerifyAtBoot (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 Decision carries the engine's native-identity block — four additive, tolerant-parse properties (absent ⇒ "", never fabricated), surfaced on both transports:

  • PolicyId — 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 PolicyId alone.
  • VersionHash — blake3(policy.yaml), the revision of the combined set of policies enforced — not the version of the policy PolicyId 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).
var d = SolcisPep.Decide(action, policyBin: "/path/to/policy", bundle: bundle, trust: trust);
// PolicyId is "" when no single policy decided.
var decidedBy = d.PolicyId.Length > 0 ? d.PolicyId : "(no single policy)";
Console.WriteLine($"decided by {decidedBy} {d.Action} {d.Verification}");

Read the block in this order: if Ok is false, ignore it; an empty VersionHash means the demo engine or no bundle; an empty PolicyId with a VersionHash 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. ReasonCode alone cannot tell that hold from one the policy asked for. EngineExceptions can: one record for each rule, or eval within a rule, that could not be evaluated, on both transports. It is empty when the engine evaluated every rule, when the engine predates the field, and when Ok is false. EngineExceptionsOmitted counts any further records the engine left out, and is 0 when there are none.

property meaning
RuleId the rule that could not be evaluated
EvalId the eval within the rule, or null
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 null
Capability the capability name the policy's author gave the rule's AI model, never an endpoint; otherwise null
PolicyId the id: of the policy the rule belongs to; "" when no single policy owns it; null on the demo engine
Deciding true on the one record, if any, whose failure is the outcome this decision took
ObserveOnly 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.

var (outcome, d) = SolcisPep.DecideAndEnforce(action, policyBin: policyBin, bundle: bundle, trust: trust);
foreach (var e in d.EngineExceptions)
    if (e.Deciding) Console.Error.WriteLine($"{e.RuleId} could not be evaluated ({e.Cause})");

Each record is an EngineExceptionRecord.

These records explain an outcome and never change it: route on the outcome (or Verdict) as always. The onDecision 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. Severity carries that rating on both transports, and is null when the decision has none. null means not rated and never means "low". It is also null 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 RuleId: 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.

Severity is a SeverityRating?:

property meaning
Label the rating's word, compared with the SeverityLabels constants (Low, Medium, High, Critical). A string rather than an enum: 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; null 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
DeterministicCheck the built-in check that produced a deterministic rating, such as pii.credit-card; null 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 Verdict == "deny", RuleId == "FLAGGED-DENY" and Severity of high, score 50, source deterministic, DeterministicCheck 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:

static int Rank(string label) => label switch
{
    SeverityLabels.Low => 0,
    SeverityLabels.Medium => 1,
    SeverityLabels.High => 2,
    SeverityLabels.Critical => 3,
    _ => 2, // an unknown label counts as at least High
};

var (outcome, d) = SolcisPep.DecideAndEnforce(action, policyBin: policyBin, bundle: bundle, trust: trust);
if (outcome == SolcisPep.Proceed && !d.Shadow && d.Severity is { } sev && Rank(sev.Label) >= Rank(SeverityLabels.High))
    Console.Error.WriteLine("allowed with a high rating: " + JsonSerializer.Serialize(new { sev.Label, sev.Score, sev.Source, d.RuleId }));

Label and Source are strings the engine supplies, so escape them before writing them into a log line; serialising them with JsonSerializer.Serialize, as above, does that.

A rating never changes the outcome: route on the outcome (or Verdict) 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.

SolcisPep.ParseSeverity(JsonElement) is public for a caller that holds a raw rating of its own, such as a severity element from a decide reply it parsed itself. It applies the same rules as the result and returns the rating, or null when the element is missing or malformed. A receipt's signed rating is already parsed, as SolcisReceipt.ContentSeverity.

The DecisionRecord passed to onDecision carries Severity too, 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. SolcisPep.AssertExecutedAction(result, executedAction) 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.

var (outcome, decision) = SolcisPep.DecideAndEnforce(action, policyBin: policyBin, bundle: bundle, trust: trust);
if (outcome == SolcisPep.Proceed && SolcisPep.AssertExecutedAction(decision, executedAction) == SolcisPep.Proceed)
{
    // safe to execute exactly `executedAction`
}

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 decision.BoundFields, and the PEP must bind every one. SolcisPep.AssertExecutedRequest(result, executedRequest) is the generalisation of AssertExecutedAction: it binds the action and every BoundFields 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.)

var (outcome, decision) = SolcisPep.DecideAndEnforce(action, policyBin: policyBin, bundle: bundle, trust: trust);
if (outcome == SolcisPep.Proceed && SolcisPep.AssertExecutedRequest(decision, action) == SolcisPep.Proceed)
{
    // safe to execute `action`: its action AND every bound field matched the decided request
}
Skip per-call re-verify (VerifyAtBoot)

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

var handle = SolcisPep.VerifyAtBoot(policyBin, bundle, trust); // runs the real anchor verify, captures bundle_hash
var d = SolcisPep.Decide(action, policyBin: policyBin, bundle: bundle, trust: trust, verified: handle);

VerifyAtBoot 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: VersionHash (blake3(policy.yaml), identity) ≠ h (the signed-set bundle_hash, integrity) — never pin VersionHash as the skip hash.

The decision-provenance hook

Pass an Action<DecisionRecord> as onDecision: to observe the finalized decision — Outcome, Decision, ReasonCode, RuleId, PolicyId, VersionHash, 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 throw is swallowed (the observer must be non-blocking):

SolcisPep.DecideAndEnforce(action, policyBin: policyBin, onDecision: record => Log(record));

The two transports (one API)

The Solcis.Pep namespace in the Solcis.Sdk package carries both transports: SolcisPep (the process-exec transport, above) and SolcisServe (the framed-TCP serve transport), with SolcisReceipt verifying the signed serve receipt. The example above uses the process-exec entry point (SolcisPep.DecideAndEnforce). For the unified front door that auto-selects the transport by config, use SolcisPep.DecideAndEnforceAuto(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.agent_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 ReportOutcome 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 (the resolver reads the raw Environment.GetEnvironmentVariable and branches on != null, 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 and ../SERVE_PROTOCOL.md.

What is this daemon enforcing? (SolcisServe.Health)

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

var h = SolcisServe.Health();             // null on any fault (unreachable / refused / malformed)
if (h is null)
{
    // could not reach the daemon — render "unknown"
}
else if (h.PolicySetId is null)
{
    // no bundle yet (demo engine, or awaiting its first pulled bundle)
}
else
{
    Console.WriteLine($"enforcing {h.PolicySetId} @ {h.VersionHash} ({h.Verification})");
    // on serve, h.Verification is always HealthReport.VerificationVerified today
}

Three rules keep this honest:

  • Provenance, not permission. PolicySetId / VersionHash / 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 BundleManifestHash / BundleStale, absent (null) from a Pep report.
  • null means unknown. SolcisServe.Health() collapses every fault to null. Render "unknown"; never fall back to a negative claim about enforcement.

Enrol — bind your agent's identity at startup (SolcisEnrol)

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.

using Solcis.Pep;

try
{
    // Each option falls back to its env var, so a fully-configured agent can pass no options.
    EnrolResult instance = await SolcisEnrol.EnrolAsync(
        new EnrolOptions { BaseUrl = baseUrl, Credential = credential, Did = did });
    // instance.Id / instance.Did / instance.FirstSeenAt / instance.Status == "active"
}
catch (SolcisEnrolException e) when (e.Retryable)
{
    // transient (control-plane down / rate-limited / 5xx) — safe to retry: a repeat enrol is idempotent
}
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, FirstSeenAt preserved) — call it every boot; retry when e.Retryable. It throws (it does not fail closed to BLOCK): SolcisEnrolUsageException on misuse (missing config or a non-https base), SolcisEnrolException (with a closed .Code, a .Retryable flag, and the HTTP .Status) on an HTTP/transport fault — surfaced on the awaited Task. It uses the BCL HttpClient — no new dependency — and transits your credential to the control-plane, storing none. TLS trust uses the OS certificate store.

Migration: ProjectReference → PackageReference

If your project still consumes this client by path — a <ProjectReference Include=".../oss/pep-sdk/dotnet/SolcisPep.csproj" /> — follow Install to add the package source, then replace that <ProjectReference> with a <PackageReference Include="Solcis.Sdk" />. The namespace is pinned, not restructured: using Solcis.Pep; and the call sites (SolcisPep.DecideAndEnforce(...), or SolcisPep.DecideAndEnforceAuto(...) for transport auto-selection) are unchanged. The package targets net8.0+ (also net10.0) with PackageLicenseExpression=MIT.

Breaking change: none to the namespace or the enforcement result. The only additions are the minimal crypto runtime dependencies (NSec.Cryptography + Blake3, pulled transitively, used only by the serve/receipt path — the process-exec path is pure-BCL) and the new serve transport + unified front door. The .nupkg ships only this library's own DLLs + metadata; the crypto deps resolve transitively.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.0 91 10/16/2026