Policy Studio API Contract

Contract ID: CONTRACT-POLICY-STUDIO-007 Version: 1.1 Status: Published Last Updated: 2026-05-30

Reconciliation note (2026-05-30): This contract was rewritten to match the implemented Policy Engine HTTP surface. The previous revision described a Rego/OPA-based draft lifecycle (/api/v1/policy/drafts, default allow := false, policy:write/policy:submit/policy:archive) that does not exist in the code. The implemented authoring surface uses the stella-dsl@1DSL (not Rego), a policy pack / revision model (not “drafts”), and the route prefix /api/policy/...(not /api/v1/policy/..., except the lint sub-surface which is under /api/v1/policy/lint). Sections that describe a forward design (review workflow, runs, archive) are annotated as DESIGN SPEC — not implemented in Policy Engine where they diverge from the running service.

Overview

This contract defines the Policy Studio authoring surface implemented by the Stella Ops Policy Engine service. It covers DSL compilation, policy-pack/revision management, bundle compilation and signing, two-person activation, deterministic runtime evaluation, determinism linting, and policy decision retrieval.

Audience: authors of Policy Studio clients and CI integrations, and operators that gate releases on policy decisions. For the request/response wire detail, this contract is authoritative for the implemented surface; the forward-design REST reference is docs/api/policy.md.

The Policy Engine consolidates the former Policy Gateway: both /api/policy/* and /policy/* routes are served by the single StellaOps.Policy.Engine host (Program.cs).

Implementation References

Policy Lifecycle

The implemented revision status enum (PolicyRevisionStatus in PolicyPackEndpoints.cs / Domain/PolicyPackRecord.cs) has exactly three states:

Draft → Approved → Active
StateEnum valueDescription
DraftPolicyRevisionStatus.DraftRevision created, not yet approved
ApprovedPolicyRevisionStatus.ApprovedRevision approved, eligible for activation
ActivePolicyRevisionStatus.ActiveRevision activated for the tenant

Notes:

API Endpoints

All routes below are served by StellaOps.Policy.Engine. Routing lowercases URLs (AddRouting(LowercaseUrls = true)). Authorization is enforced with RequireStellaOpsScopes(...); tenant isolation is enforced by middleware and by PostgreSQL row-level security.

DSL Compilation

Compile a policy revision

POST /api/policy/policies/{policyId}/versions/{version}:compile
Scope: policy:edit
Content-Type: application/json
Authorization: Bearer <token>

{
 "dsl": {
 "syntax": "stella-dsl@1",
 "source": "policy \"Default Org Policy\" syntax \"stella-dsl@1\" { rule r1 priority 1 { when true then severity := \"low\" because \"baseline\" } }"
 }
}

The request body is PolicyCompileRequest { PolicyDslPayload Dsl } where PolicyDslPayload = { string Syntax, string Source }. Syntax MUST equal the literal stella-dsl@1; any other value is rejected (diagnostic POLICY-DSL-PARSE-004).

Response 200 OK (PolicyCompileResponse):

{
 "digest": "sha256:7e1d…",
 "statistics": { "ruleCount": 24, "actionCounts": { "block": 5, "warn": 4 } },
 "complexity": {
 "score": 12.5,
 "ruleCount": 24,
 "actionCount": 14,
 "expressionCount": 60,
 "maxExpressionDepth": 4
 },
 "durationMilliseconds": 8,
 "warnings": [
 { "code": "POLICY-DSL-…", "message": "…", "path": "$.rules[0]" }
 ]
}

Response 400 Bad Request returns an RFC7807 ProblemDetails extended with code (ERR_POL_001), policyId, policyVersion, and a diagnostics array of PolicyIssue. The endpoint emits ERR_POL_001 both for a missing body and for a failed compilation.

There is no POST /api/v1/policy/dsl/compile and no POST /api/v1/policy/dsl/validate endpoint. Standalone validation is performed through the lint surface (below) or by calling :compile and inspecting diagnostics.

Policy Packs & Revisions

A policy pack is the versioned container; a revision is an immutable version within a pack. This replaces the “draft” model from earlier drafts of this contract.

Create a policy pack

POST /api/policy/packs
Scope: policy:edit
Content-Type: application/json

{
 "packId": "pack-baseline", // optional; server generates "pack-<guid>" if omitted
 "displayName": "Baseline Org Policy"
}

Response 201 Created (PolicyPackDto):

{
 "packId": "pack-baseline",
 "displayName": "Baseline Org Policy",
 "createdAt": "2026-05-30T10:00:00Z",
 "revisions": []
}

This endpoint is audited (AuditModules.Policy / AuditActions.Policy.CreatePack).

List policy packs

GET /api/policy/packs
Scope: policy:read

Response 200 OK — array of PolicyPackSummaryDto:

[
 {
 "packId": "pack-baseline",
 "displayName": "Baseline Org Policy",
 "createdAt": "2026-05-30T10:00:00Z",
 "versions": [1, 2]
 }
]

There is no GET /api/policy/packs/{packId} detail endpoint and no standalone GET /api/policy/{policyId}/versions / …/versions/{version} listing endpoint in the implemented engine. Revision summaries are returned inline with the pack list / create responses. (A richer list/version surface exists only in the forward design docs/api/policy.md.)

Create or upsert a revision

POST /api/policy/packs/{packId}/revisions
Scope: policy:edit
Content-Type: application/json

{
 "version": 2, // optional; 0/omitted upserts
 "requiresTwoPersonApproval": true, // optional; resolved against activation settings
 "initialStatus": "Approved" // "Draft" or "Approved" only (default "Approved")
}

Response 201 Created (PolicyRevisionDto):

{
 "packId": "pack-baseline",
 "version": 2,
 "status": "Approved",
 "requiresTwoPersonApproval": true,
 "createdAt": "2026-05-30T10:00:00Z",
 "activatedAt": null,
 "approvals": []
}

400 Bad Request if initialStatus is anything other than Draft or Approved. Audited (AuditActions.Policy.CreateRevision).

Compile & sign a revision bundle

POST /api/policy/packs/{packId}/revisions/{version}/bundle
Scope: policy:edit
Content-Type: application/json

{
 "dsl": { "syntax": "stella-dsl@1", "source": "policy \"…\" syntax \"stella-dsl@1\" { … }" },
 "signingKeyId": "key-1",
 "provenance": {
 "sourceType": "git",
 "sourceUrl": "https://…",
 "submitter": "user:ali",
 "commitSha": "abc123",
 "branch": "main"
 }
}

Response 201 Created (PolicyBundleResponse):

{
 "success": true,
 "digest": "sha256:…",
 "signature": "…",
 "sizeBytes": 4096,
 "createdAt": "2026-05-30T10:01:00Z",
 "diagnostics": [],
 "aocMetadata": { "…": "…" }
}

On compilation failure, returns 400 with success: false and a populated diagnostics array.

Evaluate a revision (deterministic, in-memory cached)

POST /api/policy/packs/{packId}/revisions/{version}/evaluate
Scope: policy:read
Content-Type: application/json

{
 "packId": "pack-baseline",
 "version": 2,
 "subject": "<release id / subject identifier>"
}

The request body (PolicyEvaluationRequest) carries packId, version, and subject; the packId/version in the body MUST match the route (mismatch → 400). A bundle must have been created first (404 otherwise).

Subject resolution: the findings lookup (Services/PostgresFindingsLookup.cs) reads only the release:{releaseId} segment of the subject, plus an optional env-id:{environmentId} segment for environment-scoped VEX overlays. The release gate sends release:{id}|env:{name}|tenant:{tenant}|approval:{id}[|env-id:{id}]; the Console pack Test tab (/ops/policy/packs/:packId/simulate) sends release:{id} for a release it has just re-read from ReleaseOrchestrator. A subject without a release: segment has no findings source; when the revision has CVE rules Policy answers allow with findingsBasis absent and the note findings basis absent: no findings source exists for the subject. Nothing was measured, so that verdict certifies nothing about findings, and ReleaseOrchestrator records findings_projection_absent for it and does not approve on it. A release whose findings source answered with no findings answers allow with findingsBasis resolved and the note no findings resolved for subject. A revision with no recognised rule answers allow with findingsBasis not_consulted and the note no finding.cve_id rules recognised in bundle without resolving any finding; denylistRuleCount counts deny-list rules only, so a revision with reachability rules alone also reports 0.

evidence.findingsBasis (SPRINT_20261007_013 CCP-096) is the structured basis of the verdict; consumers decide on it, never on the note text:

findingsBasisMeaning
resolvedThe findings source answered for the release; zero findings is a clean result.
absentThe revision has CVE rules and no findings source exists for the subject (no release: segment).
unavailableA findings source exists for the release but could not answer; PolicyEngine:FindingsLookup:OnUnavailable decides (deny by default, or allow with a “findings source UNAVAILABLE” note under the warn posture).
not_consultedThe revision has no recognised rule (no CVE deny-list or reachability rule), so no findings were read.

Evaluation never activates a revision. The optional purpose query parameter (SPRINT_20261006_128 PCD-974) separates a gate evaluation from an authoring evaluation. It never changes decision, evidence or correlationId:

purposeCallerVerdict attestation
gate (also when absent)Every caller that names no purpose: Release Orchestrator’s release gate, and Replay’s verdict replay and replay-bundle sealing when Replay:PolicyEngine is enabled (Replay architecture)When VerdictAttestation:Enabled is true (the default), the evaluation submits the release-set verdict attestation and the response carries attestationRef (attested, or unattested with a reason).
authoringThe Console pack Test tabNo verdict is submitted or recorded. The response carries attestationRef with status not_recorded and reason authoring_evaluation, and no verdict ID, URI, predicate type or determinism hash.

Any other purpose value returns 400 with the detail purpose must be 'gate' or 'authoring'. Any authoring or simulation caller, not only the Test tab, names purpose=authoring. A caller that names no purpose evaluates as gate: besides the release gate, Replay’s verdict replay and bundle sealing re-evaluate a sealed decision this way, so when verdict attestation is enabled each of their evaluations that the cache does not answer also submits a release-set verdict. Both purposes require only policy:read, because an authoring evaluation writes nothing; the purpose is chosen by the caller, so any policy:read caller that names no purpose still records a gate verdict. The cache keeps the two purposes apart: a gate evaluation is never answered from an authoring entry, and an authoring evaluation never returns the gate’s attested reference. A Test tab run therefore never adds a policy-verdict entry that a reader could take for the gate verdict of the same release.

Response 200 OK (PolicyEvaluationResponse):

{
 "packId": "pack-baseline",
 "version": 2,
 "digest": "sha256:…",
 "decision": "deny",
 "correlationId": "…",
 "cached": false,
 "evidence": {
 "subjectReleaseId": "release-1",
 "totalFindingsCves": 12,
 "denylistRuleCount": 3,
 "matches": [
 {
 "ruleName": "deny-kev",
 "cveId": "CVE-2024-12345",
 "packageName": "pkg:npm/lodash",
 "matchClass": "denylist"
 }
 ],
 "note": null,
 "findingsBasis": "resolved"
 }
}

The evaluator is CVE-aware (Sprint 20260512_006): it joins the subject’s findings against denylist rules and returns per-CVE matches as evidence. Each match now includes matchClass so operators can distinguish presence-based denylist citations from future callability-backed reachability citations. The current promote-time evaluator emits denylist for CVE-id rule matches; reachability citations require the Sprint 20260531_051 reachability evaluator wiring.

Activate a revision (two-person approval)

POST /api/policy/packs/{packId}/revisions/{version}:activate
Scope: policy:activate
Content-Type: application/json

{ "comment": "Approved after review" }

Records the calling actor’s approval. When the revision requiresTwoPersonApproval, a single approval returns 202 Accepted with status pending_second_approval; the second distinct approver triggers activation.

Response 200 OK / 202 Accepted (PolicyRevisionActivationResponse):

{
 "status": "activated", // pending_second_approval | activated | already_active
 "revision": {
 "packId": "pack-baseline",
 "version": 2,
 "status": "Active",
 "requiresTwoPersonApproval": true,
 "createdAt": "2026-05-30T10:00:00Z",
 "activatedAt": "2026-05-30T10:05:00Z",
 "approvals": [
 { "actorId": "user:ali", "approvedAt": "2026-05-30T10:04:00Z", "comment": "ok" },
 { "actorId": "user:kay", "approvedAt": "2026-05-30T10:05:00Z", "comment": "ok" }
 ]
 }
}

Error statuses (all RFC7807 ProblemDetails): 404 pack/revision not found; 400 revision not approved (Only approved revisions may be activated.); 400 duplicate approval by the same actor. Audited (AuditActions.Policy.ActivateRevision).

Approval is not a separate :approve endpoint in the implemented engine — a revision becomes Approved either at creation (initialStatus: "Approved") or is gated by the two-person approval recorded during :activate. A standalone review/approve workflow (…/reviews, :approve with compliance gates) exists only in the forward design docs/api/policy.md §4.2–4.3 — DESIGN SPEC, not implemented in Policy Engine.

Determinism Linting

Implements POLICY-AOC-19-001. Note this sub-surface is mounted under /api/v1/policy/lint (the only /api/v1/... prefix in the Policy Engine).

POST /api/v1/policy/lint/analyze
Scope: policy:read
Content-Type: application/json

{
 "source": "<C# / evaluation source to inspect>",
 "fileName": "rules.cs",
 "minSeverity": "error", // info | warning | error | critical
 "enforceErrors": true
}

Response 200 OK (LintResultResponse):

{
 "passed": true,
 "violations": [
 {
 "category": "WallClock",
 "violationType": "…",
 "message": "…",
 "severity": "error",
 "sourceFile": "rules.cs",
 "lineNumber": 12,
 "memberName": "…",
 "remediation": "Use TimeProvider.GetUtcNow()"
 }
 ],
 "countBySeverity": { "error": 1 },
 "analysisDurationMs": 3,
 "enforcementEnabled": true
}

Related lint endpoints:

POST /api/v1/policy/lint/analyze-batch (Scope: policy:read) // { files: [{source, fileName}], minSeverity, enforceErrors }
GET /api/v1/policy/lint/rules (Scope: policy:read) // DET-001…DET-013 rule catalog + categories + severities

The rule catalog (DET-001…DET-013) covers categories WallClock, RandomNumber, GuidGeneration, NetworkAccess, FileSystemAccess, EnvironmentAccess, UnstableIteration, FloatingPointHazard, ConcurrencyHazard with severities info, warning, error, critical.

Policy Decisions

POST /policy/decisions
Scope: policy:read (requires tenant context)

GET /policy/decisions/{snapshotId}?componentPurl=…&advisoryId=…&includeEvidence=true&maxSources=5
Scope: policy:read (requires tenant context)

POST evaluates and retrieves decisions for component snapshots; GET retrieves previously computed decisions for a snapshot with optional filters. The request tenant is always overridden with the middleware-resolved tenant (POL-TEN-03), and the snapshot is read within that tenant only: another tenant’s snapshot ID answers 404, the same as an unknown ID (PCD-887).

Quota

GET /api/policy/quota
Scope: policy:read

Returns static per-day allowances (simulationsPerDay, evaluationsPerDay, used counts, resetAt).

Simulation (compatibility surface) — DESIGN SPEC / NOT IMPLEMENTED

The /policy/* simulation, shadow, coverage, batch-evaluation, exceptions, merge/conflict, and promotion-gate compatibility routes are registered (PolicySimulationEndpoints.cs) but return 501 Not Implemented(“Policy simulation compatibility backend unavailable”). They exist to reserve the route shape; do not depend on them returning data. Authoring tools requiring simulation should use the bundle :compile//evaluate path; the Console pack Test tab does (CCP-015). There is no /api/policy/simulate route. Scopes enforced on these stubs: policy:read (reads), policy:simulate (mutations), policy:approve (promotion-gate override).

stella-dsl@1 Format

The implemented compiler accepts the stella-dsl@1language, not Rego/OPA. Every document begins with a policy "<name>" syntax "stella-dsl@1" { … } header and contains one or more named rule blocks with when / then / because clauses. (Grammar grounded in src/Policy/__Tests/StellaOps.PolicyDsl.Tests/Golden/PolicyDslValidationGoldenTests.cs.)

policy "Default Org Policy" syntax "stella-dsl@1" {
 rule deny_kev priority 1 {
 when kev == true
 then severity := "critical"
 because "KEV vulnerability present"
 }

 rule allow_low priority 5 {
 when severity in ["low", "informational"]
 then status := "not_affected"
 because "Low-severity finding"
 }
}

Required structural elements (missing any → compile error):

The earlier Rego example (default allow := false, allow if { … }, import future.keywords.if) does not compile under stella-dsl@1 and has been removed. Note: the policy.rules persistence table carries a legacy rule_type DEFAULT 'rego' column and a comment referencing OPA/Rego (001_initial_schema.sql); this is a vestigial schema artifact — the active compilation/evaluation surface is stella-dsl@1.

Error Codes

The Policy Engine HTTP surface emits exactly two ERR_POL_* codes today; DSL compile diagnostics use the POLICY-DSL-* code family.

CodeWhere emittedMeaning
ERR_POL_001PolicyCompilationEndpoints (:compile)Missing request body or compilation failed (carries diagnostics)
ERR_POL_007Program.cs rate limiter OnRejectedRate limit exceeded on rate-limited endpoints (429)

DSL compiler diagnostic codes (StellaOps.PolicyDsl.DiagnosticCodes):

CodeMeaning
POLICY-DSL-LEX-001Unexpected character
POLICY-DSL-LEX-002Unterminated string
POLICY-DSL-LEX-003Invalid escape sequence
POLICY-DSL-LEX-004Invalid number
POLICY-DSL-PARSE-001Unexpected token
POLICY-DSL-PARSE-002Duplicate section
POLICY-DSL-PARSE-003Missing policy header
POLICY-DSL-PARSE-004Unsupported syntax version
POLICY-DSL-PARSE-005Duplicate rule name
POLICY-DSL-PARSE-006Missing because clause
POLICY-DSL-PARSE-007Missing terminator
POLICY-DSL-PARSE-008Invalid action
POLICY-DSL-PARSE-009Invalid literal
POLICY-DSL-PARSE-010Unexpected section

DESIGN SPEC — not implemented: the codes ERR_POL_002–ERR_POL_006 (policy not approved, missing inputs, determinism violation, unauthorized materialisation, run canceled/timed out) appear in the REST design reference docs/api/policy.md §2 but are not emitted by the running Policy Engine. The previous “Invalid policy syntax / Compilation failed / Validation failed / Policy not found / Invalid state transition / Insufficient permissions” mapping in this contract was not grounded in code and has been replaced by the table above.

Authority Scopes

Scopes are the JWT authorization surface defined in StellaOps.Auth.Abstractions.StellaOpsScopes. The following policy:* scopes are actually enforced on implemented Policy Engine routes:

ScopeConstEnforced on
policy:readPolicyReadlist packs, evaluate, lint, decisions, quota, simulation reads
policy:editPolicyEdit:compile, create pack, create revision, create bundle
policy:activatePolicyActivaterevision :activate
policy:simulatePolicySimulatesimulation/batch compatibility mutations (501)
policy:runPolicyRungate-run endpoints
policy:authorPolicyAuthorgate authoring endpoints
policy:approvePolicyApprovepromotion-gate override (compatibility)
policy:auditPolicyAuditreplay endpoints, gate-decision history

Additional policy:* scopes exist in the canonical catalog but are reserved / used by other surfaces (CLI, Registry, Console roles): policy:write, policy:submit, policy:review, policy:operate, policy:publish, policy:promote.

Correction: The implemented authoring endpoints use policy:edit(not policy:write), and there is no submit endpoint. policy:archive does not exist in StellaOpsScopes.cs and has been removed from this table.

Persistence

Policy packs and revisions persist to PostgreSQL under the policy schema with row-level security (per-tenant isolation via policy_app.require_current_tenant()).

Key tables (001_initial_schema.sql, augmented by 010_policy_pack_runtime_state.sql):

Migrations are applied on startup; tenant isolation is enforced by RLS policies (packs_tenant_isolation, pack_versions_tenant_isolation, rules_tenant_isolation).

Unblocks

This contract unblocks the following tasks: