diff --git a/docs/implplan/BLOCKED_DEPENDENCY_TREE.md b/docs/implplan/BLOCKED_DEPENDENCY_TREE.md
index 47e8f8ce7..e37754c12 100644
--- a/docs/implplan/BLOCKED_DEPENDENCY_TREE.md
+++ b/docs/implplan/BLOCKED_DEPENDENCY_TREE.md
@@ -1,9 +1,17 @@
# BLOCKED Tasks Dependency Tree
-> **Last Updated:** 2025-12-06 (Wave 5: 43 specs + 8 implementations = ~252+ tasks unblocked)
+> **Last Updated:** 2025-12-06 (Wave 6: 49 specs + 8 implementations = ~270+ tasks unblocked)
> **Purpose:** This document maps all BLOCKED tasks and their root causes to help teams prioritize unblocking work.
> **Visual DAG:** See [DEPENDENCY_DAG.md](./DEPENDENCY_DAG.md) for Mermaid graphs, cascade analysis, and guild blocking matrix.
>
-> **Recent Unblocks (2025-12-06 Wave 5):**
+> **Recent Unblocks (2025-12-06 Wave 6):**
+> - ✅ SDK Generator Samples Schema (`docs/schemas/sdk-generator-samples.schema.json`) — 2+ tasks (DEVPORT-63-002, DOCS-SDK-62-001)
+> - ✅ Graph Demo Outputs Schema (`docs/schemas/graph-demo-outputs.schema.json`) — 1+ task (GRAPH-OPS-0001)
+> - ✅ Risk API Schema (`docs/schemas/risk-api.schema.json`) — 5 tasks (DOCS-RISK-67-002 through 68-002)
+> - ✅ Ops Incident Runbook Schema (`docs/schemas/ops-incident-runbook.schema.json`) — 1+ task (DOCS-RUNBOOK-55-001)
+> - ✅ Export Bundle Shapes Schema (`docs/schemas/export-bundle-shapes.schema.json`) — 2 tasks (DOCS-RISK-68-001/002)
+> - ✅ Security Scopes Matrix Schema (`docs/schemas/security-scopes-matrix.schema.json`) — 2 tasks (DOCS-SEC-62-001, DOCS-SEC-OBS-50-001)
+>
+> **Wave 5 Unblocks (2025-12-06):**
> - ✅ DevPortal API Schema (`docs/schemas/devportal-api.schema.json`) — 6 tasks (APIG0101 62-001 to 63-004)
> - ✅ Deployment Service List (`docs/schemas/deployment-service-list.schema.json`) — 7 tasks (COMPOSE-44-001 to 45-003)
> - ✅ Exception Lifecycle Schema (`docs/schemas/exception-lifecycle.schema.json`) — 5 tasks (DOCS-EXC-25-001 to 25-006)
@@ -297,19 +305,31 @@ attestor SDK transport contract ✅ CREATED (chain UNBLOCKED)
## 7. DOCS MD.IX (SPRINT_0309_0001_0009_docs_tasks_md_ix)
-**Root Blocker:** `DOCS-RISK-67-002 draft (risk API)` (due 2025-12-09; reminder ping 2025-12-09, escalate 2025-12-13)
+**Root Blocker:** ~~`DOCS-RISK-67-002 draft (risk API)`~~ ✅ RESOLVED (2025-12-06 Wave 6)
+
+> **Update 2025-12-06 Wave 6:**
+> - ✅ **Risk API Schema** CREATED (`docs/schemas/risk-api.schema.json`)
+> - RiskScore with rating, confidence, and factor breakdown
+> - RiskFactor with weights, contributions, and evidence
+> - RiskProfile with scoring models, thresholds, and modifiers
+> - ScoringModel with weighted_sum, geometric_mean, max_severity types
+> - RiskAssessmentRequest/Response for API endpoints
+> - RiskExplainability for human-readable explanations
+> - RiskAggregation for entity-wide scoring
+> - **5 tasks UNBLOCKED**
```
-DOCS-RISK-67-002 draft missing
- +-- DOCS-RISK-67-003 (risk UI docs)
- +-- DOCS-RISK-67-004 (CLI risk guide)
- +-- DOCS-RISK-68-001 (airgap risk bundles)
- +-- DOCS-RISK-68-002 (AOC invariants update)
+Risk API schema ✅ CREATED (chain UNBLOCKED)
+ +-- DOCS-RISK-67-002 (risk API docs) → UNBLOCKED
+ +-- DOCS-RISK-67-003 (risk UI docs) → UNBLOCKED
+ +-- DOCS-RISK-67-004 (CLI risk guide) → UNBLOCKED
+ +-- DOCS-RISK-68-001 (airgap risk bundles) → UNBLOCKED
+ +-- DOCS-RISK-68-002 (AOC invariants update) → UNBLOCKED
```
-**Impact:** 4 docs tasks (risk chain)
+**Impact:** 5 docs tasks — ✅ ALL UNBLOCKED
-**To Unblock:** API Guild to deliver DOCS-RISK-67-002 draft by 2025-12-09; Console Guild to provide UI captures/hashes by 2025-12-10.
+**Status:** ✅ RESOLVED — Schema created at `docs/schemas/risk-api.schema.json`
---
diff --git a/docs/schemas/export-bundle-shapes.schema.json b/docs/schemas/export-bundle-shapes.schema.json
new file mode 100644
index 000000000..7fa6623e7
--- /dev/null
+++ b/docs/schemas/export-bundle-shapes.schema.json
@@ -0,0 +1,628 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/export-bundle-shapes.schema.json",
+ "title": "StellaOps Export Bundle Shapes Schema",
+ "description": "Schema for export bundle formats, hashing inputs, and airgap bundle structures. Unblocks DOCS-RISK-68-001, DOCS-RISK-68-002 (2+ tasks).",
+ "type": "object",
+ "definitions": {
+ "ExportBundle": {
+ "type": "object",
+ "description": "Export bundle package",
+ "required": ["bundle_id", "bundle_type", "version", "created_at", "contents"],
+ "properties": {
+ "bundle_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "bundle_type": {
+ "type": "string",
+ "enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"],
+ "description": "Type of export bundle"
+ },
+ "version": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ },
+ "format": {
+ "type": "string",
+ "enum": ["json", "ndjson", "csv", "xml", "cyclonedx", "spdx", "sarif"],
+ "description": "Output format"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "created_by": {
+ "type": "string"
+ },
+ "tenant_id": {
+ "type": "string"
+ },
+ "scope": {
+ "$ref": "#/definitions/ExportScope"
+ },
+ "contents": {
+ "$ref": "#/definitions/BundleContents"
+ },
+ "metadata": {
+ "$ref": "#/definitions/BundleMetadata"
+ },
+ "signatures": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/BundleSignature"
+ }
+ },
+ "manifest_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$",
+ "description": "Digest of bundle manifest"
+ }
+ }
+ },
+ "ExportScope": {
+ "type": "object",
+ "description": "Scope of exported data",
+ "properties": {
+ "projects": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "assets": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "time_range": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "end": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "severities": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["critical", "high", "medium", "low", "info"]
+ }
+ },
+ "statuses": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "filters": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Additional filter criteria"
+ }
+ }
+ },
+ "BundleContents": {
+ "type": "object",
+ "description": "Bundle content inventory",
+ "properties": {
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/BundleFile"
+ }
+ },
+ "record_counts": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "description": "Count of records by type"
+ },
+ "total_size_bytes": {
+ "type": "integer"
+ },
+ "compressed_size_bytes": {
+ "type": "integer"
+ },
+ "compression": {
+ "type": "string",
+ "enum": ["none", "gzip", "zstd", "lz4"]
+ }
+ }
+ },
+ "BundleFile": {
+ "type": "object",
+ "description": "Individual file in bundle",
+ "required": ["path", "digest", "size_bytes"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["data", "metadata", "schema", "signature", "index"]
+ },
+ "format": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "size_bytes": {
+ "type": "integer"
+ },
+ "record_count": {
+ "type": "integer"
+ },
+ "schema_ref": {
+ "type": "string",
+ "description": "Reference to schema for this file"
+ }
+ }
+ },
+ "BundleMetadata": {
+ "type": "object",
+ "description": "Bundle metadata",
+ "properties": {
+ "export_job_id": {
+ "type": "string"
+ },
+ "source_system": {
+ "type": "string"
+ },
+ "source_version": {
+ "type": "string"
+ },
+ "export_profile": {
+ "type": "string"
+ },
+ "redaction_applied": {
+ "type": "boolean",
+ "default": false
+ },
+ "redaction_policy": {
+ "type": "string"
+ },
+ "retention_policy": {
+ "type": "string"
+ },
+ "classification": {
+ "type": "string",
+ "enum": ["public", "internal", "confidential", "restricted"]
+ },
+ "custom": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "BundleSignature": {
+ "type": "object",
+ "description": "Digital signature on bundle",
+ "required": ["signature_type", "signature"],
+ "properties": {
+ "signature_type": {
+ "type": "string",
+ "enum": ["dsse", "cosign", "gpg", "x509"]
+ },
+ "signature": {
+ "type": "string",
+ "description": "Base64-encoded signature"
+ },
+ "public_key": {
+ "type": "string",
+ "description": "Public key or key reference"
+ },
+ "key_id": {
+ "type": "string"
+ },
+ "signed_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "signer": {
+ "type": "string"
+ },
+ "certificate_chain": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "AirgapBundle": {
+ "type": "object",
+ "description": "Air-gapped export bundle for offline environments",
+ "required": ["bundle_id", "created_at", "manifest"],
+ "properties": {
+ "bundle_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "bundle_type": {
+ "type": "string",
+ "const": "airgap"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "valid_until": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Expiration for time-sensitive data"
+ },
+ "manifest": {
+ "$ref": "#/definitions/AirgapManifest"
+ },
+ "advisory_data": {
+ "$ref": "#/definitions/AdvisoryBundle"
+ },
+ "risk_data": {
+ "$ref": "#/definitions/RiskBundle"
+ },
+ "policy_data": {
+ "$ref": "#/definitions/PolicyBundle"
+ },
+ "time_anchor": {
+ "$ref": "#/definitions/TimeAnchor"
+ },
+ "aggregate_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "AirgapManifest": {
+ "type": "object",
+ "description": "Manifest of airgap bundle contents",
+ "required": ["version", "files"],
+ "properties": {
+ "version": {
+ "type": "string"
+ },
+ "format_version": {
+ "type": "string",
+ "const": "1.0"
+ },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/BundleFile"
+ }
+ },
+ "dependencies": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "bundle_id": {
+ "type": "string"
+ },
+ "required": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "AdvisoryBundle": {
+ "type": "object",
+ "description": "Advisory data for airgap bundle",
+ "properties": {
+ "sources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Advisory sources included (NVD, OSV, etc.)"
+ },
+ "advisory_count": {
+ "type": "integer"
+ },
+ "cve_count": {
+ "type": "integer"
+ },
+ "last_sync": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "file_ref": {
+ "type": "string",
+ "description": "Path to advisory data file"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "RiskBundle": {
+ "type": "object",
+ "description": "Risk scoring data for airgap bundle",
+ "properties": {
+ "profiles": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Risk profiles included"
+ },
+ "epss_data": {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "string"
+ },
+ "date": {
+ "type": "string",
+ "format": "date"
+ },
+ "record_count": {
+ "type": "integer"
+ }
+ }
+ },
+ "kev_data": {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "string"
+ },
+ "date": {
+ "type": "string",
+ "format": "date"
+ },
+ "record_count": {
+ "type": "integer"
+ }
+ }
+ },
+ "file_ref": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "PolicyBundle": {
+ "type": "object",
+ "description": "Policy data for airgap bundle",
+ "properties": {
+ "policy_packs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "pack_id": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ }
+ },
+ "file_ref": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "TimeAnchor": {
+ "type": "object",
+ "description": "Time anchor for bundle validity",
+ "required": ["anchor_time", "source"],
+ "properties": {
+ "anchor_time": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "source": {
+ "type": "string",
+ "enum": ["ntp", "tsa", "rekor", "manual"]
+ },
+ "tsa_response": {
+ "type": "string",
+ "description": "RFC 3161 timestamp response (base64)"
+ },
+ "rekor_entry": {
+ "type": "string",
+ "description": "Rekor transparency log entry ID"
+ },
+ "drift_tolerance": {
+ "type": "string",
+ "description": "Acceptable clock drift (e.g., 1h)"
+ }
+ }
+ },
+ "HashingInputs": {
+ "type": "object",
+ "description": "Inputs used for deterministic hashing",
+ "required": ["algorithm", "inputs"],
+ "properties": {
+ "algorithm": {
+ "type": "string",
+ "enum": ["sha256", "sha384", "sha512"],
+ "default": "sha256"
+ },
+ "inputs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/HashInput"
+ },
+ "description": "Ordered list of inputs for hash computation"
+ },
+ "canonicalization": {
+ "type": "string",
+ "enum": ["none", "json-canonical", "xml-c14n"],
+ "description": "Canonicalization method before hashing"
+ },
+ "encoding": {
+ "type": "string",
+ "enum": ["utf8", "base64"],
+ "default": "utf8"
+ },
+ "computed_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "HashInput": {
+ "type": "object",
+ "description": "Single input for hash computation",
+ "required": ["type", "value"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["file", "field", "literal", "nested_digest"]
+ },
+ "path": {
+ "type": "string",
+ "description": "File path or JSON path"
+ },
+ "value": {
+ "type": "string",
+ "description": "Literal value or computed digest"
+ },
+ "order": {
+ "type": "integer",
+ "description": "Order in hash computation"
+ }
+ }
+ },
+ "ExportProfile": {
+ "type": "object",
+ "description": "Export profile configuration",
+ "required": ["profile_id", "name", "bundle_type"],
+ "properties": {
+ "profile_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "bundle_type": {
+ "type": "string",
+ "enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"]
+ },
+ "format": {
+ "type": "string"
+ },
+ "scope_defaults": {
+ "$ref": "#/definitions/ExportScope"
+ },
+ "include_signatures": {
+ "type": "boolean",
+ "default": true
+ },
+ "compression": {
+ "type": "string",
+ "enum": ["none", "gzip", "zstd"]
+ },
+ "redaction_policy": {
+ "type": "string"
+ },
+ "retention_days": {
+ "type": "integer"
+ },
+ "schedule": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "cron": {
+ "type": "string"
+ },
+ "destination": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "properties": {
+ "export_profiles": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ExportProfile"
+ }
+ },
+ "bundle_schemas": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Schema references by bundle type"
+ }
+ },
+ "examples": [
+ {
+ "export_profiles": [
+ {
+ "profile_id": "findings-weekly",
+ "name": "Weekly Findings Export",
+ "description": "Weekly export of all findings for compliance reporting",
+ "bundle_type": "findings",
+ "format": "ndjson",
+ "scope_defaults": {
+ "time_range": {
+ "start": "{{now-7d}}",
+ "end": "{{now}}"
+ },
+ "severities": ["critical", "high", "medium"]
+ },
+ "include_signatures": true,
+ "compression": "gzip",
+ "redaction_policy": "pii-removal",
+ "retention_days": 90,
+ "schedule": {
+ "enabled": true,
+ "cron": "0 0 * * 0",
+ "destination": "s3://exports/weekly/"
+ }
+ },
+ {
+ "profile_id": "airgap-full",
+ "name": "Air-Gap Full Bundle",
+ "description": "Complete bundle for air-gapped environments",
+ "bundle_type": "full",
+ "format": "json",
+ "include_signatures": true,
+ "compression": "zstd"
+ }
+ ],
+ "bundle_schemas": {
+ "findings": "https://stella-ops.org/schemas/findings-bundle.schema.json",
+ "sbom": "https://cyclonedx.org/schema/bom-1.6.schema.json",
+ "vex": "https://stella-ops.org/schemas/vex-normalization.schema.json"
+ }
+ }
+ ]
+}
diff --git a/docs/schemas/graph-demo-outputs.schema.json b/docs/schemas/graph-demo-outputs.schema.json
new file mode 100644
index 000000000..d4c02e9d1
--- /dev/null
+++ b/docs/schemas/graph-demo-outputs.schema.json
@@ -0,0 +1,562 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/graph-demo-outputs.schema.json",
+ "title": "StellaOps Graph Demo Outputs Schema",
+ "description": "Schema for graph observability demo outputs, dashboard configurations, and metrics samples. Unblocks GRAPH-OPS-0001 (1+ tasks).",
+ "type": "object",
+ "definitions": {
+ "DemoMetricSample": {
+ "type": "object",
+ "description": "Sample metric data for demo/documentation",
+ "required": ["metric_name", "value", "timestamp"],
+ "properties": {
+ "metric_name": {
+ "type": "string",
+ "description": "Prometheus-style metric name"
+ },
+ "value": {
+ "type": "number"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "unit": {
+ "type": "string",
+ "description": "Metric unit (bytes, seconds, count, etc.)"
+ }
+ }
+ },
+ "DemoTimeSeries": {
+ "type": "object",
+ "description": "Time series data for demo visualizations",
+ "required": ["series_id", "metric_name", "data_points"],
+ "properties": {
+ "series_id": {
+ "type": "string"
+ },
+ "metric_name": {
+ "type": "string"
+ },
+ "labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "data_points": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "value": {
+ "type": "number"
+ }
+ },
+ "required": ["timestamp", "value"]
+ }
+ },
+ "resolution": {
+ "type": "string",
+ "enum": ["1m", "5m", "15m", "1h", "1d"],
+ "description": "Data point resolution"
+ }
+ }
+ },
+ "DemoDashboard": {
+ "type": "object",
+ "description": "Demo dashboard configuration",
+ "required": ["dashboard_id", "title", "panels"],
+ "properties": {
+ "dashboard_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "time_range": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "string"
+ },
+ "to": {
+ "type": "string"
+ }
+ }
+ },
+ "refresh_interval": {
+ "type": "string",
+ "default": "30s"
+ },
+ "panels": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoPanel"
+ }
+ },
+ "variables": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DashboardVariable"
+ }
+ }
+ }
+ },
+ "DemoPanel": {
+ "type": "object",
+ "description": "Dashboard panel configuration",
+ "required": ["panel_id", "title", "type"],
+ "properties": {
+ "panel_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["graph", "stat", "gauge", "table", "heatmap", "logs", "alert_list", "pie_chart", "bar_chart"]
+ },
+ "grid_pos": {
+ "type": "object",
+ "properties": {
+ "x": { "type": "integer" },
+ "y": { "type": "integer" },
+ "w": { "type": "integer" },
+ "h": { "type": "integer" }
+ }
+ },
+ "queries": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PanelQuery"
+ }
+ },
+ "thresholds": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Threshold"
+ }
+ },
+ "demo_data": {
+ "$ref": "#/definitions/DemoTimeSeries",
+ "description": "Pre-populated demo data for this panel"
+ }
+ }
+ },
+ "PanelQuery": {
+ "type": "object",
+ "description": "Panel query definition",
+ "properties": {
+ "query_id": {
+ "type": "string"
+ },
+ "expr": {
+ "type": "string",
+ "description": "PromQL expression"
+ },
+ "legend": {
+ "type": "string"
+ },
+ "datasource": {
+ "type": "string"
+ }
+ }
+ },
+ "Threshold": {
+ "type": "object",
+ "description": "Visual threshold",
+ "properties": {
+ "value": {
+ "type": "number"
+ },
+ "color": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": ["ok", "warning", "critical"]
+ }
+ }
+ },
+ "DashboardVariable": {
+ "type": "object",
+ "description": "Dashboard variable/filter",
+ "required": ["name", "type"],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["query", "custom", "constant", "interval"]
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "type": "string"
+ }
+ }
+ },
+ "DemoAlertRule": {
+ "type": "object",
+ "description": "Demo alert rule",
+ "required": ["rule_id", "name", "expr"],
+ "properties": {
+ "rule_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "expr": {
+ "type": "string",
+ "description": "PromQL alert expression"
+ },
+ "for": {
+ "type": "string",
+ "default": "5m",
+ "description": "Duration condition must be true"
+ },
+ "severity": {
+ "type": "string",
+ "enum": ["critical", "warning", "info"]
+ },
+ "summary": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "runbook_url": {
+ "type": "string",
+ "format": "uri"
+ },
+ "labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "annotations": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "DemoRunbook": {
+ "type": "object",
+ "description": "Demo runbook configuration",
+ "required": ["runbook_id", "title", "steps"],
+ "properties": {
+ "runbook_id": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "trigger_alerts": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Alert rule IDs that trigger this runbook"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RunbookStep"
+ }
+ },
+ "estimated_duration": {
+ "type": "string",
+ "description": "Estimated time to complete (e.g., 15m)"
+ },
+ "severity": {
+ "type": "string",
+ "enum": ["critical", "high", "medium", "low"]
+ }
+ }
+ },
+ "RunbookStep": {
+ "type": "object",
+ "description": "Individual runbook step",
+ "required": ["step_number", "action"],
+ "properties": {
+ "step_number": {
+ "type": "integer"
+ },
+ "action": {
+ "type": "string",
+ "description": "Action to perform"
+ },
+ "command": {
+ "type": "string",
+ "description": "CLI command if applicable"
+ },
+ "expected_outcome": {
+ "type": "string"
+ },
+ "escalation_if_failed": {
+ "type": "string"
+ }
+ }
+ },
+ "DemoOutputPack": {
+ "type": "object",
+ "description": "Complete demo output package",
+ "required": ["pack_id", "version", "generated_at"],
+ "properties": {
+ "pack_id": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "generated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "dashboards": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoDashboard"
+ }
+ },
+ "alert_rules": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoAlertRule"
+ }
+ },
+ "runbooks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoRunbook"
+ }
+ },
+ "sample_data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoTimeSeries"
+ }
+ },
+ "screenshots": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DemoScreenshot"
+ }
+ },
+ "aggregate_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "DemoScreenshot": {
+ "type": "object",
+ "description": "Demo screenshot for documentation",
+ "required": ["screenshot_id", "filename", "digest"],
+ "properties": {
+ "screenshot_id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "dashboard_id": {
+ "type": "string",
+ "description": "Dashboard this screenshot is from"
+ },
+ "panel_id": {
+ "type": "string",
+ "description": "Specific panel if applicable"
+ },
+ "width": {
+ "type": "integer"
+ },
+ "height": {
+ "type": "integer"
+ },
+ "format": {
+ "type": "string",
+ "enum": ["png", "webp", "svg"]
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ }
+ },
+ "properties": {
+ "output_pack": {
+ "$ref": "#/definitions/DemoOutputPack"
+ }
+ },
+ "examples": [
+ {
+ "output_pack": {
+ "pack_id": "stellaops-graph-demo-2025.10",
+ "version": "2025.10.0",
+ "generated_at": "2025-12-06T10:00:00Z",
+ "dashboards": [
+ {
+ "dashboard_id": "vulnerability-overview",
+ "title": "Vulnerability Overview",
+ "description": "High-level vulnerability metrics across all scanned assets",
+ "tags": ["vulnerabilities", "overview"],
+ "time_range": { "from": "now-24h", "to": "now" },
+ "refresh_interval": "1m",
+ "panels": [
+ {
+ "panel_id": "total-vulns",
+ "title": "Total Vulnerabilities",
+ "type": "stat",
+ "grid_pos": { "x": 0, "y": 0, "w": 6, "h": 4 },
+ "queries": [
+ {
+ "query_id": "q1",
+ "expr": "sum(stellaops_vulnerabilities_total)"
+ }
+ ],
+ "thresholds": [
+ { "value": 0, "color": "green", "state": "ok" },
+ { "value": 100, "color": "yellow", "state": "warning" },
+ { "value": 500, "color": "red", "state": "critical" }
+ ]
+ },
+ {
+ "panel_id": "critical-vulns",
+ "title": "Critical Vulnerabilities",
+ "type": "stat",
+ "grid_pos": { "x": 6, "y": 0, "w": 6, "h": 4 },
+ "queries": [
+ {
+ "query_id": "q2",
+ "expr": "sum(stellaops_vulnerabilities_total{severity=\"critical\"})"
+ }
+ ]
+ }
+ ],
+ "variables": [
+ {
+ "name": "tenant",
+ "label": "Tenant",
+ "type": "query",
+ "options": ["all", "tenant-a", "tenant-b"]
+ }
+ ]
+ }
+ ],
+ "alert_rules": [
+ {
+ "rule_id": "critical-vuln-spike",
+ "name": "Critical Vulnerability Spike",
+ "expr": "increase(stellaops_vulnerabilities_total{severity=\"critical\"}[1h]) > 10",
+ "for": "5m",
+ "severity": "critical",
+ "summary": "Critical vulnerabilities increased by more than 10 in the last hour",
+ "runbook_url": "https://docs.stella-ops.org/runbooks/critical-vuln-spike"
+ }
+ ],
+ "runbooks": [
+ {
+ "runbook_id": "critical-vuln-spike-response",
+ "title": "Critical Vulnerability Spike Response",
+ "description": "Steps to investigate and respond to a spike in critical vulnerabilities",
+ "trigger_alerts": ["critical-vuln-spike"],
+ "steps": [
+ {
+ "step_number": 1,
+ "action": "Identify the source of new vulnerabilities",
+ "command": "stella findings list --severity critical --since 1h",
+ "expected_outcome": "List of new critical findings with affected assets"
+ },
+ {
+ "step_number": 2,
+ "action": "Check if vulnerabilities are from new scans or advisory updates",
+ "command": "stella scan jobs --status completed --since 1h"
+ },
+ {
+ "step_number": 3,
+ "action": "Review VEX applicability for affected components",
+ "command": "stella vex check --vuln-id CVE-XXXX-YYYY"
+ }
+ ],
+ "estimated_duration": "15m",
+ "severity": "critical"
+ }
+ ],
+ "sample_data": [
+ {
+ "series_id": "vulns-24h",
+ "metric_name": "stellaops_vulnerabilities_total",
+ "labels": { "severity": "critical" },
+ "data_points": [
+ { "timestamp": "2025-12-05T10:00:00Z", "value": 45 },
+ { "timestamp": "2025-12-05T14:00:00Z", "value": 48 },
+ { "timestamp": "2025-12-05T18:00:00Z", "value": 52 },
+ { "timestamp": "2025-12-05T22:00:00Z", "value": 51 },
+ { "timestamp": "2025-12-06T02:00:00Z", "value": 49 },
+ { "timestamp": "2025-12-06T06:00:00Z", "value": 47 },
+ { "timestamp": "2025-12-06T10:00:00Z", "value": 50 }
+ ],
+ "resolution": "1h"
+ }
+ ],
+ "screenshots": [
+ {
+ "screenshot_id": "vuln-overview-full",
+ "filename": "vulnerability-overview-dashboard.png",
+ "description": "Full vulnerability overview dashboard",
+ "dashboard_id": "vulnerability-overview",
+ "width": 1920,
+ "height": 1080,
+ "format": "png",
+ "digest": "sha256:scr123def456789012345678901234567890123456789012345678901234scr"
+ }
+ ],
+ "aggregate_digest": "sha256:demo123def456789012345678901234567890123456789012345678901234demo"
+ }
+ }
+ ]
+}
diff --git a/docs/schemas/ops-incident-runbook.schema.json b/docs/schemas/ops-incident-runbook.schema.json
new file mode 100644
index 000000000..48d307dc4
--- /dev/null
+++ b/docs/schemas/ops-incident-runbook.schema.json
@@ -0,0 +1,686 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/ops-incident-runbook.schema.json",
+ "title": "StellaOps Operations Incident Runbook Schema",
+ "description": "Schema for incident runbooks, escalation procedures, and operational checklists. Unblocks DOCS-RUNBOOK-55-001 (1+ tasks).",
+ "type": "object",
+ "definitions": {
+ "Runbook": {
+ "type": "object",
+ "description": "Complete incident runbook",
+ "required": ["runbook_id", "title", "severity", "steps"],
+ "properties": {
+ "runbook_id": {
+ "type": "string",
+ "pattern": "^RB-[A-Z]+-[0-9]+$",
+ "description": "Unique runbook identifier (e.g., RB-VULN-001)"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "severity": {
+ "type": "string",
+ "enum": ["critical", "high", "medium", "low"],
+ "description": "Severity level this runbook addresses"
+ },
+ "category": {
+ "type": "string",
+ "enum": ["vulnerability", "outage", "security", "performance", "data", "compliance"],
+ "description": "Incident category"
+ },
+ "trigger_conditions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/TriggerCondition"
+ },
+ "description": "Conditions that trigger this runbook"
+ },
+ "prerequisites": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Required access/tools before starting"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RunbookStep"
+ }
+ },
+ "escalation": {
+ "$ref": "#/definitions/EscalationProcedure"
+ },
+ "communication": {
+ "$ref": "#/definitions/CommunicationPlan"
+ },
+ "rollback": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RunbookStep"
+ },
+ "description": "Rollback steps if resolution fails"
+ },
+ "post_incident": {
+ "$ref": "#/definitions/PostIncidentChecklist"
+ },
+ "estimated_duration": {
+ "type": "string",
+ "description": "Expected time to resolve (e.g., 30m, 2h)"
+ },
+ "last_updated": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "owner": {
+ "type": "string",
+ "description": "Team/person responsible for maintaining this runbook"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "TriggerCondition": {
+ "type": "object",
+ "description": "Condition that triggers the runbook",
+ "required": ["condition_type", "description"],
+ "properties": {
+ "condition_type": {
+ "type": "string",
+ "enum": ["alert", "metric_threshold", "user_report", "scheduled", "manual"]
+ },
+ "description": {
+ "type": "string"
+ },
+ "alert_name": {
+ "type": "string",
+ "description": "Alert name if condition_type is 'alert'"
+ },
+ "metric_expr": {
+ "type": "string",
+ "description": "PromQL expression if condition_type is 'metric_threshold'"
+ },
+ "threshold": {
+ "type": "number"
+ }
+ }
+ },
+ "RunbookStep": {
+ "type": "object",
+ "description": "Individual runbook step",
+ "required": ["step_number", "action"],
+ "properties": {
+ "step_number": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "action": {
+ "type": "string",
+ "description": "What to do"
+ },
+ "description": {
+ "type": "string",
+ "description": "Detailed explanation"
+ },
+ "command": {
+ "type": "string",
+ "description": "CLI command to execute"
+ },
+ "commands": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/CommandSpec"
+ },
+ "description": "Multiple commands if needed"
+ },
+ "expected_output": {
+ "type": "string",
+ "description": "What success looks like"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Max time for this step (e.g., 5m)"
+ },
+ "decision_point": {
+ "$ref": "#/definitions/DecisionPoint",
+ "description": "If step requires a decision"
+ },
+ "verification": {
+ "type": "string",
+ "description": "How to verify step completed successfully"
+ },
+ "notes": {
+ "type": "string",
+ "description": "Additional context or warnings"
+ },
+ "skip_conditions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Conditions under which to skip this step"
+ }
+ }
+ },
+ "CommandSpec": {
+ "type": "object",
+ "description": "Command specification",
+ "required": ["command"],
+ "properties": {
+ "command": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "requires_sudo": {
+ "type": "boolean",
+ "default": false
+ },
+ "environment": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "DecisionPoint": {
+ "type": "object",
+ "description": "Decision branch in runbook",
+ "required": ["question", "options"],
+ "properties": {
+ "question": {
+ "type": "string"
+ },
+ "options": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "condition": {
+ "type": "string"
+ },
+ "next_step": {
+ "type": "integer"
+ },
+ "action": {
+ "type": "string"
+ }
+ },
+ "required": ["condition"]
+ }
+ }
+ }
+ },
+ "EscalationProcedure": {
+ "type": "object",
+ "description": "Escalation procedure",
+ "properties": {
+ "levels": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/EscalationLevel"
+ }
+ },
+ "auto_escalate_after": {
+ "type": "string",
+ "description": "Time after which to auto-escalate (e.g., 30m)"
+ },
+ "escalation_criteria": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Conditions that trigger escalation"
+ }
+ }
+ },
+ "EscalationLevel": {
+ "type": "object",
+ "description": "Single escalation level",
+ "required": ["level", "contacts"],
+ "properties": {
+ "level": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "name": {
+ "type": "string"
+ },
+ "contacts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Contact"
+ }
+ },
+ "response_time_sla": {
+ "type": "string",
+ "description": "Expected response time (e.g., 15m)"
+ },
+ "notification_channels": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["pagerduty", "slack", "email", "phone", "sms", "teams"]
+ }
+ }
+ }
+ },
+ "Contact": {
+ "type": "object",
+ "description": "Contact information",
+ "required": ["name", "role"],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "role": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string",
+ "format": "email"
+ },
+ "phone": {
+ "type": "string"
+ },
+ "slack_handle": {
+ "type": "string"
+ },
+ "pagerduty_id": {
+ "type": "string"
+ }
+ }
+ },
+ "CommunicationPlan": {
+ "type": "object",
+ "description": "Communication during incident",
+ "properties": {
+ "status_page": {
+ "type": "string",
+ "format": "uri",
+ "description": "Public status page URL"
+ },
+ "internal_channel": {
+ "type": "string",
+ "description": "Internal communication channel (e.g., #incident-response)"
+ },
+ "stakeholder_updates": {
+ "type": "object",
+ "properties": {
+ "frequency": {
+ "type": "string",
+ "description": "Update frequency (e.g., every 30m)"
+ },
+ "recipients": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "template": {
+ "type": "string",
+ "description": "Status update template"
+ }
+ }
+ },
+ "customer_notification": {
+ "type": "object",
+ "properties": {
+ "required": {
+ "type": "boolean"
+ },
+ "template": {
+ "type": "string"
+ },
+ "approval_required": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "PostIncidentChecklist": {
+ "type": "object",
+ "description": "Post-incident activities",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "task": {
+ "type": "string"
+ },
+ "owner": {
+ "type": "string"
+ },
+ "due": {
+ "type": "string",
+ "description": "Due timeframe (e.g., within 24h, within 1 week)"
+ },
+ "required": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "required": ["task"]
+ }
+ },
+ "postmortem_required": {
+ "type": "boolean",
+ "default": true
+ },
+ "postmortem_due": {
+ "type": "string",
+ "description": "Timeframe for postmortem (e.g., 5 business days)"
+ }
+ }
+ },
+ "IncidentChecklist": {
+ "type": "object",
+ "description": "Pre-flight checklist for incident response",
+ "required": ["checklist_id", "name", "items"],
+ "properties": {
+ "checklist_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string",
+ "enum": ["access", "tools", "documentation", "communication", "monitoring"]
+ },
+ "verification": {
+ "type": "string"
+ }
+ },
+ "required": ["description"]
+ }
+ }
+ }
+ },
+ "RunbookCatalog": {
+ "type": "object",
+ "description": "Catalog of all runbooks",
+ "required": ["catalog_id", "version", "runbooks"],
+ "properties": {
+ "catalog_id": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "runbooks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Runbook"
+ }
+ },
+ "checklists": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/IncidentChecklist"
+ }
+ },
+ "global_contacts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Contact"
+ }
+ }
+ }
+ }
+ },
+ "properties": {
+ "catalog": {
+ "$ref": "#/definitions/RunbookCatalog"
+ }
+ },
+ "examples": [
+ {
+ "catalog": {
+ "catalog_id": "stellaops-runbooks",
+ "version": "2025.10.0",
+ "updated_at": "2025-12-06T10:00:00Z",
+ "runbooks": [
+ {
+ "runbook_id": "RB-VULN-001",
+ "title": "Critical Vulnerability Spike Response",
+ "description": "Response procedure when critical vulnerabilities spike significantly",
+ "severity": "critical",
+ "category": "vulnerability",
+ "trigger_conditions": [
+ {
+ "condition_type": "alert",
+ "description": "Critical vulnerability count increased by >10 in 1 hour",
+ "alert_name": "CriticalVulnerabilitySpike"
+ }
+ ],
+ "prerequisites": [
+ "Access to StellaOps CLI (stella)",
+ "Read access to Findings Ledger",
+ "Access to #security-incidents Slack channel"
+ ],
+ "steps": [
+ {
+ "step_number": 1,
+ "action": "Acknowledge the alert",
+ "description": "Acknowledge in PagerDuty/alerting system to stop escalation",
+ "timeout": "5m"
+ },
+ {
+ "step_number": 2,
+ "action": "Identify scope of new vulnerabilities",
+ "command": "stella findings list --severity critical --since 1h --format table",
+ "expected_output": "List of new critical findings with CVE IDs and affected assets",
+ "verification": "Output shows findings with timestamps within last hour"
+ },
+ {
+ "step_number": 3,
+ "action": "Determine if spike is from new scans or advisory updates",
+ "commands": [
+ {
+ "command": "stella scan jobs --status completed --since 1h",
+ "description": "Check for recent scan completions"
+ },
+ {
+ "command": "stella advisory updates --since 1h",
+ "description": "Check for recent advisory updates"
+ }
+ ],
+ "decision_point": {
+ "question": "What caused the spike?",
+ "options": [
+ {
+ "condition": "New scans completed",
+ "next_step": 4,
+ "action": "Review scan results"
+ },
+ {
+ "condition": "Advisory update",
+ "next_step": 5,
+ "action": "Review advisory impact"
+ },
+ {
+ "condition": "Unknown/Both",
+ "next_step": 4,
+ "action": "Continue with full investigation"
+ }
+ ]
+ }
+ },
+ {
+ "step_number": 4,
+ "action": "Review affected assets and determine business impact",
+ "command": "stella findings group-by asset --severity critical --since 1h",
+ "description": "Group findings by asset to understand impact scope"
+ },
+ {
+ "step_number": 5,
+ "action": "Check VEX applicability",
+ "command": "stella vex check --vuln-ids $(stella findings list --severity critical --since 1h --format ids)",
+ "description": "Check if any vulnerabilities have VEX statements that reduce severity"
+ },
+ {
+ "step_number": 6,
+ "action": "Update stakeholders",
+ "description": "Post status update to #security-incidents with findings summary",
+ "notes": "Use template: 'VULN SPIKE: [count] new critical vulns affecting [assets]. Investigation in progress.'"
+ },
+ {
+ "step_number": 7,
+ "action": "Create remediation tickets if needed",
+ "command": "stella findings export --severity critical --since 1h --format jira",
+ "skip_conditions": [
+ "All vulnerabilities covered by VEX not_affected",
+ "Vulnerabilities are duplicates from rescan"
+ ]
+ }
+ ],
+ "escalation": {
+ "levels": [
+ {
+ "level": 1,
+ "name": "On-call Security Engineer",
+ "contacts": [
+ {
+ "name": "Security On-Call",
+ "role": "Security Engineer",
+ "slack_handle": "@security-oncall"
+ }
+ ],
+ "response_time_sla": "15m",
+ "notification_channels": ["pagerduty", "slack"]
+ },
+ {
+ "level": 2,
+ "name": "Security Team Lead",
+ "contacts": [
+ {
+ "name": "Security Lead",
+ "role": "Security Team Lead",
+ "slack_handle": "@security-lead"
+ }
+ ],
+ "response_time_sla": "30m",
+ "notification_channels": ["pagerduty", "slack", "phone"]
+ }
+ ],
+ "auto_escalate_after": "30m",
+ "escalation_criteria": [
+ "No acknowledgment within 15 minutes",
+ "More than 50 critical vulnerabilities",
+ "Production systems affected"
+ ]
+ },
+ "communication": {
+ "internal_channel": "#security-incidents",
+ "stakeholder_updates": {
+ "frequency": "every 30m during active incident",
+ "recipients": ["security-team", "engineering-leads"],
+ "template": "VULN INCIDENT UPDATE: Status: [status]. Critical count: [count]. Affected systems: [systems]. Next update: [time]."
+ }
+ },
+ "post_incident": {
+ "items": [
+ {
+ "task": "Document incident timeline",
+ "owner": "Incident Commander",
+ "due": "within 24h",
+ "required": true
+ },
+ {
+ "task": "Update vulnerability scanning schedules if needed",
+ "owner": "Security Team",
+ "due": "within 1 week",
+ "required": false
+ },
+ {
+ "task": "Review and update this runbook",
+ "owner": "Runbook Owner",
+ "due": "within 1 week",
+ "required": true
+ }
+ ],
+ "postmortem_required": true,
+ "postmortem_due": "5 business days"
+ },
+ "estimated_duration": "1h",
+ "last_updated": "2025-12-06T10:00:00Z",
+ "owner": "Security Operations Team",
+ "tags": ["vulnerability", "security", "critical"]
+ }
+ ],
+ "checklists": [
+ {
+ "checklist_id": "incident-preflight",
+ "name": "Incident Response Pre-flight Checklist",
+ "description": "Verify access and tools before incident response",
+ "items": [
+ {
+ "item_id": "cli-access",
+ "description": "StellaOps CLI is installed and authenticated",
+ "category": "tools",
+ "verification": "Run 'stella whoami' successfully"
+ },
+ {
+ "item_id": "slack-access",
+ "description": "Access to #security-incidents channel",
+ "category": "communication",
+ "verification": "Can post messages to channel"
+ },
+ {
+ "item_id": "pagerduty-access",
+ "description": "Can acknowledge alerts in PagerDuty",
+ "category": "tools",
+ "verification": "PagerDuty mobile app logged in"
+ },
+ {
+ "item_id": "runbooks-access",
+ "description": "Can access runbook documentation",
+ "category": "documentation",
+ "verification": "docs.stella-ops.org/runbooks accessible"
+ }
+ ]
+ }
+ ],
+ "global_contacts": [
+ {
+ "name": "Security Operations",
+ "role": "Primary Response Team",
+ "email": "security-ops@example.com",
+ "slack_handle": "@security-ops"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/docs/schemas/risk-api.schema.json b/docs/schemas/risk-api.schema.json
new file mode 100644
index 000000000..59f71dddd
--- /dev/null
+++ b/docs/schemas/risk-api.schema.json
@@ -0,0 +1,606 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/risk-api.schema.json",
+ "title": "StellaOps Risk API Schema",
+ "description": "Schema for Risk API endpoints, scoring models, and factor weights. Unblocks DOCS-RISK-67-002 through 68-002 (5+ tasks).",
+ "type": "object",
+ "definitions": {
+ "RiskScore": {
+ "type": "object",
+ "description": "Computed risk score",
+ "required": ["score", "rating", "computed_at"],
+ "properties": {
+ "score": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 100,
+ "description": "Numeric risk score (0-100)"
+ },
+ "rating": {
+ "type": "string",
+ "enum": ["critical", "high", "medium", "low", "info", "none"],
+ "description": "Risk rating category"
+ },
+ "computed_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "confidence": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Confidence level in the score (0-1)"
+ },
+ "factors": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RiskFactor"
+ }
+ },
+ "trend": {
+ "$ref": "#/definitions/RiskTrend"
+ }
+ }
+ },
+ "RiskFactor": {
+ "type": "object",
+ "description": "Individual risk factor contribution",
+ "required": ["factor_id", "name", "value", "weight", "contribution"],
+ "properties": {
+ "factor_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string",
+ "enum": ["vulnerability", "exposure", "asset", "context", "temporal", "environmental"]
+ },
+ "value": {
+ "type": "number",
+ "description": "Raw factor value"
+ },
+ "normalized_value": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Normalized value (0-1)"
+ },
+ "weight": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Factor weight in scoring model"
+ },
+ "contribution": {
+ "type": "number",
+ "description": "Contribution to final score"
+ },
+ "source": {
+ "type": "string",
+ "description": "Data source for this factor"
+ },
+ "evidence": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Evidence supporting this factor"
+ }
+ }
+ },
+ "RiskTrend": {
+ "type": "object",
+ "description": "Risk score trend over time",
+ "properties": {
+ "direction": {
+ "type": "string",
+ "enum": ["improving", "stable", "degrading"]
+ },
+ "change_percent": {
+ "type": "number",
+ "description": "Percent change from previous period"
+ },
+ "period": {
+ "type": "string",
+ "enum": ["24h", "7d", "30d"]
+ },
+ "previous_score": {
+ "type": "number"
+ }
+ }
+ },
+ "RiskProfile": {
+ "type": "object",
+ "description": "Risk profile configuration",
+ "required": ["profile_id", "name"],
+ "properties": {
+ "profile_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "scoring_model": {
+ "$ref": "#/definitions/ScoringModel"
+ },
+ "thresholds": {
+ "$ref": "#/definitions/RiskThresholds"
+ },
+ "factor_weights": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1
+ }
+ },
+ "enabled_factors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "ScoringModel": {
+ "type": "object",
+ "description": "Risk scoring model configuration",
+ "required": ["model_id", "name"],
+ "properties": {
+ "model_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["weighted_sum", "geometric_mean", "max_severity", "custom"],
+ "description": "Scoring algorithm type"
+ },
+ "base_score_source": {
+ "type": "string",
+ "enum": ["cvss_v3", "cvss_v4", "epss", "custom"],
+ "description": "Primary score source"
+ },
+ "modifiers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ScoreModifier"
+ }
+ }
+ }
+ },
+ "ScoreModifier": {
+ "type": "object",
+ "description": "Score modifier rule",
+ "required": ["modifier_id", "condition", "adjustment"],
+ "properties": {
+ "modifier_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "condition": {
+ "type": "string",
+ "description": "Condition expression (e.g., 'kev == true')"
+ },
+ "adjustment": {
+ "type": "number",
+ "description": "Score adjustment (can be positive or negative)"
+ },
+ "adjustment_type": {
+ "type": "string",
+ "enum": ["absolute", "percent", "multiply"],
+ "default": "absolute"
+ },
+ "priority": {
+ "type": "integer",
+ "description": "Order of modifier application"
+ }
+ }
+ },
+ "RiskThresholds": {
+ "type": "object",
+ "description": "Risk rating thresholds",
+ "properties": {
+ "critical": {
+ "type": "number",
+ "default": 90,
+ "description": "Score >= this is Critical"
+ },
+ "high": {
+ "type": "number",
+ "default": 70,
+ "description": "Score >= this is High"
+ },
+ "medium": {
+ "type": "number",
+ "default": 40,
+ "description": "Score >= this is Medium"
+ },
+ "low": {
+ "type": "number",
+ "default": 10,
+ "description": "Score >= this is Low"
+ },
+ "info": {
+ "type": "number",
+ "default": 0,
+ "description": "Score >= this is Info"
+ }
+ }
+ },
+ "RiskAssessmentRequest": {
+ "type": "object",
+ "description": "Request to compute risk for an entity",
+ "required": ["entity_type", "entity_id"],
+ "properties": {
+ "entity_type": {
+ "type": "string",
+ "enum": ["vulnerability", "asset", "component", "project", "tenant"]
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "profile_id": {
+ "type": "string",
+ "description": "Risk profile to use (uses default if not specified)"
+ },
+ "include_factors": {
+ "type": "boolean",
+ "default": true,
+ "description": "Include factor breakdown in response"
+ },
+ "include_trend": {
+ "type": "boolean",
+ "default": false,
+ "description": "Include trend analysis"
+ },
+ "context": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Additional context for scoring"
+ }
+ }
+ },
+ "RiskAssessmentResponse": {
+ "type": "object",
+ "description": "Risk assessment result",
+ "required": ["assessment_id", "entity_type", "entity_id", "score"],
+ "properties": {
+ "assessment_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "entity_type": {
+ "type": "string"
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "profile_id": {
+ "type": "string"
+ },
+ "score": {
+ "$ref": "#/definitions/RiskScore"
+ },
+ "explainability": {
+ "$ref": "#/definitions/RiskExplainability"
+ },
+ "recommendations": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RiskRecommendation"
+ }
+ }
+ }
+ },
+ "RiskExplainability": {
+ "type": "object",
+ "description": "Human-readable explanation of risk score",
+ "properties": {
+ "summary": {
+ "type": "string",
+ "description": "One-line summary"
+ },
+ "narrative": {
+ "type": "string",
+ "description": "Detailed explanation"
+ },
+ "top_factors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "factor": {
+ "type": "string"
+ },
+ "impact": {
+ "type": "string",
+ "enum": ["major", "moderate", "minor"]
+ },
+ "explanation": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Top contributing factors"
+ },
+ "comparisons": {
+ "type": "object",
+ "properties": {
+ "org_percentile": {
+ "type": "number",
+ "description": "Percentile within organization"
+ },
+ "industry_percentile": {
+ "type": "number",
+ "description": "Percentile within industry"
+ }
+ }
+ }
+ }
+ },
+ "RiskRecommendation": {
+ "type": "object",
+ "description": "Risk mitigation recommendation",
+ "required": ["recommendation_id", "action"],
+ "properties": {
+ "recommendation_id": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string",
+ "description": "Recommended action"
+ },
+ "priority": {
+ "type": "string",
+ "enum": ["critical", "high", "medium", "low"]
+ },
+ "impact_estimate": {
+ "type": "number",
+ "description": "Estimated score reduction if implemented"
+ },
+ "effort": {
+ "type": "string",
+ "enum": ["minimal", "moderate", "significant"]
+ },
+ "related_factors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "RiskAggregation": {
+ "type": "object",
+ "description": "Aggregated risk across multiple entities",
+ "required": ["aggregation_id", "entity_type", "count", "scores"],
+ "properties": {
+ "aggregation_id": {
+ "type": "string"
+ },
+ "entity_type": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "description": "Aggregation scope (e.g., project, tenant)"
+ },
+ "count": {
+ "type": "integer",
+ "description": "Number of entities aggregated"
+ },
+ "scores": {
+ "type": "object",
+ "properties": {
+ "average": {
+ "type": "number"
+ },
+ "median": {
+ "type": "number"
+ },
+ "max": {
+ "type": "number"
+ },
+ "min": {
+ "type": "number"
+ },
+ "p95": {
+ "type": "number"
+ }
+ }
+ },
+ "distribution": {
+ "type": "object",
+ "properties": {
+ "critical": { "type": "integer" },
+ "high": { "type": "integer" },
+ "medium": { "type": "integer" },
+ "low": { "type": "integer" },
+ "info": { "type": "integer" }
+ }
+ },
+ "trend": {
+ "$ref": "#/definitions/RiskTrend"
+ }
+ }
+ },
+ "RiskApiEndpoint": {
+ "type": "object",
+ "description": "Risk API endpoint definition",
+ "required": ["path", "method", "operation_id"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string",
+ "enum": ["GET", "POST", "PUT", "DELETE"]
+ },
+ "operation_id": {
+ "type": "string"
+ },
+ "summary": {
+ "type": "string"
+ },
+ "request_schema": {
+ "type": "string",
+ "description": "Reference to request schema"
+ },
+ "response_schema": {
+ "type": "string",
+ "description": "Reference to response schema"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Required OAuth scopes"
+ }
+ }
+ }
+ },
+ "properties": {
+ "endpoints": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RiskApiEndpoint"
+ }
+ },
+ "profiles": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RiskProfile"
+ }
+ }
+ },
+ "examples": [
+ {
+ "endpoints": [
+ {
+ "path": "/api/v1/risk/assess",
+ "method": "POST",
+ "operation_id": "assessRisk",
+ "summary": "Compute risk score for an entity",
+ "request_schema": "RiskAssessmentRequest",
+ "response_schema": "RiskAssessmentResponse",
+ "scopes": ["risk:read"]
+ },
+ {
+ "path": "/api/v1/risk/profiles",
+ "method": "GET",
+ "operation_id": "listRiskProfiles",
+ "summary": "List available risk profiles",
+ "response_schema": "RiskProfile[]",
+ "scopes": ["risk:read"]
+ },
+ {
+ "path": "/api/v1/risk/aggregate",
+ "method": "POST",
+ "operation_id": "aggregateRisk",
+ "summary": "Compute aggregated risk across entities",
+ "response_schema": "RiskAggregation",
+ "scopes": ["risk:read"]
+ },
+ {
+ "path": "/api/v1/risk/profiles/{profile_id}",
+ "method": "PUT",
+ "operation_id": "updateRiskProfile",
+ "summary": "Update a risk profile",
+ "request_schema": "RiskProfile",
+ "scopes": ["risk:write", "admin"]
+ },
+ {
+ "path": "/api/v1/risk/explain/{assessment_id}",
+ "method": "GET",
+ "operation_id": "explainRisk",
+ "summary": "Get detailed explanation for a risk assessment",
+ "response_schema": "RiskExplainability",
+ "scopes": ["risk:read"]
+ }
+ ],
+ "profiles": [
+ {
+ "profile_id": "default",
+ "name": "Default Risk Profile",
+ "description": "Standard risk scoring with balanced factor weights",
+ "scoring_model": {
+ "model_id": "weighted-sum-v1",
+ "name": "Weighted Sum Model",
+ "version": "1.0.0",
+ "type": "weighted_sum",
+ "base_score_source": "cvss_v3",
+ "modifiers": [
+ {
+ "modifier_id": "kev-boost",
+ "name": "KEV Exploit Known",
+ "condition": "kev == true",
+ "adjustment": 15,
+ "adjustment_type": "absolute",
+ "priority": 1
+ },
+ {
+ "modifier_id": "reachability-reduction",
+ "name": "Not Reachable",
+ "condition": "reachability == 'not_reachable'",
+ "adjustment": -30,
+ "adjustment_type": "absolute",
+ "priority": 2
+ },
+ {
+ "modifier_id": "vex-not-affected",
+ "name": "VEX Not Affected",
+ "condition": "vex_status == 'not_affected'",
+ "adjustment": -50,
+ "adjustment_type": "absolute",
+ "priority": 3
+ }
+ ]
+ },
+ "thresholds": {
+ "critical": 90,
+ "high": 70,
+ "medium": 40,
+ "low": 10,
+ "info": 0
+ },
+ "factor_weights": {
+ "cvss_base": 0.35,
+ "exploitability": 0.25,
+ "reachability": 0.20,
+ "asset_criticality": 0.10,
+ "exposure": 0.10
+ },
+ "enabled_factors": [
+ "cvss_base",
+ "exploitability",
+ "kev",
+ "epss",
+ "reachability",
+ "vex_status",
+ "asset_criticality",
+ "exposure",
+ "age"
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/schemas/sdk-generator-samples.schema.json b/docs/schemas/sdk-generator-samples.schema.json
new file mode 100644
index 000000000..d78091940
--- /dev/null
+++ b/docs/schemas/sdk-generator-samples.schema.json
@@ -0,0 +1,498 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/sdk-generator-samples.schema.json",
+ "title": "StellaOps SDK Generator Samples Schema",
+ "description": "Schema for SDK generator output samples, snippet packs, and language bindings. Unblocks DEVPORT-63-002, DOCS-SDK-62-001 (2+ tasks).",
+ "type": "object",
+ "definitions": {
+ "SdkLanguage": {
+ "type": "string",
+ "enum": ["typescript", "python", "go", "java", "csharp", "ruby", "php", "rust"],
+ "description": "Supported SDK target languages"
+ },
+ "SdkSample": {
+ "type": "object",
+ "description": "Individual SDK code sample",
+ "required": ["sample_id", "language", "operation", "code"],
+ "properties": {
+ "sample_id": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]*$",
+ "description": "Unique sample identifier"
+ },
+ "language": {
+ "$ref": "#/definitions/SdkLanguage"
+ },
+ "operation": {
+ "type": "string",
+ "description": "API operation this sample demonstrates"
+ },
+ "title": {
+ "type": "string",
+ "description": "Human-readable title"
+ },
+ "description": {
+ "type": "string",
+ "description": "Explanation of what the sample demonstrates"
+ },
+ "code": {
+ "type": "string",
+ "description": "The actual code sample"
+ },
+ "imports": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Required imports/dependencies"
+ },
+ "prerequisites": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Setup steps before running"
+ },
+ "expected_output": {
+ "type": "string",
+ "description": "Expected console output or result"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Categorization tags (auth, scanning, vex, etc.)"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$",
+ "description": "SHA-256 hash of code content for verification"
+ }
+ }
+ },
+ "SnippetPack": {
+ "type": "object",
+ "description": "Collection of SDK samples for a specific language",
+ "required": ["pack_id", "language", "version", "samples"],
+ "properties": {
+ "pack_id": {
+ "type": "string",
+ "description": "Unique pack identifier"
+ },
+ "language": {
+ "$ref": "#/definitions/SdkLanguage"
+ },
+ "version": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
+ "description": "Pack version (semver)"
+ },
+ "sdk_version": {
+ "type": "string",
+ "description": "Target SDK version these samples work with"
+ },
+ "api_version": {
+ "type": "string",
+ "description": "API version (e.g., v1, v2)"
+ },
+ "generated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "samples": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SdkSample"
+ }
+ },
+ "aggregate_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$",
+ "description": "Hash of all sample digests combined"
+ },
+ "package_info": {
+ "$ref": "#/definitions/PackageInfo"
+ }
+ }
+ },
+ "PackageInfo": {
+ "type": "object",
+ "description": "Language-specific package information",
+ "properties": {
+ "package_name": {
+ "type": "string",
+ "description": "Package name (e.g., @stellaops/sdk, stellaops-sdk)"
+ },
+ "registry_url": {
+ "type": "string",
+ "format": "uri",
+ "description": "Package registry URL"
+ },
+ "install_command": {
+ "type": "string",
+ "description": "Command to install the SDK"
+ },
+ "min_runtime_version": {
+ "type": "string",
+ "description": "Minimum runtime version required"
+ },
+ "dependencies": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Dependency"
+ }
+ }
+ }
+ },
+ "Dependency": {
+ "type": "object",
+ "description": "SDK dependency",
+ "required": ["name", "version"],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "optional": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "SdkGeneratorConfig": {
+ "type": "object",
+ "description": "SDK generator configuration",
+ "required": ["generator_id", "openapi_source"],
+ "properties": {
+ "generator_id": {
+ "type": "string"
+ },
+ "openapi_source": {
+ "type": "string",
+ "format": "uri",
+ "description": "OpenAPI spec URL or path"
+ },
+ "target_languages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SdkLanguage"
+ }
+ },
+ "output_dir": {
+ "type": "string"
+ },
+ "templates": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Custom templates per language"
+ },
+ "options": {
+ "$ref": "#/definitions/GeneratorOptions"
+ }
+ }
+ },
+ "GeneratorOptions": {
+ "type": "object",
+ "description": "SDK generation options",
+ "properties": {
+ "include_samples": {
+ "type": "boolean",
+ "default": true,
+ "description": "Generate usage samples"
+ },
+ "include_tests": {
+ "type": "boolean",
+ "default": true,
+ "description": "Generate test stubs"
+ },
+ "include_docs": {
+ "type": "boolean",
+ "default": true,
+ "description": "Generate API documentation"
+ },
+ "async_style": {
+ "type": "string",
+ "enum": ["async_await", "promises", "callbacks", "sync"],
+ "default": "async_await"
+ },
+ "error_handling": {
+ "type": "string",
+ "enum": ["exceptions", "result_types", "error_codes"],
+ "default": "exceptions"
+ },
+ "naming_convention": {
+ "type": "string",
+ "enum": ["camelCase", "snake_case", "PascalCase"],
+ "description": "Override default for language"
+ }
+ }
+ },
+ "SdkGeneratorOutput": {
+ "type": "object",
+ "description": "Output from SDK generation",
+ "required": ["output_id", "generator_id", "generated_at", "files"],
+ "properties": {
+ "output_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "generator_id": {
+ "type": "string"
+ },
+ "generated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "language": {
+ "$ref": "#/definitions/SdkLanguage"
+ },
+ "sdk_version": {
+ "type": "string"
+ },
+ "api_version": {
+ "type": "string"
+ },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/GeneratedFile"
+ }
+ },
+ "stats": {
+ "$ref": "#/definitions/GenerationStats"
+ },
+ "manifest_digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "GeneratedFile": {
+ "type": "object",
+ "description": "Generated SDK file",
+ "required": ["path", "digest"],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["source", "test", "sample", "docs", "config"]
+ },
+ "size_bytes": {
+ "type": "integer"
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ }
+ },
+ "GenerationStats": {
+ "type": "object",
+ "description": "SDK generation statistics",
+ "properties": {
+ "total_files": {
+ "type": "integer"
+ },
+ "source_files": {
+ "type": "integer"
+ },
+ "test_files": {
+ "type": "integer"
+ },
+ "sample_files": {
+ "type": "integer"
+ },
+ "total_lines": {
+ "type": "integer"
+ },
+ "endpoints_covered": {
+ "type": "integer"
+ },
+ "models_generated": {
+ "type": "integer"
+ }
+ }
+ },
+ "SampleCategory": {
+ "type": "object",
+ "description": "Category grouping for samples",
+ "required": ["category_id", "name"],
+ "properties": {
+ "category_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "samples": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Sample IDs in this category"
+ },
+ "order": {
+ "type": "integer",
+ "description": "Display order"
+ }
+ }
+ },
+ "SdkDocumentation": {
+ "type": "object",
+ "description": "SDK documentation structure",
+ "properties": {
+ "overview": {
+ "type": "string",
+ "description": "SDK overview markdown"
+ },
+ "quickstart": {
+ "type": "string",
+ "description": "Quickstart guide markdown"
+ },
+ "authentication": {
+ "type": "string",
+ "description": "Authentication guide"
+ },
+ "error_handling": {
+ "type": "string",
+ "description": "Error handling guide"
+ },
+ "changelog": {
+ "type": "string",
+ "description": "SDK changelog"
+ },
+ "api_reference_url": {
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ }
+ },
+ "properties": {
+ "snippet_packs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SnippetPack"
+ }
+ },
+ "categories": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SampleCategory"
+ }
+ },
+ "generator_outputs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/SdkGeneratorOutput"
+ }
+ }
+ },
+ "examples": [
+ {
+ "snippet_packs": [
+ {
+ "pack_id": "stellaops-typescript-samples",
+ "language": "typescript",
+ "version": "1.0.0",
+ "sdk_version": "2025.10.0",
+ "api_version": "v1",
+ "generated_at": "2025-12-06T10:00:00Z",
+ "samples": [
+ {
+ "sample_id": "auth-token-exchange",
+ "language": "typescript",
+ "operation": "POST /oauth/token",
+ "title": "Token Exchange",
+ "description": "Exchange client credentials for an access token",
+ "code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient({\n clientId: process.env.STELLAOPS_CLIENT_ID,\n clientSecret: process.env.STELLAOPS_CLIENT_SECRET,\n});\n\nconst token = await client.auth.getToken();\nconsole.log('Token expires at:', token.expiresAt);",
+ "imports": ["@stellaops/sdk"],
+ "prerequisites": [
+ "Set STELLAOPS_CLIENT_ID environment variable",
+ "Set STELLAOPS_CLIENT_SECRET environment variable"
+ ],
+ "expected_output": "Token expires at: 2025-12-06T11:00:00Z",
+ "tags": ["authentication", "oauth"],
+ "digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
+ },
+ {
+ "sample_id": "scan-container-image",
+ "language": "typescript",
+ "operation": "POST /api/v1/scanner/scan",
+ "title": "Scan Container Image",
+ "description": "Scan a container image for vulnerabilities",
+ "code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient();\nconst result = await client.scanner.scanImage({\n image: 'nginx:1.25',\n generateSbom: true,\n format: 'cyclonedx',\n});\n\nconsole.log(`Found ${result.vulnerabilities.length} vulnerabilities`);",
+ "imports": ["@stellaops/sdk"],
+ "tags": ["scanning", "sbom", "vulnerabilities"],
+ "digest": "sha256:def456abc789012345678901234567890123456789012345678901234defabc"
+ }
+ ],
+ "aggregate_digest": "sha256:agg123def456789012345678901234567890123456789012345678901234agg",
+ "package_info": {
+ "package_name": "@stellaops/sdk",
+ "registry_url": "https://registry.npmjs.org",
+ "install_command": "npm install @stellaops/sdk",
+ "min_runtime_version": "18.0.0",
+ "dependencies": [
+ { "name": "axios", "version": "^1.6.0" },
+ { "name": "jose", "version": "^5.0.0" }
+ ]
+ }
+ },
+ {
+ "pack_id": "stellaops-python-samples",
+ "language": "python",
+ "version": "1.0.0",
+ "sdk_version": "2025.10.0",
+ "api_version": "v1",
+ "generated_at": "2025-12-06T10:00:00Z",
+ "samples": [
+ {
+ "sample_id": "auth-token-exchange-py",
+ "language": "python",
+ "operation": "POST /oauth/token",
+ "title": "Token Exchange",
+ "description": "Exchange client credentials for an access token",
+ "code": "from stellaops import StellaOpsClient\nimport os\n\nclient = StellaOpsClient(\n client_id=os.environ['STELLAOPS_CLIENT_ID'],\n client_secret=os.environ['STELLAOPS_CLIENT_SECRET'],\n)\n\ntoken = client.auth.get_token()\nprint(f'Token expires at: {token.expires_at}')",
+ "imports": ["stellaops"],
+ "tags": ["authentication", "oauth"],
+ "digest": "sha256:py123def456789012345678901234567890123456789012345678901234pyth"
+ }
+ ],
+ "package_info": {
+ "package_name": "stellaops-sdk",
+ "registry_url": "https://pypi.org/project/stellaops-sdk/",
+ "install_command": "pip install stellaops-sdk",
+ "min_runtime_version": "3.10"
+ }
+ }
+ ],
+ "categories": [
+ {
+ "category_id": "authentication",
+ "name": "Authentication",
+ "description": "Token exchange and authentication samples",
+ "samples": ["auth-token-exchange", "auth-token-exchange-py"],
+ "order": 1
+ },
+ {
+ "category_id": "scanning",
+ "name": "Container Scanning",
+ "description": "Container image scanning and SBOM generation",
+ "samples": ["scan-container-image"],
+ "order": 2
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/schemas/security-scopes-matrix.schema.json b/docs/schemas/security-scopes-matrix.schema.json
new file mode 100644
index 000000000..71a7d6a4e
--- /dev/null
+++ b/docs/schemas/security-scopes-matrix.schema.json
@@ -0,0 +1,641 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://stella-ops.org/schemas/security-scopes-matrix.schema.json",
+ "title": "StellaOps Security Scopes Matrix Schema",
+ "description": "Schema for security scopes, roles, permissions, and privacy controls. Unblocks DOCS-SEC-62-001, DOCS-SEC-OBS-50-001 (2+ tasks).",
+ "type": "object",
+ "definitions": {
+ "Scope": {
+ "type": "object",
+ "description": "OAuth2/OIDC scope definition",
+ "required": ["scope_id", "name"],
+ "properties": {
+ "scope_id": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_:]+$",
+ "description": "Scope identifier (e.g., findings:read, admin:write)"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string",
+ "enum": ["read", "write", "admin", "system"],
+ "description": "Scope category"
+ },
+ "resource": {
+ "type": "string",
+ "description": "Resource this scope applies to"
+ },
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"]
+ }
+ },
+ "requires_mfa": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether MFA is required for this scope"
+ },
+ "sensitive": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether this scope accesses sensitive data"
+ },
+ "audit_level": {
+ "type": "string",
+ "enum": ["none", "basic", "detailed", "full"],
+ "default": "basic"
+ },
+ "parent_scope": {
+ "type": "string",
+ "description": "Parent scope that implies this scope"
+ }
+ }
+ },
+ "Role": {
+ "type": "object",
+ "description": "Role definition with assigned scopes",
+ "required": ["role_id", "name", "scopes"],
+ "properties": {
+ "role_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["system", "tenant", "project", "custom"],
+ "description": "Role type"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Scopes assigned to this role"
+ },
+ "inherits_from": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Roles this role inherits from"
+ },
+ "restrictions": {
+ "$ref": "#/definitions/RoleRestrictions"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "RoleRestrictions": {
+ "type": "object",
+ "description": "Restrictions on role usage",
+ "properties": {
+ "max_sessions": {
+ "type": "integer",
+ "description": "Maximum concurrent sessions"
+ },
+ "ip_allowlist": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "time_restrictions": {
+ "type": "object",
+ "properties": {
+ "allowed_hours": {
+ "type": "object",
+ "properties": {
+ "start": {
+ "type": "string",
+ "pattern": "^[0-2][0-9]:[0-5][0-9]$"
+ },
+ "end": {
+ "type": "string",
+ "pattern": "^[0-2][0-9]:[0-5][0-9]$"
+ },
+ "timezone": {
+ "type": "string"
+ }
+ }
+ },
+ "allowed_days": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
+ }
+ }
+ }
+ },
+ "require_approval": {
+ "type": "boolean",
+ "description": "Require approval for role activation"
+ }
+ }
+ },
+ "Permission": {
+ "type": "object",
+ "description": "Fine-grained permission",
+ "required": ["permission_id", "resource", "action"],
+ "properties": {
+ "permission_id": {
+ "type": "string"
+ },
+ "resource": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string",
+ "enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"]
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionCondition"
+ }
+ },
+ "effect": {
+ "type": "string",
+ "enum": ["allow", "deny"],
+ "default": "allow"
+ }
+ }
+ },
+ "PermissionCondition": {
+ "type": "object",
+ "description": "Condition for permission evaluation",
+ "required": ["type", "value"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["attribute", "context", "time", "resource_owner", "tenant"]
+ },
+ "attribute": {
+ "type": "string"
+ },
+ "operator": {
+ "type": "string",
+ "enum": ["eq", "neq", "in", "not_in", "contains", "gt", "lt", "gte", "lte"]
+ },
+ "value": {}
+ }
+ },
+ "TenancyHeader": {
+ "type": "object",
+ "description": "Multi-tenancy header specification",
+ "required": ["header_name", "required"],
+ "properties": {
+ "header_name": {
+ "type": "string",
+ "default": "X-Tenant-ID"
+ },
+ "required": {
+ "type": "boolean",
+ "default": true
+ },
+ "validation": {
+ "type": "object",
+ "properties": {
+ "format": {
+ "type": "string",
+ "enum": ["uuid", "slug", "custom"]
+ },
+ "pattern": {
+ "type": "string"
+ },
+ "max_length": {
+ "type": "integer"
+ }
+ }
+ },
+ "default_value": {
+ "type": "string",
+ "description": "Default tenant if header not provided"
+ },
+ "extract_from_token": {
+ "type": "boolean",
+ "default": true,
+ "description": "Allow extraction from JWT token"
+ },
+ "token_claim": {
+ "type": "string",
+ "default": "tenant_id"
+ }
+ }
+ },
+ "PrivacyControl": {
+ "type": "object",
+ "description": "Privacy control configuration",
+ "required": ["control_id", "name"],
+ "properties": {
+ "control_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "data_classification": {
+ "type": "string",
+ "enum": ["public", "internal", "confidential", "restricted", "pii", "phi"]
+ },
+ "redaction_policy": {
+ "$ref": "#/definitions/RedactionPolicy"
+ },
+ "retention_policy": {
+ "$ref": "#/definitions/RetentionPolicy"
+ },
+ "consent_required": {
+ "type": "boolean",
+ "default": false
+ },
+ "audit_access": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ },
+ "RedactionPolicy": {
+ "type": "object",
+ "description": "Data redaction policy",
+ "properties": {
+ "policy_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "rules": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/RedactionRule"
+ }
+ },
+ "default_action": {
+ "type": "string",
+ "enum": ["pass", "mask", "hash", "remove"],
+ "default": "pass"
+ }
+ }
+ },
+ "RedactionRule": {
+ "type": "object",
+ "description": "Individual redaction rule",
+ "required": ["field_pattern", "action"],
+ "properties": {
+ "rule_id": {
+ "type": "string"
+ },
+ "field_pattern": {
+ "type": "string",
+ "description": "JSON path or field name pattern"
+ },
+ "data_type": {
+ "type": "string",
+ "enum": ["email", "phone", "ssn", "ip_address", "credit_card", "name", "address", "custom"]
+ },
+ "action": {
+ "type": "string",
+ "enum": ["mask", "hash", "remove", "tokenize", "truncate"]
+ },
+ "mask_char": {
+ "type": "string",
+ "default": "*"
+ },
+ "preserve_chars": {
+ "type": "integer",
+ "description": "Number of chars to preserve (e.g., last 4 of phone)"
+ },
+ "hash_algorithm": {
+ "type": "string",
+ "enum": ["sha256", "sha512", "hmac-sha256"]
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PermissionCondition"
+ },
+ "description": "Conditions when to apply redaction"
+ }
+ }
+ },
+ "RetentionPolicy": {
+ "type": "object",
+ "description": "Data retention policy",
+ "properties": {
+ "policy_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "default_retention_days": {
+ "type": "integer"
+ },
+ "rules": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "data_type": {
+ "type": "string"
+ },
+ "retention_days": {
+ "type": "integer"
+ },
+ "action_on_expiry": {
+ "type": "string",
+ "enum": ["delete", "archive", "anonymize"]
+ }
+ }
+ }
+ },
+ "legal_hold_enabled": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "DebugOptIn": {
+ "type": "object",
+ "description": "Debug/diagnostic opt-in configuration",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false
+ },
+ "opt_in_required": {
+ "type": "boolean",
+ "default": true
+ },
+ "scopes_required": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Scopes required to access debug data"
+ },
+ "data_collected": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "data_type": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "retention_hours": {
+ "type": "integer"
+ }
+ }
+ }
+ },
+ "redaction_applied": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ },
+ "ScopeMatrix": {
+ "type": "object",
+ "description": "Complete scope matrix",
+ "required": ["version", "scopes"],
+ "properties": {
+ "version": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Scope"
+ }
+ },
+ "roles": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Role"
+ }
+ },
+ "tenancy_config": {
+ "$ref": "#/definitions/TenancyHeader"
+ },
+ "privacy_controls": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PrivacyControl"
+ }
+ },
+ "debug_config": {
+ "$ref": "#/definitions/DebugOptIn"
+ }
+ }
+ }
+ },
+ "properties": {
+ "matrix": {
+ "$ref": "#/definitions/ScopeMatrix"
+ }
+ },
+ "examples": [
+ {
+ "matrix": {
+ "version": "2025.10.0",
+ "updated_at": "2025-12-06T10:00:00Z",
+ "scopes": [
+ {
+ "scope_id": "findings:read",
+ "name": "Read Findings",
+ "description": "Read vulnerability findings",
+ "category": "read",
+ "resource": "findings",
+ "actions": ["read", "list"],
+ "audit_level": "basic"
+ },
+ {
+ "scope_id": "findings:write",
+ "name": "Write Findings",
+ "description": "Create and update findings",
+ "category": "write",
+ "resource": "findings",
+ "actions": ["create", "update"],
+ "audit_level": "detailed",
+ "parent_scope": "findings:read"
+ },
+ {
+ "scope_id": "findings:delete",
+ "name": "Delete Findings",
+ "description": "Delete findings (requires approval)",
+ "category": "admin",
+ "resource": "findings",
+ "actions": ["delete"],
+ "requires_mfa": true,
+ "audit_level": "full",
+ "parent_scope": "findings:write"
+ },
+ {
+ "scope_id": "scanner:execute",
+ "name": "Execute Scans",
+ "description": "Initiate container scans",
+ "category": "write",
+ "resource": "scanner",
+ "actions": ["execute"],
+ "audit_level": "detailed"
+ },
+ {
+ "scope_id": "risk:read",
+ "name": "Read Risk Scores",
+ "description": "Access risk scoring data",
+ "category": "read",
+ "resource": "risk",
+ "actions": ["read", "list"],
+ "audit_level": "basic"
+ },
+ {
+ "scope_id": "admin:*",
+ "name": "Full Admin Access",
+ "description": "Full administrative access",
+ "category": "admin",
+ "resource": "*",
+ "actions": ["create", "read", "update", "delete", "list", "execute"],
+ "requires_mfa": true,
+ "sensitive": true,
+ "audit_level": "full"
+ }
+ ],
+ "roles": [
+ {
+ "role_id": "viewer",
+ "name": "Viewer",
+ "description": "Read-only access to findings and risk data",
+ "type": "tenant",
+ "scopes": ["findings:read", "risk:read"]
+ },
+ {
+ "role_id": "analyst",
+ "name": "Security Analyst",
+ "description": "Can view and update findings, execute scans",
+ "type": "tenant",
+ "scopes": ["findings:read", "findings:write", "scanner:execute", "risk:read"],
+ "inherits_from": ["viewer"]
+ },
+ {
+ "role_id": "admin",
+ "name": "Tenant Admin",
+ "description": "Full tenant administrative access",
+ "type": "tenant",
+ "scopes": ["findings:read", "findings:write", "findings:delete", "scanner:execute", "risk:read", "risk:write"],
+ "inherits_from": ["analyst"],
+ "restrictions": {
+ "max_sessions": 3,
+ "require_approval": false
+ }
+ },
+ {
+ "role_id": "super_admin",
+ "name": "Super Admin",
+ "description": "System-wide administrative access",
+ "type": "system",
+ "scopes": ["admin:*"],
+ "restrictions": {
+ "max_sessions": 1,
+ "require_approval": true
+ }
+ }
+ ],
+ "tenancy_config": {
+ "header_name": "X-Tenant-ID",
+ "required": true,
+ "validation": {
+ "format": "uuid"
+ },
+ "extract_from_token": true,
+ "token_claim": "tenant_id"
+ },
+ "privacy_controls": [
+ {
+ "control_id": "pii-protection",
+ "name": "PII Protection",
+ "description": "Protection for personally identifiable information",
+ "data_classification": "pii",
+ "redaction_policy": {
+ "policy_id": "pii-redaction",
+ "name": "PII Redaction",
+ "rules": [
+ {
+ "rule_id": "email-mask",
+ "field_pattern": "$.**.email",
+ "data_type": "email",
+ "action": "mask",
+ "preserve_chars": 3
+ },
+ {
+ "rule_id": "ip-hash",
+ "field_pattern": "$.**.ip_address",
+ "data_type": "ip_address",
+ "action": "hash",
+ "hash_algorithm": "sha256"
+ }
+ ],
+ "default_action": "pass"
+ },
+ "retention_policy": {
+ "policy_id": "pii-retention",
+ "name": "PII Retention",
+ "default_retention_days": 90,
+ "rules": [
+ {
+ "data_type": "audit_logs",
+ "retention_days": 365,
+ "action_on_expiry": "archive"
+ }
+ ]
+ },
+ "consent_required": true,
+ "audit_access": true
+ }
+ ],
+ "debug_config": {
+ "enabled": true,
+ "opt_in_required": true,
+ "scopes_required": ["admin:*"],
+ "data_collected": [
+ {
+ "data_type": "request_traces",
+ "description": "HTTP request/response traces for debugging",
+ "retention_hours": 24
+ },
+ {
+ "data_type": "performance_metrics",
+ "description": "Detailed performance timing",
+ "retention_hours": 72
+ }
+ ],
+ "redaction_applied": true
+ }
+ }
+ }
+ ]
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs b/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
new file mode 100644
index 000000000..2469c6e3a
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
@@ -0,0 +1,232 @@
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// Storage entity for policy pack.
+///
+public sealed record PolicyPackEntity
+{
+ public required Guid TenantId { get; init; }
+ public required Guid PackId { get; init; }
+ public required string Name { get; init; }
+ public required string Version { get; init; }
+ public string? Description { get; init; }
+ public required PolicyPackStatus Status { get; init; }
+ public IReadOnlyList? Rules { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public required DateTimeOffset UpdatedAt { get; init; }
+ public DateTimeOffset? PublishedAt { get; init; }
+ public string? Digest { get; init; }
+ public string? CreatedBy { get; init; }
+ public string? UpdatedBy { get; init; }
+
+ ///
+ /// Converts to API contract.
+ ///
+ public PolicyPack ToContract() => new()
+ {
+ PackId = PackId,
+ Name = Name,
+ Version = Version,
+ Description = Description,
+ Status = Status,
+ Rules = Rules,
+ Metadata = Metadata,
+ CreatedAt = CreatedAt,
+ UpdatedAt = UpdatedAt,
+ PublishedAt = PublishedAt,
+ Digest = Digest
+ };
+}
+
+///
+/// Storage entity for verification policy.
+///
+public sealed record VerificationPolicyEntity
+{
+ public required Guid TenantId { get; init; }
+ public required string PolicyId { get; init; }
+ public required string Version { get; init; }
+ public string? Description { get; init; }
+ public required string TenantScope { get; init; }
+ public required IReadOnlyList PredicateTypes { get; init; }
+ public required SignerRequirements SignerRequirements { get; init; }
+ public ValidityWindow? ValidityWindow { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public required DateTimeOffset UpdatedAt { get; init; }
+ public string? CreatedBy { get; init; }
+ public string? UpdatedBy { get; init; }
+
+ ///
+ /// Converts to API contract.
+ ///
+ public VerificationPolicy ToContract() => new()
+ {
+ PolicyId = PolicyId,
+ Version = Version,
+ Description = Description,
+ TenantScope = TenantScope,
+ PredicateTypes = PredicateTypes,
+ SignerRequirements = SignerRequirements,
+ ValidityWindow = ValidityWindow,
+ Metadata = Metadata,
+ CreatedAt = CreatedAt,
+ UpdatedAt = UpdatedAt
+ };
+}
+
+///
+/// Storage entity for snapshot.
+///
+public sealed record SnapshotEntity
+{
+ public required Guid TenantId { get; init; }
+ public required Guid SnapshotId { get; init; }
+ public required string Digest { get; init; }
+ public string? Description { get; init; }
+ public IReadOnlyList? PackIds { get; init; }
+ public IReadOnlyDictionary? Metadata { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public string? CreatedBy { get; init; }
+
+ ///
+ /// Converts to API contract.
+ ///
+ public Snapshot ToContract() => new()
+ {
+ SnapshotId = SnapshotId,
+ Digest = Digest,
+ Description = Description,
+ PackIds = PackIds,
+ Metadata = Metadata,
+ CreatedAt = CreatedAt,
+ CreatedBy = CreatedBy
+ };
+}
+
+///
+/// Storage entity for violation.
+///
+public sealed record ViolationEntity
+{
+ public required Guid TenantId { get; init; }
+ public required Guid ViolationId { get; init; }
+ public string? PolicyId { get; init; }
+ public required string RuleId { get; init; }
+ public required Severity Severity { get; init; }
+ public required string Message { get; init; }
+ public string? Purl { get; init; }
+ public string? CveId { get; init; }
+ public IReadOnlyDictionary? Context { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+
+ ///
+ /// Converts to API contract.
+ ///
+ public Violation ToContract() => new()
+ {
+ ViolationId = ViolationId,
+ PolicyId = PolicyId,
+ RuleId = RuleId,
+ Severity = Severity,
+ Message = Message,
+ Purl = Purl,
+ CveId = CveId,
+ Context = Context,
+ CreatedAt = CreatedAt
+ };
+}
+
+///
+/// Storage entity for override.
+///
+public sealed record OverrideEntity
+{
+ public required Guid TenantId { get; init; }
+ public required Guid OverrideId { get; init; }
+ public Guid? ProfileId { get; init; }
+ public required string RuleId { get; init; }
+ public required OverrideStatus Status { get; init; }
+ public string? Reason { get; init; }
+ public OverrideScope? Scope { get; init; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public string? ApprovedBy { get; init; }
+ public DateTimeOffset? ApprovedAt { get; init; }
+ public required DateTimeOffset CreatedAt { get; init; }
+ public string? CreatedBy { get; init; }
+
+ ///
+ /// Converts to API contract.
+ ///
+ public Override ToContract() => new()
+ {
+ OverrideId = OverrideId,
+ ProfileId = ProfileId,
+ RuleId = RuleId,
+ Status = Status,
+ Reason = Reason,
+ Scope = Scope,
+ ExpiresAt = ExpiresAt,
+ ApprovedBy = ApprovedBy,
+ ApprovedAt = ApprovedAt,
+ CreatedAt = CreatedAt,
+ CreatedBy = CreatedBy
+ };
+}
+
+///
+/// History entry for policy pack changes.
+///
+public sealed record PolicyPackHistoryEntry
+{
+ public required Guid PackId { get; init; }
+ public required string Action { get; init; }
+ public required DateTimeOffset Timestamp { get; init; }
+ public string? PerformedBy { get; init; }
+ public PolicyPackStatus? PreviousStatus { get; init; }
+ public PolicyPackStatus? NewStatus { get; init; }
+ public string? Description { get; init; }
+}
+
+///
+/// Result for paginated policy pack list.
+///
+public sealed record PolicyPackListResult
+{
+ public required IReadOnlyList Items { get; init; }
+ public string? NextPageToken { get; init; }
+ public int TotalCount { get; init; }
+}
+
+///
+/// Result for paginated verification policy list.
+///
+public sealed record VerificationPolicyListResult
+{
+ public required IReadOnlyList Items { get; init; }
+ public string? NextPageToken { get; init; }
+ public int TotalCount { get; init; }
+}
+
+///
+/// Result for paginated snapshot list.
+///
+public sealed record SnapshotListResult
+{
+ public required IReadOnlyList Items { get; init; }
+ public string? NextPageToken { get; init; }
+ public int TotalCount { get; init; }
+}
+
+///
+/// Result for paginated violation list.
+///
+public sealed record ViolationListResult
+{
+ public required IReadOnlyList Items { get; init; }
+ public string? NextPageToken { get; init; }
+ public int TotalCount { get; init; }
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
new file mode 100644
index 000000000..49c0b47ad
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
@@ -0,0 +1,212 @@
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// Store interface for policy pack workspace operations with history tracking.
+/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history.
+///
+public interface IPolicyPackStore
+{
+ ///
+ /// Creates a new policy pack.
+ ///
+ Task CreateAsync(
+ Guid tenantId,
+ CreatePolicyPackRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a policy pack by ID.
+ ///
+ Task GetByIdAsync(
+ Guid tenantId,
+ Guid packId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a policy pack by name.
+ ///
+ Task GetByNameAsync(
+ Guid tenantId,
+ string name,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists policy packs with optional filtering.
+ ///
+ Task ListAsync(
+ Guid tenantId,
+ PolicyPackStatus? status = null,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates a policy pack.
+ ///
+ Task UpdateAsync(
+ Guid tenantId,
+ Guid packId,
+ UpdatePolicyPackRequest request,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a policy pack (only drafts can be deleted).
+ ///
+ Task DeleteAsync(
+ Guid tenantId,
+ Guid packId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates the status of a policy pack.
+ ///
+ Task UpdateStatusAsync(
+ Guid tenantId,
+ Guid packId,
+ PolicyPackStatus newStatus,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the history of changes for a policy pack.
+ ///
+ Task> GetHistoryAsync(
+ Guid tenantId,
+ Guid packId,
+ int limit = 50,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Store interface for verification policy operations.
+///
+public interface IVerificationPolicyStore
+{
+ Task CreateAsync(
+ Guid tenantId,
+ CreateVerificationPolicyRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ Guid tenantId,
+ string policyId,
+ CancellationToken cancellationToken = default);
+
+ Task ListAsync(
+ Guid tenantId,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ Guid tenantId,
+ string policyId,
+ UpdateVerificationPolicyRequest request,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ Guid tenantId,
+ string policyId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Store interface for policy snapshot operations.
+///
+public interface ISnapshotStore
+{
+ Task CreateAsync(
+ Guid tenantId,
+ CreateSnapshotRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ Guid tenantId,
+ Guid snapshotId,
+ CancellationToken cancellationToken = default);
+
+ Task GetByDigestAsync(
+ Guid tenantId,
+ string digest,
+ CancellationToken cancellationToken = default);
+
+ Task ListAsync(
+ Guid tenantId,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ Guid tenantId,
+ Guid snapshotId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Store interface for violation operations.
+///
+public interface IViolationStore
+{
+ Task AppendAsync(
+ Guid tenantId,
+ CreateViolationRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task AppendBatchAsync(
+ Guid tenantId,
+ IReadOnlyList requests,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ Guid tenantId,
+ Guid violationId,
+ CancellationToken cancellationToken = default);
+
+ Task ListAsync(
+ Guid tenantId,
+ Severity? severity = null,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Store interface for override operations.
+///
+public interface IOverrideStore
+{
+ Task CreateAsync(
+ Guid tenantId,
+ CreateOverrideRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default);
+
+ Task ApproveAsync(
+ Guid tenantId,
+ Guid overrideId,
+ string? approvedBy = null,
+ string? comment = null,
+ CancellationToken cancellationToken = default);
+
+ Task DisableAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs
new file mode 100644
index 000000000..5a18d6593
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryOverrideStore.cs
@@ -0,0 +1,108 @@
+using System.Collections.Concurrent;
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// In-memory implementation of IOverrideStore for testing and development.
+///
+public sealed class InMemoryOverrideStore : IOverrideStore
+{
+ private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
+
+ public Task CreateAsync(
+ Guid tenantId,
+ CreateOverrideRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var overrideId = Guid.NewGuid();
+
+ var entity = new OverrideEntity
+ {
+ TenantId = tenantId,
+ OverrideId = overrideId,
+ ProfileId = request.ProfileId,
+ RuleId = request.RuleId,
+ Status = OverrideStatus.Pending,
+ Reason = request.Reason,
+ Scope = request.Scope,
+ ExpiresAt = request.ExpiresAt,
+ CreatedAt = now,
+ CreatedBy = createdBy
+ };
+
+ _overrides[(tenantId, overrideId)] = entity;
+
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByIdAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default)
+ {
+ _overrides.TryGetValue((tenantId, overrideId), out var entity);
+ return Task.FromResult(entity);
+ }
+
+ public Task DeleteAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _));
+ }
+
+ public Task ApproveAsync(
+ Guid tenantId,
+ Guid overrideId,
+ string? approvedBy = null,
+ string? comment = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
+ {
+ return Task.FromResult(null);
+ }
+
+ // Only pending overrides can be approved
+ if (existing.Status != OverrideStatus.Pending)
+ {
+ return Task.FromResult(null);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var updated = existing with
+ {
+ Status = OverrideStatus.Approved,
+ ApprovedBy = approvedBy,
+ ApprovedAt = now
+ };
+
+ _overrides[(tenantId, overrideId)] = updated;
+
+ return Task.FromResult(updated);
+ }
+
+ public Task DisableAsync(
+ Guid tenantId,
+ Guid overrideId,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
+ {
+ return Task.FromResult(null);
+ }
+
+ var updated = existing with
+ {
+ Status = OverrideStatus.Disabled
+ };
+
+ _overrides[(tenantId, overrideId)] = updated;
+
+ return Task.FromResult(updated);
+ }
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs
new file mode 100644
index 000000000..010f6121a
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryPolicyPackStore.cs
@@ -0,0 +1,260 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// In-memory implementation of IPolicyPackStore for testing and development.
+///
+public sealed class InMemoryPolicyPackStore : IPolicyPackStore
+{
+ private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
+ private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List> _history = new();
+
+ public Task CreateAsync(
+ Guid tenantId,
+ CreatePolicyPackRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var packId = Guid.NewGuid();
+
+ var entity = new PolicyPackEntity
+ {
+ TenantId = tenantId,
+ PackId = packId,
+ Name = request.Name,
+ Version = request.Version,
+ Description = request.Description,
+ Status = PolicyPackStatus.Draft,
+ Rules = request.Rules,
+ Metadata = request.Metadata,
+ CreatedAt = now,
+ UpdatedAt = now,
+ CreatedBy = createdBy
+ };
+
+ _packs[(tenantId, packId)] = entity;
+
+ // Add history entry
+ AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created");
+
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByIdAsync(
+ Guid tenantId,
+ Guid packId,
+ CancellationToken cancellationToken = default)
+ {
+ _packs.TryGetValue((tenantId, packId), out var entity);
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByNameAsync(
+ Guid tenantId,
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = _packs.Values
+ .Where(p => p.TenantId == tenantId && p.Name == name)
+ .OrderByDescending(p => p.UpdatedAt)
+ .FirstOrDefault();
+
+ return Task.FromResult(entity);
+ }
+
+ public Task ListAsync(
+ Guid tenantId,
+ PolicyPackStatus? status = null,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default)
+ {
+ var query = _packs.Values
+ .Where(p => p.TenantId == tenantId);
+
+ if (status.HasValue)
+ {
+ query = query.Where(p => p.Status == status.Value);
+ }
+
+ var items = query
+ .OrderByDescending(p => p.UpdatedAt)
+ .ToList();
+
+ int skip = 0;
+ if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
+ {
+ skip = offset;
+ }
+
+ var pagedItems = items.Skip(skip).Take(pageSize).ToList();
+ string? nextToken = skip + pagedItems.Count < items.Count
+ ? (skip + pagedItems.Count).ToString()
+ : null;
+
+ return Task.FromResult(new PolicyPackListResult
+ {
+ Items = pagedItems,
+ NextPageToken = nextToken,
+ TotalCount = items.Count
+ });
+ }
+
+ public Task UpdateAsync(
+ Guid tenantId,
+ Guid packId,
+ UpdatePolicyPackRequest request,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_packs.TryGetValue((tenantId, packId), out var existing))
+ {
+ return Task.FromResult(null);
+ }
+
+ // Only allow updates to drafts
+ if (existing.Status != PolicyPackStatus.Draft)
+ {
+ return Task.FromResult(null);
+ }
+
+ var updated = existing with
+ {
+ Name = request.Name ?? existing.Name,
+ Description = request.Description ?? existing.Description,
+ Rules = request.Rules ?? existing.Rules,
+ Metadata = request.Metadata ?? existing.Metadata,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = updatedBy
+ };
+
+ _packs[(tenantId, packId)] = updated;
+
+ AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated");
+
+ return Task.FromResult(updated);
+ }
+
+ public Task DeleteAsync(
+ Guid tenantId,
+ Guid packId,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_packs.TryGetValue((tenantId, packId), out var existing))
+ {
+ return Task.FromResult(false);
+ }
+
+ // Only allow deletion of drafts
+ if (existing.Status != PolicyPackStatus.Draft)
+ {
+ return Task.FromResult(false);
+ }
+
+ var removed = _packs.TryRemove((tenantId, packId), out _);
+ if (removed)
+ {
+ _history.TryRemove((tenantId, packId), out _);
+ }
+
+ return Task.FromResult(removed);
+ }
+
+ public Task UpdateStatusAsync(
+ Guid tenantId,
+ Guid packId,
+ PolicyPackStatus newStatus,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_packs.TryGetValue((tenantId, packId), out var existing))
+ {
+ return Task.FromResult(null);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var updated = existing with
+ {
+ Status = newStatus,
+ UpdatedAt = now,
+ UpdatedBy = updatedBy,
+ PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt,
+ Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest
+ };
+
+ _packs[(tenantId, packId)] = updated;
+
+ AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus,
+ $"Status changed from {existing.Status} to {newStatus}");
+
+ return Task.FromResult(updated);
+ }
+
+ public Task> GetHistoryAsync(
+ Guid tenantId,
+ Guid packId,
+ int limit = 50,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_history.TryGetValue((tenantId, packId), out var history))
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+
+ var entries = history
+ .OrderByDescending(h => h.Timestamp)
+ .Take(limit)
+ .ToList();
+
+ return Task.FromResult>(entries);
+ }
+
+ private void AddHistoryEntry(
+ Guid tenantId,
+ Guid packId,
+ string action,
+ string? performedBy,
+ PolicyPackStatus? previousStatus,
+ PolicyPackStatus? newStatus,
+ string? description)
+ {
+ var entry = new PolicyPackHistoryEntry
+ {
+ PackId = packId,
+ Action = action,
+ Timestamp = DateTimeOffset.UtcNow,
+ PerformedBy = performedBy,
+ PreviousStatus = previousStatus,
+ NewStatus = newStatus,
+ Description = description
+ };
+
+ _history.AddOrUpdate(
+ (tenantId, packId),
+ _ => [entry],
+ (_, list) =>
+ {
+ list.Add(entry);
+ return list;
+ });
+ }
+
+ private static string ComputeDigest(PolicyPackEntity pack)
+ {
+ var content = JsonSerializer.Serialize(new
+ {
+ pack.Name,
+ pack.Version,
+ pack.Rules
+ });
+
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
+ return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
+ }
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs
new file mode 100644
index 000000000..9dfe1af7b
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemorySnapshotStore.cs
@@ -0,0 +1,115 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// In-memory implementation of ISnapshotStore for testing and development.
+///
+public sealed class InMemorySnapshotStore : ISnapshotStore
+{
+ private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
+
+ public Task CreateAsync(
+ Guid tenantId,
+ CreateSnapshotRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var snapshotId = Guid.NewGuid();
+
+ // Compute digest from pack IDs and timestamp for uniqueness
+ var digest = ComputeDigest(request.PackIds, now);
+
+ var entity = new SnapshotEntity
+ {
+ TenantId = tenantId,
+ SnapshotId = snapshotId,
+ Digest = digest,
+ Description = request.Description,
+ PackIds = request.PackIds,
+ Metadata = request.Metadata,
+ CreatedAt = now,
+ CreatedBy = createdBy
+ };
+
+ _snapshots[(tenantId, snapshotId)] = entity;
+
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByIdAsync(
+ Guid tenantId,
+ Guid snapshotId,
+ CancellationToken cancellationToken = default)
+ {
+ _snapshots.TryGetValue((tenantId, snapshotId), out var entity);
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByDigestAsync(
+ Guid tenantId,
+ string digest,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = _snapshots.Values
+ .Where(s => s.TenantId == tenantId && s.Digest == digest)
+ .FirstOrDefault();
+
+ return Task.FromResult(entity);
+ }
+
+ public Task ListAsync(
+ Guid tenantId,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default)
+ {
+ var items = _snapshots.Values
+ .Where(s => s.TenantId == tenantId)
+ .OrderByDescending(s => s.CreatedAt)
+ .ToList();
+
+ int skip = 0;
+ if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
+ {
+ skip = offset;
+ }
+
+ var pagedItems = items.Skip(skip).Take(pageSize).ToList();
+ string? nextToken = skip + pagedItems.Count < items.Count
+ ? (skip + pagedItems.Count).ToString()
+ : null;
+
+ return Task.FromResult(new SnapshotListResult
+ {
+ Items = pagedItems,
+ NextPageToken = nextToken,
+ TotalCount = items.Count
+ });
+ }
+
+ public Task DeleteAsync(
+ Guid tenantId,
+ Guid snapshotId,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _));
+ }
+
+ private static string ComputeDigest(IReadOnlyList packIds, DateTimeOffset timestamp)
+ {
+ var content = JsonSerializer.Serialize(new
+ {
+ PackIds = packIds.OrderBy(id => id).ToList(),
+ Timestamp = timestamp.ToUnixTimeMilliseconds()
+ });
+
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
+ return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
+ }
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs
new file mode 100644
index 000000000..da26a4bab
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryVerificationPolicyStore.cs
@@ -0,0 +1,121 @@
+using System.Collections.Concurrent;
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// In-memory implementation of IVerificationPolicyStore for testing and development.
+///
+public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
+{
+ private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
+
+ public Task CreateAsync(
+ Guid tenantId,
+ CreateVerificationPolicyRequest request,
+ string? createdBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var entity = new VerificationPolicyEntity
+ {
+ TenantId = tenantId,
+ PolicyId = request.PolicyId,
+ Version = request.Version,
+ Description = request.Description,
+ TenantScope = request.TenantScope ?? tenantId.ToString(),
+ PredicateTypes = request.PredicateTypes,
+ SignerRequirements = request.SignerRequirements ?? new SignerRequirements
+ {
+ MinimumSignatures = 1,
+ TrustedKeyFingerprints = Array.Empty()
+ },
+ ValidityWindow = request.ValidityWindow,
+ Metadata = request.Metadata,
+ CreatedAt = now,
+ UpdatedAt = now,
+ CreatedBy = createdBy
+ };
+
+ _policies[(tenantId, request.PolicyId)] = entity;
+
+ return Task.FromResult(entity);
+ }
+
+ public Task GetByIdAsync(
+ Guid tenantId,
+ string policyId,
+ CancellationToken cancellationToken = default)
+ {
+ _policies.TryGetValue((tenantId, policyId), out var entity);
+ return Task.FromResult(entity);
+ }
+
+ public Task ListAsync(
+ Guid tenantId,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default)
+ {
+ var items = _policies.Values
+ .Where(p => p.TenantId == tenantId)
+ .OrderByDescending(p => p.UpdatedAt)
+ .ToList();
+
+ int skip = 0;
+ if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
+ {
+ skip = offset;
+ }
+
+ var pagedItems = items.Skip(skip).Take(pageSize).ToList();
+ string? nextToken = skip + pagedItems.Count < items.Count
+ ? (skip + pagedItems.Count).ToString()
+ : null;
+
+ return Task.FromResult(new VerificationPolicyListResult
+ {
+ Items = pagedItems,
+ NextPageToken = nextToken,
+ TotalCount = items.Count
+ });
+ }
+
+ public Task UpdateAsync(
+ Guid tenantId,
+ string policyId,
+ UpdateVerificationPolicyRequest request,
+ string? updatedBy = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!_policies.TryGetValue((tenantId, policyId), out var existing))
+ {
+ return Task.FromResult(null);
+ }
+
+ var updated = existing with
+ {
+ Version = request.Version ?? existing.Version,
+ Description = request.Description ?? existing.Description,
+ PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
+ SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
+ ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
+ Metadata = request.Metadata ?? existing.Metadata,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ UpdatedBy = updatedBy
+ };
+
+ _policies[(tenantId, policyId)] = updated;
+
+ return Task.FromResult(updated);
+ }
+
+ public Task DeleteAsync(
+ Guid tenantId,
+ string policyId,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _));
+ }
+}
diff --git a/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs
new file mode 100644
index 000000000..45e576e10
--- /dev/null
+++ b/src/Policy/StellaOps.Policy.Registry/Storage/InMemoryViolationStore.cs
@@ -0,0 +1,139 @@
+using System.Collections.Concurrent;
+using StellaOps.Policy.Registry.Contracts;
+
+namespace StellaOps.Policy.Registry.Storage;
+
+///
+/// In-memory implementation of IViolationStore for testing and development.
+///
+public sealed class InMemoryViolationStore : IViolationStore
+{
+ private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
+
+ public Task AppendAsync(
+ Guid tenantId,
+ CreateViolationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var violationId = Guid.NewGuid();
+
+ var entity = new ViolationEntity
+ {
+ TenantId = tenantId,
+ ViolationId = violationId,
+ PolicyId = request.PolicyId,
+ RuleId = request.RuleId,
+ Severity = request.Severity,
+ Message = request.Message,
+ Purl = request.Purl,
+ CveId = request.CveId,
+ Context = request.Context,
+ CreatedAt = now
+ };
+
+ _violations[(tenantId, violationId)] = entity;
+
+ return Task.FromResult(entity);
+ }
+
+ public Task AppendBatchAsync(
+ Guid tenantId,
+ IReadOnlyList requests,
+ CancellationToken cancellationToken = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ int created = 0;
+ int failed = 0;
+ var errors = new List();
+
+ for (int i = 0; i < requests.Count; i++)
+ {
+ try
+ {
+ var request = requests[i];
+ var violationId = Guid.NewGuid();
+
+ var entity = new ViolationEntity
+ {
+ TenantId = tenantId,
+ ViolationId = violationId,
+ PolicyId = request.PolicyId,
+ RuleId = request.RuleId,
+ Severity = request.Severity,
+ Message = request.Message,
+ Purl = request.Purl,
+ CveId = request.CveId,
+ Context = request.Context,
+ CreatedAt = now
+ };
+
+ _violations[(tenantId, violationId)] = entity;
+ created++;
+ }
+ catch (Exception ex)
+ {
+ failed++;
+ errors.Add(new BatchError
+ {
+ Index = i,
+ Error = ex.Message
+ });
+ }
+ }
+
+ return Task.FromResult(new ViolationBatchResult
+ {
+ Created = created,
+ Failed = failed,
+ Errors = errors.Count > 0 ? errors : null
+ });
+ }
+
+ public Task GetByIdAsync(
+ Guid tenantId,
+ Guid violationId,
+ CancellationToken cancellationToken = default)
+ {
+ _violations.TryGetValue((tenantId, violationId), out var entity);
+ return Task.FromResult(entity);
+ }
+
+ public Task ListAsync(
+ Guid tenantId,
+ Severity? severity = null,
+ int pageSize = 20,
+ string? pageToken = null,
+ CancellationToken cancellationToken = default)
+ {
+ var query = _violations.Values
+ .Where(v => v.TenantId == tenantId);
+
+ if (severity.HasValue)
+ {
+ query = query.Where(v => v.Severity == severity.Value);
+ }
+
+ var items = query
+ .OrderByDescending(v => v.CreatedAt)
+ .ToList();
+
+ int skip = 0;
+ if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
+ {
+ skip = offset;
+ }
+
+ var pagedItems = items.Skip(skip).Take(pageSize).ToList();
+ string? nextToken = skip + pagedItems.Count < items.Count
+ ? (skip + pagedItems.Count).ToString()
+ : null;
+
+ return Task.FromResult(new ViolationListResult
+ {
+ Items = pagedItems,
+ NextPageToken = nextToken,
+ TotalCount = items.Count
+ });
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs
new file mode 100644
index 000000000..298ca248e
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/JavaPropertyResolverTests.cs
@@ -0,0 +1,325 @@
+using System.Collections.Immutable;
+using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
+using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
+
+namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
+
+public sealed class JavaPropertyResolverTests
+{
+ [Fact]
+ public void ResolvesSimpleProperty()
+ {
+ var properties = new Dictionary
+ {
+ ["version"] = "1.0.0"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${version}");
+
+ Assert.Equal("1.0.0", result.ResolvedValue);
+ Assert.True(result.IsFullyResolved);
+ Assert.Empty(result.UnresolvedProperties);
+ }
+
+ [Fact]
+ public void ResolvesMultipleProperties()
+ {
+ var properties = new Dictionary
+ {
+ ["groupId"] = "com.example",
+ ["artifactId"] = "demo",
+ ["version"] = "2.0.0"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${groupId}:${artifactId}:${version}");
+
+ Assert.Equal("com.example:demo:2.0.0", result.ResolvedValue);
+ Assert.True(result.IsFullyResolved);
+ }
+
+ [Fact]
+ public void ResolvesNestedProperties()
+ {
+ var properties = new Dictionary
+ {
+ ["slf4j.version"] = "2.0.7",
+ ["logging.version"] = "${slf4j.version}"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${logging.version}");
+
+ Assert.Equal("2.0.7", result.ResolvedValue);
+ Assert.True(result.IsFullyResolved);
+ }
+
+ [Fact]
+ public void HandlesCircularReference()
+ {
+ // Circular: a → b → a (should stop at max depth)
+ var properties = new Dictionary
+ {
+ ["a"] = "${b}",
+ ["b"] = "${a}"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${a}");
+
+ // Should stop recursing and return whatever state it reaches
+ Assert.False(result.IsFullyResolved);
+ }
+
+ [Fact]
+ public void HandlesMaxRecursionDepth()
+ {
+ // Create a chain of 15 nested properties (exceeds max depth of 10)
+ var properties = new Dictionary();
+ for (int i = 0; i < 15; i++)
+ {
+ properties[$"prop{i}"] = $"${{prop{i + 1}}}";
+ }
+ properties["prop15"] = "final-value";
+
+ var resolver = new JavaPropertyResolver(properties.ToImmutableDictionary());
+ var result = resolver.Resolve("${prop0}");
+
+ // Should not reach final-value due to depth limit
+ Assert.Contains("${", result.ResolvedValue);
+ }
+
+ [Fact]
+ public void PreservesUnresolvedPlaceholder()
+ {
+ var properties = new Dictionary
+ {
+ ["known"] = "value"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${unknown}");
+
+ Assert.Equal("${unknown}", result.ResolvedValue);
+ Assert.False(result.IsFullyResolved);
+ Assert.Single(result.UnresolvedProperties);
+ Assert.Contains("unknown", result.UnresolvedProperties);
+ }
+
+ [Fact]
+ public void ResolvesMavenStandardProperties()
+ {
+ var resolver = new JavaPropertyResolver();
+
+ var basedir = resolver.Resolve("${project.basedir}");
+ Assert.Equal(".", basedir.ResolvedValue);
+
+ var buildDir = resolver.Resolve("${project.build.directory}");
+ Assert.Equal("target", buildDir.ResolvedValue);
+
+ var sourceDir = resolver.Resolve("${project.build.sourceDirectory}");
+ Assert.Equal("src/main/java", sourceDir.ResolvedValue);
+ }
+
+ [Fact]
+ public void HandlesEmptyInput()
+ {
+ var resolver = new JavaPropertyResolver();
+
+ var nullResult = resolver.Resolve(null);
+ Assert.Equal(PropertyResolutionResult.Empty, nullResult);
+
+ var emptyResult = resolver.Resolve(string.Empty);
+ Assert.Equal(PropertyResolutionResult.Empty, emptyResult);
+ }
+
+ [Fact]
+ public void HandlesInputWithoutPlaceholders()
+ {
+ var resolver = new JavaPropertyResolver();
+ var result = resolver.Resolve("plain-text-value");
+
+ Assert.Equal("plain-text-value", result.ResolvedValue);
+ Assert.True(result.IsFullyResolved);
+ Assert.Empty(result.UnresolvedProperties);
+ }
+
+ [Fact]
+ public void ResolvesFromParentChain()
+ {
+ var childProps = new Dictionary
+ {
+ ["child.version"] = "1.0.0"
+ }.ToImmutableDictionary();
+
+ var parentProps = new Dictionary
+ {
+ ["parent.version"] = "2.0.0",
+ ["shared.version"] = "parent-value"
+ }.ToImmutableDictionary();
+
+ var grandparentProps = new Dictionary
+ {
+ ["grandparent.version"] = "3.0.0"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(childProps, [parentProps, grandparentProps]);
+
+ // Child property
+ Assert.Equal("1.0.0", resolver.Resolve("${child.version}").ResolvedValue);
+
+ // Parent property
+ Assert.Equal("2.0.0", resolver.Resolve("${parent.version}").ResolvedValue);
+
+ // Grandparent property
+ Assert.Equal("3.0.0", resolver.Resolve("${grandparent.version}").ResolvedValue);
+ }
+
+ [Fact]
+ public void ChildPropertyOverridesParent()
+ {
+ var childProps = new Dictionary
+ {
+ ["version"] = "child-value"
+ }.ToImmutableDictionary();
+
+ var parentProps = new Dictionary
+ {
+ ["version"] = "parent-value"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(childProps, [parentProps]);
+ var result = resolver.Resolve("${version}");
+
+ Assert.Equal("child-value", result.ResolvedValue);
+ }
+
+ [Fact]
+ public void ResolvesProjectCoordinateProperties()
+ {
+ var builder = new JavaPropertyBuilder()
+ .AddProjectCoordinates("com.example", "demo", "1.0.0");
+
+ var resolver = new JavaPropertyResolver(builder.Build());
+
+ Assert.Equal("com.example", resolver.Resolve("${project.groupId}").ResolvedValue);
+ Assert.Equal("com.example", resolver.Resolve("${groupId}").ResolvedValue);
+ Assert.Equal("demo", resolver.Resolve("${project.artifactId}").ResolvedValue);
+ Assert.Equal("1.0.0", resolver.Resolve("${project.version}").ResolvedValue);
+ }
+
+ [Fact]
+ public void ResolvesDependencyVersion()
+ {
+ var properties = new Dictionary
+ {
+ ["slf4j.version"] = "2.0.7"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+
+ var dependency = new JavaDependencyDeclaration
+ {
+ GroupId = "org.slf4j",
+ ArtifactId = "slf4j-api",
+ Version = "${slf4j.version}",
+ Source = "pom.xml"
+ };
+
+ var resolved = resolver.ResolveDependency(dependency);
+
+ Assert.Equal("2.0.7", resolved.Version);
+ Assert.Equal(JavaVersionSource.Property, resolved.VersionSource);
+ Assert.Equal("slf4j.version", resolved.VersionProperty);
+ }
+
+ [Fact]
+ public void ResolvesDependencyWithUnresolvedVersion()
+ {
+ var resolver = new JavaPropertyResolver();
+
+ var dependency = new JavaDependencyDeclaration
+ {
+ GroupId = "org.unknown",
+ ArtifactId = "unknown",
+ Version = "${unknown.version}",
+ Source = "pom.xml"
+ };
+
+ var resolved = resolver.ResolveDependency(dependency);
+
+ Assert.Equal("${unknown.version}", resolved.Version);
+ Assert.Equal(JavaVersionSource.Unresolved, resolved.VersionSource);
+ }
+
+ [Fact]
+ public void PropertyBuilderAddRange()
+ {
+ var existing = new Dictionary
+ {
+ ["existing"] = "value1",
+ ["override"] = "original"
+ };
+
+ var builder = new JavaPropertyBuilder()
+ .Add("override", "new-value") // Added first
+ .AddRange(existing); // Won't override
+
+ var props = builder.Build();
+
+ Assert.Equal("new-value", props["override"]);
+ Assert.Equal("value1", props["existing"]);
+ }
+
+ [Fact]
+ public void PropertyBuilderAddParentCoordinates()
+ {
+ var parent = new JavaParentReference
+ {
+ GroupId = "org.springframework.boot",
+ ArtifactId = "spring-boot-starter-parent",
+ Version = "3.1.0"
+ };
+
+ var builder = new JavaPropertyBuilder().AddParentCoordinates(parent);
+ var props = builder.Build();
+
+ Assert.Equal("org.springframework.boot", props["project.parent.groupId"]);
+ Assert.Equal("spring-boot-starter-parent", props["project.parent.artifactId"]);
+ Assert.Equal("3.1.0", props["project.parent.version"]);
+ }
+
+ [Fact]
+ public void ResolvesComplexMavenExpression()
+ {
+ var properties = new Dictionary
+ {
+ ["spring.version"] = "6.0.0",
+ ["project.version"] = "1.0.0-SNAPSHOT"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("spring-${spring.version}-app-${project.version}");
+
+ Assert.Equal("spring-6.0.0-app-1.0.0-SNAPSHOT", result.ResolvedValue);
+ Assert.True(result.IsFullyResolved);
+ }
+
+ [Fact]
+ public void HandlesMixedResolvedAndUnresolved()
+ {
+ var properties = new Dictionary
+ {
+ ["known"] = "resolved"
+ }.ToImmutableDictionary();
+
+ var resolver = new JavaPropertyResolver(properties);
+ var result = resolver.Resolve("${known}-${unknown}");
+
+ Assert.Equal("resolved-${unknown}", result.ResolvedValue);
+ Assert.False(result.IsFullyResolved);
+ Assert.Single(result.UnresolvedProperties);
+ Assert.Contains("unknown", result.UnresolvedProperties);
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs
new file mode 100644
index 000000000..9789a1f4f
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenEffectivePomBuilderTests.cs
@@ -0,0 +1,547 @@
+using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
+using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
+using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
+
+namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
+
+public sealed class MavenEffectivePomBuilderTests
+{
+ [Fact]
+ public async Task BuildsEffectivePomWithParentPropertiesAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with properties
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+ 17
+ 31.1-jre
+
+
+ """, cancellationToken);
+
+ // Child using parent properties
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+
+ com.google.guava
+ guava
+ ${guava.version}
+
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(childPom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Equal("17", result.EffectiveProperties["java.version"]);
+ Assert.Single(result.ResolvedDependencies);
+
+ var dep = result.ResolvedDependencies[0];
+ Assert.Equal("31.1-jre", dep.Version);
+ Assert.Equal(JavaVersionSource.Property, dep.VersionSource);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task MergesParentDependencyManagementAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with dependencyManagement
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.7
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+
+ """, cancellationToken);
+
+ // Child with version-less dependencies
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+
+ org.slf4j
+ slf4j-api
+
+
+ junit
+ junit
+
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(childPom, cancellationToken);
+
+ Assert.Equal(2, result.ResolvedDependencies.Length);
+
+ var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
+ Assert.Equal("2.0.7", slf4j.Version);
+
+ var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
+ Assert.Equal("4.13.2", junit.Version);
+ Assert.Equal("test", junit.Scope);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ChildDependencyManagementOverridesParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with version
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+
+ org.slf4j
+ slf4j-api
+ 1.7.36
+
+
+
+
+ """, cancellationToken);
+
+ // Child overriding version
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.9
+
+
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(childPom, cancellationToken);
+
+ var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
+ Assert.Equal("2.0.9", dep.Version); // Child's version wins
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task HandlesStandalonePomAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ standalone
+ 1.0.0
+
+ UTF-8
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.7
+
+
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(pom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Single(result.ParentChain); // Only the POM itself
+ Assert.Equal("UTF-8", result.EffectiveProperties["encoding"]);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ResolvesPropertyInDependencyManagementVersionAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ app
+ 1.0.0
+
+ 3.12.0
+
+
+
+
+ org.apache.commons
+ commons-lang3
+ ${commons.version}
+
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(pom, cancellationToken);
+
+ var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "commons-lang3");
+ Assert.Equal("3.12.0", dep.Version);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task TracksVersionSourceCorrectlyAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with dependencyManagement
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.7
+
+
+
+
+ """, cancellationToken);
+
+ // Child with various version sources
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+ 31.1-jre
+
+
+
+
+ junit
+ junit
+ 4.13.2
+
+
+
+ com.google.guava
+ guava
+ ${guava.version}
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(childPom, cancellationToken);
+
+ var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
+ Assert.Equal("4.13.2", junit.Version);
+ Assert.Equal(JavaVersionSource.Direct, junit.VersionSource);
+
+ var guava = result.ResolvedDependencies.First(d => d.ArtifactId == "guava");
+ Assert.Equal("31.1-jre", guava.Version);
+ Assert.Equal(JavaVersionSource.Property, guava.VersionSource);
+ Assert.Equal("guava.version", guava.VersionProperty);
+
+ var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
+ Assert.Equal("2.0.7", slf4j.Version);
+ // From parent's dependencyManagement
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task CollectsAllLicensesAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with Apache license
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+ Apache License 2.0
+ https://www.apache.org/licenses/LICENSE-2.0
+
+
+
+ """, cancellationToken);
+
+ // Child (inherits license)
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(childPom, cancellationToken);
+
+ Assert.Single(result.Licenses);
+ Assert.Equal("Apache-2.0", result.Licenses[0].SpdxId);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task GetsUnresolvedDependenciesAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ app
+ 1.0.0
+
+
+
+ org.slf4j
+ slf4j-api
+ ${undefined.version}
+
+
+
+ com.example
+ missing
+
+
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(pom, cancellationToken);
+
+ var unresolved = result.GetUnresolvedDependencies().ToList();
+ Assert.Equal(2, unresolved.Count);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task PopulatesManagedVersionsIndexAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ app
+ 1.0.0
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.7
+
+
+ com.google.guava
+ guava
+ 31.1-jre
+
+
+
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var builder = new MavenEffectivePomBuilder(root);
+ var result = await builder.BuildAsync(pom, cancellationToken);
+
+ Assert.Equal(2, result.ManagedVersions.Count);
+ Assert.True(result.ManagedVersions.ContainsKey("org.slf4j:slf4j-api"));
+ Assert.True(result.ManagedVersions.ContainsKey("com.google.guava:guava"));
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs
new file mode 100644
index 000000000..d3160f1ce
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests/Java/Parsers/MavenParentResolverTests.cs
@@ -0,0 +1,556 @@
+using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
+using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
+
+namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
+
+public sealed class MavenParentResolverTests
+{
+ [Fact]
+ public async Task ResolvesRelativePathParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Create parent/pom.xml
+ var parentDir = Path.Combine(root, "parent");
+ Directory.CreateDirectory(parentDir);
+ await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+ 17
+
+
+ """, cancellationToken);
+
+ // Create child/pom.xml with relativePath to parent
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+ ../parent/pom.xml
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Equal(2, result.ParentChain.Length); // child + parent
+ Assert.Equal("17", result.EffectiveProperties["java.version"]);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ResolvesDefaultRelativePathAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Create parent pom.xml in parent directory (default ../pom.xml)
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 2.0.0
+ pom
+
+ """, cancellationToken);
+
+ // Create child in subdirectory with no relativePath (defaults to ../pom.xml)
+ var childDir = Path.Combine(root, "module");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 2.0.0
+
+ module
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Equal("2.0.0", result.EffectiveVersion);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ResolvesMultiLevelParentChainAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Grandparent
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ grandparent
+ 1.0.0
+ pom
+
+ gp-value
+
+
+ """, cancellationToken);
+
+ // Parent
+ var parentDir = Path.Combine(root, "parent");
+ Directory.CreateDirectory(parentDir);
+ await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
+
+
+
+ com.example
+ grandparent
+ 1.0.0
+
+ parent
+
+ parent-value
+
+
+ """, cancellationToken);
+
+ // Child
+ var childDir = Path.Combine(parentDir, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Equal(3, result.ParentChain.Length); // child, parent, grandparent
+ Assert.Equal("gp-value", result.EffectiveProperties["grandparent.prop"]);
+ Assert.Equal("parent-value", result.EffectiveProperties["parent.prop"]);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ReturnsUnresolvedForMissingParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var childPomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ missing-parent
+ 1.0.0
+
+ orphan
+ 1.0.0
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.False(result.IsFullyResolved);
+ Assert.Single(result.UnresolvedParents);
+ Assert.Contains("com.example:missing-parent:1.0.0", result.UnresolvedParents);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task HandlesNoParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ standalone
+ 1.0.0
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(pom, cancellationToken);
+
+ Assert.True(result.IsFullyResolved);
+ Assert.Single(result.ParentChain); // Only the original POM
+ Assert.Equal("com.example", result.EffectiveGroupId);
+ Assert.Equal("1.0.0", result.EffectiveVersion);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task InheritsGroupIdFromParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with groupId
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ org.parent
+ parent
+ 1.0.0
+ pom
+
+ """, cancellationToken);
+
+ // Child without explicit groupId
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ org.parent
+ parent
+ 1.0.0
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.Equal("org.parent", result.EffectiveGroupId);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task InheritsVersionFromParentAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 2.5.0
+ pom
+
+ """, cancellationToken);
+
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 2.5.0
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.Equal("2.5.0", result.EffectiveVersion);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ResolvesDependencyVersionFromManagementAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with dependencyManagement
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.7
+
+
+
+
+ """, cancellationToken);
+
+ // Child with dependency without version
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.Single(result.ResolvedDependencies);
+ var dep = result.ResolvedDependencies[0];
+ Assert.Equal("2.0.7", dep.Version);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ResolvesPropertyInDependencyVersionAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ var pomPath = Path.Combine(root, "pom.xml");
+ await File.WriteAllTextAsync(pomPath, """
+
+
+ com.example
+ app
+ 1.0.0
+
+ 31.1-jre
+
+
+
+ com.google.guava
+ guava
+ ${guava.version}
+
+
+
+ """, cancellationToken);
+
+ var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(pom, cancellationToken);
+
+ Assert.Single(result.ResolvedDependencies);
+ var dep = result.ResolvedDependencies[0];
+ Assert.Equal("31.1-jre", dep.Version);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task CollectsLicensesFromChainAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with license
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+
+ Apache License 2.0
+ https://www.apache.org/licenses/LICENSE-2.0
+
+
+
+ """, cancellationToken);
+
+ // Child
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ Assert.Single(result.AllLicenses);
+ Assert.Equal("Apache-2.0", result.AllLicenses[0].SpdxId);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+
+ [Fact]
+ public async Task ChildPropertyOverridesParentPropertyAsync()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var root = TestPaths.CreateTemporaryDirectory();
+
+ try
+ {
+ // Parent with property
+ await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
+
+
+ com.example
+ parent
+ 1.0.0
+ pom
+
+ 11
+
+
+ """, cancellationToken);
+
+ // Child overriding property
+ var childDir = Path.Combine(root, "child");
+ Directory.CreateDirectory(childDir);
+ var childPomPath = Path.Combine(childDir, "pom.xml");
+ await File.WriteAllTextAsync(childPomPath, """
+
+
+
+ com.example
+ parent
+ 1.0.0
+
+ child
+
+ 17
+
+
+ """, cancellationToken);
+
+ var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
+ var resolver = new MavenParentResolver(root);
+ var result = await resolver.ResolveAsync(childPom, cancellationToken);
+
+ // Child property should win
+ Assert.Equal("17", result.EffectiveProperties["java.version"]);
+ }
+ finally
+ {
+ TestPaths.SafeDelete(root);
+ }
+ }
+}
diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs
index 65ea74b9b..715ed4340 100644
--- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs
+++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/Events/PackRunTimelineEvent.cs
@@ -328,6 +328,18 @@ public static class PackRunEventTypes
/// Attestation was revoked.
public const string AttestationRevoked = "pack.attestation.revoked";
+ /// Incident mode activated (per TASKRUN-OBS-55-001).
+ public const string IncidentModeActivated = "pack.incident.activated";
+
+ /// Incident mode deactivated.
+ public const string IncidentModeDeactivated = "pack.incident.deactivated";
+
+ /// Incident mode escalated to higher level.
+ public const string IncidentModeEscalated = "pack.incident.escalated";
+
+ /// SLO breach detected triggering incident mode.
+ public const string SloBreachDetected = "pack.incident.slo_breach";
+
/// Checks if the event type is a pack run event.
public static bool IsPackRunEvent(string eventType) =>
eventType.StartsWith(Prefix, StringComparison.Ordinal);
diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs
new file mode 100644
index 000000000..ad934d991
--- /dev/null
+++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IPackRunIncidentModeService.cs
@@ -0,0 +1,534 @@
+using Microsoft.Extensions.Logging;
+using StellaOps.TaskRunner.Core.Events;
+
+namespace StellaOps.TaskRunner.Core.IncidentMode;
+
+///
+/// Service for managing pack run incident mode.
+/// Per TASKRUN-OBS-55-001.
+///
+public interface IPackRunIncidentModeService
+{
+ ///
+ /// Activates incident mode for a run.
+ ///
+ Task ActivateAsync(
+ IncidentModeActivationRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deactivates incident mode for a run.
+ ///
+ Task DeactivateAsync(
+ string runId,
+ string? reason = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the current incident mode status for a run.
+ ///
+ Task GetStatusAsync(
+ string runId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Handles an SLO breach notification.
+ ///
+ Task HandleSloBreachAsync(
+ SloBreachNotification notification,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Escalates incident mode to a higher level.
+ ///
+ Task EscalateAsync(
+ string runId,
+ IncidentEscalationLevel newLevel,
+ string? reason = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets settings for the current incident mode level.
+ ///
+ IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level);
+}
+
+///
+/// Store for incident mode state.
+///
+public interface IPackRunIncidentModeStore
+{
+ ///
+ /// Stores incident mode status.
+ ///
+ Task StoreAsync(
+ string runId,
+ PackRunIncidentModeStatus status,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets incident mode status.
+ ///
+ Task GetAsync(
+ string runId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists all runs in incident mode.
+ ///
+ Task> ListActiveRunsAsync(
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes incident mode status.
+ ///
+ Task RemoveAsync(
+ string runId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Settings for incident mode levels.
+///
+public sealed record IncidentModeSettings(
+ /// Escalation level.
+ IncidentEscalationLevel Level,
+
+ /// Retention policy.
+ IncidentRetentionPolicy RetentionPolicy,
+
+ /// Telemetry settings.
+ IncidentTelemetrySettings TelemetrySettings,
+
+ /// Debug capture settings.
+ IncidentDebugCaptureSettings DebugCaptureSettings);
+
+///
+/// Default implementation of pack run incident mode service.
+///
+public sealed class PackRunIncidentModeService : IPackRunIncidentModeService
+{
+ private readonly IPackRunIncidentModeStore _store;
+ private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _timeProvider;
+
+ public PackRunIncidentModeService(
+ IPackRunIncidentModeStore store,
+ ILogger logger,
+ TimeProvider? timeProvider = null,
+ IPackRunTimelineEventEmitter? timelineEmitter = null)
+ {
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _timelineEmitter = timelineEmitter;
+ }
+
+ ///
+ public async Task ActivateAsync(
+ IncidentModeActivationRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ try
+ {
+ var now = _timeProvider.GetUtcNow();
+ var settings = GetSettingsForLevel(request.Level);
+
+ var expiresAt = request.DurationMinutes.HasValue
+ ? now.AddMinutes(request.DurationMinutes.Value)
+ : (DateTimeOffset?)null;
+
+ var status = new PackRunIncidentModeStatus(
+ Active: true,
+ Level: request.Level,
+ ActivatedAt: now,
+ ActivationReason: request.Reason,
+ Source: request.Source,
+ ExpiresAt: expiresAt,
+ RetentionPolicy: settings.RetentionPolicy,
+ TelemetrySettings: settings.TelemetrySettings,
+ DebugCaptureSettings: settings.DebugCaptureSettings);
+
+ await _store.StoreAsync(request.RunId, status, cancellationToken);
+
+ // Emit timeline event
+ await EmitTimelineEventAsync(
+ request.TenantId,
+ request.RunId,
+ PackRunIncidentEventTypes.IncidentModeActivated,
+ new Dictionary
+ {
+ ["level"] = request.Level.ToString(),
+ ["source"] = request.Source.ToString(),
+ ["reason"] = request.Reason,
+ ["requestedBy"] = request.RequestedBy ?? "system"
+ },
+ cancellationToken);
+
+ _logger.LogWarning(
+ "Incident mode activated for run {RunId} at level {Level} due to: {Reason}",
+ request.RunId,
+ request.Level,
+ request.Reason);
+
+ return new IncidentModeActivationResult(
+ Success: true,
+ Status: status,
+ Error: null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId);
+
+ return new IncidentModeActivationResult(
+ Success: false,
+ Status: PackRunIncidentModeStatus.Inactive(),
+ Error: ex.Message);
+ }
+ }
+
+ ///
+ public async Task DeactivateAsync(
+ string runId,
+ string? reason = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var current = await _store.GetAsync(runId, cancellationToken);
+ if (current is null || !current.Active)
+ {
+ return new IncidentModeActivationResult(
+ Success: true,
+ Status: PackRunIncidentModeStatus.Inactive(),
+ Error: null);
+ }
+
+ await _store.RemoveAsync(runId, cancellationToken);
+ var inactive = PackRunIncidentModeStatus.Inactive();
+
+ // Emit timeline event (using default tenant since we don't have it)
+ await EmitTimelineEventAsync(
+ "default",
+ runId,
+ PackRunIncidentEventTypes.IncidentModeDeactivated,
+ new Dictionary
+ {
+ ["previousLevel"] = current.Level.ToString(),
+ ["reason"] = reason ?? "Manual deactivation",
+ ["activeDuration"] = current.ActivatedAt.HasValue
+ ? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString()
+ : "unknown"
+ },
+ cancellationToken);
+
+ _logger.LogInformation(
+ "Incident mode deactivated for run {RunId}. Reason: {Reason}",
+ runId,
+ reason ?? "Manual deactivation");
+
+ return new IncidentModeActivationResult(
+ Success: true,
+ Status: inactive,
+ Error: null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId);
+
+ return new IncidentModeActivationResult(
+ Success: false,
+ Status: PackRunIncidentModeStatus.Inactive(),
+ Error: ex.Message);
+ }
+ }
+
+ ///
+ public async Task GetStatusAsync(
+ string runId,
+ CancellationToken cancellationToken = default)
+ {
+ var status = await _store.GetAsync(runId, cancellationToken);
+
+ if (status is null)
+ {
+ return PackRunIncidentModeStatus.Inactive();
+ }
+
+ // Check if expired
+ if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow())
+ {
+ await _store.RemoveAsync(runId, cancellationToken);
+ return PackRunIncidentModeStatus.Inactive();
+ }
+
+ return status;
+ }
+
+ ///
+ public async Task HandleSloBreachAsync(
+ SloBreachNotification notification,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(notification);
+
+ if (string.IsNullOrWhiteSpace(notification.ResourceId))
+ {
+ _logger.LogWarning(
+ "Received SLO breach notification {BreachId} without resource ID, skipping incident activation",
+ notification.BreachId);
+
+ return new IncidentModeActivationResult(
+ Success: false,
+ Status: PackRunIncidentModeStatus.Inactive(),
+ Error: "No resource ID in SLO breach notification");
+ }
+
+ // Map severity to escalation level
+ var level = notification.Severity?.ToUpperInvariant() switch
+ {
+ "CRITICAL" => IncidentEscalationLevel.Critical,
+ "HIGH" => IncidentEscalationLevel.High,
+ "MEDIUM" => IncidentEscalationLevel.Medium,
+ "LOW" => IncidentEscalationLevel.Low,
+ _ => IncidentEscalationLevel.Medium
+ };
+
+ var request = new IncidentModeActivationRequest(
+ RunId: notification.ResourceId,
+ TenantId: notification.TenantId ?? "default",
+ Level: level,
+ Source: IncidentModeSource.SloBreach,
+ Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})",
+ DurationMinutes: 60, // Auto-expire after 1 hour
+ RequestedBy: "slo-monitor");
+
+ _logger.LogWarning(
+ "Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}",
+ notification.BreachId,
+ notification.SloName,
+ notification.ResourceId);
+
+ return await ActivateAsync(request, cancellationToken);
+ }
+
+ ///
+ public async Task EscalateAsync(
+ string runId,
+ IncidentEscalationLevel newLevel,
+ string? reason = null,
+ CancellationToken cancellationToken = default)
+ {
+ var current = await _store.GetAsync(runId, cancellationToken);
+
+ if (current is null || !current.Active)
+ {
+ return new IncidentModeActivationResult(
+ Success: false,
+ Status: PackRunIncidentModeStatus.Inactive(),
+ Error: "Incident mode is not active for this run");
+ }
+
+ if (newLevel <= current.Level)
+ {
+ return new IncidentModeActivationResult(
+ Success: false,
+ Status: current,
+ Error: $"Cannot escalate to {newLevel} - current level is {current.Level}");
+ }
+
+ var settings = GetSettingsForLevel(newLevel);
+ var now = _timeProvider.GetUtcNow();
+
+ var escalated = current with
+ {
+ Level = newLevel,
+ ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]",
+ RetentionPolicy = settings.RetentionPolicy,
+ TelemetrySettings = settings.TelemetrySettings,
+ DebugCaptureSettings = settings.DebugCaptureSettings
+ };
+
+ await _store.StoreAsync(runId, escalated, cancellationToken);
+
+ // Emit timeline event
+ await EmitTimelineEventAsync(
+ "default",
+ runId,
+ PackRunIncidentEventTypes.IncidentModeEscalated,
+ new Dictionary
+ {
+ ["previousLevel"] = current.Level.ToString(),
+ ["newLevel"] = newLevel.ToString(),
+ ["reason"] = reason ?? "Manual escalation"
+ },
+ cancellationToken);
+
+ _logger.LogWarning(
+ "Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}",
+ runId,
+ current.Level,
+ newLevel,
+ reason ?? "Manual escalation");
+
+ return new IncidentModeActivationResult(
+ Success: true,
+ Status: escalated,
+ Error: null);
+ }
+
+ ///
+ public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch
+ {
+ IncidentEscalationLevel.None => new IncidentModeSettings(
+ level,
+ IncidentRetentionPolicy.Default(),
+ IncidentTelemetrySettings.Default(),
+ IncidentDebugCaptureSettings.Default()),
+
+ IncidentEscalationLevel.Low => new IncidentModeSettings(
+ level,
+ IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 },
+ IncidentTelemetrySettings.Default() with
+ {
+ EnhancedTelemetryActive = true,
+ LogVerbosity = IncidentLogVerbosity.Verbose,
+ TraceSamplingRate = 0.5
+ },
+ IncidentDebugCaptureSettings.Default()),
+
+ IncidentEscalationLevel.Medium => new IncidentModeSettings(
+ level,
+ IncidentRetentionPolicy.Extended(),
+ IncidentTelemetrySettings.Enhanced(),
+ IncidentDebugCaptureSettings.Basic()),
+
+ IncidentEscalationLevel.High => new IncidentModeSettings(
+ level,
+ IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 },
+ IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug },
+ IncidentDebugCaptureSettings.Full()),
+
+ IncidentEscalationLevel.Critical => new IncidentModeSettings(
+ level,
+ IncidentRetentionPolicy.Maximum(),
+ IncidentTelemetrySettings.Maximum(),
+ IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(level))
+ };
+
+ private async Task EmitTimelineEventAsync(
+ string tenantId,
+ string runId,
+ string eventType,
+ IReadOnlyDictionary attributes,
+ CancellationToken cancellationToken)
+ {
+ if (_timelineEmitter is null) return;
+
+ await _timelineEmitter.EmitAsync(
+ PackRunTimelineEvent.Create(
+ tenantId: tenantId,
+ eventType: eventType,
+ source: "taskrunner-incident-mode",
+ occurredAt: _timeProvider.GetUtcNow(),
+ runId: runId,
+ severity: PackRunEventSeverity.Warning,
+ attributes: attributes),
+ cancellationToken);
+ }
+}
+
+///
+/// Incident mode timeline event types.
+///
+public static class PackRunIncidentEventTypes
+{
+ /// Incident mode activated.
+ public const string IncidentModeActivated = "pack.incident.activated";
+
+ /// Incident mode deactivated.
+ public const string IncidentModeDeactivated = "pack.incident.deactivated";
+
+ /// Incident mode escalated.
+ public const string IncidentModeEscalated = "pack.incident.escalated";
+
+ /// SLO breach detected.
+ public const string SloBreachDetected = "pack.incident.slo_breach";
+}
+
+///
+/// In-memory incident mode store for testing.
+///
+public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore
+{
+ private readonly Dictionary _statuses = new();
+ private readonly object _lock = new();
+
+ ///
+ public Task StoreAsync(
+ string runId,
+ PackRunIncidentModeStatus status,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ _statuses[runId] = status;
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetAsync(
+ string runId,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ _statuses.TryGetValue(runId, out var status);
+ return Task.FromResult(status);
+ }
+ }
+
+ ///
+ public Task> ListActiveRunsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ var active = _statuses
+ .Where(kvp => kvp.Value.Active)
+ .Select(kvp => kvp.Key)
+ .ToList();
+ return Task.FromResult>(active);
+ }
+ }
+
+ ///
+ public Task RemoveAsync(
+ string runId,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ _statuses.Remove(runId);
+ }
+ return Task.CompletedTask;
+ }
+
+ /// Gets count of stored statuses.
+ public int Count
+ {
+ get { lock (_lock) { return _statuses.Count; } }
+ }
+
+ /// Clears all statuses.
+ public void Clear()
+ {
+ lock (_lock) { _statuses.Clear(); }
+ }
+}
diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs
new file mode 100644
index 000000000..b9aaf0310
--- /dev/null
+++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/IncidentMode/IncidentModeModels.cs
@@ -0,0 +1,363 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.TaskRunner.Core.IncidentMode;
+
+///
+/// Incident mode status for a pack run.
+/// Per TASKRUN-OBS-55-001.
+///
+public sealed record PackRunIncidentModeStatus(
+ /// Whether incident mode is active.
+ bool Active,
+
+ /// Current escalation level.
+ IncidentEscalationLevel Level,
+
+ /// When incident mode was activated.
+ DateTimeOffset? ActivatedAt,
+
+ /// Reason for activation.
+ string? ActivationReason,
+
+ /// Source of activation (SLO breach, manual, etc.).
+ IncidentModeSource Source,
+
+ /// When incident mode will auto-deactivate (if set).
+ DateTimeOffset? ExpiresAt,
+
+ /// Current retention policy in effect.
+ IncidentRetentionPolicy RetentionPolicy,
+
+ /// Active telemetry escalation settings.
+ IncidentTelemetrySettings TelemetrySettings,
+
+ /// Debug artifact capture settings.
+ IncidentDebugCaptureSettings DebugCaptureSettings)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false
+ };
+
+ ///
+ /// Creates a default inactive status.
+ ///
+ public static PackRunIncidentModeStatus Inactive() => new(
+ Active: false,
+ Level: IncidentEscalationLevel.None,
+ ActivatedAt: null,
+ ActivationReason: null,
+ Source: IncidentModeSource.None,
+ ExpiresAt: null,
+ RetentionPolicy: IncidentRetentionPolicy.Default(),
+ TelemetrySettings: IncidentTelemetrySettings.Default(),
+ DebugCaptureSettings: IncidentDebugCaptureSettings.Default());
+
+ ///
+ /// Serializes to JSON.
+ ///
+ public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+}
+
+///
+/// Incident escalation levels.
+///
+public enum IncidentEscalationLevel
+{
+ /// No incident mode.
+ None = 0,
+
+ /// Low severity - enhanced logging.
+ Low = 1,
+
+ /// Medium severity - debug capture enabled.
+ Medium = 2,
+
+ /// High severity - full debug + extended retention.
+ High = 3,
+
+ /// Critical - maximum telemetry + indefinite retention.
+ Critical = 4
+}
+
+///
+/// Source of incident mode activation.
+///
+public enum IncidentModeSource
+{
+ /// No incident mode.
+ None,
+
+ /// Activated manually by operator.
+ Manual,
+
+ /// Activated by SLO breach webhook.
+ SloBreach,
+
+ /// Activated by error rate threshold.
+ ErrorRate,
+
+ /// Activated by policy evaluation.
+ PolicyTrigger,
+
+ /// Activated by external system.
+ External
+}
+
+///
+/// Retention policy during incident mode.
+///
+public sealed record IncidentRetentionPolicy(
+ /// Whether extended retention is active.
+ bool ExtendedRetentionActive,
+
+ /// Log retention in days.
+ int LogRetentionDays,
+
+ /// Artifact retention in days.
+ int ArtifactRetentionDays,
+
+ /// Debug capture retention in days.
+ int DebugCaptureRetentionDays,
+
+ /// Trace retention in days.
+ int TraceRetentionDays)
+{
+ /// Default retention policy.
+ public static IncidentRetentionPolicy Default() => new(
+ ExtendedRetentionActive: false,
+ LogRetentionDays: 7,
+ ArtifactRetentionDays: 30,
+ DebugCaptureRetentionDays: 3,
+ TraceRetentionDays: 7);
+
+ /// Extended retention for incident mode.
+ public static IncidentRetentionPolicy Extended() => new(
+ ExtendedRetentionActive: true,
+ LogRetentionDays: 90,
+ ArtifactRetentionDays: 180,
+ DebugCaptureRetentionDays: 30,
+ TraceRetentionDays: 90);
+
+ /// Maximum retention for critical incidents.
+ public static IncidentRetentionPolicy Maximum() => new(
+ ExtendedRetentionActive: true,
+ LogRetentionDays: 365,
+ ArtifactRetentionDays: 365,
+ DebugCaptureRetentionDays: 90,
+ TraceRetentionDays: 365);
+}
+
+///
+/// Telemetry settings during incident mode.
+///
+public sealed record IncidentTelemetrySettings(
+ /// Whether enhanced telemetry is active.
+ bool EnhancedTelemetryActive,
+
+ /// Log verbosity level.
+ IncidentLogVerbosity LogVerbosity,
+
+ /// Trace sampling rate (0.0 to 1.0).
+ double TraceSamplingRate,
+
+ /// Whether to capture environment variables.
+ bool CaptureEnvironment,
+
+ /// Whether to capture step inputs/outputs.
+ bool CaptureStepIo,
+
+ /// Whether to capture network calls.
+ bool CaptureNetworkCalls,
+
+ /// Maximum trace spans per step.
+ int MaxTraceSpansPerStep)
+{
+ /// Default telemetry settings.
+ public static IncidentTelemetrySettings Default() => new(
+ EnhancedTelemetryActive: false,
+ LogVerbosity: IncidentLogVerbosity.Normal,
+ TraceSamplingRate: 0.1,
+ CaptureEnvironment: false,
+ CaptureStepIo: false,
+ CaptureNetworkCalls: false,
+ MaxTraceSpansPerStep: 100);
+
+ /// Enhanced telemetry for incident mode.
+ public static IncidentTelemetrySettings Enhanced() => new(
+ EnhancedTelemetryActive: true,
+ LogVerbosity: IncidentLogVerbosity.Verbose,
+ TraceSamplingRate: 1.0,
+ CaptureEnvironment: true,
+ CaptureStepIo: true,
+ CaptureNetworkCalls: true,
+ MaxTraceSpansPerStep: 1000);
+
+ /// Maximum telemetry for debugging.
+ public static IncidentTelemetrySettings Maximum() => new(
+ EnhancedTelemetryActive: true,
+ LogVerbosity: IncidentLogVerbosity.Debug,
+ TraceSamplingRate: 1.0,
+ CaptureEnvironment: true,
+ CaptureStepIo: true,
+ CaptureNetworkCalls: true,
+ MaxTraceSpansPerStep: 10000);
+}
+
+///
+/// Log verbosity levels for incident mode.
+///
+public enum IncidentLogVerbosity
+{
+ /// Minimal logging (errors only).
+ Minimal,
+
+ /// Normal logging.
+ Normal,
+
+ /// Verbose logging.
+ Verbose,
+
+ /// Debug logging (maximum detail).
+ Debug
+}
+
+///
+/// Debug artifact capture settings.
+///
+public sealed record IncidentDebugCaptureSettings(
+ /// Whether debug capture is active.
+ bool CaptureActive,
+
+ /// Whether to capture heap dumps.
+ bool CaptureHeapDumps,
+
+ /// Whether to capture thread dumps.
+ bool CaptureThreadDumps,
+
+ /// Whether to capture profiling data.
+ bool CaptureProfilingData,
+
+ /// Whether to capture system metrics.
+ bool CaptureSystemMetrics,
+
+ /// Maximum capture size in MB.
+ int MaxCaptureSizeMb,
+
+ /// Capture interval in seconds.
+ int CaptureIntervalSeconds)
+{
+ /// Default capture settings (disabled).
+ public static IncidentDebugCaptureSettings Default() => new(
+ CaptureActive: false,
+ CaptureHeapDumps: false,
+ CaptureThreadDumps: false,
+ CaptureProfilingData: false,
+ CaptureSystemMetrics: false,
+ MaxCaptureSizeMb: 0,
+ CaptureIntervalSeconds: 0);
+
+ /// Basic debug capture.
+ public static IncidentDebugCaptureSettings Basic() => new(
+ CaptureActive: true,
+ CaptureHeapDumps: false,
+ CaptureThreadDumps: true,
+ CaptureProfilingData: false,
+ CaptureSystemMetrics: true,
+ MaxCaptureSizeMb: 100,
+ CaptureIntervalSeconds: 60);
+
+ /// Full debug capture.
+ public static IncidentDebugCaptureSettings Full() => new(
+ CaptureActive: true,
+ CaptureHeapDumps: true,
+ CaptureThreadDumps: true,
+ CaptureProfilingData: true,
+ CaptureSystemMetrics: true,
+ MaxCaptureSizeMb: 500,
+ CaptureIntervalSeconds: 30);
+}
+
+///
+/// SLO breach notification payload.
+///
+public sealed record SloBreachNotification(
+ /// Breach identifier.
+ [property: JsonPropertyName("breachId")]
+ string BreachId,
+
+ /// SLO that was breached.
+ [property: JsonPropertyName("sloName")]
+ string SloName,
+
+ /// Breach severity.
+ [property: JsonPropertyName("severity")]
+ string Severity,
+
+ /// When the breach occurred.
+ [property: JsonPropertyName("occurredAt")]
+ DateTimeOffset OccurredAt,
+
+ /// Current metric value.
+ [property: JsonPropertyName("currentValue")]
+ double CurrentValue,
+
+ /// Threshold that was breached.
+ [property: JsonPropertyName("threshold")]
+ double Threshold,
+
+ /// Target metric value.
+ [property: JsonPropertyName("target")]
+ double Target,
+
+ /// Affected resource (run ID, step ID, etc.).
+ [property: JsonPropertyName("resourceId")]
+ string? ResourceId,
+
+ /// Affected tenant.
+ [property: JsonPropertyName("tenantId")]
+ string? TenantId,
+
+ /// Additional context.
+ [property: JsonPropertyName("context")]
+ IReadOnlyDictionary? Context);
+
+///
+/// Request to activate incident mode.
+///
+public sealed record IncidentModeActivationRequest(
+ /// Run ID to activate incident mode for.
+ string RunId,
+
+ /// Tenant ID.
+ string TenantId,
+
+ /// Escalation level to activate.
+ IncidentEscalationLevel Level,
+
+ /// Activation source.
+ IncidentModeSource Source,
+
+ /// Reason for activation.
+ string Reason,
+
+ /// Duration in minutes (null for indefinite).
+ int? DurationMinutes,
+
+ /// Operator or system that requested activation.
+ string? RequestedBy);
+
+///
+/// Result of incident mode activation.
+///
+public sealed record IncidentModeActivationResult(
+ /// Whether activation succeeded.
+ bool Success,
+
+ /// Current incident mode status.
+ PackRunIncidentModeStatus Status,
+
+ /// Error message if activation failed.
+ string? Error);
diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs
new file mode 100644
index 000000000..16b803b73
--- /dev/null
+++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/PackRunIncidentModeTests.cs
@@ -0,0 +1,396 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Time.Testing;
+using StellaOps.TaskRunner.Core.Events;
+using StellaOps.TaskRunner.Core.IncidentMode;
+
+namespace StellaOps.TaskRunner.Tests;
+
+public sealed class PackRunIncidentModeTests
+{
+ [Fact]
+ public async Task ActivateAsync_ActivatesIncidentModeSuccessfully()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var request = new IncidentModeActivationRequest(
+ RunId: "run-001",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.Medium,
+ Source: IncidentModeSource.Manual,
+ Reason: "Debugging production issue",
+ DurationMinutes: 60,
+ RequestedBy: "admin@example.com");
+
+ var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.Status.Active);
+ Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level);
+ Assert.Equal(IncidentModeSource.Manual, result.Status.Source);
+ Assert.NotNull(result.Status.ActivatedAt);
+ Assert.NotNull(result.Status.ExpiresAt);
+ }
+
+ [Fact]
+ public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var request = new IncidentModeActivationRequest(
+ RunId: "run-002",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.High,
+ Source: IncidentModeSource.Manual,
+ Reason: "Critical investigation",
+ DurationMinutes: null,
+ RequestedBy: null);
+
+ var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Null(result.Status.ExpiresAt);
+ }
+
+ [Fact]
+ public async Task ActivateAsync_EmitsTimelineEvent()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var timelineSink = new InMemoryPackRunTimelineEventSink();
+ var emitter = new PackRunTimelineEventEmitter(
+ timelineSink,
+ TimeProvider.System,
+ NullLogger.Instance);
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance,
+ null,
+ emitter);
+
+ var request = new IncidentModeActivationRequest(
+ RunId: "run-003",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.Low,
+ Source: IncidentModeSource.Manual,
+ Reason: "Test",
+ DurationMinutes: 30,
+ RequestedBy: null);
+
+ await service.ActivateAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(1, timelineSink.Count);
+ var evt = timelineSink.GetEvents()[0];
+ Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType);
+ }
+
+ [Fact]
+ public async Task DeactivateAsync_DeactivatesIncidentMode()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ // First activate
+ var activateRequest = new IncidentModeActivationRequest(
+ RunId: "run-004",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.Medium,
+ Source: IncidentModeSource.Manual,
+ Reason: "Test",
+ DurationMinutes: null,
+ RequestedBy: null);
+
+ await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
+
+ // Then deactivate
+ var result = await service.DeactivateAsync("run-004", "Issue resolved", TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.False(result.Status.Active);
+
+ var status = await service.GetStatusAsync("run-004", TestContext.Current.CancellationToken);
+ Assert.False(status.Active);
+ }
+
+ [Fact]
+ public async Task GetStatusAsync_ReturnsInactiveForUnknownRun()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var status = await service.GetStatusAsync("unknown-run", TestContext.Current.CancellationToken);
+
+ Assert.False(status.Active);
+ Assert.Equal(IncidentEscalationLevel.None, status.Level);
+ }
+
+ [Fact]
+ public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance,
+ fakeTime);
+
+ var request = new IncidentModeActivationRequest(
+ RunId: "run-005",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.Medium,
+ Source: IncidentModeSource.Manual,
+ Reason: "Test",
+ DurationMinutes: 30,
+ RequestedBy: null);
+
+ await service.ActivateAsync(request, TestContext.Current.CancellationToken);
+
+ // Advance time past expiration
+ fakeTime.Advance(TimeSpan.FromMinutes(31));
+
+ var status = await service.GetStatusAsync("run-005", TestContext.Current.CancellationToken);
+
+ Assert.False(status.Active);
+ }
+
+ [Fact]
+ public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var breach = new SloBreachNotification(
+ BreachId: "breach-001",
+ SloName: "error_rate_5m",
+ Severity: "HIGH",
+ OccurredAt: DateTimeOffset.UtcNow,
+ CurrentValue: 15.5,
+ Threshold: 5.0,
+ Target: 1.0,
+ ResourceId: "run-006",
+ TenantId: "tenant-1",
+ Context: new Dictionary { ["step"] = "scan" });
+
+ var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.Status.Active);
+ Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
+ Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source);
+ Assert.Contains("error_rate_5m", result.Status.ActivationReason!);
+ }
+
+ [Fact]
+ public async Task HandleSloBreachAsync_MapsSeverityToLevel()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var severityToLevel = new Dictionary
+ {
+ ["CRITICAL"] = IncidentEscalationLevel.Critical,
+ ["HIGH"] = IncidentEscalationLevel.High,
+ ["MEDIUM"] = IncidentEscalationLevel.Medium,
+ ["LOW"] = IncidentEscalationLevel.Low
+ };
+
+ var runIndex = 0;
+ foreach (var (severity, expectedLevel) in severityToLevel)
+ {
+ var breach = new SloBreachNotification(
+ BreachId: $"breach-{runIndex}",
+ SloName: "test_slo",
+ Severity: severity,
+ OccurredAt: DateTimeOffset.UtcNow,
+ CurrentValue: 10.0,
+ Threshold: 5.0,
+ Target: 1.0,
+ ResourceId: $"run-severity-{runIndex++}",
+ TenantId: "tenant-1",
+ Context: null);
+
+ var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal(expectedLevel, result.Status.Level);
+ }
+ }
+
+ [Fact]
+ public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var breach = new SloBreachNotification(
+ BreachId: "breach-no-resource",
+ SloName: "test_slo",
+ Severity: "HIGH",
+ OccurredAt: DateTimeOffset.UtcNow,
+ CurrentValue: 10.0,
+ Threshold: 5.0,
+ Target: 1.0,
+ ResourceId: null,
+ TenantId: "tenant-1",
+ Context: null);
+
+ var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Contains("No resource ID", result.Error);
+ }
+
+ [Fact]
+ public async Task EscalateAsync_IncreasesEscalationLevel()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ // First activate at Low level
+ var activateRequest = new IncidentModeActivationRequest(
+ RunId: "run-escalate",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.Low,
+ Source: IncidentModeSource.Manual,
+ Reason: "Initial activation",
+ DurationMinutes: null,
+ RequestedBy: null);
+
+ await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
+
+ // Escalate to High
+ var result = await service.EscalateAsync(
+ "run-escalate",
+ IncidentEscalationLevel.High,
+ "Issue is more severe than expected",
+ TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
+ Assert.Contains("Escalated", result.Status.ActivationReason);
+ }
+
+ [Fact]
+ public async Task EscalateAsync_FailsWhenNotInIncidentMode()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var result = await service.EscalateAsync(
+ "unknown-run",
+ IncidentEscalationLevel.High,
+ null,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Contains("not active", result.Error);
+ }
+
+ [Fact]
+ public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ var activateRequest = new IncidentModeActivationRequest(
+ RunId: "run-no-deescalate",
+ TenantId: "tenant-1",
+ Level: IncidentEscalationLevel.High,
+ Source: IncidentModeSource.Manual,
+ Reason: "Test",
+ DurationMinutes: null,
+ RequestedBy: null);
+
+ await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
+
+ var result = await service.EscalateAsync(
+ "run-no-deescalate",
+ IncidentEscalationLevel.Medium, // Lower than High
+ null,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.Contains("Cannot escalate", result.Error);
+ }
+
+ [Fact]
+ public void GetSettingsForLevel_ReturnsCorrectSettings()
+ {
+ var store = new InMemoryPackRunIncidentModeStore();
+ var service = new PackRunIncidentModeService(
+ store,
+ NullLogger.Instance);
+
+ // Test None level
+ var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None);
+ Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive);
+ Assert.False(noneSettings.DebugCaptureSettings.CaptureActive);
+
+ // Test Critical level
+ var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical);
+ Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive);
+ Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity);
+ Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate);
+ Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive);
+ Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps);
+ Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays);
+ }
+
+ [Fact]
+ public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues()
+ {
+ var inactive = PackRunIncidentModeStatus.Inactive();
+
+ Assert.False(inactive.Active);
+ Assert.Equal(IncidentEscalationLevel.None, inactive.Level);
+ Assert.Null(inactive.ActivatedAt);
+ Assert.Null(inactive.ActivationReason);
+ Assert.Equal(IncidentModeSource.None, inactive.Source);
+ Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive);
+ Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive);
+ Assert.False(inactive.DebugCaptureSettings.CaptureActive);
+ }
+
+ [Fact]
+ public void IncidentRetentionPolicy_Extended_HasLongerRetention()
+ {
+ var defaultPolicy = IncidentRetentionPolicy.Default();
+ var extendedPolicy = IncidentRetentionPolicy.Extended();
+
+ Assert.True(extendedPolicy.ExtendedRetentionActive);
+ Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays);
+ Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays);
+ }
+
+ [Fact]
+ public void IncidentTelemetrySettings_Enhanced_HasHigherSampling()
+ {
+ var defaultSettings = IncidentTelemetrySettings.Default();
+ var enhancedSettings = IncidentTelemetrySettings.Enhanced();
+
+ Assert.True(enhancedSettings.EnhancedTelemetryActive);
+ Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate);
+ Assert.True(enhancedSettings.CaptureEnvironment);
+ Assert.True(enhancedSettings.CaptureStepIo);
+ }
+}
diff --git a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs
index 894381d52..df5f0eb63 100644
--- a/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs
+++ b/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs
@@ -16,6 +16,7 @@ using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.AirGap;
using StellaOps.TaskRunner.Core.Attestation;
using StellaOps.TaskRunner.Core.Configuration;
+using StellaOps.TaskRunner.Core.IncidentMode;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
@@ -127,6 +128,10 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Pack run incident mode (TASKRUN-OBS-55-001)
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
builder.Services.AddOpenApi();
var app = builder.Build();
@@ -230,6 +235,22 @@ app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnv
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
+// Incident mode endpoints (TASKRUN-OBS-55-001)
+app.MapGet("/v1/task-runner/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatus");
+app.MapGet("/api/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatusApi");
+
+app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentMode");
+app.MapPost("/api/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentModeApi");
+
+app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentMode");
+app.MapPost("/api/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentModeApi");
+
+app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentMode");
+app.MapPost("/api/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentModeApi");
+
+app.MapPost("/v1/task-runner/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhook");
+app.MapPost("/api/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhookApi");
+
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
{
var metadata = OpenApiMetadataFactory.Create("/openapi");
@@ -681,6 +702,175 @@ async Task HandleVerifyAttestation(
}, statusCode: statusCode);
}
+// Incident mode handlers (TASKRUN-OBS-55-001)
+async Task HandleGetIncidentModeStatus(
+ string runId,
+ IPackRunIncidentModeService incidentModeService,
+ CancellationToken cancellationToken)
+{
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ return Results.BadRequest(new { error = "runId is required." });
+ }
+
+ var status = await incidentModeService.GetStatusAsync(runId, cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(new
+ {
+ runId,
+ active = status.Active,
+ level = status.Level.ToString().ToLowerInvariant(),
+ activatedAt = status.ActivatedAt?.ToString("O"),
+ activationReason = status.ActivationReason,
+ source = status.Source.ToString().ToLowerInvariant(),
+ expiresAt = status.ExpiresAt?.ToString("O"),
+ retentionPolicy = new
+ {
+ extendedRetentionActive = status.RetentionPolicy.ExtendedRetentionActive,
+ logRetentionDays = status.RetentionPolicy.LogRetentionDays,
+ artifactRetentionDays = status.RetentionPolicy.ArtifactRetentionDays
+ },
+ telemetrySettings = new
+ {
+ enhancedTelemetryActive = status.TelemetrySettings.EnhancedTelemetryActive,
+ logVerbosity = status.TelemetrySettings.LogVerbosity.ToString().ToLowerInvariant(),
+ traceSamplingRate = status.TelemetrySettings.TraceSamplingRate
+ },
+ debugCaptureSettings = new
+ {
+ captureActive = status.DebugCaptureSettings.CaptureActive,
+ captureHeapDumps = status.DebugCaptureSettings.CaptureHeapDumps,
+ captureThreadDumps = status.DebugCaptureSettings.CaptureThreadDumps
+ }
+ });
+}
+
+async Task HandleActivateIncidentMode(
+ string runId,
+ [FromBody] ActivateIncidentModeRequest? request,
+ [FromHeader(Name = "X-Tenant-ID")] string? tenantId,
+ IPackRunIncidentModeService incidentModeService,
+ CancellationToken cancellationToken)
+{
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ return Results.BadRequest(new { error = "runId is required." });
+ }
+
+ var level = Enum.TryParse(request?.Level, ignoreCase: true, out var parsedLevel)
+ ? parsedLevel
+ : IncidentEscalationLevel.Medium;
+
+ var activationRequest = new IncidentModeActivationRequest(
+ RunId: runId,
+ TenantId: tenantId ?? "default",
+ Level: level,
+ Source: StellaOps.TaskRunner.Core.IncidentMode.IncidentModeSource.Manual,
+ Reason: request?.Reason ?? "Manual activation via API",
+ DurationMinutes: request?.DurationMinutes,
+ RequestedBy: request?.RequestedBy);
+
+ var result = await incidentModeService.ActivateAsync(activationRequest, cancellationToken).ConfigureAwait(false);
+
+ if (!result.Success)
+ {
+ return Results.BadRequest(new { error = result.Error });
+ }
+
+ return Results.Ok(new
+ {
+ success = result.Success,
+ active = result.Status.Active,
+ level = result.Status.Level.ToString().ToLowerInvariant(),
+ activatedAt = result.Status.ActivatedAt?.ToString("O"),
+ expiresAt = result.Status.ExpiresAt?.ToString("O")
+ });
+}
+
+async Task HandleDeactivateIncidentMode(
+ string runId,
+ [FromBody] DeactivateIncidentModeRequest? request,
+ IPackRunIncidentModeService incidentModeService,
+ CancellationToken cancellationToken)
+{
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ return Results.BadRequest(new { error = "runId is required." });
+ }
+
+ var result = await incidentModeService.DeactivateAsync(runId, request?.Reason, cancellationToken)
+ .ConfigureAwait(false);
+
+ return Results.Ok(new
+ {
+ success = result.Success,
+ active = result.Status.Active
+ });
+}
+
+async Task HandleEscalateIncidentMode(
+ string runId,
+ [FromBody] EscalateIncidentModeRequest? request,
+ IPackRunIncidentModeService incidentModeService,
+ CancellationToken cancellationToken)
+{
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ return Results.BadRequest(new { error = "runId is required." });
+ }
+
+ if (request is null || string.IsNullOrWhiteSpace(request.Level))
+ {
+ return Results.BadRequest(new { error = "Level is required for escalation." });
+ }
+
+ if (!Enum.TryParse(request.Level, ignoreCase: true, out var newLevel))
+ {
+ return Results.BadRequest(new { error = $"Invalid escalation level: {request.Level}" });
+ }
+
+ var result = await incidentModeService.EscalateAsync(runId, newLevel, request.Reason, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!result.Success)
+ {
+ return Results.BadRequest(new { error = result.Error });
+ }
+
+ return Results.Ok(new
+ {
+ success = result.Success,
+ level = result.Status.Level.ToString().ToLowerInvariant()
+ });
+}
+
+async Task HandleSloBreachWebhook(
+ [FromBody] SloBreachNotification notification,
+ IPackRunIncidentModeService incidentModeService,
+ CancellationToken cancellationToken)
+{
+ if (notification is null)
+ {
+ return Results.BadRequest(new { error = "Notification body is required." });
+ }
+
+ var result = await incidentModeService.HandleSloBreachAsync(notification, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!result.Success)
+ {
+ return Results.BadRequest(new { error = result.Error });
+ }
+
+ return Results.Ok(new
+ {
+ success = result.Success,
+ runId = notification.ResourceId,
+ level = result.Status.Level.ToString().ToLowerInvariant(),
+ activatedAt = result.Status.ActivatedAt?.ToString("O")
+ });
+}
+
app.Run();
static IDictionary? ConvertInputs(JsonObject? node)
@@ -712,6 +902,17 @@ internal sealed record VerifyAttestationRequest(
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary? Digest);
+// Incident mode API request models (TASKRUN-OBS-55-001)
+internal sealed record ActivateIncidentModeRequest(
+ string? Level,
+ string? Reason,
+ int? DurationMinutes,
+ string? RequestedBy);
+
+internal sealed record DeactivateIncidentModeRequest(string? Reason);
+
+internal sealed record EscalateIncidentModeRequest(string Level, string? Reason);
+
internal sealed record SimulationResponse(
string PlanHash,
FailurePolicyResponse FailurePolicy,
diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts
index 21da480c1..c20411431 100644
--- a/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/editor/policy-editor.component.ts
@@ -40,7 +40,7 @@ interface ChecklistItem {
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
-