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 thestella-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 Engine host & routing:
src/Policy/StellaOps.Policy.Engine/Program.cs - Pack/revision/bundle/evaluate/activate:
src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyPackEndpoints.cs - DSL compile:
src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyCompilationEndpoints.cs,Services/PolicyCompilationService.cs - Determinism lint:
src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyLintEndpoints.cs - Policy decisions:
src/Policy/StellaOps.Policy.Engine/Endpoints/PolicyDecisionEndpoint.cs - DSL compiler & diagnostics:
src/Policy/StellaOps.PolicyDsl/(DiagnosticCodes.cs,PolicyCompiler) - Persistence (RLS tenant isolation):
src/Policy/__Libraries/StellaOps.Policy.Persistence/Migrations/001_initial_schema.sql,010_policy_pack_runtime_state.sql - Scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs - REST reference (design + implemented mix):
docs/api/policy.md
Policy Lifecycle
The implemented revision status enum (PolicyRevisionStatus in PolicyPackEndpoints.cs / Domain/PolicyPackRecord.cs) has exactly three states:
Draft → Approved → Active
| State | Enum value | Description |
|---|---|---|
Draft | PolicyRevisionStatus.Draft | Revision created, not yet approved |
Approved | PolicyRevisionStatus.Approved | Revision approved, eligible for activation |
Active | PolicyRevisionStatus.Active | Revision activated for the tenant |
Notes:
- A new revision is created with
initialStatusof eitherDraftorApproved(any other value is rejected with400). There is no separatesubmittedstate and no separatearchivedstate in the implemented engine enum. - The JSON wire format is case-insensitive on input and emitted as a string (e.g.
"Approved"), viaJsonStringEnumConverter(seePolicyPackRecord.cs). - A
submitted/review workflow and anarchivedstate exist only in the forward design (docs/api/policy.md§4) — DESIGN SPEC, not implemented in the engine.
API Endpoints
All routes below are served by
StellaOps.Policy.Engine. Routing lowercases URLs (AddRouting(LowercaseUrls = true)). Authorization is enforced withRequireStellaOpsScopes(...); 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/compileand noPOST /api/v1/policy/dsl/validateendpoint. Standalone validation is performed through the lint surface (below) or by calling:compileand 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 standaloneGET /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 designdocs/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:
findingsBasis | Meaning |
|---|---|
resolved | The findings source answered for the release; zero findings is a clean result. |
absent | The revision has CVE rules and no findings source exists for the subject (no release: segment). |
unavailable | A 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_consulted | The 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:
purpose | Caller | Verdict 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). |
authoring | The Console pack Test tab | No 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
:approveendpoint in the implemented engine — a revision becomesApprovedeither at creation (initialStatus: "Approved") or is gated by the two-person approval recorded during:activate. A standalone review/approve workflow (…/reviews,:approvewith compliance gates) exists only in the forward designdocs/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 apolicy "<name>" syntax "stella-dsl@1" { … }header and contains one or more namedruleblocks withwhen/then/becauseclauses. (Grammar grounded insrc/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):
policykeyword (missing →POLICY-DSL-PARSE-003, missing header)- a quoted policy name
syntax "stella-dsl@1"declaration (wrong/unknown version →POLICY-DSL-PARSE-004)- each
rulerequires abecauseclause (missing →POLICY-DSL-PARSE-006)
The earlier Rego example (
default allow := false,allow if { … },import future.keywords.if) does not compile understella-dsl@1and has been removed. Note: thepolicy.rulespersistence table carries a legacyrule_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 isstella-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.
| Code | Where emitted | Meaning |
|---|---|---|
ERR_POL_001 | PolicyCompilationEndpoints (:compile) | Missing request body or compilation failed (carries diagnostics) |
ERR_POL_007 | Program.cs rate limiter OnRejected | Rate limit exceeded on rate-limited endpoints (429) |
DSL compiler diagnostic codes (StellaOps.PolicyDsl.DiagnosticCodes):
| Code | Meaning |
|---|---|
POLICY-DSL-LEX-001 | Unexpected character |
POLICY-DSL-LEX-002 | Unterminated string |
POLICY-DSL-LEX-003 | Invalid escape sequence |
POLICY-DSL-LEX-004 | Invalid number |
POLICY-DSL-PARSE-001 | Unexpected token |
POLICY-DSL-PARSE-002 | Duplicate section |
POLICY-DSL-PARSE-003 | Missing policy header |
POLICY-DSL-PARSE-004 | Unsupported syntax version |
POLICY-DSL-PARSE-005 | Duplicate rule name |
POLICY-DSL-PARSE-006 | Missing because clause |
POLICY-DSL-PARSE-007 | Missing terminator |
POLICY-DSL-PARSE-008 | Invalid action |
POLICY-DSL-PARSE-009 | Invalid literal |
POLICY-DSL-PARSE-010 | Unexpected 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 referencedocs/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:
| Scope | Const | Enforced on |
|---|---|---|
policy:read | PolicyRead | list packs, evaluate, lint, decisions, quota, simulation reads |
policy:edit | PolicyEdit | :compile, create pack, create revision, create bundle |
policy:activate | PolicyActivate | revision :activate |
policy:simulate | PolicySimulate | simulation/batch compatibility mutations (501) |
policy:run | PolicyRun | gate-run endpoints |
policy:author | PolicyAuthor | gate authoring endpoints |
policy:approve | PolicyApprove | promotion-gate override (compatibility) |
policy:audit | PolicyAudit | replay 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(notpolicy:write), and there is no submit endpoint.policy:archivedoes not exist inStellaOpsScopes.csand 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):
policy.packs—id(UUID),tenant_id,name,display_name,description,active_version,is_builtin,is_deprecated,metadata(JSONB), timestamps.policy.pack_versions—id,pack_id,version,rules_hash,is_published, plus runtime-state columns added by migration 010:requires_two_person_approval,activation_approvals_json(JSONB),activated_at,bundle_digest,bundle_signature,bundle_payload(BYTEA),bundle_created_at,bundle_source_syntax,bundle_source_text,bundle_aoc_metadata_json(JSONB).policy.rules— per-version rule rows (rule_typelegacy default'rego').
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:
- CONCELIER-RISK-68-001
- POLICY-RISK-68-001
- POLICY-RISK-68-002
Related Contracts
- Risk Scoring Contract — policy affects scoring.
- Authority Effective Write Contract — policy attachment.
- REST reference — mix of implemented surface and forward design.
