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

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 :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).

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 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_002ERR_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: earlier revisions of this contract listed policy:write as the create/edit scope and policy:submit for submitting drafts. 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: