diff --git a/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md b/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md
index 1e516f773..db8c6872a 100644
--- a/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md
+++ b/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md
@@ -1,6 +1,6 @@
# SPRINT_1103_0001_0001 - Replay Token Library
-**Status:** TODO
+**Status:** DONE
**Priority:** P0 - CRITICAL
**Module:** Core Libraries, Attestor
**Working Directory:** `src/__Libraries/StellaOps.Audit.ReplayToken/`
@@ -451,17 +451,17 @@ public static class ServiceCollectionExtensions
| # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------|
-| 1 | Create project `StellaOps.Audit.ReplayToken` | TODO | | New library |
-| 2 | Implement `IReplayTokenGenerator` interface | TODO | | Per §3.1 |
-| 3 | Implement `ReplayTokenRequest` model | TODO | | Per §3.2 |
-| 4 | Implement `ReplayToken` model | TODO | | Per §3.3 |
-| 5 | Implement `Sha256ReplayTokenGenerator` | TODO | | Per §3.4 |
-| 6 | Implement decision token extensions | TODO | | Per §3.5 |
-| 7 | Implement CLI snippet generator | TODO | | Per §3.6 |
-| 8 | Add service registration | TODO | | Per §3.7 |
-| 9 | Write unit tests for determinism | TODO | | Verify same inputs → same output |
-| 10 | Write unit tests for verification | TODO | | |
-| 11 | Document API in README | TODO | | |
+| 1 | Create project `StellaOps.Audit.ReplayToken` | DONE | | New library |
+| 2 | Implement `IReplayTokenGenerator` interface | DONE | | Per §3.1 |
+| 3 | Implement `ReplayTokenRequest` model | DONE | | Per §3.2 |
+| 4 | Implement `ReplayToken` model | DONE | | Per §3.3 |
+| 5 | Implement `Sha256ReplayTokenGenerator` | DONE | | Per §3.4 |
+| 6 | Implement decision token extensions | DONE | | Per §3.5 |
+| 7 | Implement CLI snippet generator | DONE | | Per §3.6 |
+| 8 | Add service registration | DONE | | Per §3.7 |
+| 9 | Write unit tests for determinism | DONE | | Verify same inputs → same output |
+| 10 | Write unit tests for verification | DONE | | |
+| 11 | Document API in README | DONE | | |
---
@@ -469,22 +469,22 @@ public static class ServiceCollectionExtensions
### 5.1 Determinism Requirements
-- [ ] Same inputs always produce same token
-- [ ] Array ordering doesn't affect output (sorted internally)
-- [ ] Null handling is consistent
-- [ ] Token format is stable across versions
+- [x] Same inputs always produce same token
+- [x] Array ordering doesn't affect output (sorted internally)
+- [x] Null handling is consistent
+- [x] Token format is stable across versions
### 5.2 Verification Requirements
-- [ ] `Verify()` returns true for matching inputs
-- [ ] `Verify()` returns false for different inputs
-- [ ] Token parsing handles valid and invalid formats
+- [x] `Verify()` returns true for matching inputs
+- [x] `Verify()` returns false for different inputs
+- [x] Token parsing handles valid and invalid formats
### 5.3 CLI Requirements
-- [ ] Generated CLI snippet is valid bash
-- [ ] Snippet includes all necessary parameters
-- [ ] Snippet uses proper escaping
+- [x] Generated CLI snippet is valid bash
+- [x] Snippet includes all necessary parameters
+- [x] Snippet uses proper escaping
---
diff --git a/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md b/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md
index fd297a275..b0c9c6925 100644
--- a/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md
+++ b/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md
@@ -1,6 +1,6 @@
# SPRINT_3101_0001_0001 - Scanner API Standardization
-**Status:** TODO
+**Status:** DOING
**Priority:** P0 - CRITICAL
**Module:** Scanner.WebService
**Working Directory:** `src/Scanner/StellaOps.Scanner.WebService/`
diff --git a/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md b/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md
index ea704bf87..4b540bdcf 100644
--- a/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md
+++ b/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md
@@ -1,6 +1,6 @@
# SPRINT_3102_0001_0001 - Postgres Call Graph Tables
-**Status:** TODO
+**Status:** DOING
**Priority:** P2 - MEDIUM
**Module:** Signals, Scanner
**Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/`
diff --git a/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml b/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml
index 0f0fdbf3a..da761358c 100644
--- a/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml
+++ b/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml
@@ -8,8 +8,8 @@ info:
idempotent submissions and async computation.
servers:
- - url: /api
- description: Scanner service endpoint
+ - url: https://scanner.stellaops.local/api/v1
+ description: Example Scanner endpoint
tags:
- name: Scans
@@ -83,7 +83,7 @@ paths:
description: SHA-256 digest for idempotency (RFC 9530)
schema:
type: string
- example: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
+ example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
requestBody:
required: true
content:
diff --git a/src/Api/StellaOps.Api.OpenApi/stella.yaml b/src/Api/StellaOps.Api.OpenApi/stella.yaml
index 1f704e3bf..a6729f2e4 100644
--- a/src/Api/StellaOps.Api.OpenApi/stella.yaml
+++ b/src/Api/StellaOps.Api.OpenApi/stella.yaml
@@ -23,6 +23,9 @@ servers:
- url: https://policy.stellaops.local
description: Example Policy Engine endpoint
x-service: policy
+ - url: https://scanner.stellaops.local/api/v1
+ description: Example Scanner endpoint
+ x-service: scanner
- url: https://scheduler.stellaops.local
description: Example Scheduler endpoint
x-service: scheduler
@@ -47,6 +50,18 @@ tags:
description: Policy management APIs
- name: Queues
description: Queue metrics APIs
+ - name: Scans
+ description: Scan lifecycle management
+ - name: CallGraphs
+ description: Call graph ingestion
+ - name: RuntimeEvidence
+ description: Runtime evidence collection
+ - name: Reachability
+ description: Reachability analysis and queries
+ - name: Exports
+ description: Report exports
+ - name: ProofSpines
+ description: Verifiable audit trails
paths:
/authority/introspect:
post:
@@ -1129,6 +1144,367 @@ paths:
$ref: "#/components/responses/ErrorResponse"
x-service: policy
x-original-path: /policies
+ /scanner/scans:
+ post:
+ tags:
+ - Scans
+ operationId: createScan
+ summary: Create a new scan
+ description: |
+ Initiates a new scan context. Returns a scanId for subsequent
+ call graph and evidence submissions.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.CreateScanRequest"
+ responses:
+ "201":
+ description: Scan created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.CreateScanResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ x-service: scanner
+ x-original-path: /scans
+ /scanner/scans/{scanId}:
+ get:
+ tags:
+ - Scans
+ operationId: getScan
+ summary: Get scan status
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ responses:
+ "200":
+ description: Scan details
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ScanDetails"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ x-service: scanner
+ x-original-path: /scans/{scanId}
+ /scanner/scans/{scanId}/callgraphs:
+ post:
+ tags:
+ - CallGraphs
+ operationId: submitCallGraph
+ summary: Submit a call graph
+ description: |
+ Submits a language-specific call graph for reachability analysis.
+ Idempotent: duplicate submissions with same Content-Digest are ignored.
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ - name: Content-Digest
+ in: header
+ required: true
+ description: SHA-256 digest for idempotency (RFC 9530)
+ schema:
+ type: string
+ example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.CallGraphV1"
+ application/x-ndjson:
+ schema:
+ type: string
+ description: Streaming NDJSON for large graphs
+ responses:
+ "202":
+ description: Call graph accepted
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.CallGraphAcceptedResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "409":
+ description: Duplicate submission (idempotent success)
+ "413":
+ description: Call graph too large
+ x-service: scanner
+ x-original-path: /scans/{scanId}/callgraphs
+ /scanner/scans/{scanId}/compute-reachability:
+ post:
+ tags:
+ - Reachability
+ operationId: computeReachability
+ summary: Trigger reachability computation
+ description: |
+ Triggers reachability analysis for the scan. Idempotent.
+ Computation is asynchronous; poll scan status for completion.
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ComputeReachabilityRequest"
+ responses:
+ "202":
+ description: Computation started
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ComputeReachabilityResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "409":
+ description: Computation already in progress
+ x-service: scanner
+ x-original-path: /scans/{scanId}/compute-reachability
+ /scanner/scans/{scanId}/exports/cdxr:
+ get:
+ tags:
+ - Exports
+ operationId: exportCycloneDxReachability
+ summary: Export as CycloneDX with reachability extension
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ responses:
+ "200":
+ description: CycloneDX with reachability
+ content:
+ application/vnd.cyclonedx+json:
+ schema:
+ type: object
+ x-service: scanner
+ x-original-path: /scans/{scanId}/exports/cdxr
+ /scanner/scans/{scanId}/exports/openvex:
+ get:
+ tags:
+ - Exports
+ operationId: exportOpenVex
+ summary: Export as OpenVEX
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ responses:
+ "200":
+ description: OpenVEX document
+ content:
+ application/json:
+ schema:
+ type: object
+ x-service: scanner
+ x-original-path: /scans/{scanId}/exports/openvex
+ /scanner/scans/{scanId}/exports/sarif:
+ get:
+ tags:
+ - Exports
+ operationId: exportSarif
+ summary: Export findings as SARIF
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ responses:
+ "200":
+ description: SARIF report
+ content:
+ application/sarif+json:
+ schema:
+ type: object
+ x-service: scanner
+ x-original-path: /scans/{scanId}/exports/sarif
+ /scanner/scans/{scanId}/reachability/components:
+ get:
+ tags:
+ - Reachability
+ operationId: getReachabilityByComponent
+ summary: Get reachability status by component
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ - name: purl
+ in: query
+ description: Filter by Package URL
+ schema:
+ type: string
+ - name: status
+ in: query
+ description: Filter by reachability status
+ schema:
+ type: string
+ enum:
+ - reachable
+ - unreachable
+ - possibly_reachable
+ - unknown
+ responses:
+ "200":
+ description: Component reachability results
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ComponentReachabilityList"
+ x-service: scanner
+ x-original-path: /scans/{scanId}/reachability/components
+ /scanner/scans/{scanId}/reachability/explain:
+ get:
+ tags:
+ - Reachability
+ operationId: explainReachability
+ summary: Explain reachability for CVE/component
+ description: |
+ Returns detailed explanation of why a CVE affects a component,
+ including path witness, evidence chain, and contributing factors.
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ - name: cve
+ in: query
+ required: true
+ schema:
+ type: string
+ - name: purl
+ in: query
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Reachability explanation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ReachabilityExplanation"
+ "404":
+ description: CVE/component combination not found
+ x-service: scanner
+ x-original-path: /scans/{scanId}/reachability/explain
+ /scanner/scans/{scanId}/reachability/findings:
+ get:
+ tags:
+ - Reachability
+ operationId: getReachabilityFindings
+ summary: Get vulnerability findings with reachability
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ - name: cve
+ in: query
+ description: Filter by CVE ID
+ schema:
+ type: string
+ - name: status
+ in: query
+ description: Filter by reachability status
+ schema:
+ type: string
+ enum:
+ - reachable
+ - unreachable
+ - possibly_reachable
+ - unknown
+ responses:
+ "200":
+ description: Vulnerability findings with reachability
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ReachabilityFindingList"
+ x-service: scanner
+ x-original-path: /scans/{scanId}/reachability/findings
+ /scanner/scans/{scanId}/runtimeevidence:
+ post:
+ tags:
+ - RuntimeEvidence
+ operationId: submitRuntimeEvidence
+ summary: Submit runtime evidence
+ description: |
+ Submits runtime execution evidence (stack traces, loaded modules).
+ Merges with existing evidence for the scan.
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.RuntimeEvidenceV1"
+ responses:
+ "202":
+ description: Evidence accepted
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.RuntimeEvidenceAcceptedResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ x-service: scanner
+ x-original-path: /scans/{scanId}/runtimeevidence
+ /scanner/scans/{scanId}/sbom:
+ post:
+ tags:
+ - Scans
+ operationId: submitSbom
+ summary: Submit SBOM for scan
+ description: |
+ Associates an SBOM (CycloneDX or SPDX) with the scan.
+ Required before reachability computation.
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ requestBody:
+ required: true
+ content:
+ application/vnd.cyclonedx+json:
+ schema:
+ type: object
+ application/spdx+json:
+ schema:
+ type: object
+ responses:
+ "202":
+ description: SBOM accepted
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ x-service: scanner
+ x-original-path: /scans/{scanId}/sbom
+ /scanner/scans/{scanId}/spines:
+ get:
+ tags:
+ - ProofSpines
+ operationId: getSpinesByScan
+ summary: List proof spines for a scan
+ parameters:
+ - $ref: "#/components/parameters/ScanIdPath"
+ responses:
+ "200":
+ description: Proof spines for scan
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ProofSpineList"
+ x-service: scanner
+ x-original-path: /scans/{scanId}/spines
+ /scanner/spines/{spineId}:
+ get:
+ tags:
+ - ProofSpines
+ operationId: getSpine
+ summary: Get a proof spine
+ description: Returns full spine with all segments and verification status.
+ parameters:
+ - name: spineId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Proof spine details
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/scanner.ProofSpine"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ x-service: scanner
+ x-original-path: /spines/{spineId}
/scheduler/health:
get:
tags:
@@ -1250,6 +1626,212 @@ paths:
x-original-path: /queues/{name}
components:
schemas:
+ BreakingChange:
+ type: object
+ description: Description of a breaking change between deprecated and successor
+ endpoints.
+ required:
+ - type
+ - description
+ properties:
+ type:
+ type: string
+ enum:
+ - parameter-removed
+ - parameter-renamed
+ - parameter-type-changed
+ - response-schema-changed
+ - header-removed
+ - header-renamed
+ - status-code-changed
+ - content-type-changed
+ - authentication-changed
+ description: Category of the breaking change.
+ path:
+ type: string
+ description: JSON path to the affected element.
+ example: $.parameters[0].name
+ description:
+ type: string
+ description: Human-readable description of the change.
+ example: Parameter 'page' renamed to 'cursor'
+ migrationAction:
+ type: string
+ description: Recommended action for consumers.
+ example: Replace 'page' parameter with 'cursor' using the nextCursor value from
+ previous response.
+ DeprecationMetadata:
+ type: object
+ description: >
+ Deprecation metadata for API endpoints. Applied as x-deprecation
+ extension
+
+ on operation objects. Used by Spectral rules, changelog generation, and
+
+ notification templates.
+ required:
+ - deprecatedAt
+ - sunsetAt
+ - successorPath
+ - reason
+ properties:
+ deprecatedAt:
+ type: string
+ format: date-time
+ description: ISO 8601 timestamp when the endpoint was marked deprecated.
+ example: 2025-01-15T00:00:00Z
+ sunsetAt:
+ type: string
+ format: date-time
+ description: ISO 8601 timestamp when the endpoint will be removed.
+ example: 2025-07-15T00:00:00Z
+ successorPath:
+ type: string
+ description: Path to the replacement endpoint (if available).
+ example: /v2/resources
+ successorOperationId:
+ type: string
+ description: Operation ID of the replacement endpoint.
+ example: getResourcesV2
+ reason:
+ type: string
+ description: Human-readable explanation for the deprecation.
+ example: Replaced by paginated v2 endpoint with cursor-based pagination.
+ migrationGuide:
+ type: string
+ format: uri
+ description: URL to migration documentation.
+ example: https://docs.stella-ops.org/migration/resources-v2
+ notificationChannels:
+ type: array
+ description: Notification channels for deprecation announcements.
+ items:
+ type: string
+ enum:
+ - slack
+ - teams
+ - email
+ - webhook
+ default:
+ - email
+ affectedConsumerHints:
+ type: array
+ description: Hints about affected consumers (e.g., SDK names, client IDs).
+ items:
+ type: string
+ breakingChanges:
+ type: array
+ description: List of breaking changes in the successor endpoint.
+ items:
+ $ref: "#/schemas/BreakingChange"
+ DeprecationNotificationEvent:
+ type: object
+ description: Event payload for deprecation notifications sent to Notify service.
+ required:
+ - eventId
+ - eventType
+ - timestamp
+ - tenantId
+ - deprecation
+ properties:
+ eventId:
+ type: string
+ format: uuid
+ description: Unique identifier for this notification event.
+ eventType:
+ type: string
+ const: api.deprecation.announced
+ description: Event type for routing in Notify service.
+ timestamp:
+ type: string
+ format: date-time
+ description: ISO 8601 timestamp when the event was generated.
+ tenantId:
+ type: string
+ description: Tenant scope for the notification.
+ deprecation:
+ $ref: "#/schemas/DeprecationSummary"
+ DeprecationReport:
+ type: object
+ description: Aggregated report of all deprecations for changelog/SDK publishing.
+ required:
+ - generatedAt
+ - schemaVersion
+ - deprecations
+ properties:
+ generatedAt:
+ type: string
+ format: date-time
+ description: When this report was generated.
+ schemaVersion:
+ type: string
+ const: api.deprecation.report@1
+ totalCount:
+ type: integer
+ description: Total number of deprecated endpoints.
+ upcomingSunsets:
+ type: integer
+ description: Number of endpoints with sunset within 90 days.
+ deprecations:
+ type: array
+ items:
+ $ref: "#/schemas/DeprecationSummary"
+ DeprecationSummary:
+ type: object
+ description: Summary of a deprecated endpoint for notification purposes.
+ required:
+ - service
+ - path
+ - method
+ - deprecatedAt
+ - sunsetAt
+ properties:
+ service:
+ type: string
+ description: Service name owning the deprecated endpoint.
+ example: authority
+ path:
+ type: string
+ description: API path of the deprecated endpoint.
+ example: /v1/tokens
+ method:
+ type: string
+ enum:
+ - GET
+ - POST
+ - PUT
+ - PATCH
+ - DELETE
+ - HEAD
+ - OPTIONS
+ description: HTTP method of the deprecated endpoint.
+ operationId:
+ type: string
+ description: OpenAPI operation ID.
+ example: createToken
+ deprecatedAt:
+ type: string
+ format: date-time
+ sunsetAt:
+ type: string
+ format: date-time
+ daysUntilSunset:
+ type: integer
+ description: Computed days remaining until sunset.
+ example: 180
+ successorPath:
+ type: string
+ description: Path to the replacement endpoint.
+ reason:
+ type: string
+ description: Deprecation reason.
+ migrationGuide:
+ type: string
+ format: uri
+ changelogUrl:
+ type: string
+ format: uri
+ description: URL to the API changelog entry for this deprecation.
ErrorEnvelope:
type: object
required:
@@ -1809,6 +2391,552 @@ components:
type: integer
nextPageToken:
type: string
+ scanner.CallGraphAcceptedResponse:
+ type: object
+ properties:
+ callgraphId:
+ type: string
+ nodeCount:
+ type: integer
+ edgeCount:
+ type: integer
+ digest:
+ type: string
+ scanner.CallGraphArtifact:
+ type: object
+ properties:
+ artifactKey:
+ type: string
+ kind:
+ type: string
+ enum:
+ - assembly
+ - jar
+ - module
+ - binary
+ sha256:
+ type: string
+ purl:
+ type: string
+ scanner.CallGraphEdge:
+ type: object
+ required:
+ - from
+ - to
+ properties:
+ from:
+ type: string
+ description: Source node ID
+ to:
+ type: string
+ description: Target node ID
+ kind:
+ type: string
+ enum:
+ - static
+ - heuristic
+ default: static
+ reason:
+ type: string
+ enum:
+ - direct_call
+ - virtual_call
+ - reflection_string
+ - di_binding
+ - dynamic_import
+ - unknown
+ weight:
+ type: number
+ default: 1
+ scanner.CallGraphEntrypoint:
+ type: object
+ required:
+ - nodeId
+ - kind
+ properties:
+ nodeId:
+ type: string
+ kind:
+ type: string
+ enum:
+ - http
+ - grpc
+ - cli
+ - job
+ - event
+ - unknown
+ route:
+ type: string
+ description: HTTP route pattern (e.g., /api/orders/{id})
+ framework:
+ type: string
+ enum:
+ - aspnetcore
+ - minimalapi
+ - spring
+ - express
+ - fastapi
+ - unknown
+ scanner.CallGraphNode:
+ type: object
+ required:
+ - nodeId
+ - symbolKey
+ properties:
+ nodeId:
+ type: string
+ artifactKey:
+ type: string
+ symbolKey:
+ type: string
+ description: Canonical symbol key (Namespace.Type::Method(signature))
+ visibility:
+ type: string
+ enum:
+ - public
+ - internal
+ - private
+ - unknown
+ isEntrypointCandidate:
+ type: boolean
+ default: false
+ scanner.CallGraphV1:
+ type: object
+ required:
+ - schema
+ - scanKey
+ - language
+ - nodes
+ - edges
+ properties:
+ schema:
+ type: string
+ const: stella.callgraph.v1
+ scanKey:
+ type: string
+ format: uuid
+ language:
+ type: string
+ enum:
+ - dotnet
+ - java
+ - node
+ - python
+ - go
+ - rust
+ - binary
+ - ruby
+ - php
+ artifacts:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.CallGraphArtifact"
+ nodes:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.CallGraphNode"
+ edges:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.CallGraphEdge"
+ entrypoints:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.CallGraphEntrypoint"
+ scanner.ComponentReachability:
+ type: object
+ properties:
+ purl:
+ type: string
+ status:
+ type: string
+ enum:
+ - reachable
+ - unreachable
+ - possibly_reachable
+ - unknown
+ confidence:
+ type: number
+ latticeState:
+ type: string
+ why:
+ type: array
+ items:
+ type: string
+ scanner.ComponentReachabilityList:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.ComponentReachability"
+ total:
+ type: integer
+ scanner.ComputeReachabilityRequest:
+ type: object
+ properties:
+ forceRecompute:
+ type: boolean
+ default: false
+ entrypoints:
+ type: array
+ items:
+ type: string
+ description: Override auto-detected entrypoints
+ targets:
+ type: array
+ items:
+ type: string
+ description: Specific symbols to analyze
+ scanner.ComputeReachabilityResponse:
+ type: object
+ properties:
+ jobId:
+ type: string
+ status:
+ type: string
+ enum:
+ - queued
+ - processing
+ estimatedDuration:
+ type: string
+ description: ISO-8601 duration estimate
+ scanner.CreateScanRequest:
+ type: object
+ required:
+ - artifactDigest
+ properties:
+ artifactDigest:
+ type: string
+ description: Image or artifact digest (sha256:...)
+ repoUri:
+ type: string
+ commitSha:
+ type: string
+ policyProfileId:
+ type: string
+ metadata:
+ type: object
+ additionalProperties: true
+ scanner.CreateScanResponse:
+ type: object
+ properties:
+ scanId:
+ type: string
+ format: uuid
+ status:
+ type: string
+ enum:
+ - created
+ - pending
+ - processing
+ - completed
+ - failed
+ createdAt:
+ type: string
+ format: date-time
+ scanner.ErrorResponse:
+ type: object
+ properties:
+ error:
+ type: string
+ message:
+ type: string
+ details:
+ type: object
+ scanner.EvidenceChain:
+ type: object
+ properties:
+ staticAnalysis:
+ type: object
+ properties:
+ callgraphDigest:
+ type: string
+ pathLength:
+ type: integer
+ edgeTypes:
+ type: array
+ items:
+ type: string
+ runtimeEvidence:
+ type: object
+ properties:
+ observed:
+ type: boolean
+ hitCount:
+ type: integer
+ lastObserved:
+ type: string
+ format: date-time
+ policyEvaluation:
+ type: object
+ properties:
+ policyDigest:
+ type: string
+ verdict:
+ type: string
+ verdictReason:
+ type: string
+ scanner.ExplanationReason:
+ type: object
+ properties:
+ code:
+ type: string
+ description:
+ type: string
+ impact:
+ type: number
+ scanner.LoadedArtifact:
+ type: object
+ properties:
+ artifactKey:
+ type: string
+ evidence:
+ type: string
+ enum:
+ - loaded_module
+ - mapped_file
+ - jar_loaded
+ scanner.ProofSegment:
+ type: object
+ properties:
+ segmentId:
+ type: string
+ segmentType:
+ type: string
+ enum:
+ - SBOM_SLICE
+ - MATCH
+ - REACHABILITY
+ - GUARD_ANALYSIS
+ - RUNTIME_OBSERVATION
+ - POLICY_EVAL
+ index:
+ type: integer
+ inputHash:
+ type: string
+ resultHash:
+ type: string
+ prevSegmentHash:
+ type: string
+ toolId:
+ type: string
+ toolVersion:
+ type: string
+ status:
+ type: string
+ enum:
+ - pending
+ - verified
+ - partial
+ - invalid
+ - untrusted
+ createdAt:
+ type: string
+ format: date-time
+ scanner.ProofSpine:
+ type: object
+ properties:
+ spineId:
+ type: string
+ artifactId:
+ type: string
+ vulnerabilityId:
+ type: string
+ policyProfileId:
+ type: string
+ verdict:
+ type: string
+ verdictReason:
+ type: string
+ rootHash:
+ type: string
+ scanRunId:
+ type: string
+ segments:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.ProofSegment"
+ createdAt:
+ type: string
+ format: date-time
+ supersededBySpineId:
+ type: string
+ scanner.ProofSpineList:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.ProofSpineSummary"
+ total:
+ type: integer
+ scanner.ProofSpineSummary:
+ type: object
+ properties:
+ spineId:
+ type: string
+ artifactId:
+ type: string
+ vulnerabilityId:
+ type: string
+ verdict:
+ type: string
+ segmentCount:
+ type: integer
+ createdAt:
+ type: string
+ format: date-time
+ scanner.ReachabilityExplanation:
+ type: object
+ properties:
+ cveId:
+ type: string
+ purl:
+ type: string
+ status:
+ type: string
+ confidence:
+ type: number
+ latticeState:
+ type: string
+ pathWitness:
+ type: array
+ items:
+ type: string
+ description: Symbol path from entrypoint to vulnerable code
+ why:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.ExplanationReason"
+ evidence:
+ $ref: "#/components/schemas/scanner.EvidenceChain"
+ spineId:
+ type: string
+ description: Reference to ProofSpine for full audit trail
+ scanner.ReachabilityFinding:
+ type: object
+ properties:
+ cveId:
+ type: string
+ purl:
+ type: string
+ status:
+ type: string
+ confidence:
+ type: number
+ latticeState:
+ type: string
+ severity:
+ type: string
+ affectedVersions:
+ type: string
+ scanner.ReachabilityFindingList:
+ type: object
+ properties:
+ items:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.ReachabilityFinding"
+ total:
+ type: integer
+ scanner.RuntimeEnvironment:
+ type: object
+ properties:
+ os:
+ type: string
+ k8s:
+ type: object
+ properties:
+ namespace:
+ type: string
+ pod:
+ type: string
+ container:
+ type: string
+ imageDigest:
+ type: string
+ buildId:
+ type: string
+ scanner.RuntimeEvidenceAcceptedResponse:
+ type: object
+ properties:
+ evidenceId:
+ type: string
+ sampleCount:
+ type: integer
+ loadedArtifactCount:
+ type: integer
+ scanner.RuntimeEvidenceV1:
+ type: object
+ required:
+ - schema
+ - scanKey
+ - collectedAt
+ properties:
+ schema:
+ type: string
+ const: stella.runtimeevidence.v1
+ scanKey:
+ type: string
+ format: uuid
+ collectedAt:
+ type: string
+ format: date-time
+ environment:
+ $ref: "#/components/schemas/scanner.RuntimeEnvironment"
+ samples:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.RuntimeSample"
+ loadedArtifacts:
+ type: array
+ items:
+ $ref: "#/components/schemas/scanner.LoadedArtifact"
+ scanner.RuntimeSample:
+ type: object
+ properties:
+ timestamp:
+ type: string
+ format: date-time
+ pid:
+ type: integer
+ threadId:
+ type: integer
+ frames:
+ type: array
+ items:
+ type: string
+ description: Array of node IDs representing call stack
+ sampleWeight:
+ type: number
+ default: 1
+ scanner.ScanDetails:
+ type: object
+ properties:
+ scanId:
+ type: string
+ status:
+ type: string
+ artifactDigest:
+ type: string
+ callGraphCount:
+ type: integer
+ runtimeEvidenceCount:
+ type: integer
+ reachabilityStatus:
+ type: string
+ enum:
+ - pending
+ - computing
+ - completed
+ - failed
+ createdAt:
+ type: string
+ format: date-time
+ completedAt:
+ type: string
+ format: date-time
scheduler.ErrorEnvelope:
type: object
properties:
diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj
index 1dc2531e4..6073f43d1 100644
--- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj
+++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj
@@ -21,6 +21,7 @@
+
-
\ No newline at end of file
+
diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md
new file mode 100644
index 000000000..7a31e7ad5
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md
@@ -0,0 +1,6 @@
+# Scanner WebService Local Tasks
+
+| Task ID | Sprint | Status | Notes |
+| --- | --- | --- | --- |
+| `SCAN-API-3101-001` | `docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md` | DOING | Align Scanner OpenAPI spec with current endpoints and include ProofSpine routes; compose into `src/Api/StellaOps.Api.OpenApi/stella.yaml`. |
+| `PROOFSPINE-3100-API` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Implement and test `/api/v1/spines/*` endpoints and wire verification output. |
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs
index bc2407655..a00d227f7 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs
@@ -77,7 +77,7 @@ public sealed class HmacDsseSigningService : IDsseSigningService
if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided))
{
- return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, failureReason: null));
+ return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, FailureReason: null));
}
return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch"));
@@ -141,4 +141,3 @@ public sealed class HmacDsseSigningService : IDsseSigningService
}
}
}
-
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs
new file mode 100644
index 000000000..bc00a18b7
--- /dev/null
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs
@@ -0,0 +1,66 @@
+using StellaOps.Replay.Core;
+
+namespace StellaOps.Scanner.ProofSpine;
+
+///
+/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
+///
+public sealed record ProofSpine(
+ string SpineId,
+ string ArtifactId,
+ string VulnerabilityId,
+ string PolicyProfileId,
+ IReadOnlyList Segments,
+ string Verdict,
+ string VerdictReason,
+ string RootHash,
+ string ScanRunId,
+ DateTimeOffset CreatedAt,
+ string? SupersededBySpineId);
+
+///
+/// A single evidence segment in the proof chain.
+///
+public sealed record ProofSegment(
+ string SegmentId,
+ ProofSegmentType SegmentType,
+ int Index,
+ string InputHash,
+ string ResultHash,
+ string? PrevSegmentHash,
+ DsseEnvelope Envelope,
+ string ToolId,
+ string ToolVersion,
+ ProofSegmentStatus Status,
+ DateTimeOffset CreatedAt);
+
+public sealed record GuardCondition(
+ string Name,
+ string Type,
+ string Value,
+ bool Passed);
+
+///
+/// Segment types in execution order.
+///
+public enum ProofSegmentType
+{
+ SbomSlice = 1,
+ Match = 2,
+ Reachability = 3,
+ GuardAnalysis = 4,
+ RuntimeObservation = 5,
+ PolicyEval = 6
+}
+
+///
+/// Verification status of a segment.
+///
+public enum ProofSegmentStatus
+{
+ Pending = 0,
+ Verified = 1,
+ Partial = 2,
+ Invalid = 3,
+ Untrusted = 4
+}
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj
new file mode 100644
index 000000000..00ee305fd
--- /dev/null
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net10.0
+ preview
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md
new file mode 100644
index 000000000..3b03546c3
--- /dev/null
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md
@@ -0,0 +1,5 @@
+# Scanner Storage Local Tasks
+
+| Task ID | Sprint | Status | Notes |
+| --- | --- | --- | --- |
+| `PROOFSPINE-3100-DB` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Add Postgres migrations and repository for ProofSpine persistence (`proof_spines`, `proof_segments`, `proof_spine_history`). |
diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md b/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md
new file mode 100644
index 000000000..cefb6485a
--- /dev/null
+++ b/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md
@@ -0,0 +1,24 @@
+# Signals Storage Postgres Guild Charter
+
+## Mission
+Provide deterministic, offline-first PostgreSQL persistence for Signals, including call graph storage/projection, unknowns registry/scoring, and reachability facts needed by Scanner and Policy.
+
+## Scope
+- PostgreSQL schema owned by Signals (default schema: `signals`).
+- Embedded SQL migrations under `Migrations/*.sql`, executed via `AddStartupMigrations`.
+- Repository implementations under `Repositories/` (query + ingestion/sync).
+
+## Required Reading
+- `docs/modules/platform/architecture-overview.md`
+- `docs/signals/reachability.md`
+- `docs/signals/callgraph-formats.md`
+- `docs/signals/runtime-facts.md`
+- `docs/signals/unknowns-registry.md`
+- Current sprint file: `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md`
+
+## Working Agreement
+1. Update task state to `DOING`/`DONE` in both `/docs/implplan/SPRINT_*.md` and local `TASKS.md`.
+2. Keep outputs deterministic: stable ordering, canonical JSON where applicable, UTC timestamps only.
+3. Prefer additive, non-breaking startup migrations; avoid long-running data rewrites at startup.
+4. Maintain offline posture: no network I/O, no external schema downloads.
+5. Changes must be covered by tests (integration preferred for migrations + repositories).
diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md b/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md
new file mode 100644
index 000000000..085d3b0d4
--- /dev/null
+++ b/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md
@@ -0,0 +1,5 @@
+# Signals Storage Postgres Local Tasks
+
+| Task ID | Sprint | Status | Notes |
+| --- | --- | --- | --- |
+| `SIG-PG-3102-001` | `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` | DOING | Add relational call graph tables + migrations wiring; register query repository and add integration coverage. |
diff --git a/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs b/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs
new file mode 100644
index 000000000..8f1590a9e
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs
@@ -0,0 +1,19 @@
+namespace StellaOps.Signals.Options;
+
+///
+/// Configuration for unknowns decay batch processing.
+///
+public sealed class UnknownsDecayOptions
+{
+ public const string SectionName = "Signals:UnknownsDecay";
+
+ ///
+ /// Time of day (UTC hour) for nightly decay batch. Default: 2 (2 AM UTC).
+ ///
+ public int NightlyBatchHourUtc { get; set; } = 2;
+
+ ///
+ /// Maximum subjects per batch run. Default: 10000.
+ ///
+ public int MaxSubjectsPerBatch { get; set; } = 10_000;
+}
diff --git a/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs b/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs
index 877324a35..ca27c03ea 100644
--- a/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs
+++ b/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs
@@ -66,6 +66,11 @@ public sealed class UnknownsScoringOptions
///
public int StalenessMaxDays { get; set; } = 14;
+ ///
+ /// Staleness time constant (tau) in days for exponential decay. Default: 14
+ ///
+ public double StalenessTauDays { get; set; } = 14.0;
+
// ===== BAND THRESHOLDS =====
///
@@ -80,6 +85,11 @@ public sealed class UnknownsScoringOptions
// ===== RESCAN SCHEDULING =====
+ ///
+ /// Minutes until HOT items are rescanned. Default: 15
+ ///
+ public int HotRescanMinutes { get; set; } = 15;
+
///
/// Hours until WARM items are rescanned. Default: 24
///
diff --git a/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs b/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs
index 3ae6c9abc..c61bb272c 100644
--- a/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs
+++ b/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs
@@ -8,6 +8,12 @@ namespace StellaOps.Signals.Persistence;
public sealed class InMemoryUnknownsRepository : IUnknownsRepository
{
private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase);
+ private readonly TimeProvider _timeProvider;
+
+ public InMemoryUnknownsRepository(TimeProvider? timeProvider = null)
+ {
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
public Task UpsertAsync(string subjectKey, IEnumerable items, CancellationToken cancellationToken)
{
@@ -59,12 +65,23 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository
return Task.CompletedTask;
}
+ public Task> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var keys = _store.Keys
+ .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return Task.FromResult>(keys);
+ }
+
public Task> GetDueForRescanAsync(
UnknownsBand band,
int limit,
CancellationToken cancellationToken)
{
- var now = DateTimeOffset.UtcNow;
+ var now = _timeProvider.GetUtcNow();
var results = _store.Values
.SelectMany(x => x)
diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs
index 30026176d..44fe05f0c 100644
--- a/src/Signals/StellaOps.Signals/Program.cs
+++ b/src/Signals/StellaOps.Signals/Program.cs
@@ -132,6 +132,16 @@ builder.Services.AddSingleton(sp =>
return new ReachabilityFactCacheDecorator(inner, cache);
});
builder.Services.AddSingleton();
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection(UnknownsScoringOptions.SectionName));
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection(UnknownsDecayOptions.SectionName));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
builder.Services.AddSingleton();
builder.Services.AddHttpClient((sp, client) =>
{
diff --git a/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs b/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs
new file mode 100644
index 000000000..58697c55d
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs
@@ -0,0 +1,52 @@
+namespace StellaOps.Signals.Services;
+
+///
+/// Handles signal refresh events that reset decay.
+///
+public interface ISignalRefreshService
+{
+ ///
+ /// Records a signal refresh event.
+ ///
+ Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default);
+}
+
+///
+/// Signal refresh event types per advisory.
+///
+public sealed class SignalRefreshEvent
+{
+ ///
+ /// Subject key for the unknown.
+ ///
+ public required string SubjectKey { get; init; }
+
+ ///
+ /// Unknown ID being refreshed.
+ ///
+ public required string UnknownId { get; init; }
+
+ ///
+ /// Type of signal refresh.
+ ///
+ public required SignalRefreshType RefreshType { get; init; }
+
+ ///
+ /// Weight of this signal type.
+ ///
+ public double Weight { get; init; }
+
+ ///
+ /// Additional context.
+ ///
+ public IReadOnlyDictionary? Context { get; init; }
+}
+
+public enum SignalRefreshType
+{
+ UnknownsIngested,
+ ReachabilityRecomputed,
+ RuntimeFactsIngested,
+ ProvenanceAnchored,
+ VexUpdated
+}
diff --git a/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs b/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs
new file mode 100644
index 000000000..f03ef6ef5
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs
@@ -0,0 +1,45 @@
+using StellaOps.Signals.Models;
+
+namespace StellaOps.Signals.Services;
+
+///
+/// Service for computing confidence decay on unknowns.
+///
+public interface IUnknownsDecayService
+{
+ ///
+ /// Applies decay to all unknowns in a subject and recomputes bands.
+ ///
+ Task ApplyDecayAsync(
+ string subjectKey,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Applies decay to a single unknown.
+ ///
+ Task ApplyDecayToUnknownAsync(
+ UnknownSymbolDocument unknown,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Recomputes all scores and bands for nightly batch.
+ ///
+ Task RunNightlyDecayBatchAsync(
+ CancellationToken cancellationToken = default);
+}
+
+public sealed record DecayResult(
+ string SubjectKey,
+ int ProcessedCount,
+ int HotCount,
+ int WarmCount,
+ int ColdCount,
+ int BandChanges,
+ DateTimeOffset ComputedAt);
+
+public sealed record BatchDecayResult(
+ int TotalSubjects,
+ int TotalUnknowns,
+ int TotalBandChanges,
+ TimeSpan Duration,
+ DateTimeOffset CompletedAt);
diff --git a/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs b/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs
new file mode 100644
index 000000000..290e700fd
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Signals.Options;
+
+namespace StellaOps.Signals.Services;
+
+public sealed class NightlyDecayWorker : BackgroundService
+{
+ private readonly IUnknownsDecayService _decayService;
+ private readonly IOptions _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public NightlyDecayWorker(
+ IUnknownsDecayService decayService,
+ IOptions options,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _decayService = decayService ?? throw new ArgumentNullException(nameof(decayService));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var opts = _options.Value;
+ var nextRun = GetNextRunUtc(_timeProvider.GetUtcNow(), opts.NightlyBatchHourUtc);
+ var delay = nextRun - _timeProvider.GetUtcNow();
+
+ if (delay > TimeSpan.Zero)
+ {
+ _logger.LogInformation("Next unknowns decay batch scheduled for {NextRun}", nextRun);
+ await Task.Delay(delay, _timeProvider, stoppingToken).ConfigureAwait(false);
+ }
+
+ try
+ {
+ await _decayService.RunNightlyDecayBatchAsync(stoppingToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ // Shutdown requested.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Nightly unknowns decay batch failed.");
+ }
+ }
+ }
+
+ private static DateTimeOffset GetNextRunUtc(DateTimeOffset nowUtc, int hourUtc)
+ {
+ var clampedHour = Math.Clamp(hourUtc, 0, 23);
+ var today = new DateTimeOffset(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, TimeSpan.Zero);
+ var candidate = today.AddHours(clampedHour);
+ return candidate <= nowUtc ? candidate.AddDays(1) : candidate;
+ }
+}
diff --git a/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs b/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs
new file mode 100644
index 000000000..a241c4386
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs
@@ -0,0 +1,61 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Signals.Options;
+using StellaOps.Signals.Persistence;
+
+namespace StellaOps.Signals.Services;
+
+public sealed class SignalRefreshService : ISignalRefreshService
+{
+ private readonly IUnknownsRepository _repository;
+ private readonly IUnknownsScoringService _scoringService;
+ private readonly IOptions _scoringOptions;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public SignalRefreshService(
+ IUnknownsRepository repository,
+ IUnknownsScoringService scoringService,
+ IOptions scoringOptions,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
+ _scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(refreshEvent);
+ ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.SubjectKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.UnknownId);
+
+ var now = _timeProvider.GetUtcNow();
+ var unknowns = await _repository.GetBySubjectAsync(refreshEvent.SubjectKey, cancellationToken).ConfigureAwait(false);
+
+ var target = unknowns.FirstOrDefault(u => string.Equals(u.Id, refreshEvent.UnknownId, StringComparison.Ordinal));
+ if (target is null)
+ {
+ _logger.LogWarning(
+ "Signal refresh ignored: unknown {UnknownId} not found for subject {SubjectKey}",
+ refreshEvent.UnknownId,
+ refreshEvent.SubjectKey);
+ return;
+ }
+
+ target.LastAnalyzedAt = now;
+ target.UpdatedAt = now;
+
+ await _scoringService.ScoreUnknownAsync(target, _scoringOptions.Value, cancellationToken).ConfigureAwait(false);
+ await _repository.BulkUpdateAsync(new[] { target }, cancellationToken).ConfigureAwait(false);
+
+ _logger.LogInformation(
+ "Signal refresh applied: subject={SubjectKey}, unknownId={UnknownId}, type={Type}",
+ refreshEvent.SubjectKey,
+ refreshEvent.UnknownId,
+ refreshEvent.RefreshType);
+ }
+}
diff --git a/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs b/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs
new file mode 100644
index 000000000..25b4291a2
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics.Metrics;
+
+namespace StellaOps.Signals.Services;
+
+internal static class UnknownsDecayMetrics
+{
+ private static readonly Meter Meter = new("StellaOps.Signals.Decay", "1.0.0");
+
+ public static readonly Counter SubjectsProcessed = Meter.CreateCounter(
+ "stellaops_unknowns_decay_subjects_processed_total",
+ description: "Total subjects processed by unknowns decay batches");
+
+ public static readonly Counter UnknownsProcessed = Meter.CreateCounter(
+ "stellaops_unknowns_decay_unknowns_processed_total",
+ description: "Total unknowns processed by unknowns decay batches");
+
+ public static readonly Counter BandChanges = Meter.CreateCounter(
+ "stellaops_unknowns_decay_band_changes_total",
+ description: "Total band changes caused by decay rescoring");
+
+ public static readonly Histogram BatchDurationSeconds = Meter.CreateHistogram(
+ "stellaops_unknowns_decay_batch_duration_seconds",
+ unit: "s",
+ description: "Duration of unknowns decay batch runs");
+}
diff --git a/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs b/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs
new file mode 100644
index 000000000..763a743be
--- /dev/null
+++ b/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs
@@ -0,0 +1,143 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Signals.Models;
+using StellaOps.Signals.Options;
+using StellaOps.Signals.Persistence;
+
+namespace StellaOps.Signals.Services;
+
+///
+/// Implements time-based confidence decay for unknowns by periodically recomputing staleness and band assignment.
+///
+public sealed class UnknownsDecayService : IUnknownsDecayService
+{
+ private readonly IUnknownsRepository _repository;
+ private readonly IUnknownsScoringService _scoringService;
+ private readonly IOptions _scoringOptions;
+ private readonly IOptions _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public UnknownsDecayService(
+ IUnknownsRepository repository,
+ IUnknownsScoringService scoringService,
+ IOptions scoringOptions,
+ IOptions options,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
+ _scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task ApplyDecayAsync(string subjectKey, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
+
+ var now = _timeProvider.GetUtcNow();
+ var unknowns = await _repository.GetBySubjectAsync(subjectKey, cancellationToken).ConfigureAwait(false);
+ if (unknowns.Count == 0)
+ {
+ return new DecayResult(subjectKey, 0, 0, 0, 0, 0, now);
+ }
+
+ var updated = new List(unknowns.Count);
+ var bandChanges = 0;
+
+ foreach (var unknown in unknowns)
+ {
+ var oldBand = unknown.Band;
+ var decayed = await ApplyDecayToUnknownAsync(unknown, cancellationToken).ConfigureAwait(false);
+ updated.Add(decayed);
+
+ if (oldBand != decayed.Band)
+ {
+ bandChanges++;
+ }
+ }
+
+ await _repository.BulkUpdateAsync(updated, cancellationToken).ConfigureAwait(false);
+
+ var result = new DecayResult(
+ SubjectKey: subjectKey,
+ ProcessedCount: updated.Count,
+ HotCount: updated.Count(u => u.Band == UnknownsBand.Hot),
+ WarmCount: updated.Count(u => u.Band == UnknownsBand.Warm),
+ ColdCount: updated.Count(u => u.Band == UnknownsBand.Cold),
+ BandChanges: bandChanges,
+ ComputedAt: now);
+
+ UnknownsDecayMetrics.SubjectsProcessed.Add(1);
+ UnknownsDecayMetrics.UnknownsProcessed.Add(result.ProcessedCount);
+ UnknownsDecayMetrics.BandChanges.Add(result.BandChanges);
+
+ _logger.LogInformation(
+ "Applied unknowns decay for {SubjectKey}: processed={ProcessedCount}, hot={HotCount}, warm={WarmCount}, cold={ColdCount}, bandChanges={BandChanges}",
+ result.SubjectKey,
+ result.ProcessedCount,
+ result.HotCount,
+ result.WarmCount,
+ result.ColdCount,
+ result.BandChanges);
+
+ return result;
+ }
+
+ public async Task ApplyDecayToUnknownAsync(UnknownSymbolDocument unknown, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(unknown);
+ var opts = _scoringOptions.Value;
+ return await _scoringService.ScoreUnknownAsync(unknown, opts, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task RunNightlyDecayBatchAsync(CancellationToken cancellationToken = default)
+ {
+ var startTime = _timeProvider.GetUtcNow();
+
+ var subjects = await _repository.GetAllSubjectKeysAsync(cancellationToken).ConfigureAwait(false);
+ var maxSubjects = Math.Max(0, _options.Value.MaxSubjectsPerBatch);
+ if (maxSubjects > 0 && subjects.Count > maxSubjects)
+ {
+ subjects = subjects.Take(maxSubjects).ToArray();
+ }
+
+ _logger.LogInformation("Starting nightly unknowns decay batch for {Count} subjects", subjects.Count);
+
+ var totalUnknowns = 0;
+ var totalBandChanges = 0;
+
+ foreach (var subjectKey in subjects)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var result = await ApplyDecayAsync(subjectKey, cancellationToken).ConfigureAwait(false);
+ totalUnknowns += result.ProcessedCount;
+ totalBandChanges += result.BandChanges;
+ }
+
+ var endTime = _timeProvider.GetUtcNow();
+ var duration = endTime - startTime;
+
+ UnknownsDecayMetrics.BatchDurationSeconds.Record(duration.TotalSeconds);
+
+ var batchResult = new BatchDecayResult(
+ TotalSubjects: subjects.Count,
+ TotalUnknowns: totalUnknowns,
+ TotalBandChanges: totalBandChanges,
+ Duration: duration,
+ CompletedAt: endTime);
+
+ _logger.LogInformation(
+ "Completed nightly unknowns decay batch: subjects={TotalSubjects}, unknowns={TotalUnknowns}, bandChanges={TotalBandChanges}, duration={Duration}",
+ batchResult.TotalSubjects,
+ batchResult.TotalUnknowns,
+ batchResult.TotalBandChanges,
+ batchResult.Duration);
+
+ return batchResult;
+ }
+}
diff --git a/src/Signals/StellaOps.Signals/TASKS.md b/src/Signals/StellaOps.Signals/TASKS.md
index fa35dfa0e..109b4c935 100644
--- a/src/Signals/StellaOps.Signals/TASKS.md
+++ b/src/Signals/StellaOps.Signals/TASKS.md
@@ -7,3 +7,4 @@ This file mirrors sprint work for the Signals module.
| `SIG-STORE-401-016` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Added reachability store repository APIs and models; callgraph ingestion now populates the store; Mongo index script at `ops/mongo/indices/reachability_store_indices.js`. |
| `UNCERTAINTY-SCHEMA-401-024` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented uncertainty tiers and scoring integration; see `src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs` and `src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs`. |
| `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. |
+| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DOING (2025-12-15) | Implement decay worker/service, signal refresh hook, and deterministic unit/integration tests. |
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs
new file mode 100644
index 000000000..f0b47afee
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs
@@ -0,0 +1,71 @@
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// Extension for decision- and scoring-specific replay tokens.
+///
+public static class DecisionReplayTokenExtensions
+{
+ ///
+ /// Generates a replay token for a triage decision.
+ ///
+ public static ReplayToken GenerateForDecision(
+ this IReplayTokenGenerator generator,
+ string alertId,
+ string actorId,
+ string decisionStatus,
+ IEnumerable evidenceHashes,
+ string? policyContext,
+ string? rulesVersion)
+ {
+ ArgumentNullException.ThrowIfNull(generator);
+ ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(decisionStatus);
+ ArgumentNullException.ThrowIfNull(evidenceHashes);
+
+ var request = new ReplayTokenRequest
+ {
+ InputHashes = new[] { alertId },
+ EvidenceHashes = evidenceHashes.ToList(),
+ RulesVersion = rulesVersion,
+ AdditionalContext = new Dictionary
+ {
+ ["actor_id"] = actorId,
+ ["decision_status"] = decisionStatus,
+ ["policy_context"] = policyContext ?? string.Empty
+ }
+ };
+
+ return generator.Generate(request);
+ }
+
+ ///
+ /// Generates a replay token for unknowns scoring.
+ ///
+ public static ReplayToken GenerateForScoring(
+ this IReplayTokenGenerator generator,
+ string subjectKey,
+ IEnumerable feedManifests,
+ string scoringConfigVersion,
+ IEnumerable inputHashes)
+ {
+ ArgumentNullException.ThrowIfNull(generator);
+ ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
+ ArgumentNullException.ThrowIfNull(feedManifests);
+ ArgumentException.ThrowIfNullOrWhiteSpace(scoringConfigVersion);
+ ArgumentNullException.ThrowIfNull(inputHashes);
+
+ var request = new ReplayTokenRequest
+ {
+ FeedManifests = feedManifests.ToList(),
+ ScoringConfigVersion = scoringConfigVersion,
+ InputHashes = inputHashes.ToList(),
+ AdditionalContext = new Dictionary
+ {
+ ["subject_key"] = subjectKey
+ }
+ };
+
+ return generator.Generate(request);
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs
new file mode 100644
index 000000000..3cda9b0ba
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs
@@ -0,0 +1,19 @@
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// Generates deterministic replay tokens for audit and reproducibility.
+///
+public interface IReplayTokenGenerator
+{
+ ///
+ /// Generates a replay token from the given inputs.
+ ///
+ /// The inputs to hash.
+ /// A deterministic replay token.
+ ReplayToken Generate(ReplayTokenRequest request);
+
+ ///
+ /// Verifies that inputs match a previously generated token.
+ ///
+ bool Verify(ReplayToken token, ReplayTokenRequest request);
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/README.md b/src/__Libraries/StellaOps.Audit.ReplayToken/README.md
new file mode 100644
index 000000000..89b0d5920
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/README.md
@@ -0,0 +1,18 @@
+# StellaOps.Audit.ReplayToken
+
+Deterministic replay token generation used to make triage decisions and scoring reproducible and audit-ready.
+
+## Token format
+
+`replay:v::`
+
+Example:
+
+`replay:v1.0:SHA-256:0123abcd...`
+
+## Usage
+
+- Create a `ReplayTokenRequest` with feed/rules/policy/input digests.
+- Call `IReplayTokenGenerator.Generate(request)` to get a stable token value.
+- Store the token’s `Canonical` string alongside immutable decision events.
+
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs
new file mode 100644
index 000000000..d842a1edb
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs
@@ -0,0 +1,69 @@
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// Generates CLI snippets for one-click reproduce functionality.
+///
+public sealed class ReplayCliSnippetGenerator
+{
+ ///
+ /// Generates a CLI command to reproduce a decision.
+ ///
+ public string GenerateDecisionReplay(
+ ReplayToken token,
+ string alertId,
+ string? feedManifestUri = null,
+ string? policyVersion = null)
+ {
+ ArgumentNullException.ThrowIfNull(token);
+ ArgumentException.ThrowIfNullOrWhiteSpace(alertId);
+
+ var parts = new List
+ {
+ "stellaops",
+ "replay",
+ "decision",
+ $"--token {token.Value}",
+ $"--alert-id {alertId}"
+ };
+
+ if (!string.IsNullOrWhiteSpace(feedManifestUri))
+ {
+ parts.Add($"--feed-manifest {feedManifestUri.Trim()}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(policyVersion))
+ {
+ parts.Add($"--policy-version {policyVersion.Trim()}");
+ }
+
+ return string.Join(" \\\n+ ", parts);
+ }
+
+ ///
+ /// Generates a CLI command to reproduce unknowns scoring.
+ ///
+ public string GenerateScoringReplay(
+ ReplayToken token,
+ string subjectKey,
+ string? configVersion = null)
+ {
+ ArgumentNullException.ThrowIfNull(token);
+ ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
+
+ var parts = new List
+ {
+ "stellaops",
+ "replay",
+ "scoring",
+ $"--token {token.Value}",
+ $"--subject {subjectKey}"
+ };
+
+ if (!string.IsNullOrWhiteSpace(configVersion))
+ {
+ parts.Add($"--config-version {configVersion.Trim()}");
+ }
+
+ return string.Join(" \\\n+ ", parts);
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs
new file mode 100644
index 000000000..c0b3bdb5a
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs
@@ -0,0 +1,94 @@
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// A deterministic, content-addressable replay token.
+///
+public sealed class ReplayToken : IEquatable
+{
+ public const string Scheme = "replay";
+ public const string DefaultAlgorithm = "SHA-256";
+ public const string DefaultVersion = "1.0";
+
+ ///
+ /// The token value (SHA-256 hash in hex).
+ ///
+ public string Value { get; }
+
+ ///
+ /// Algorithm used for hashing.
+ ///
+ public string Algorithm { get; }
+
+ ///
+ /// Version of the token generation algorithm.
+ ///
+ public string Version { get; }
+
+ ///
+ /// Timestamp when token was generated.
+ ///
+ public DateTimeOffset GeneratedAt { get; }
+
+ ///
+ /// Canonical representation for storage.
+ ///
+ public string Canonical => $"{Scheme}:v{Version}:{Algorithm}:{Value}";
+
+ public ReplayToken(string value, DateTimeOffset generatedAt, string? algorithm = null, string? version = null)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("Token value cannot be empty.", nameof(value));
+ }
+
+ Value = value.Trim();
+ GeneratedAt = generatedAt;
+ Algorithm = string.IsNullOrWhiteSpace(algorithm) ? DefaultAlgorithm : algorithm.Trim();
+ Version = string.IsNullOrWhiteSpace(version) ? DefaultVersion : version.Trim();
+ }
+
+ ///
+ /// Parse a canonical token string.
+ ///
+ public static ReplayToken Parse(string canonical)
+ {
+ if (string.IsNullOrWhiteSpace(canonical))
+ {
+ throw new ArgumentException("Token cannot be empty.", nameof(canonical));
+ }
+
+ var parts = canonical.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 4 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal))
+ {
+ throw new FormatException($"Invalid replay token format: {canonical}");
+ }
+
+ var versionPart = parts[1];
+ if (!versionPart.StartsWith("v", StringComparison.Ordinal) || versionPart.Length <= 1)
+ {
+ throw new FormatException($"Invalid replay token version: {canonical}");
+ }
+
+ var version = versionPart[1..];
+ var algorithm = parts[2];
+ var value = parts[3];
+
+ return new ReplayToken(value, DateTimeOffset.UnixEpoch, algorithm, version);
+ }
+
+ public override string ToString() => Canonical;
+
+ public bool Equals(ReplayToken? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ return string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);
+ }
+
+ public override bool Equals(object? obj) => obj is ReplayToken other && Equals(other);
+
+ public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value);
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs
new file mode 100644
index 000000000..977547606
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs
@@ -0,0 +1,52 @@
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// Inputs for replay token generation.
+///
+public sealed class ReplayTokenRequest
+{
+ ///
+ /// Feed manifest hashes (advisory sources).
+ ///
+ public IReadOnlyList FeedManifests { get; init; } = Array.Empty();
+
+ ///
+ /// Rule set version identifier.
+ ///
+ public string? RulesVersion { get; init; }
+
+ ///
+ /// Rule set content hash.
+ ///
+ public string? RulesHash { get; init; }
+
+ ///
+ /// Lattice policy version identifier.
+ ///
+ public string? LatticePolicyVersion { get; init; }
+
+ ///
+ /// Lattice policy content hash.
+ ///
+ public string? LatticePolicyHash { get; init; }
+
+ ///
+ /// Input artifact hashes (SBOMs, images, etc.).
+ ///
+ public IReadOnlyList InputHashes { get; init; } = Array.Empty();
+
+ ///
+ /// Scoring configuration version.
+ ///
+ public string? ScoringConfigVersion { get; init; }
+
+ ///
+ /// Evidence artifact hashes.
+ ///
+ public IReadOnlyList EvidenceHashes { get; init; } = Array.Empty();
+
+ ///
+ /// Additional context for extensibility.
+ ///
+ public IReadOnlyDictionary AdditionalContext { get; init; } = new Dictionary();
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..09231ee8d
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace StellaOps.Audit.ReplayToken;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddReplayTokenServices(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ services.TryAddSingleton(TimeProvider.System);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ return services;
+ }
+
+ public static IServiceCollection AddReplayTokenServices(this IServiceCollection services, TimeProvider timeProvider)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ services.AddSingleton(timeProvider);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ return services;
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs
new file mode 100644
index 000000000..b2171bdab
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs
@@ -0,0 +1,130 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using StellaOps.Cryptography;
+
+namespace StellaOps.Audit.ReplayToken;
+
+///
+/// Generates replay tokens using SHA-256 hashing with deterministic canonicalization.
+///
+public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private readonly ICryptoHash _cryptoHash;
+ private readonly TimeProvider _timeProvider;
+
+ public Sha256ReplayTokenGenerator(ICryptoHash cryptoHash, TimeProvider timeProvider)
+ {
+ _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ }
+
+ public ReplayToken Generate(ReplayTokenRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var canonical = Canonicalize(request);
+ var hashHex = ComputeHash(canonical);
+
+ return new ReplayToken(hashHex, _timeProvider.GetUtcNow());
+ }
+
+ public bool Verify(ReplayToken token, ReplayTokenRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(token);
+ ArgumentNullException.ThrowIfNull(request);
+
+ var computed = Generate(request);
+ return string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private string ComputeHash(string input)
+ {
+ var bytes = Encoding.UTF8.GetBytes(input);
+ return _cryptoHash.ComputeHashHex(bytes, HashAlgorithms.Sha256);
+ }
+
+ private static string? NormalizeValue(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ return value.Trim();
+ }
+
+ private static List NormalizeSortedList(IReadOnlyList? values)
+ {
+ if (values is null || values.Count == 0)
+ {
+ return new List();
+ }
+
+ var normalized = values
+ .Where(static x => !string.IsNullOrWhiteSpace(x))
+ .Select(static x => x.Trim())
+ .OrderBy(static x => x, StringComparer.Ordinal)
+ .ToList();
+
+ return normalized;
+ }
+
+ private static Dictionary NormalizeSortedDictionary(IReadOnlyDictionary? values)
+ {
+ if (values is null || values.Count == 0)
+ {
+ return new Dictionary();
+ }
+
+ var normalized = values
+ .Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key))
+ .Select(static kvp => new KeyValuePair(kvp.Key.Trim(), kvp.Value?.Trim() ?? string.Empty))
+ .OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
+ .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal);
+
+ return normalized;
+ }
+
+ ///
+ /// Produces deterministic canonical representation of inputs.
+ ///
+ private static string Canonicalize(ReplayTokenRequest request)
+ {
+ var canonical = new CanonicalReplayInput
+ {
+ Version = ReplayToken.DefaultVersion,
+ FeedManifests = NormalizeSortedList(request.FeedManifests),
+ RulesVersion = NormalizeValue(request.RulesVersion),
+ RulesHash = NormalizeValue(request.RulesHash),
+ LatticePolicyVersion = NormalizeValue(request.LatticePolicyVersion),
+ LatticePolicyHash = NormalizeValue(request.LatticePolicyHash),
+ InputHashes = NormalizeSortedList(request.InputHashes),
+ ScoringConfigVersion = NormalizeValue(request.ScoringConfigVersion),
+ EvidenceHashes = NormalizeSortedList(request.EvidenceHashes),
+ AdditionalContext = NormalizeSortedDictionary(request.AdditionalContext)
+ };
+
+ return JsonSerializer.Serialize(canonical, JsonOptions);
+ }
+
+ private sealed class CanonicalReplayInput
+ {
+ public required string Version { get; init; }
+ public required List FeedManifests { get; init; }
+ public string? RulesVersion { get; init; }
+ public string? RulesHash { get; init; }
+ public string? LatticePolicyVersion { get; init; }
+ public string? LatticePolicyHash { get; init; }
+ public required List InputHashes { get; init; }
+ public string? ScoringConfigVersion { get; init; }
+ public required List EvidenceHashes { get; init; }
+ public required Dictionary AdditionalContext { get; init; }
+ }
+}
diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj b/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj
new file mode 100644
index 000000000..3e440c4c8
--- /dev/null
+++ b/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ preview
+ false
+ StellaOps.Audit.ReplayToken
+ Deterministic replay token generation for audit and reproducibility
+
+
+
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
index 80eafd806..f983c6028 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
@@ -12,5 +12,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
index f2ae13087..1e3450aa6 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
@@ -18,6 +18,7 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
index 9d64b2a65..2ff1d13ca 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
@@ -13,5 +13,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
index 79829334c..6b7fade0c 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj
index 0c0948a31..b7c552b33 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj
@@ -13,5 +13,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj
index 3197618c2..065315963 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj
@@ -6,5 +6,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj
index 2d41e5741..09b701ffd 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj
@@ -6,5 +6,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj
index cfee29dbb..2ea87bbf0 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj
@@ -14,5 +14,6 @@
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj
index 53b9ad97c..4e88ba218 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj
@@ -14,5 +14,6 @@
+
diff --git a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs
new file mode 100644
index 000000000..ef97797d6
--- /dev/null
+++ b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs
@@ -0,0 +1,118 @@
+using StellaOps.Cryptography;
+
+namespace StellaOps.Audit.ReplayToken.Tests;
+
+public sealed class ReplayTokenGeneratorTests
+{
+ [Fact]
+ public void Generate_SameInputs_ReturnsSameValue()
+ {
+ var cryptoHash = DefaultCryptoHash.CreateForTests();
+ var fixedNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var timeProvider = new FixedTimeProvider(fixedNow);
+
+ var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
+
+ var request = new ReplayTokenRequest
+ {
+ FeedManifests = new[] { "sha256:bbb", "sha256:aaa" },
+ RulesVersion = "rules-v1",
+ RulesHash = "sha256:rules",
+ LatticePolicyVersion = "lattice-v1",
+ LatticePolicyHash = "sha256:lattice",
+ InputHashes = new[] { "sha256:input2", "sha256:input1" },
+ ScoringConfigVersion = "score-v1",
+ EvidenceHashes = new[] { "sha256:e2", "sha256:e1" },
+ AdditionalContext = new Dictionary
+ {
+ ["b"] = "2",
+ ["a"] = "1"
+ }
+ };
+
+ var token1 = generator.Generate(request);
+ var token2 = generator.Generate(request);
+
+ Assert.Equal(token1.Value, token2.Value);
+ Assert.Equal(token1.Canonical, token2.Canonical);
+ }
+
+ [Fact]
+ public void Generate_IgnoresArrayOrdering()
+ {
+ var cryptoHash = DefaultCryptoHash.CreateForTests();
+ var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
+
+ var requestA = new ReplayTokenRequest
+ {
+ FeedManifests = new[] { "sha256:aaa", "sha256:bbb" },
+ InputHashes = new[] { "sha256:input1", "sha256:input2" }
+ };
+
+ var requestB = new ReplayTokenRequest
+ {
+ FeedManifests = new[] { "sha256:bbb", "sha256:aaa" },
+ InputHashes = new[] { "sha256:input2", "sha256:input1" }
+ };
+
+ var tokenA = generator.Generate(requestA);
+ var tokenB = generator.Generate(requestB);
+
+ Assert.Equal(tokenA.Value, tokenB.Value);
+ }
+
+ [Fact]
+ public void Verify_MatchingInputs_ReturnsTrue()
+ {
+ var cryptoHash = DefaultCryptoHash.CreateForTests();
+ var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
+ var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
+
+ var token = generator.Generate(request);
+ Assert.True(generator.Verify(token, request));
+ }
+
+ [Fact]
+ public void Verify_DifferentInputs_ReturnsFalse()
+ {
+ var cryptoHash = DefaultCryptoHash.CreateForTests();
+ var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider);
+ var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } };
+ var different = new ReplayTokenRequest { InputHashes = new[] { "sha256:other" } };
+
+ var token = generator.Generate(request);
+ Assert.False(generator.Verify(token, different));
+ }
+
+ [Fact]
+ public void ReplayToken_Parse_RoundTripsCanonical()
+ {
+ var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch);
+ var parsed = ReplayToken.Parse(token.Canonical);
+
+ Assert.Equal(token.Value, parsed.Value);
+ Assert.Equal(token.Algorithm, parsed.Algorithm);
+ Assert.Equal(token.Version, parsed.Version);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("replay")]
+ [InlineData("replay:v1.0:SHA-256")]
+ [InlineData("other:v1.0:SHA-256:abc")]
+ public void ReplayToken_Parse_Invalid_Throws(string canonical)
+ {
+ Assert.ThrowsAny(() => ReplayToken.Parse(canonical));
+ }
+
+ private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ }
+}
diff --git a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj
new file mode 100644
index 000000000..979c804d5
--- /dev/null
+++ b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net10.0
+ enable
+ enable
+ preview
+ false
+ true
+
+
+
+
+