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).
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
}
}
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).
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. 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: earlier revisions of this contract listed
policy:writeas the create/edit scope andpolicy:submitfor submitting drafts. The implemented authoring endpoints usepolicy: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.
