diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md
index 5b189b8ec..0d21030f9 100644
--- a/docs/implplan/SPRINTS.md
+++ b/docs/implplan/SPRINTS.md
@@ -1,31 +1,31 @@
-# Sprint Index
-
-Follow the sprint files below in order. Update task status in both `SPRINTS` and module `TASKS.md` as you progress.
-
-- [Identity & Signing](./SPRINT_100_identity_signing.md)
-- [Ingestion & Evidence](./SPRINT_110_ingestion_evidence.md)
-- [Policy & Reasoning](./SPRINT_120_policy_reasoning.md)
-- [Scanner & Surface](./SPRINT_130_scanner_surface.md)
-- [Runtime & Signals](./SPRINT_140_runtime_signals.md)
-- [Scheduling & Automation](./SPRINT_150_scheduling_automation.md)
-- [Export & Evidence](./SPRINT_160_export_evidence.md)
-- [Notifications & Telemetry](./SPRINT_170_notifications_telemetry.md)
-- [Experience & SDKs](./SPRINT_180_experience_sdks.md)
-- [Ops & Offline](./SPRINT_190_ops_offline.md)
-- [Documentation & Process](./SPRINT_200_documentation_process.md)
-
+# Sprint Index
+
+Follow the sprint files below in order. Update task status in both `SPRINTS` and module `TASKS.md` as you progress.
+
+- [Identity & Signing](./SPRINT_100_identity_signing.md)
+- [Ingestion & Evidence](./SPRINT_110_ingestion_evidence.md)
+- [Policy & Reasoning](./SPRINT_120_policy_reasoning.md)
+- [Scanner & Surface](./SPRINT_130_scanner_surface.md)
+- [Runtime & Signals](./SPRINT_140_runtime_signals.md)
+- [Scheduling & Automation](./SPRINT_150_scheduling_automation.md)
+- [Export & Evidence](./SPRINT_160_export_evidence.md)
+- [Notifications & Telemetry](./SPRINT_170_notifications_telemetry.md)
+- [Experience & SDKs](./SPRINT_180_experience_sdks.md)
+- [Ops & Offline](./SPRINT_190_ops_offline.md)
+- [Documentation & Process](./SPRINT_200_documentation_process.md)
+
> 2025-11-03: ATTESTOR-72-003 moved to DOING (Attestor Service Guild) – running live TTL validation against local MongoDB/Redis processes (manual hosts, no Docker).
-> 2025-11-03: ATTESTOR-72-003 marked DONE (Attestor Service Guild) – Mongo/Redis TTL expiry logs archived under `docs/modules/attestor/evidence/2025-11-03-*.txt` with summary in `docs/modules/attestor/ttl-validation.md`.
-> 2025-11-03: AIAI-31-004B moved to DOING (Advisory AI Guild) – starting prompt assembler/guardrail plumbing, cache persistence contract, and DSSE provenance wiring.
-> 2025-11-03: PLG7.RFC marked DONE (Auth Plugin Guild, Security Guild) – LDAP plugin RFC accepted; review log stored at `docs/notes/2025-11-03-authority-plugin-ldap-review.md`, follow-up PLG7.IMPL-001..005 queued.
-> 2025-11-03: PLG7.IMPL-001 marked DONE (Auth Plugin Guild) – new `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration normalization & validation; sample manifest refreshed and smoke tests run (`dotnet test`).
-> 2025-11-03: AIAI-31-004B marked DONE (Advisory AI Guild) – prompt assembler, guardrail hooks, DSSE-ready output persistence, and golden prompt tests landed.
-> 2025-11-03: AIAI-31-005 moved to DOING (Advisory AI Guild) – beginning guardrail enforcement (redaction, injection defence, output validator) implementation.
-> 2025-11-03: AIAI-31-006 moved to DOING (Advisory AI Guild) – starting Advisory AI REST API surface work (RBAC, rate limits, batching contract).
-> 2025-11-03: EVID-OBS-53-001 moved to DOING (Evidence Locker Guild) – bootstrapping Evidence Locker schema and storage abstractions.
-> 2025-11-03: GRAPH-INDEX-28-002 marked DONE (Graph Indexer Guild) – SBOM ingest transformer, processor, and metrics landed with refreshed fixtures/tests for license and base artifact determinism.
-> 2025-11-03: GRAPH-INDEX-28-003 marked DONE (Graph Indexer Guild) – advisory linkset snapshot model repaired, transformer finalized with dedupe/canonical provenance, fixtures refreshed, and overlay tests passing across the graph suite.
-> 2025-11-03: GRAPH-INDEX-28-004 moved to DOING (Graph Indexer Guild) – beginning VEX overlay integration with precedent/justification metadata.
+> 2025-11-03: ATTESTOR-72-003 marked DONE (Attestor Service Guild) – Mongo/Redis TTL expiry logs archived under `docs/modules/attestor/evidence/2025-11-03-*.txt` with summary in `docs/modules/attestor/ttl-validation.md`.
+> 2025-11-03: AIAI-31-004B moved to DOING (Advisory AI Guild) – starting prompt assembler/guardrail plumbing, cache persistence contract, and DSSE provenance wiring.
+> 2025-11-03: PLG7.RFC marked DONE (Auth Plugin Guild, Security Guild) – LDAP plugin RFC accepted; review log stored at `docs/notes/2025-11-03-authority-plugin-ldap-review.md`, follow-up PLG7.IMPL-001..005 queued.
+> 2025-11-03: PLG7.IMPL-001 marked DONE (Auth Plugin Guild) – new `StellaOps.Authority.Plugin.Ldap` project/tests scaffolded with configuration normalization & validation; sample manifest refreshed and smoke tests run (`dotnet test`).
+> 2025-11-03: AIAI-31-004B marked DONE (Advisory AI Guild) – prompt assembler, guardrail hooks, DSSE-ready output persistence, and golden prompt tests landed.
+> 2025-11-03: AIAI-31-005 moved to DOING (Advisory AI Guild) – beginning guardrail enforcement (redaction, injection defence, output validator) implementation.
+> 2025-11-03: AIAI-31-006 moved to DOING (Advisory AI Guild) – starting Advisory AI REST API surface work (RBAC, rate limits, batching contract).
+> 2025-11-03: EVID-OBS-53-001 moved to DOING (Evidence Locker Guild) – bootstrapping Evidence Locker schema and storage abstractions.
+> 2025-11-03: GRAPH-INDEX-28-002 marked DONE (Graph Indexer Guild) – SBOM ingest transformer, processor, and metrics landed with refreshed fixtures/tests for license and base artifact determinism.
+> 2025-11-03: GRAPH-INDEX-28-003 marked DONE (Graph Indexer Guild) – advisory linkset snapshot model repaired, transformer finalized with dedupe/canonical provenance, fixtures refreshed, and overlay tests passing across the graph suite.
+> 2025-11-03: GRAPH-INDEX-28-004 moved to DOING (Graph Indexer Guild) – beginning VEX overlay integration with precedent/justification metadata.
> 2025-11-03: GRAPH-INDEX-28-004 marked DONE (Graph Indexer Guild) – VEX snapshot/transformer merged with deterministic overlays, fixtures refreshed, and graph indexer tests passing.
> 2025-11-03: GRAPH-INDEX-28-005 moved to DOING (Graph Indexer Guild, Policy Guild) – starting policy overlay hydration (`governs_with` nodes/edges) with explain hash references.
> 2025-11-03: GRAPH-INDEX-28-005 marked DONE (Graph Indexer Guild, Policy Guild) – policy overlay snapshot/transformer landed with deterministic nodes/edges and fixture-backed tests; Mongo writer tests now probe `STELLAOPS_TEST_MONGO_URI`/localhost before falling back to Mongo2Go and skip when no mongod is reachable.
@@ -38,123 +38,123 @@ Follow the sprint files below in order. Update task status in both `SPRINTS` and
> 2025-11-04: SCHED-WEB-21-004 marked DONE (Scheduler WebService Guild, Scheduler Storage Guild) – Mongo lifecycle persistence, single-shot completion events/webhooks, and idempotent result URI refresh landed with unit/integration coverage.
> 2025-11-04: TASKRUN-42-001 resumed (Task Runner Guild) – planning loops/conditionals/maxParallel execution upgrades, simulation mode, policy gate wiring, and deterministic retry/abort handling.
> 2025-11-04: TASKRUN-42-001 progress update – execution graph + simulation endpoints wired; retry windows now persisted for orchestration clients.
-> 2025-11-03: AIRGAP-POL-57-002 confirmed DOING (AirGap Policy Guild, Task Runner Guild) – continuing Task Runner sealed-mode egress validation and test sweep.
-> 2025-11-03: AIRGAP-POL-57-002 marked DONE (AirGap Policy Guild, Task Runner Guild) – worker now injects `IEgressPolicy`, filesystem dispatcher enforces sealed-mode egress, planner grants normalized, sealed-mode dispatcher test added; follow-up queued to lift remaining dispatchers/executors onto the shared policy before sealing the full worker loop.
-> 2025-11-03: MERGE-LNM-21-001 moved to DOING (BE-Merge, Architecture Guild) – drafting `no-merge` migration playbook outline and capturing rollout/backfill checkpoints.
-> 2025-11-03: MERGE-LNM-21-001 marked DONE – published `docs/migration/no-merge.md` with rollout, backfill, validation, and rollback guidance for the LNM cutover.
-> 2025-11-04: GRAPH-INDEX-28-011 marked DONE (Graph Indexer Guild) – SBOM ingest DI wiring now emits graph snapshots by default, snapshot root configurable via `STELLAOPS_GRAPH_SNAPSHOT_DIR`, and Graph Indexer tests exercised with Mongo URI guidance.
-> 2025-11-03: MERGE-LNM-21-002 moved to DOING (BE-Merge) – auditing `AdvisoryMergeService` call sites to scope removal and analyzer enforcement.
-> 2025-11-03: DOCS-LNM-22-008 moved to DOING (Docs Guild, DevOps Guild) – aligning migration playbook structure and readiness checklist.
-> 2025-11-03: DOCS-LNM-22-008 marked DONE – `/docs/migration/no-merge.md` published for DevOps/Export Center planning with checklist for cutover readiness.
-> 2025-11-03: SCHED-CONSOLE-27-001 marked DONE (Scheduler WebService Guild, Policy Registry Guild) – policy simulation endpoints now emit SSE retry/heartbeat, enforce metadata normalization, support Mongo-backed integration, and ship auth/stream coverage.
-> 2025-11-03: SCHED-CONSOLE-27-002 moved to DOING (Scheduler WebService Guild, Observability Guild) – wiring policy simulation telemetry endpoints, OTEL metrics, and Registry webhooks on completion/failure.
-> 2025-11-03: FEEDCONN-KISA-02-008 moved to DOING (BE-Conn-KISA, Models) – starting Hangul firmware range normalization and provenance mapping for KISA advisories.
-> 2025-11-03: FEEDCONN-KISA-02-008 progress – SemVer normalization wired through KISA mapper with provenance slugs, exclusive marker handling, and fresh connector tests for `이상`/`미만`/`초과` scenarios plus non-numeric fallback; follow-up review queued for additional phrasing coverage before closing. Captured current detail pages via `scripts/kisa_capture_html.py` so offline HTML is available under `seed-data/kisa/html/`.
-> 2025-11-03: FEEDCONN-ICSCISA-02-012 marked DONE (BE-Conn-ICS-CISA) – ICS CISA connector now emits semver-aware affected.version ranges with `ics-cisa` provenance, SourceFetchService RSS fallback passes the AOC guard, and the Fetch/Parse/Map integration test is green.
-> 2025-11-01: SCANNER-ANALYZERS-LANG-10-308R marked DONE (Language Analyzer Guild) – heuristics fixtures, benchmarks, and coverage comparison published.
-> 2025-11-01: SCANNER-ANALYZERS-LANG-10-309R marked DONE (Language Analyzer Guild) – Rust analyzer packaged with offline kit smoke tests and docs.
-> 2025-11-01: ENTRYTRACE-SURFACE-01 moved to DOING (EntryTrace Guild) – wiring Surface.Validation and Surface.FS reuse ahead of EntryTrace runs.
-> 2025-11-01: AUTH-OBS-50-001 (Sprint 50 – Observability & Forensics) moved to DOING (Authority Core & Security Guild).
-> 2025-11-01: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – add Packs.* scopes to Authority.
-> 2025-11-01: AUTH-OBS-55-001 (Sprint 55 – Observability & Forensics) moved to DOING (Authority Core & Security Guild, Ops Guild).
-> 2025-11-01: TASKRUN-41-001 moved to DOING (Task Runner Guild) – request packs.* scopes when calling Authority.
-> 2025-11-01: PACKS-REG-41-001 moved to DOING (Packs Registry Guild) – enforce packs.* scopes for registry publish/run flows.
-> 2025-11-01: ATTEST-VERIFY-74-001 re-opened and set to DOING to unblock build/test regressions (Verification Guild, Observability Guild).
-> 2025-11-01: ATTEST-VERIFY-74-001 marked DONE after configuration and test fixes (Verification Guild, Observability Guild).
-> 2025-11-01: AUTH-AIAI-31-001 marked DONE (Authority Core & Security Guild) – Advisory AI scopes published and remote inference toggles documented.
-> 2025-11-01: AUTH-AIRGAP-56-001 moved to DOING (Authority Core & Security Guild) – add airgap scope catalogue and defaults.
-> 2025-11-01: AUTH-AIRGAP-56-002 moved to DOING (Authority Core & Security Guild) – implement airgap audit endpoint and logging.
-> 2025-11-01: ISSUER-30-001 marked DONE (Issuer Directory Guild) – Issuer Directory service scaffolded with CRUD APIs, audit sink, CSAF seed import, and unit tests.
-> 2025-11-01: ISSUER-30-002 marked DONE (Issuer Directory Guild, Security Guild) – Key management domain, Mongo persistence, CRUD/rotate/revoke endpoints, validation, and tests delivered.
-> 2025-11-01: ISSUER-30-004 marked DONE (Issuer Directory Guild, VEX Lens Guild) – Excititor worker consumes issuer directory client for key/trust lookup with cached offline support.
-> 2025-11-01: ISSUER-30-005 marked DONE (Issuer Directory Guild, Observability Guild) – Issuer Directory service emits structured logs + metrics for issuer/key flows with OTEL meter.
-> 2025-11-02: SURFACE-ENV-01 moved to DOING (Surface Env Guild) – drafting shared environment spec for Scanner/Zastava.
-> 2025-11-02: SURFACE-ENV-02 moved to DOING (Surface Env Guild) – implementing typed environment resolver and unit tests.
-> 2025-11-02: SURFACE-VAL-01 moved to DOING (Surface Validation Guild) – aligning design document with implementation plan.
-> 2025-11-02: SURFACE-FS-01 moved to DOING (Surface FS Guild) – finalising cache layout and manifest spec.
-> 2025-11-02: SURFACE-FS-02 moved to DOING (Surface FS Guild) – building core abstractions and deterministic serializers.
-> 2025-11-02: SURFACE-SECRETS-01 moved to DOING (Surface Secrets Guild) – updating secrets design for provider matrix.
-> 2025-11-02: SURFACE-SECRETS-02 moved to DOING (Surface Secrets Guild) – implementing base providers + tests.
-> 2025-11-02: AUTH-POLICY-27-002 marked DONE (Authority Core & Security Guild) – interactive-only policy publish/promote scopes delivered with metadata, fresh-auth enforcement, and audit/docs updates.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-506 moved to DOING (EntryTrace Guild, Scanner WebService Guild) – surfacing EntryTrace results via WebService/CLI with confidence metadata.
-> 2025-11-02: ATTESTOR-74-001 marked DONE (Attestor Service Guild) – witness client integration, repository schema, and verification/reporting updates landed with tests.
-> 2025-11-02: AUTH-OAS-63-001 moved to DOING (Authority Core & Security Guild, API Governance Guild) – verifying legacy `/oauth/*` deprecation signalling and notifications ahead of sunset.
-> 2025-11-02: AUTH-OAS-63-001 marked DONE (Authority Core & Security Guild, API Governance Guild) – legacy shims emit Deprecation/Sunset/Warning headers, audit event coverage validated, and migration guide published.
-> 2025-11-02: AUTH-NOTIFY-40-001 marked DONE (Authority Core & Security Guild) – `/notify/ack-tokens/rotate` (notify.admin) now rotates DSSE keys with audit trails and integration tests.
-> 2025-11-02: AUTH-OAS-62-001 moved to DOING (Authority Core & Security Guild, SDK Generator Guild) – wiring SDK helpers for OAuth2/PAT flows and tenancy override header.
-> 2025-11-02: AUTH-OAS-62-001 marked DONE (Authority Core & Security Guild, SDK Generator Guild) – HttpClient auth helper (OAuth2/PAT) shipped with tenant header support and unit tests.
-> 2025-11-02: AUTH-OBS-50-001 moved to DOING (Authority Core & Security Guild) – defining observability scopes and updating discovery/offline defaults.
-> 2025-11-02: AUTH-OBS-52-001 moved to DOING (Authority Core & Security Guild) – rolling observability scopes through resource server policies and audit wiring.
-> 2025-11-02: AUTH-OBS-55-001 marked DONE (Authority Core & Security Guild, Ops Guild) – incident-mode tokens now require fresh auth, audit records expose `incident.reason`, and `/authority/audit/incident` verification path documented.
-> 2025-11-02: AUTH-ORCH-34-001 marked DONE (Authority Core & Security Guild) – `orch:backfill` scope enforced with reason/ticket metadata, Authority + CLI updated, docs/config refreshed for Orchestrator admins.
-> 2025-11-02: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – defining packs scope catalogue, issuer templates, and offline defaults.
-> 2025-11-02: AUTH-PACKS-41-001 added shared OpenSSL 1.1 test libs so Authority & Signals Mongo2Go suites run on OpenSSL 3.
-> 2025-11-02: AUTH-NOTIFY-42-001 moved to DOING (Authority Core & Security Guild) – investigating `/notify/ack-tokens/rotate` 500 responses when key metadata missing.
-> 2025-11-02: AUTH-NOTIFY-42-001 marked DONE (Authority Core & Security Guild) – bootstrap rotate defaults fixed, `StellaOpsBearer` test alias added, and notify ack rotation regression passes.
-> 2025-11-03: AUTH-TEN-49-001 marked DONE (Authority Core & Security Guild) – service account delegation (`act` chain) shipped with quota/audit coverage; Authority tests green.
-> 2025-11-03: AUTH-VULN-29-003 marked DONE (Authority Core & Docs Guild) – Vuln Explorer security docs, samples, and release notes refreshed for roles, ABAC policies, attachment signing, and ledger verification.
-> 2025-11-03: ISSUER-30-003 marked DONE (Issuer Directory Guild, Policy Guild) – trust override APIs/client finalized with cache invalidation/failure-path tests; Issuer Directory suite passing.
-> 2025-11-03: AUTH-AIRGAP-56-001/56-002 marked DONE (Authority Core & Security Guild) – air-gap scope catalog surfaced in discovery/OpenAPI and `/authority/audit/airgap` endpoint shipped with tests.
-> 2025-11-03: AUTH-PACKS-41-001 marked DONE (Authority Core & Security Guild) – packs scope bundle now emitted via discovery metadata, reflected in OpenAPI, and covered by Authority tests.
-> 2025-11-03: AUTH-POLICY-27-003 marked DONE (Authority Core & Docs Guild) – Policy Studio docs/config updated for publish/promote signing workflow, CLI commands, and compliance checklist.
-> 2025-11-02: ENTRYTRACE-SURFACE-02 moved to DOING (EntryTrace Guild) – replacing direct env/secret access with Surface.Secrets provider for EntryTrace runs.
-> 2025-11-02: ENTRYTRACE-SURFACE-01 marked DONE (EntryTrace Guild) – Surface.Validation + Surface.FS cache now drive EntryTrace reuse with regression tests.
-> 2025-11-02: ENTRYTRACE-SURFACE-02 marked DONE (EntryTrace Guild) – EntryTrace environment placeholders resolved via Surface.Secrets with updated docs/tests.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-506 marked DONE (EntryTrace Guild, Scanner WebService Guild) – EntryTrace graph surfaced via WebService and CLI with confidence metadata.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-509 moved to DOING (EntryTrace Guild, QA Guild) – adding regression coverage for EntryTrace surfaces and NDJSON hashing.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-509 marked DONE (EntryTrace Guild, QA Guild) – regression coverage landed for result store/WebService/CLI with NDJSON hashing snapshot.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-507 marked DONE (EntryTrace Guild) – fallback candidate discovery now covers history, supervisor configs, service directories, and entrypoint scripts with tests.
-> 2025-11-02: SCANNER-ENTRYTRACE-18-508 marked DONE (EntryTrace Guild) – wrapper catalogue expanded for bundle, docker-php-entrypoint, npm, yarn, pipenv, and poetry with wrapper metadata assertions.
-> 2025-11-02: CONCELIER-WEB-OAS-61-001 moved to DOING (Concelier WebService Guild) – implementing discovery endpoint for `.well-known/openapi` with version metadata and ETag.
-> 2025-11-02: CONCELIER-WEB-OAS-61-001 marked DONE (Concelier WebService Guild) – discovery endpoint now serves signed OpenAPI 3.1 document with ETag support.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-001 moved to DOING (Docs Guild, Scanner Guild) – refreshing Trivy/Grype/Snyk comparison docs and ecosystem matrix with source-linked coverage.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-001 marked DONE (Docs Guild, Scanner Guild) – matrix updated with Windows/macOS coverage row and secret detection techniques; deep dives cite Trivy/Grype/Snyk sources.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-003 added (Docs Guild, Product Guild) – recording Python lockfile/editable-install demand signals for policy guidance follow-up.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-004 added (Docs Guild, Java Analyzer Guild) – documenting Java lockfile ingestion plan and policy templates.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-005 added (Docs Guild, Go Analyzer Guild) – documenting Go stripped-binary fallback enrichment guidance.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-006 added (Docs Guild, Rust Analyzer Guild) – documenting Rust fingerprint enrichment guidance.
+> 2025-11-03: AIRGAP-POL-57-002 confirmed DOING (AirGap Policy Guild, Task Runner Guild) – continuing Task Runner sealed-mode egress validation and test sweep.
+> 2025-11-03: AIRGAP-POL-57-002 marked DONE (AirGap Policy Guild, Task Runner Guild) – worker now injects `IEgressPolicy`, filesystem dispatcher enforces sealed-mode egress, planner grants normalized, sealed-mode dispatcher test added; follow-up queued to lift remaining dispatchers/executors onto the shared policy before sealing the full worker loop.
+> 2025-11-03: MERGE-LNM-21-001 moved to DOING (BE-Merge, Architecture Guild) – drafting `no-merge` migration playbook outline and capturing rollout/backfill checkpoints.
+> 2025-11-03: MERGE-LNM-21-001 marked DONE – published `docs/migration/no-merge.md` with rollout, backfill, validation, and rollback guidance for the LNM cutover.
+> 2025-11-04: GRAPH-INDEX-28-011 marked DONE (Graph Indexer Guild) – SBOM ingest DI wiring now emits graph snapshots by default, snapshot root configurable via `STELLAOPS_GRAPH_SNAPSHOT_DIR`, and Graph Indexer tests exercised with Mongo URI guidance.
+> 2025-11-06: MERGE-LNM-21-002 remains DOING (BE-Merge) – default-off merge DI + job gating landed, but Concelier WebService ingest/mirror tests are failing; guard and migration fixes pending before completion.
+> 2025-11-03: DOCS-LNM-22-008 moved to DOING (Docs Guild, DevOps Guild) – aligning migration playbook structure and readiness checklist.
+> 2025-11-03: DOCS-LNM-22-008 marked DONE – `/docs/migration/no-merge.md` published for DevOps/Export Center planning with checklist for cutover readiness.
+> 2025-11-03: SCHED-CONSOLE-27-001 marked DONE (Scheduler WebService Guild, Policy Registry Guild) – policy simulation endpoints now emit SSE retry/heartbeat, enforce metadata normalization, support Mongo-backed integration, and ship auth/stream coverage.
+> 2025-11-03: SCHED-CONSOLE-27-002 moved to DOING (Scheduler WebService Guild, Observability Guild) – wiring policy simulation telemetry endpoints, OTEL metrics, and Registry webhooks on completion/failure.
+> 2025-11-03: FEEDCONN-KISA-02-008 moved to DOING (BE-Conn-KISA, Models) – starting Hangul firmware range normalization and provenance mapping for KISA advisories.
+> 2025-11-03: FEEDCONN-KISA-02-008 progress – SemVer normalization wired through KISA mapper with provenance slugs, exclusive marker handling, and fresh connector tests for `이상`/`미만`/`초과` scenarios plus non-numeric fallback; follow-up review queued for additional phrasing coverage before closing. Captured current detail pages via `scripts/kisa_capture_html.py` so offline HTML is available under `seed-data/kisa/html/`.
+> 2025-11-03: FEEDCONN-ICSCISA-02-012 marked DONE (BE-Conn-ICS-CISA) – ICS CISA connector now emits semver-aware affected.version ranges with `ics-cisa` provenance, SourceFetchService RSS fallback passes the AOC guard, and the Fetch/Parse/Map integration test is green.
+> 2025-11-01: SCANNER-ANALYZERS-LANG-10-308R marked DONE (Language Analyzer Guild) – heuristics fixtures, benchmarks, and coverage comparison published.
+> 2025-11-01: SCANNER-ANALYZERS-LANG-10-309R marked DONE (Language Analyzer Guild) – Rust analyzer packaged with offline kit smoke tests and docs.
+> 2025-11-01: ENTRYTRACE-SURFACE-01 moved to DOING (EntryTrace Guild) – wiring Surface.Validation and Surface.FS reuse ahead of EntryTrace runs.
+> 2025-11-01: AUTH-OBS-50-001 (Sprint 50 – Observability & Forensics) moved to DOING (Authority Core & Security Guild).
+> 2025-11-01: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – add Packs.* scopes to Authority.
+> 2025-11-01: AUTH-OBS-55-001 (Sprint 55 – Observability & Forensics) moved to DOING (Authority Core & Security Guild, Ops Guild).
+> 2025-11-01: TASKRUN-41-001 moved to DOING (Task Runner Guild) – request packs.* scopes when calling Authority.
+> 2025-11-01: PACKS-REG-41-001 moved to DOING (Packs Registry Guild) – enforce packs.* scopes for registry publish/run flows.
+> 2025-11-01: ATTEST-VERIFY-74-001 re-opened and set to DOING to unblock build/test regressions (Verification Guild, Observability Guild).
+> 2025-11-01: ATTEST-VERIFY-74-001 marked DONE after configuration and test fixes (Verification Guild, Observability Guild).
+> 2025-11-01: AUTH-AIAI-31-001 marked DONE (Authority Core & Security Guild) – Advisory AI scopes published and remote inference toggles documented.
+> 2025-11-01: AUTH-AIRGAP-56-001 moved to DOING (Authority Core & Security Guild) – add airgap scope catalogue and defaults.
+> 2025-11-01: AUTH-AIRGAP-56-002 moved to DOING (Authority Core & Security Guild) – implement airgap audit endpoint and logging.
+> 2025-11-01: ISSUER-30-001 marked DONE (Issuer Directory Guild) – Issuer Directory service scaffolded with CRUD APIs, audit sink, CSAF seed import, and unit tests.
+> 2025-11-01: ISSUER-30-002 marked DONE (Issuer Directory Guild, Security Guild) – Key management domain, Mongo persistence, CRUD/rotate/revoke endpoints, validation, and tests delivered.
+> 2025-11-01: ISSUER-30-004 marked DONE (Issuer Directory Guild, VEX Lens Guild) – Excititor worker consumes issuer directory client for key/trust lookup with cached offline support.
+> 2025-11-01: ISSUER-30-005 marked DONE (Issuer Directory Guild, Observability Guild) – Issuer Directory service emits structured logs + metrics for issuer/key flows with OTEL meter.
+> 2025-11-02: SURFACE-ENV-01 moved to DOING (Surface Env Guild) – drafting shared environment spec for Scanner/Zastava.
+> 2025-11-02: SURFACE-ENV-02 moved to DOING (Surface Env Guild) – implementing typed environment resolver and unit tests.
+> 2025-11-02: SURFACE-VAL-01 moved to DOING (Surface Validation Guild) – aligning design document with implementation plan.
+> 2025-11-02: SURFACE-FS-01 moved to DOING (Surface FS Guild) – finalising cache layout and manifest spec.
+> 2025-11-02: SURFACE-FS-02 moved to DOING (Surface FS Guild) – building core abstractions and deterministic serializers.
+> 2025-11-02: SURFACE-SECRETS-01 moved to DOING (Surface Secrets Guild) – updating secrets design for provider matrix.
+> 2025-11-02: SURFACE-SECRETS-02 moved to DOING (Surface Secrets Guild) – implementing base providers + tests.
+> 2025-11-02: AUTH-POLICY-27-002 marked DONE (Authority Core & Security Guild) – interactive-only policy publish/promote scopes delivered with metadata, fresh-auth enforcement, and audit/docs updates.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-506 moved to DOING (EntryTrace Guild, Scanner WebService Guild) – surfacing EntryTrace results via WebService/CLI with confidence metadata.
+> 2025-11-02: ATTESTOR-74-001 marked DONE (Attestor Service Guild) – witness client integration, repository schema, and verification/reporting updates landed with tests.
+> 2025-11-02: AUTH-OAS-63-001 moved to DOING (Authority Core & Security Guild, API Governance Guild) – verifying legacy `/oauth/*` deprecation signalling and notifications ahead of sunset.
+> 2025-11-02: AUTH-OAS-63-001 marked DONE (Authority Core & Security Guild, API Governance Guild) – legacy shims emit Deprecation/Sunset/Warning headers, audit event coverage validated, and migration guide published.
+> 2025-11-02: AUTH-NOTIFY-40-001 marked DONE (Authority Core & Security Guild) – `/notify/ack-tokens/rotate` (notify.admin) now rotates DSSE keys with audit trails and integration tests.
+> 2025-11-02: AUTH-OAS-62-001 moved to DOING (Authority Core & Security Guild, SDK Generator Guild) – wiring SDK helpers for OAuth2/PAT flows and tenancy override header.
+> 2025-11-02: AUTH-OAS-62-001 marked DONE (Authority Core & Security Guild, SDK Generator Guild) – HttpClient auth helper (OAuth2/PAT) shipped with tenant header support and unit tests.
+> 2025-11-02: AUTH-OBS-50-001 moved to DOING (Authority Core & Security Guild) – defining observability scopes and updating discovery/offline defaults.
+> 2025-11-02: AUTH-OBS-52-001 moved to DOING (Authority Core & Security Guild) – rolling observability scopes through resource server policies and audit wiring.
+> 2025-11-02: AUTH-OBS-55-001 marked DONE (Authority Core & Security Guild, Ops Guild) – incident-mode tokens now require fresh auth, audit records expose `incident.reason`, and `/authority/audit/incident` verification path documented.
+> 2025-11-02: AUTH-ORCH-34-001 marked DONE (Authority Core & Security Guild) – `orch:backfill` scope enforced with reason/ticket metadata, Authority + CLI updated, docs/config refreshed for Orchestrator admins.
+> 2025-11-02: AUTH-PACKS-41-001 moved to DOING (Authority Core & Security Guild) – defining packs scope catalogue, issuer templates, and offline defaults.
+> 2025-11-02: AUTH-PACKS-41-001 added shared OpenSSL 1.1 test libs so Authority & Signals Mongo2Go suites run on OpenSSL 3.
+> 2025-11-02: AUTH-NOTIFY-42-001 moved to DOING (Authority Core & Security Guild) – investigating `/notify/ack-tokens/rotate` 500 responses when key metadata missing.
+> 2025-11-02: AUTH-NOTIFY-42-001 marked DONE (Authority Core & Security Guild) – bootstrap rotate defaults fixed, `StellaOpsBearer` test alias added, and notify ack rotation regression passes.
+> 2025-11-03: AUTH-TEN-49-001 marked DONE (Authority Core & Security Guild) – service account delegation (`act` chain) shipped with quota/audit coverage; Authority tests green.
+> 2025-11-03: AUTH-VULN-29-003 marked DONE (Authority Core & Docs Guild) – Vuln Explorer security docs, samples, and release notes refreshed for roles, ABAC policies, attachment signing, and ledger verification.
+> 2025-11-03: ISSUER-30-003 marked DONE (Issuer Directory Guild, Policy Guild) – trust override APIs/client finalized with cache invalidation/failure-path tests; Issuer Directory suite passing.
+> 2025-11-03: AUTH-AIRGAP-56-001/56-002 marked DONE (Authority Core & Security Guild) – air-gap scope catalog surfaced in discovery/OpenAPI and `/authority/audit/airgap` endpoint shipped with tests.
+> 2025-11-03: AUTH-PACKS-41-001 marked DONE (Authority Core & Security Guild) – packs scope bundle now emitted via discovery metadata, reflected in OpenAPI, and covered by Authority tests.
+> 2025-11-03: AUTH-POLICY-27-003 marked DONE (Authority Core & Docs Guild) – Policy Studio docs/config updated for publish/promote signing workflow, CLI commands, and compliance checklist.
+> 2025-11-02: ENTRYTRACE-SURFACE-02 moved to DOING (EntryTrace Guild) – replacing direct env/secret access with Surface.Secrets provider for EntryTrace runs.
+> 2025-11-02: ENTRYTRACE-SURFACE-01 marked DONE (EntryTrace Guild) – Surface.Validation + Surface.FS cache now drive EntryTrace reuse with regression tests.
+> 2025-11-02: ENTRYTRACE-SURFACE-02 marked DONE (EntryTrace Guild) – EntryTrace environment placeholders resolved via Surface.Secrets with updated docs/tests.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-506 marked DONE (EntryTrace Guild, Scanner WebService Guild) – EntryTrace graph surfaced via WebService and CLI with confidence metadata.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-509 moved to DOING (EntryTrace Guild, QA Guild) – adding regression coverage for EntryTrace surfaces and NDJSON hashing.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-509 marked DONE (EntryTrace Guild, QA Guild) – regression coverage landed for result store/WebService/CLI with NDJSON hashing snapshot.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-507 marked DONE (EntryTrace Guild) – fallback candidate discovery now covers history, supervisor configs, service directories, and entrypoint scripts with tests.
+> 2025-11-02: SCANNER-ENTRYTRACE-18-508 marked DONE (EntryTrace Guild) – wrapper catalogue expanded for bundle, docker-php-entrypoint, npm, yarn, pipenv, and poetry with wrapper metadata assertions.
+> 2025-11-02: CONCELIER-WEB-OAS-61-001 moved to DOING (Concelier WebService Guild) – implementing discovery endpoint for `.well-known/openapi` with version metadata and ETag.
+> 2025-11-02: CONCELIER-WEB-OAS-61-001 marked DONE (Concelier WebService Guild) – discovery endpoint now serves signed OpenAPI 3.1 document with ETag support.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-001 moved to DOING (Docs Guild, Scanner Guild) – refreshing Trivy/Grype/Snyk comparison docs and ecosystem matrix with source-linked coverage.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-001 marked DONE (Docs Guild, Scanner Guild) – matrix updated with Windows/macOS coverage row and secret detection techniques; deep dives cite Trivy/Grype/Snyk sources.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-003 added (Docs Guild, Product Guild) – recording Python lockfile/editable-install demand signals for policy guidance follow-up.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-004 added (Docs Guild, Java Analyzer Guild) – documenting Java lockfile ingestion plan and policy templates.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-005 added (Docs Guild, Go Analyzer Guild) – documenting Go stripped-binary fallback enrichment guidance.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-006 added (Docs Guild, Rust Analyzer Guild) – documenting Rust fingerprint enrichment guidance.
> 2025-11-02: DOCS-SCANNER-BENCH-62-007 added (Docs Guild, Security Guild) – documenting secret leak detection guidance.
> 2025-11-05: DOCS-SCANNER-BENCH-62-007 marked DONE (Docs Guild, Security Guild) – secret leak detection runbook, benchmark updates, and policy templates published.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-008 added (Docs Guild, EntryTrace Guild) – documenting EntryTrace heuristic maintenance guidance.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-009 added (Docs Guild, Ruby Analyzer Guild) – deepening Ruby gap analysis with detection tables; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-010 added (Docs Guild, PHP Analyzer Guild) – documenting PHP analyzer parity gaps; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-011 added (Docs Guild, Language Analyzer Guild) – capturing Deno runtime gap analysis; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-012 added (Docs Guild, Language Analyzer Guild) – expanding Dart ecosystem comparison; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-013 added (Docs Guild, Swift Analyzer Guild) – expanding Swift coverage analysis; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-014 added (Docs Guild, Runtime Guild) – detailing Kubernetes/VM coverage plan; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-015 added (Docs Guild, Export Center Guild) – outlining DSSE/Rekor operator enablement guidance; status set to DOING.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-009 marked DONE (Docs Guild, Ruby Analyzer Guild) – Ruby gap section delivered with detection tables and backlog links.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-010 marked DONE (Docs Guild, PHP Analyzer Guild) – PHP gap analysis updated with implementation notes.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-011 marked DONE (Docs Guild, Language Analyzer Guild) – Deno plan documented with detection technique table.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-012 marked DONE (Docs Guild, Language Analyzer Guild) – Dart coverage section fleshed out with detection strategies.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-013 marked DONE (Docs Guild, Swift Analyzer Guild) – Swift analyzer roadmap captured with policy hooks.
-> 2025-11-02: DOCS-SCANNER-BENCH-62-014 marked DONE (Docs Guild, Runtime Guild) – Kubernetes/VM alignment section published.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-008 added (Docs Guild, EntryTrace Guild) – documenting EntryTrace heuristic maintenance guidance.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-009 added (Docs Guild, Ruby Analyzer Guild) – deepening Ruby gap analysis with detection tables; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-010 added (Docs Guild, PHP Analyzer Guild) – documenting PHP analyzer parity gaps; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-011 added (Docs Guild, Language Analyzer Guild) – capturing Deno runtime gap analysis; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-012 added (Docs Guild, Language Analyzer Guild) – expanding Dart ecosystem comparison; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-013 added (Docs Guild, Swift Analyzer Guild) – expanding Swift coverage analysis; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-014 added (Docs Guild, Runtime Guild) – detailing Kubernetes/VM coverage plan; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-015 added (Docs Guild, Export Center Guild) – outlining DSSE/Rekor operator enablement guidance; status set to DOING.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-009 marked DONE (Docs Guild, Ruby Analyzer Guild) – Ruby gap section delivered with detection tables and backlog links.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-010 marked DONE (Docs Guild, PHP Analyzer Guild) – PHP gap analysis updated with implementation notes.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-011 marked DONE (Docs Guild, Language Analyzer Guild) – Deno plan documented with detection technique table.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-012 marked DONE (Docs Guild, Language Analyzer Guild) – Dart coverage section fleshed out with detection strategies.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-013 marked DONE (Docs Guild, Swift Analyzer Guild) – Swift analyzer roadmap captured with policy hooks.
+> 2025-11-02: DOCS-SCANNER-BENCH-62-014 marked DONE (Docs Guild, Runtime Guild) – Kubernetes/VM alignment section published.
> 2025-11-02: DOCS-SCANNER-BENCH-62-015 marked DONE (Docs Guild, Export Center Guild) – DSSE/Rekor enablement guidance appended to gap doc.
> 2025-11-05: SCANNER-SURFACE-02 marked DONE (Scanner WebService Guild) – WebService now persists `surface` manifest pointers in scan/report APIs, orchestrator samples and DSSE fixtures refreshed, and readiness tests updated with Surface validators stubbed for deterministic health checks.
-> 2025-11-02: SCANNER-ENG-0009 moved to DOING (Ruby Analyzer Guild) – drafting Ruby analyzer parity design package.
-> 2025-11-02: SCANNER-ENG-0016 added (Ruby Analyzer Guild) – implementing Ruby lock collector & vendor cache ingestion.
-> 2025-11-02: SCANNER-ENG-0016 moved to DOING (Ruby Analyzer Guild) – lockfile parser skeleton committed with initial Gemfile.lock parsing.
-> 2025-11-02: SCANNER-ENG-0017 added (Ruby Analyzer Guild) – building runtime require/autoload graph builder.
-> 2025-11-02: SCANNER-ENG-0018 added (Ruby Analyzer Guild) – emitting Ruby capability and framework signals.
-> 2025-11-02: SCANNER-ENG-0019 added (Ruby Analyzer Guild, CLI Guild) – delivering Ruby CLI verbs and Offline Kit packaging.
-> 2025-11-02: SCANNER-LIC-0001 added (Scanner Guild, Legal Guild) – vetting tree-sitter Ruby licensing/offline packaging.
-> 2025-11-02: SCANNER-LIC-0001 moved to DOING (Scanner Guild, Legal Guild) – SPDX review in progress.
-> 2025-11-02: SCANNER-POLICY-0001 added (Policy Guild, Ruby Analyzer Guild) – defining Ruby capability predicates in Policy Engine.
-> 2025-11-02: SCANNER-CLI-0001 added (CLI Guild, Ruby Analyzer Guild) – coordinating CLI UX/docs for Ruby verbs.
-> 2025-11-02: AIAI-31-011 moved to DOING (Advisory AI Guild) – implementing Excititor VEX document provider.
-> 2025-11-02: AIAI-31-011 marked DONE (Advisory AI Guild) – Excititor VEX provider + OpenVEX chunking shipped with tests.
-> 2025-11-02: AIAI-31-002 moved to DOING (Advisory AI Guild, SBOM Service Guild) – building SBOM context retriever for timelines/paths/blast radius.
-> 2025-11-02: AIAI-31-002 progressing – SBOM context models/tests landed; awaiting SBOM guild client hookup.
-> 2025-11-04: AIAI-31-002 marked DONE – SBOM context HTTP client + DI wiring delivered, retriever integrated, HTTP unit tests added.
-
-> 2025-11-02: AIAI-31-003 moved to DOING – kicking off deterministic tooling (comparators, dependency lookup). First drop covers semver range evaluator + RPM EVR comparator.
-> 2025-11-04: AIAI-31-003 marked DONE – deterministic toolset now DI-registered with SBOM context client, added semver/EVR comparison & range tests, and dependency analysis feeds orchestrator metadata.
-
-> 2025-11-02: AIAI-31-004 moved to DOING – starting deterministic orchestration pipeline (summary/conflict/remediation flow).
-
-> 2025-11-02: ISSUER-30-006 moved to DOING (Issuer Directory Guild, DevOps Guild) – deployment manifests, backup/restore, secret handling, and offline kit docs in progress.
-> 2025-11-04: EVID-OBS-55-001 moved to DOING (Evidence Locker Guild, DevOps Guild) – enabling incident mode retention extension, debug artefacts, and timeline/notifier hooks.
-> 2025-11-04: EVID-OBS-55-001 marked DONE (Evidence Locker Guild, DevOps Guild) – incident mode retention, timeline events, notifier stubs, and incident artefact packaging shipped with tests/docs.
-> 2025-11-04: EVID-OBS-60-001 moved to DOING (Evidence Locker Guild) – starting sealed-mode portable evidence export flow with redacted bundle packaging and offline verification guidance.
-> 2025-11-04: EVID-OBS-60-001 marked DONE (Evidence Locker Guild) – `/evidence/{id}/portable` now emits `portable-bundle-v1.tgz` with sanitized metadata, offline verification script, docs (`docs/airgap/portable-evidence.md`) and unit/web coverage.
-> 2025-11-04: DVOFF-64-001 moved to DOING (DevPortal Offline Guild, Exporter Guild) – beginning `devportal --offline` export job bundling portal HTML, specs, SDKs, and changelog assets.
+> 2025-11-02: SCANNER-ENG-0009 moved to DOING (Ruby Analyzer Guild) – drafting Ruby analyzer parity design package.
+> 2025-11-02: SCANNER-ENG-0016 added (Ruby Analyzer Guild) – implementing Ruby lock collector & vendor cache ingestion.
+> 2025-11-02: SCANNER-ENG-0016 moved to DOING (Ruby Analyzer Guild) – lockfile parser skeleton committed with initial Gemfile.lock parsing.
+> 2025-11-02: SCANNER-ENG-0017 added (Ruby Analyzer Guild) – building runtime require/autoload graph builder.
+> 2025-11-02: SCANNER-ENG-0018 added (Ruby Analyzer Guild) – emitting Ruby capability and framework signals.
+> 2025-11-02: SCANNER-ENG-0019 added (Ruby Analyzer Guild, CLI Guild) – delivering Ruby CLI verbs and Offline Kit packaging.
+> 2025-11-02: SCANNER-LIC-0001 added (Scanner Guild, Legal Guild) – vetting tree-sitter Ruby licensing/offline packaging.
+> 2025-11-02: SCANNER-LIC-0001 moved to DOING (Scanner Guild, Legal Guild) – SPDX review in progress.
+> 2025-11-02: SCANNER-POLICY-0001 added (Policy Guild, Ruby Analyzer Guild) – defining Ruby capability predicates in Policy Engine.
+> 2025-11-02: SCANNER-CLI-0001 added (CLI Guild, Ruby Analyzer Guild) – coordinating CLI UX/docs for Ruby verbs.
+> 2025-11-02: AIAI-31-011 moved to DOING (Advisory AI Guild) – implementing Excititor VEX document provider.
+> 2025-11-02: AIAI-31-011 marked DONE (Advisory AI Guild) – Excititor VEX provider + OpenVEX chunking shipped with tests.
+> 2025-11-02: AIAI-31-002 moved to DOING (Advisory AI Guild, SBOM Service Guild) – building SBOM context retriever for timelines/paths/blast radius.
+> 2025-11-02: AIAI-31-002 progressing – SBOM context models/tests landed; awaiting SBOM guild client hookup.
+> 2025-11-04: AIAI-31-002 marked DONE – SBOM context HTTP client + DI wiring delivered, retriever integrated, HTTP unit tests added.
+
+> 2025-11-02: AIAI-31-003 moved to DOING – kicking off deterministic tooling (comparators, dependency lookup). First drop covers semver range evaluator + RPM EVR comparator.
+> 2025-11-04: AIAI-31-003 marked DONE – deterministic toolset now DI-registered with SBOM context client, added semver/EVR comparison & range tests, and dependency analysis feeds orchestrator metadata.
+
+> 2025-11-02: AIAI-31-004 moved to DOING – starting deterministic orchestration pipeline (summary/conflict/remediation flow).
+
+> 2025-11-02: ISSUER-30-006 moved to DOING (Issuer Directory Guild, DevOps Guild) – deployment manifests, backup/restore, secret handling, and offline kit docs in progress.
+> 2025-11-04: EVID-OBS-55-001 moved to DOING (Evidence Locker Guild, DevOps Guild) – enabling incident mode retention extension, debug artefacts, and timeline/notifier hooks.
+> 2025-11-04: EVID-OBS-55-001 marked DONE (Evidence Locker Guild, DevOps Guild) – incident mode retention, timeline events, notifier stubs, and incident artefact packaging shipped with tests/docs.
+> 2025-11-04: EVID-OBS-60-001 moved to DOING (Evidence Locker Guild) – starting sealed-mode portable evidence export flow with redacted bundle packaging and offline verification guidance.
+> 2025-11-04: EVID-OBS-60-001 marked DONE (Evidence Locker Guild) – `/evidence/{id}/portable` now emits `portable-bundle-v1.tgz` with sanitized metadata, offline verification script, docs (`docs/airgap/portable-evidence.md`) and unit/web coverage.
+> 2025-11-04: DVOFF-64-001 moved to DOING (DevPortal Offline Guild, Exporter Guild) – beginning `devportal --offline` export job bundling portal HTML, specs, SDKs, and changelog assets.
diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md
index 791985ff7..1ccb3a7c4 100644
--- a/docs/implplan/SPRINT_110_ingestion_evidence.md
+++ b/docs/implplan/SPRINT_110_ingestion_evidence.md
@@ -210,7 +210,7 @@ Depends on: Sprint 110.B - Concelier.VI
Summary: Ingestion & Evidence focus on Concelier (phase VII).
Task ID | State | Task description | Owners (Source)
--- | --- | --- | ---
-MERGE-LNM-21-002 | DOING (2025-11-03) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafting `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-06 23:45Z: Analyzer enforcement merged; DI removal + flag defaults pending. Analyzer test project blocked by offline feed (`Microsoft.Bcl.AsyncInterfaces >= 8.0` missing) — rerun once nuget mirror refreshed. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md)
+MERGE-LNM-21-002 | DOING (2025-11-06) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafted `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-07 03:25Z: Default-on toggle + job gating break existing Concelier WebService tests; guard/migration adjustments pending before closing the task. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md)
MERGE-LNM-21-003 Determinism/test updates | QA Guild, BE-Merge | Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible. Dependencies: MERGE-LNM-21-002. | MERGE-LNM-21-002 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md)
diff --git a/docs/implplan/SPRINT_130_scanner_surface.md b/docs/implplan/SPRINT_130_scanner_surface.md
index 308f245f3..bbf0e4671 100644
--- a/docs/implplan/SPRINT_130_scanner_surface.md
+++ b/docs/implplan/SPRINT_130_scanner_surface.md
@@ -142,8 +142,8 @@ SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Extend orchestrator event links (rep
SCANNER-GRAPH-21-001 | TODO | Provide webhook/REST endpoint for Cartographer to request policy overlays and runtime evidence for graph nodes, ensuring determinism and tenant scoping. | Scanner WebService Guild, Cartographer Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
SCANNER-LNM-21-001 | TODO | Update `/reports` and `/policy/runtime` payloads to consume advisory/vex linksets, exposing source severity arrays and conflict summaries alongside effective verdicts. | Scanner WebService Guild, Policy Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
SCANNER-LNM-21-002 | TODO | Add evidence endpoint for Console to fetch linkset summaries with policy overlay for a component/SBOM, including AOC references. Dependencies: SCANNER-LNM-21-001. | Scanner WebService Guild, UI Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
-SCANNER-SECRETS-01 | DOING (2025-11-02) | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Worker integration tests added for CAS token retrieval via Surface.Secrets abstraction; refactor under review. | Scanner Worker Guild, Security Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md)
-SCANNER-SECRETS-02 | DOING (2025-11-02) | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens). Dependencies: SCANNER-SECRETS-01.
2025-11-02: WebService export path now resolves registry credentials via Surface.Secrets stub; CI pipeline hook in progress. | Scanner WebService Guild, Security Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
+SCANNER-SECRETS-01 | DOING (2025-11-06) | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Worker integration tests added for CAS token retrieval via Surface.Secrets abstraction; refactor under review.
2025-11-06: Resumed to replace remaining registry credential plumbing and emit rotation-aware metrics.
2025-11-06 21:35Z: Surface secret configurator now hydrates `ScannerStorageOptions` from `cas-access` payloads; unit coverage added. | Scanner Worker Guild, Security Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md)
+SCANNER-SECRETS-02 | DOING (2025-11-06) | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens). Dependencies: SCANNER-SECRETS-01.
2025-11-02: WebService export path now resolves registry credentials via Surface.Secrets stub; CI pipeline hook in progress.
2025-11-06: Picking up Surface.Secrets provider usage across report/export flows and removing legacy secret file readers.
2025-11-06 21:40Z: WebService options now consume `cas-access` secrets via configurator; storage mirrors updated; targeted tests passing. | Scanner WebService Guild, Security Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
SCANNER-SECRETS-03 | TODO | Use Surface.Secrets to retrieve registry credentials when interacting with CAS/referrers. Dependencies: SCANNER-SECRETS-02. | BuildX Plugin Guild, Security Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md)
SCANNER-ENG-0020 | TODO | Implement Homebrew collector & fragment mapper per `design/macos-analyzer.md` §3.1. | Scanner Guild (docs/modules/scanner/TASKS.md)
SCANNER-ENG-0021 | TODO | Implement pkgutil receipt collector per `design/macos-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner/TASKS.md)
@@ -153,9 +153,9 @@ SCANNER-ENG-0024 | TODO | Implement Windows MSI collector per `design/windows-an
SCANNER-ENG-0025 | TODO | Implement WinSxS manifest collector per `design/windows-analyzer.md` §3.2. | Scanner Guild (docs/modules/scanner/TASKS.md)
SCANNER-ENG-0026 | TODO | Implement Windows Chocolatey & registry collectors per `design/windows-analyzer.md` §3.3–3.4. | Scanner Guild (docs/modules/scanner/TASKS.md)
SCANNER-ENG-0027 | TODO | Deliver Windows policy/offline integration per `design/windows-analyzer.md` §5–6. | Scanner Guild, Policy Guild, Offline Kit Guild (docs/modules/scanner/TASKS.md)
-SCANNER-SURFACE-01 | DOING (2025-11-02) | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Worker pipeline emitting draft Surface.FS manifests for sample scans; determinism checks running. | Scanner Worker Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md)
+SCANNER-SURFACE-01 | DOING (2025-11-06) | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Worker pipeline emitting draft Surface.FS manifests for sample scans; determinism checks running.
2025-11-06: Continuing with manifest writer abstraction + telemetry wiring for Surface.FS persistence. | Scanner Worker Guild (src/Scanner/StellaOps.Scanner.Worker/TASKS.md)
SCANNER-SURFACE-02 | DONE (2025-11-05) | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata. Dependencies: SCANNER-SURFACE-01.
2025-11-05: Surface pointer projection wired through WebService endpoints, orchestrator samples & DSSE fixtures refreshed with `surface` manifest block, and regression suite (platform events, report sample, ready check) updated. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md)
-SCANNER-SURFACE-03 | TODO | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. Dependencies: SCANNER-SURFACE-02. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md)
+SCANNER-SURFACE-03 | DOING (2025-11-06) | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. Dependencies: SCANNER-SURFACE-02.
2025-11-06: Starting BuildX manifest upload implementation with Surface.FS client abstraction and integration tests. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md)
[Scanner & Surface] 130.A) Scanner.VIII
Depends on: Sprint 130.A - Scanner.VII
diff --git a/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs b/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs
index 0d4a5527a..1a74942ed 100644
--- a/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs
+++ b/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/Routing/AocGuardEndpointFilter.cs
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
+using StellaOps.Aoc.AspNetCore.Results;
namespace StellaOps.Aoc.AspNetCore.Routing;
@@ -55,7 +56,14 @@ public sealed class AocGuardEndpointFilter : IEndpointFilter
_ => JsonSerializer.SerializeToElement(payload, _serializerOptions)
};
- guard.ValidateOrThrow(element, options);
+ try
+ {
+ guard.ValidateOrThrow(element, options);
+ }
+ catch (AocGuardException exception)
+ {
+ return AocHttpResults.Problem(context.HttpContext, exception);
+ }
}
}
}
diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs
index f6823294e..febbdaa64 100644
--- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs
+++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs
@@ -111,7 +111,7 @@ internal static class JobRegistrationExtensions
private static void ConfigureMergeJob(JobSchedulerOptions options, IConfiguration configuration)
{
- var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled", true);
+ var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled") ?? true;
if (noMergeEnabled)
{
options.Definitions.Remove(MergeReconcileBuiltInJob.Kind);
diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs
index 6821564d3..0a42a322d 100644
--- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs
+++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs
@@ -11,10 +11,10 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
-using System.Diagnostics;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Microsoft.Extensions.Logging;
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
@@ -55,17 +55,17 @@ const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest";
const string AdvisoryReadPolicyName = "Concelier.Advisories.Read";
const string AocVerifyPolicyName = "Concelier.Aoc.Verify";
const string TenantHeaderName = "X-Stella-Tenant";
-
-builder.Configuration.AddStellaOpsDefaults(options =>
-{
- options.BasePath = builder.Environment.ContentRootPath;
- options.EnvironmentPrefix = "CONCELIER_";
- options.ConfigureBuilder = configurationBuilder =>
- {
- configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml"));
- };
-});
-
+
+builder.Configuration.AddStellaOpsDefaults(options =>
+{
+ options.BasePath = builder.Environment.ContentRootPath;
+ options.EnvironmentPrefix = "CONCELIER_";
+ options.ConfigureBuilder = configurationBuilder =>
+ {
+ configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml"));
+ };
+});
+
var contentRootPath = builder.Environment.ContentRootPath;
var concelierOptions = builder.Configuration.BindOptions(postConfigure: (opts, _) =>
@@ -244,7 +244,7 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
-app.MapGet("/.well-known/openapi", (OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
+app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
{
var (payload, etag) = provider.GetDocument();
@@ -299,7 +299,7 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
[FromQuery(Name = "cpe")] string[]? cpes,
[FromQuery(Name = "limit")] int? limit,
[FromQuery(Name = "cursor")] string? cursor,
- IAdvisoryObservationQueryService queryService,
+ [FromServices] IAdvisoryObservationQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -356,8 +356,8 @@ if (authorityConfigured)
var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async (
HttpContext context,
AdvisoryIngestRequest request,
- IAdvisoryRawService rawService,
- TimeProvider timeProvider,
+ [FromServices] IAdvisoryRawService rawService,
+ [FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -470,7 +470,7 @@ if (authorityConfigured)
var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async (
HttpContext context,
- IAdvisoryRawService rawService,
+ [FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -560,7 +560,7 @@ if (authorityConfigured)
var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async (
string id,
HttpContext context,
- IAdvisoryRawService rawService,
+ [FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -604,7 +604,7 @@ if (authorityConfigured)
var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async (
string id,
HttpContext context,
- IAdvisoryRawService rawService,
+ [FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -650,8 +650,8 @@ if (authorityConfigured)
var aocVerifyEndpoint = app.MapPost("/aoc/verify", async (
HttpContext context,
AocVerifyRequest request,
- IAdvisoryRawService rawService,
- TimeProvider timeProvider,
+ [FromServices] IAdvisoryRawService rawService,
+ [FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -734,7 +734,7 @@ if (authorityConfigured)
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,
- IAdvisoryEventLog eventLog,
+ [FromServices] IAdvisoryEventLog eventLog,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
@@ -798,29 +798,29 @@ if (loggingEnabled)
};
});
}
-
+
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
- var feature = context.Features.Get();
- var error = feature?.Error;
-
- var extensions = new Dictionary(StringComparer.Ordinal)
- {
- ["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
- };
-
- var problem = Results.Problem(
- detail: error?.Message,
- instance: context.Request.Path,
- statusCode: StatusCodes.Status500InternalServerError,
- title: "Unexpected server error",
- type: ProblemTypes.JobFailure,
- extensions: extensions);
-
- await problem.ExecuteAsync(context);
+ var feature = context.Features.Get();
+ var error = feature?.Error;
+
+ var extensions = new Dictionary(StringComparer.Ordinal)
+ {
+ ["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
+ };
+
+ var problem = Results.Problem(
+ detail: error?.Message,
+ instance: context.Request.Path,
+ statusCode: StatusCodes.Status500InternalServerError,
+ title: "Unexpected server error",
+ type: ProblemTypes.JobFailure,
+ extensions: extensions);
+
+ await problem.ExecuteAsync(context);
});
});
@@ -868,13 +868,13 @@ if (authorityConfigured)
app.UseAuthentication();
app.UseAuthorization();
}
-
-IResult JsonResult(T value, int? statusCode = null)
-{
- var payload = JsonSerializer.Serialize(value, jsonOptions);
- return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
-}
-
+
+IResult JsonResult(T value, int? statusCode = null)
+{
+ var payload = JsonSerializer.Serialize(value, jsonOptions);
+ return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
+}
+
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary? extensions = null)
{
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
@@ -987,157 +987,157 @@ IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exc
var guardException = new AocGuardException(exception.Result);
return AocHttpResults.Problem(context, guardException);
}
-
-static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome)
- => new[]
- {
- new KeyValuePair("job.kind", jobKind),
- new KeyValuePair("job.trigger", trigger),
- new KeyValuePair("job.outcome", outcome),
- };
-
-void ApplyNoCache(HttpResponse response)
-{
- if (response is null)
- {
- return;
- }
-
- response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
- response.Headers.Pragma = "no-cache";
- response.Headers["Expires"] = "0";
-}
-
-await InitializeMongoAsync(app);
-
-app.MapGet("/health", (IOptions opts, ServiceStatus status, HttpContext context) =>
-{
- ApplyNoCache(context.Response);
-
- var snapshot = status.CreateSnapshot();
- var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
-
- var storage = new StorageBootstrapHealth(
- Driver: opts.Value.Storage.Driver,
- Completed: snapshot.BootstrapCompletedAt is not null,
- CompletedAt: snapshot.BootstrapCompletedAt,
- DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds);
-
- var telemetry = new TelemetryHealth(
- Enabled: opts.Value.Telemetry.Enabled,
- Tracing: opts.Value.Telemetry.EnableTracing,
- Metrics: opts.Value.Telemetry.EnableMetrics,
- Logging: opts.Value.Telemetry.EnableLogging);
-
- var response = new HealthDocument(
- Status: "healthy",
- StartedAt: snapshot.StartedAt,
- UptimeSeconds: uptimeSeconds,
- Storage: storage,
- Telemetry: telemetry);
-
- return JsonResult(response);
-});
-
-app.MapGet("/ready", async (IMongoDatabase database, ServiceStatus status, HttpContext context, CancellationToken cancellationToken) =>
-{
- ApplyNoCache(context.Response);
-
- var stopwatch = Stopwatch.StartNew();
- try
- {
- await database.RunCommandAsync((Command)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false);
- stopwatch.Stop();
- status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null);
-
- var snapshot = status.CreateSnapshot();
- var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
-
- var mongo = new MongoReadyHealth(
- Status: "ready",
- LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
- CheckedAt: snapshot.LastReadyCheckAt,
- Error: null);
-
- var response = new ReadyDocument(
- Status: "ready",
- StartedAt: snapshot.StartedAt,
- UptimeSeconds: uptimeSeconds,
- Mongo: mongo);
-
- return JsonResult(response);
- }
- catch (Exception ex)
- {
- stopwatch.Stop();
- status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
-
- var snapshot = status.CreateSnapshot();
- var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
-
- var mongo = new MongoReadyHealth(
- Status: "unready",
- LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
- CheckedAt: snapshot.LastReadyCheckAt,
- Error: snapshot.LastMongoError ?? ex.Message);
-
- var response = new ReadyDocument(
- Status: "unready",
- StartedAt: snapshot.StartedAt,
- UptimeSeconds: uptimeSeconds,
- Mongo: mongo);
-
- var extensions = new Dictionary(StringComparer.Ordinal)
- {
- ["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds,
- ["mongoError"] = snapshot.LastMongoError ?? ex.Message,
- };
-
- return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions);
- }
-});
-
-app.MapGet("/diagnostics/aliases/{seed}", async (string seed, AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) =>
-{
- ApplyNoCache(context.Response);
-
- if (string.IsNullOrWhiteSpace(seed))
- {
- return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation);
- }
-
- var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false);
-
- var aliases = component.AliasMap.ToDictionary(
- static kvp => kvp.Key,
- static kvp => kvp.Value
- .Select(record => new
- {
- record.Scheme,
- record.Value,
- UpdatedAt = record.UpdatedAt
- })
- .ToArray());
-
- var response = new
- {
- Seed = component.SeedAdvisoryKey,
- Advisories = component.AdvisoryKeys,
- Collisions = component.Collisions
- .Select(collision => new
- {
- collision.Scheme,
- collision.Value,
- AdvisoryKeys = collision.AdvisoryKeys
- })
- .ToArray(),
- Aliases = aliases
- };
-
- return JsonResult(response);
-});
-
-var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+
+static KeyValuePair[] BuildJobMetricTags(string jobKind, string trigger, string outcome)
+ => new[]
+ {
+ new KeyValuePair("job.kind", jobKind),
+ new KeyValuePair("job.trigger", trigger),
+ new KeyValuePair("job.outcome", outcome),
+ };
+
+void ApplyNoCache(HttpResponse response)
+{
+ if (response is null)
+ {
+ return;
+ }
+
+ response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
+ response.Headers.Pragma = "no-cache";
+ response.Headers["Expires"] = "0";
+}
+
+await InitializeMongoAsync(app);
+
+app.MapGet("/health", ([FromServices] IOptions opts, [FromServices] ServiceStatus status, HttpContext context) =>
+{
+ ApplyNoCache(context.Response);
+
+ var snapshot = status.CreateSnapshot();
+ var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
+
+ var storage = new StorageBootstrapHealth(
+ Driver: opts.Value.Storage.Driver,
+ Completed: snapshot.BootstrapCompletedAt is not null,
+ CompletedAt: snapshot.BootstrapCompletedAt,
+ DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds);
+
+ var telemetry = new TelemetryHealth(
+ Enabled: opts.Value.Telemetry.Enabled,
+ Tracing: opts.Value.Telemetry.EnableTracing,
+ Metrics: opts.Value.Telemetry.EnableMetrics,
+ Logging: opts.Value.Telemetry.EnableLogging);
+
+ var response = new HealthDocument(
+ Status: "healthy",
+ StartedAt: snapshot.StartedAt,
+ UptimeSeconds: uptimeSeconds,
+ Storage: storage,
+ Telemetry: telemetry);
+
+ return JsonResult(response);
+});
+
+app.MapGet("/ready", async ([FromServices] IMongoDatabase database, [FromServices] ServiceStatus status, HttpContext context, CancellationToken cancellationToken) =>
+{
+ ApplyNoCache(context.Response);
+
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ await database.RunCommandAsync((Command)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false);
+ stopwatch.Stop();
+ status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null);
+
+ var snapshot = status.CreateSnapshot();
+ var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
+
+ var mongo = new MongoReadyHealth(
+ Status: "ready",
+ LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
+ CheckedAt: snapshot.LastReadyCheckAt,
+ Error: null);
+
+ var response = new ReadyDocument(
+ Status: "ready",
+ StartedAt: snapshot.StartedAt,
+ UptimeSeconds: uptimeSeconds,
+ Mongo: mongo);
+
+ return JsonResult(response);
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+ status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
+
+ var snapshot = status.CreateSnapshot();
+ var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
+
+ var mongo = new MongoReadyHealth(
+ Status: "unready",
+ LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
+ CheckedAt: snapshot.LastReadyCheckAt,
+ Error: snapshot.LastMongoError ?? ex.Message);
+
+ var response = new ReadyDocument(
+ Status: "unready",
+ StartedAt: snapshot.StartedAt,
+ UptimeSeconds: uptimeSeconds,
+ Mongo: mongo);
+
+ var extensions = new Dictionary(StringComparer.Ordinal)
+ {
+ ["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds,
+ ["mongoError"] = snapshot.LastMongoError ?? ex.Message,
+ };
+
+ return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions);
+ }
+});
+
+app.MapGet("/diagnostics/aliases/{seed}", async (string seed, [FromServices] AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) =>
+{
+ ApplyNoCache(context.Response);
+
+ if (string.IsNullOrWhiteSpace(seed))
+ {
+ return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation);
+ }
+
+ var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false);
+
+ var aliases = component.AliasMap.ToDictionary(
+ static kvp => kvp.Key,
+ static kvp => kvp.Value
+ .Select(record => new
+ {
+ record.Scheme,
+ record.Value,
+ UpdatedAt = record.UpdatedAt
+ })
+ .ToArray());
+
+ var response = new
+ {
+ Seed = component.SeedAdvisoryKey,
+ Advisories = component.AdvisoryKeys,
+ Collisions = component.Collisions
+ .Select(collision => new
+ {
+ collision.Scheme,
+ collision.Value,
+ AdvisoryKeys = collision.AdvisoryKeys
+ })
+ .ToArray(),
+ Aliases = aliases
+ };
+
+ return JsonResult(response);
+});
+
+var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -1151,7 +1151,7 @@ if (enforceAuthority)
jobsListEndpoint.RequireAuthorization(JobsPolicyName);
}
-var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -1168,25 +1168,25 @@ if (enforceAuthority)
jobByIdEndpoint.RequireAuthorization(JobsPolicyName);
}
-var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
- ApplyNoCache(context.Response);
-
- var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false);
- if (definitions.Count == 0)
- {
- return JsonResult(Array.Empty());
- }
-
- var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray();
- var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false);
-
- var responses = new List(definitions.Count);
- foreach (var definition in definitions)
- {
- lastRuns.TryGetValue(definition.Kind, out var lastRun);
- responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun));
- }
+ ApplyNoCache(context.Response);
+
+ var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false);
+ if (definitions.Count == 0)
+ {
+ return JsonResult(Array.Empty());
+ }
+
+ var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray();
+ var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false);
+
+ var responses = new List(definitions.Count);
+ foreach (var definition in definitions)
+ {
+ lastRuns.TryGetValue(definition.Kind, out var lastRun);
+ responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun));
+ }
return JsonResult(responses);
}).AddEndpointFilter();
@@ -1195,20 +1195,20 @@ if (enforceAuthority)
jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName);
}
-var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
- ApplyNoCache(context.Response);
-
- var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
- .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
-
- if (definition is null)
- {
- return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
- }
-
- var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false);
- lastRuns.TryGetValue(definition.Kind, out var lastRun);
+ ApplyNoCache(context.Response);
+
+ var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
+ .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
+
+ if (definition is null)
+ {
+ return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
+ }
+
+ var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false);
+ lastRuns.TryGetValue(definition.Kind, out var lastRun);
var response = JobDefinitionResponse.FromDefinition(definition, lastRun);
return JsonResult(response);
@@ -1218,18 +1218,18 @@ if (enforceAuthority)
jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName);
}
-var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
- ApplyNoCache(context.Response);
-
- var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
- .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
-
- if (definition is null)
- {
- return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
- }
-
+ ApplyNoCache(context.Response);
+
+ var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
+ .FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
+
+ if (definition is null)
+ {
+ return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
+ }
+
var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200);
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
@@ -1240,9 +1240,9 @@ if (enforceAuthority)
jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName);
}
-var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
+var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
- ApplyNoCache(context.Response);
+ ApplyNoCache(context.Response);
var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
@@ -1253,22 +1253,22 @@ if (enforceAuthority)
activeJobsEndpoint.RequireAuthorization(JobsPolicyName);
}
-var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) =>
+var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, [FromServices] IJobCoordinator coordinator, HttpContext context) =>
{
- ApplyNoCache(context.Response);
-
- request ??= new JobTriggerRequest();
- request.Parameters ??= new Dictionary(StringComparer.Ordinal);
- var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger;
-
- var lifetime = context.RequestServices.GetRequiredService();
- var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false);
-
- var outcome = result.Outcome;
- var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant());
-
- switch (outcome)
- {
+ ApplyNoCache(context.Response);
+
+ request ??= new JobTriggerRequest();
+ request.Parameters ??= new Dictionary(StringComparer.Ordinal);
+ var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger;
+
+ var lifetime = context.RequestServices.GetRequiredService();
+ var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false);
+
+ var outcome = result.Outcome;
+ var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant());
+
+ switch (outcome)
+ {
case JobTriggerOutcome.Accepted:
JobMetrics.TriggerCounter.Add(1, tags);
if (result.Run is null)
@@ -1279,54 +1279,54 @@ var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind,
var acceptedRun = JobRunResponse.FromSnapshot(result.Run);
context.Response.Headers.Location = $"/jobs/{acceptedRun.RunId}";
return JsonResult(acceptedRun, StatusCodes.Status202Accepted);
-
- case JobTriggerOutcome.NotFound:
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered.");
-
- case JobTriggerOutcome.Disabled:
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled.");
-
- case JobTriggerOutcome.AlreadyRunning:
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run.");
-
- case JobTriggerOutcome.LeaseRejected:
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease.");
-
- case JobTriggerOutcome.InvalidParameters:
- {
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- var extensions = new Dictionary(StringComparer.Ordinal)
- {
- ["parameters"] = request.Parameters,
- };
- return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions);
- }
-
- case JobTriggerOutcome.Cancelled:
- {
- JobMetrics.TriggerConflictCounter.Add(1, tags);
- var extensions = new Dictionary(StringComparer.Ordinal)
- {
- ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
- };
-
- return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions);
- }
-
- case JobTriggerOutcome.Failed:
- {
- JobMetrics.TriggerFailureCounter.Add(1, tags);
- var extensions = new Dictionary(StringComparer.Ordinal)
- {
- ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
- };
-
- return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions);
- }
+
+ case JobTriggerOutcome.NotFound:
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered.");
+
+ case JobTriggerOutcome.Disabled:
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled.");
+
+ case JobTriggerOutcome.AlreadyRunning:
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run.");
+
+ case JobTriggerOutcome.LeaseRejected:
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease.");
+
+ case JobTriggerOutcome.InvalidParameters:
+ {
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ var extensions = new Dictionary(StringComparer.Ordinal)
+ {
+ ["parameters"] = request.Parameters,
+ };
+ return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions);
+ }
+
+ case JobTriggerOutcome.Cancelled:
+ {
+ JobMetrics.TriggerConflictCounter.Add(1, tags);
+ var extensions = new Dictionary(StringComparer.Ordinal)
+ {
+ ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
+ };
+
+ return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions);
+ }
+
+ case JobTriggerOutcome.Failed:
+ {
+ JobMetrics.TriggerFailureCounter.Add(1, tags);
+ var extensions = new Dictionary(StringComparer.Ordinal)
+ {
+ ["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
+ };
+
+ return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions);
+ }
default:
JobMetrics.TriggerFailureCounter.Add(1, tags);
@@ -1337,61 +1337,61 @@ if (enforceAuthority)
{
triggerJobEndpoint.RequireAuthorization(JobsPolicyName);
}
-
-await app.RunAsync();
-
-static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot)
-{
- var pluginOptions = new PluginHostOptions
- {
+
+await app.RunAsync();
+
+static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot)
+{
+ var pluginOptions = new PluginHostOptions
+ {
BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot,
PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"),
PrimaryPrefix = "StellaOps.Concelier",
- EnsureDirectoryExists = true,
- RecursiveSearch = false,
- };
-
- if (options.Plugins.SearchPatterns.Count == 0)
- {
- pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll");
- }
- else
- {
- foreach (var pattern in options.Plugins.SearchPatterns)
- {
- if (!string.IsNullOrWhiteSpace(pattern))
- {
- pluginOptions.SearchPatterns.Add(pattern);
- }
- }
- }
-
- return pluginOptions;
-}
-
-static async Task InitializeMongoAsync(WebApplication app)
-{
- await using var scope = app.Services.CreateAsyncScope();
- var bootstrapper = scope.ServiceProvider.GetRequiredService();
- var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("MongoBootstrapper");
- var status = scope.ServiceProvider.GetRequiredService();
-
- var stopwatch = Stopwatch.StartNew();
-
- try
- {
- await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false);
- stopwatch.Stop();
- status.MarkBootstrapCompleted(stopwatch.Elapsed);
- logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
- }
- catch (Exception ex)
- {
- stopwatch.Stop();
- status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
- logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
- throw;
- }
-}
-
-public partial class Program;
+ EnsureDirectoryExists = true,
+ RecursiveSearch = false,
+ };
+
+ if (options.Plugins.SearchPatterns.Count == 0)
+ {
+ pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll");
+ }
+ else
+ {
+ foreach (var pattern in options.Plugins.SearchPatterns)
+ {
+ if (!string.IsNullOrWhiteSpace(pattern))
+ {
+ pluginOptions.SearchPatterns.Add(pattern);
+ }
+ }
+ }
+
+ return pluginOptions;
+}
+
+static async Task InitializeMongoAsync(WebApplication app)
+{
+ await using var scope = app.Services.CreateAsyncScope();
+ var bootstrapper = scope.ServiceProvider.GetRequiredService();
+ var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("MongoBootstrapper");
+ var status = scope.ServiceProvider.GetRequiredService();
+
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false);
+ stopwatch.Stop();
+ status.MarkBootstrapCompleted(stopwatch.Elapsed);
+ logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+ status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
+ logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
+ throw;
+ }
+}
+
+public partial class Program;
diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs
index 1f9add9c0..6a5c9d585 100644
--- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs
+++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs
@@ -18,7 +18,7 @@ public static class MergeServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
- var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled");
+ var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled") ?? true;
if (noMergeEnabled)
{
return services;
diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md
index 589bc785a..e30363fd0 100644
--- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md
+++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md
@@ -10,6 +10,6 @@
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.|
-|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DONE (2025-11-06)** – Audited service registrations, gated legacy bindings, and delivered analyzer coverage ahead of removal.
2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-06 23:58Z: Defaulted `concelier:features:noMergeEnabled` to `true`, removed the built-in `merge:reconcile` job unless explicitly allowlisted, refreshed WebService tests/docs, and verified analyzer suites restore against local feeds.|
+|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-06)** – Defaulted `concelier:features:noMergeEnabled` to `true`, added merge job allowlist gate, and began rewiring guard/tier tests; follow-up work required to restore Concelier WebService test suite before declaring completion.
2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-07 03:25Z: Default-on toggle + job gating break existing Concelier WebService tests; guard + seed fixes pending to unblock ingest/mirror suites.|
> 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage.
|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.|
diff --git a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
index c54f1ea0e..f9ac29dd4 100644
--- a/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
+++ b/src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md
@@ -2,6 +2,6 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
-| SCANNER-SURFACE-03 | TODO | BuildX Plugin Guild | SURFACE-FS-02 | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation. | BuildX integration tests confirm cache population; CLI docs updated. |
+| SCANNER-SURFACE-03 | DOING (2025-11-06) | BuildX Plugin Guild | SURFACE-FS-02 | Push layer manifests and entry fragments into Surface.FS during build-time SBOM generation.
2025-11-06: Kicked off manifest emitter wiring within BuildX export pipeline and outlined test fixtures targeting Surface.FS client mock. | BuildX integration tests confirm cache population; CLI docs updated. |
| SCANNER-ENV-03 | TODO | BuildX Plugin Guild | SURFACE-ENV-02 | Adopt Surface.Env helpers for plugin configuration (cache roots, CAS endpoints, feature toggles). | Plugin loads helper; misconfig errors logged; README updated. |
| SCANNER-SECRETS-03 | TODO | BuildX Plugin Guild, Security Guild | SURFACE-SECRETS-02 | Use Surface.Secrets to retrieve registry credentials when interacting with CAS/referrers. | Secrets retrieved via shared library; e2e tests cover rotation; operations guide refreshed. |
diff --git a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs
index 7644be678..44e83a206 100644
--- a/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs
+++ b/src/Scanner/StellaOps.Scanner.WebService/Contracts/SurfaceContracts.cs
@@ -1,6 +1,6 @@
using System;
-using System.Collections.Generic;
using System.Text.Json.Serialization;
+using StellaOps.Scanner.Surface.FS;
namespace StellaOps.Scanner.WebService.Contracts;
@@ -28,60 +28,3 @@ public sealed record SurfacePointersDto
[JsonPropertyOrder(4)]
public SurfaceManifestDocument Manifest { get; init; } = new();
}
-
-public sealed record SurfaceManifestDocument
-{
- [JsonPropertyName("schema")]
- [JsonPropertyOrder(0)]
- public string Schema { get; init; } = "stellaops.surface.manifest@1";
-
- [JsonPropertyName("tenant")]
- [JsonPropertyOrder(1)]
- public string Tenant { get; init; } = string.Empty;
-
- [JsonPropertyName("imageDigest")]
- [JsonPropertyOrder(2)]
- public string ImageDigest { get; init; } = string.Empty;
-
- [JsonPropertyName("generatedAt")]
- [JsonPropertyOrder(3)]
- public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
-
- [JsonPropertyName("artifacts")]
- [JsonPropertyOrder(4)]
- public IReadOnlyList Artifacts { get; init; } = Array.Empty();
-}
-
-public sealed record SurfaceManifestArtifact
-{
- [JsonPropertyName("kind")]
- [JsonPropertyOrder(0)]
- public string Kind { get; init; } = string.Empty;
-
- [JsonPropertyName("uri")]
- [JsonPropertyOrder(1)]
- public string Uri { get; init; } = string.Empty;
-
- [JsonPropertyName("digest")]
- [JsonPropertyOrder(2)]
- public string Digest { get; init; } = string.Empty;
-
- [JsonPropertyName("mediaType")]
- [JsonPropertyOrder(3)]
- public string MediaType { get; init; } = string.Empty;
-
- [JsonPropertyName("format")]
- [JsonPropertyOrder(4)]
- public string Format { get; init; } = string.Empty;
-
- [JsonPropertyName("sizeBytes")]
- [JsonPropertyOrder(5)]
- public long SizeBytes { get; init; }
- = 0;
-
- [JsonPropertyName("view")]
- [JsonPropertyOrder(6)]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? View { get; init; }
- = null;
-}
diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs
new file mode 100644
index 000000000..54cfd3157
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerStorageOptionsPostConfigurator.cs
@@ -0,0 +1,118 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Scanner.Storage;
+
+namespace StellaOps.Scanner.WebService.Options;
+
+internal sealed class ScannerStorageOptionsPostConfigurator : IPostConfigureOptions
+{
+ private readonly IOptionsMonitor _webOptions;
+ private readonly ILogger _logger;
+
+ public ScannerStorageOptionsPostConfigurator(
+ IOptionsMonitor webOptions,
+ ILogger logger)
+ {
+ _webOptions = webOptions ?? throw new ArgumentNullException(nameof(webOptions));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public void PostConfigure(string? name, ScannerStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var source = _webOptions.CurrentValue?.ArtifactStore;
+ if (source is null)
+ {
+ return;
+ }
+
+ var target = options.ObjectStore ??= new ObjectStoreOptions();
+
+ if (!string.IsNullOrWhiteSpace(source.Driver))
+ {
+ target.Driver = source.Driver;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.Region))
+ {
+ target.Region = source.Region!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.Bucket))
+ {
+ target.BucketName = source.Bucket!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.RootPrefix))
+ {
+ target.RootPrefix = source.RootPrefix;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.Endpoint))
+ {
+ if (target.IsRustFsDriver())
+ {
+ target.RustFs ??= new RustFsOptions();
+ target.RustFs.BaseUrl = source.Endpoint;
+ }
+ else
+ {
+ target.ServiceUrl = source.Endpoint;
+ }
+ }
+
+ if (target.IsRustFsDriver())
+ {
+ if (target.RustFs is null)
+ {
+ target.RustFs = new RustFsOptions();
+ }
+
+ target.RustFs.AllowInsecureTls = source.AllowInsecureTls;
+
+ if (!string.IsNullOrWhiteSpace(source.ApiKeyHeader))
+ {
+ target.RustFs.ApiKeyHeader = source.ApiKeyHeader!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.ApiKey))
+ {
+ target.RustFs.ApiKey = source.ApiKey;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.Endpoint))
+ {
+ target.RustFs.BaseUrl = source.Endpoint!;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.AccessKey))
+ {
+ target.AccessKeyId = source.AccessKey;
+ }
+
+ if (!string.IsNullOrWhiteSpace(source.SecretKey))
+ {
+ target.SecretAccessKey = source.SecretKey;
+ }
+
+ if (source.Headers is { Count: > 0 })
+ {
+ foreach (var (key, value) in source.Headers)
+ {
+ if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ target.Headers[key] = value;
+ }
+ }
+
+ _logger.LogDebug(
+ "Mirrored artifact store settings into scanner storage options (driver: {Driver}, bucket: {Bucket}).",
+ target.Driver,
+ target.BucketName);
+ }
+}
diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs
new file mode 100644
index 000000000..5fd781f97
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerSurfaceSecretConfigurator.cs
@@ -0,0 +1,124 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Scanner.Surface.Env;
+using StellaOps.Scanner.Surface.Secrets;
+
+namespace StellaOps.Scanner.WebService.Options;
+
+internal sealed class ScannerSurfaceSecretConfigurator : IConfigureOptions
+{
+ private const string ComponentName = "Scanner.WebService";
+
+ private readonly ISurfaceSecretProvider _secretProvider;
+ private readonly ISurfaceEnvironment _surfaceEnvironment;
+ private readonly ILogger _logger;
+
+ public ScannerSurfaceSecretConfigurator(
+ ISurfaceSecretProvider secretProvider,
+ ISurfaceEnvironment surfaceEnvironment,
+ ILogger logger)
+ {
+ _secretProvider = secretProvider ?? throw new ArgumentNullException(nameof(secretProvider));
+ _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public void Configure(ScannerWebServiceOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var tenant = _surfaceEnvironment.Settings.Secrets.Tenant;
+ var request = new SurfaceSecretRequest(
+ Tenant: tenant,
+ Component: ComponentName,
+ SecretType: "cas-access");
+
+ CasAccessSecret? secret = null;
+ try
+ {
+ using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
+ secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
+ }
+ catch (SurfaceSecretNotFoundException)
+ {
+ _logger.LogDebug("Surface secret 'cas-access' not found for {Component}; retaining configured artifact store settings.", ComponentName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to resolve surface secret 'cas-access' for {Component}.", ComponentName);
+ }
+
+ if (secret is null)
+ {
+ return;
+ }
+
+ ApplySecret(options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions(), secret);
+ }
+
+ private void ApplySecret(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore, CasAccessSecret secret)
+ {
+ if (!string.IsNullOrWhiteSpace(secret.Driver))
+ {
+ artifactStore.Driver = secret.Driver;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Endpoint))
+ {
+ artifactStore.Endpoint = secret.Endpoint!;
+ }
+
+ if (secret.AllowInsecureTls is { } insecure)
+ {
+ artifactStore.AllowInsecureTls = insecure;
+ artifactStore.UseTls = !insecure;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Region))
+ {
+ artifactStore.Region = secret.Region;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Bucket))
+ {
+ artifactStore.Bucket = secret.Bucket!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.RootPrefix))
+ {
+ artifactStore.RootPrefix = secret.RootPrefix!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.ApiKeyHeader))
+ {
+ artifactStore.ApiKeyHeader = secret.ApiKeyHeader!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.ApiKey))
+ {
+ artifactStore.ApiKey = secret.ApiKey;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.AccessKeyId) && !string.IsNullOrWhiteSpace(secret.SecretAccessKey))
+ {
+ artifactStore.AccessKey = secret.AccessKeyId!;
+ artifactStore.SecretKey = secret.SecretAccessKey!;
+ }
+
+ foreach (var header in secret.Headers)
+ {
+ if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value))
+ {
+ continue;
+ }
+
+ artifactStore.Headers[header.Key] = header.Value;
+ }
+
+ _logger.LogInformation(
+ "Surface secret 'cas-access' applied for {Component} (driver: {Driver}, bucket: {Bucket}).",
+ ComponentName,
+ artifactStore.Driver,
+ artifactStore.Bucket);
+ }
+}
diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs
index 03b9426a5..84c7908ff 100644
--- a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs
+++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptionsPostConfigure.cs
@@ -30,13 +30,7 @@ public static class ScannerWebServiceOptionsPostConfigure
options.ArtifactStore ??= new ScannerWebServiceOptions.ArtifactStoreOptions();
var artifactStore = options.ArtifactStore;
- if (string.IsNullOrWhiteSpace(artifactStore.SecretKey)
- && !string.IsNullOrWhiteSpace(artifactStore.SecretKeyFile))
- {
- artifactStore.SecretKey = ReadSecretFile(artifactStore.SecretKeyFile!, contentRootPath);
- }
-
- options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
+ options.Signing ??= new ScannerWebServiceOptions.SigningOptions();
var signing = options.Signing;
if (string.IsNullOrWhiteSpace(signing.KeyPem)
&& !string.IsNullOrWhiteSpace(signing.KeyPemFile))
diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs
index 83df2c0cc..c9a95c6b3 100644
--- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs
+++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs
@@ -97,6 +97,7 @@ builder.Services.AddSurfaceEnvironment(options =>
builder.Services.AddSurfaceValidation();
builder.Services.AddSurfaceFileCache();
builder.Services.AddSurfaceSecrets();
+builder.Services.AddSingleton, ScannerSurfaceSecretConfigurator>();
builder.Services.AddSingleton>(sp =>
new SurfaceCacheOptionsConfigurator(sp.GetRequiredService()));
builder.Services.AddSingleton();
@@ -179,6 +180,7 @@ builder.Services.AddScannerStorage(storageOptions =>
storageOptions.ObjectStore.RustFs.BaseUrl = string.Empty;
}
});
+builder.Services.AddSingleton, ScannerStorageOptionsPostConfigurator>();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md
index fd383c5b5..35fdfa54e 100644
--- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md
+++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md
@@ -6,7 +6,7 @@
| SCANNER-SURFACE-02 | DONE (2025-11-05) | Scanner WebService Guild | SURFACE-FS-02 | Publish Surface.FS pointers (CAS URIs, manifests) via scan/report APIs and update attestation metadata.
2025-11-05: Surface pointers projected through scan/report endpoints, orchestrator samples + DSSE fixtures refreshed with manifest block, readiness tests updated to use validator stub. | OpenAPI updated; clients regenerated; integration tests validate pointer presence and tenancy. |
| SCANNER-ENV-02 | TODO (2025-11-06) | Scanner WebService Guild, Ops Guild | SURFACE-ENV-02 | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration.
2025-11-02: Cache root resolution switched to helper; feature flag bindings updated; Helm/Compose updates pending review.
2025-11-05 14:55Z: Aligning readiness checks, docs, and Helm/Compose templates with Surface.Env outputs and planning test coverage for configuration fallbacks.
2025-11-06 17:05Z: Surface.Env documentation/README refreshed; warning catalogue captured for ops handoff.
2025-11-06 07:45Z: Helm values (dev/stage/prod/airgap/mirror) and Compose examples updated with `SCANNER_SURFACE_*` defaults plus rollout warning note in `deploy/README.md`.
2025-11-06 07:55Z: Paused; follow-up automation captured under `DEVOPS-OPENSSL-11-001/002` and pending Surface.Env readiness tests. | Service uses helper; env table documented; helm/compose templates updated. |
> 2025-11-05 19:18Z: Added configurator to project wiring and unit test ensuring Surface.Env cache root is honoured.
-| SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |
+| SCANNER-SECRETS-02 | DOING (2025-11-06) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress.
2025-11-06: Restarting work to eliminate file-based secrets, plumb provider handles through report/export services, and extend failure/rotation tests.
2025-11-06 21:40Z: Added configurator + storage post-config to hydrate artifact/CAS credentials from `cas-access` secrets with unit coverage. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. |
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. |
| SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console.
2025-11-06 22:55Z: Dispatcher now honours configurable API/console base segments, JSON samples/docs refreshed, and `ReportEventDispatcherTests` extended. Tests: `StellaOps.Scanner.WebService.Tests` build until pre-existing `SurfaceCacheOptionsConfiguratorTests` ctor signature drift (tracked separately). | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. |
diff --git a/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs
new file mode 100644
index 000000000..5540fb080
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.Worker/Options/ScannerStorageSurfaceSecretConfigurator.cs
@@ -0,0 +1,141 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Scanner.Storage;
+using StellaOps.Scanner.Surface.Env;
+using StellaOps.Scanner.Surface.Secrets;
+
+namespace StellaOps.Scanner.Worker.Options;
+
+internal sealed class ScannerStorageSurfaceSecretConfigurator : IConfigureOptions
+{
+ private static readonly string ComponentName = "Scanner.Worker";
+
+ private readonly ISurfaceSecretProvider _secretProvider;
+ private readonly ISurfaceEnvironment _surfaceEnvironment;
+ private readonly ILogger _logger;
+
+ public ScannerStorageSurfaceSecretConfigurator(
+ ISurfaceSecretProvider secretProvider,
+ ISurfaceEnvironment surfaceEnvironment,
+ ILogger logger)
+ {
+ _secretProvider = secretProvider ?? throw new ArgumentNullException(nameof(secretProvider));
+ _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public void Configure(ScannerStorageOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var tenant = _surfaceEnvironment.Settings.Secrets.Tenant;
+ var request = new SurfaceSecretRequest(
+ Tenant: tenant,
+ Component: ComponentName,
+ SecretType: "cas-access");
+
+ CasAccessSecret? secret = null;
+ try
+ {
+ using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
+ secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
+ }
+ catch (SurfaceSecretNotFoundException)
+ {
+ _logger.LogDebug("Surface secret 'cas-access' not found for {Component}; using configured storage settings.", ComponentName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to resolve surface secret 'cas-access' for {Component}.", ComponentName);
+ }
+
+ if (secret is null)
+ {
+ return;
+ }
+
+ ApplySecret(options, secret);
+ }
+
+ private void ApplySecret(ScannerStorageOptions options, CasAccessSecret secret)
+ {
+ var objectStore = options.ObjectStore ??= new ObjectStoreOptions();
+
+ if (!string.IsNullOrWhiteSpace(secret.Driver))
+ {
+ objectStore.Driver = secret.Driver;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Region))
+ {
+ objectStore.Region = secret.Region;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Bucket))
+ {
+ objectStore.BucketName = secret.Bucket;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.RootPrefix))
+ {
+ objectStore.RootPrefix = secret.RootPrefix;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.Endpoint))
+ {
+ if (objectStore.IsRustFsDriver())
+ {
+ objectStore.RustFs ??= new RustFsOptions();
+ objectStore.RustFs.BaseUrl = secret.Endpoint!;
+ }
+ else
+ {
+ objectStore.ServiceUrl = secret.Endpoint;
+ }
+ }
+
+ if (objectStore.IsRustFsDriver())
+ {
+ objectStore.RustFs ??= new RustFsOptions();
+
+ if (!string.IsNullOrWhiteSpace(secret.ApiKeyHeader))
+ {
+ objectStore.RustFs.ApiKeyHeader = secret.ApiKeyHeader!;
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.ApiKey))
+ {
+ objectStore.RustFs.ApiKey = secret.ApiKey;
+ }
+
+ if (secret.AllowInsecureTls is { } insecure)
+ {
+ objectStore.RustFs.AllowInsecureTls = insecure;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(secret.AccessKeyId) && !string.IsNullOrWhiteSpace(secret.SecretAccessKey))
+ {
+ objectStore.AccessKeyId = secret.AccessKeyId;
+ objectStore.SecretAccessKey = secret.SecretAccessKey;
+ objectStore.SessionToken = secret.SessionToken;
+ }
+
+ foreach (var kvp in secret.Headers)
+ {
+ if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value))
+ {
+ continue;
+ }
+
+ objectStore.Headers[kvp.Key] = kvp.Value;
+ }
+
+ _logger.LogInformation(
+ "Surface secret 'cas-access' applied for {Component} (driver: {Driver}, bucket: {Bucket}, region: {Region}).",
+ ComponentName,
+ objectStore.Driver,
+ objectStore.BucketName,
+ objectStore.Region);
+ }
+}
diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs
index bcd193f2a..dab083a25 100644
--- a/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs
+++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/CompositeScanAnalyzerDispatcher.cs
@@ -21,6 +21,7 @@ using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Worker.Options;
+using StellaOps.Scanner.Worker.Diagnostics;
namespace StellaOps.Scanner.Worker.Processing;
@@ -206,7 +207,7 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
try
{
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
- var cacheEntry = await cacheAdapter.GetOrCreateAsync(
+ var cacheEntry = await cacheAdapter.GetOrCreateEntryAsync(
_logger,
analyzer.Id,
workspaceFingerprint,
diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs
new file mode 100644
index 000000000..4b5f8cc57
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestPublisher.cs
@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Scanner.Core.Contracts;
+using StellaOps.Scanner.Surface.FS;
+using StellaOps.Scanner.Storage;
+using StellaOps.Scanner.Storage.Catalog;
+using StellaOps.Scanner.Storage.ObjectStore;
+using StellaOps.Scanner.Storage.Repositories;
+using StellaOps.Scanner.Storage.Services;
+using StellaOps.Scanner.Surface.Env;
+
+namespace StellaOps.Scanner.Worker.Processing.Surface;
+
+internal sealed record SurfaceManifestPayload(
+ ArtifactDocumentType ArtifactType,
+ ArtifactDocumentFormat ArtifactFormat,
+ string Kind,
+ string MediaType,
+ ReadOnlyMemory Content,
+ string? View = null,
+ IReadOnlyDictionary? Metadata = null,
+ bool RegisterArtifact = false);
+
+internal sealed record SurfaceManifestRequest(
+ string ScanId,
+ string ImageDigest,
+ int Attempt,
+ IReadOnlyDictionary Metadata,
+ IReadOnlyList Payloads,
+ string Component,
+ string? Version,
+ string? WorkerInstance);
+
+internal sealed class SurfaceManifestPublisher
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private readonly IArtifactObjectStore _objectStore;
+ private readonly ArtifactRepository _artifactRepository;
+ private readonly LinkRepository _linkRepository;
+ private readonly ScannerStorageOptions _storageOptions;
+ private readonly ISurfaceEnvironment _surfaceEnvironment;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ public SurfaceManifestPublisher(
+ IArtifactObjectStore objectStore,
+ ArtifactRepository artifactRepository,
+ LinkRepository linkRepository,
+ IOptions storageOptions,
+ ISurfaceEnvironment surfaceEnvironment,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
+ _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
+ _linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
+ _storageOptions = (storageOptions ?? throw new ArgumentNullException(nameof(storageOptions))).Value;
+ _surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.Payloads.Count == 0)
+ {
+ throw new ArgumentException("At least one payload must be provided.", nameof(request));
+ }
+
+ var tenant = _surfaceEnvironment.Settings.Tenant;
+ var generatedAt = _timeProvider.GetUtcNow();
+ var artifacts = new List(request.Payloads.Count);
+
+ foreach (var payload in request.Payloads)
+ {
+ var artifact = await StorePayloadAsync(payload, tenant, cancellationToken).ConfigureAwait(false);
+ artifacts.Add(artifact);
+ }
+
+ var manifestDocument = new SurfaceManifestDocument
+ {
+ Tenant = tenant,
+ ImageDigest = NormalizeDigest(request.ImageDigest),
+ ScanId = request.ScanId,
+ GeneratedAt = generatedAt,
+ Source = new SurfaceManifestSource
+ {
+ Component = request.Component,
+ Version = request.Version,
+ WorkerInstance = request.WorkerInstance,
+ Attempt = request.Attempt
+ },
+ Artifacts = artifacts.ToImmutableArray()
+ };
+
+ var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, SerializerOptions);
+ var manifestDigest = ComputeDigest(manifestBytes);
+ var manifestKey = ArtifactObjectKeyBuilder.Build(
+ ArtifactDocumentType.SurfaceManifest,
+ ArtifactDocumentFormat.SurfaceManifestJson,
+ manifestDigest,
+ _storageOptions.ObjectStore.RootPrefix);
+ var manifestDescriptor = new ArtifactObjectDescriptor(
+ _storageOptions.ObjectStore.BucketName,
+ manifestKey,
+ Immutable: true,
+ RetainFor: _storageOptions.ObjectStore.ComplianceRetention);
+
+ await using (var stream = new MemoryStream(manifestBytes, writable: false))
+ {
+ await _objectStore.PutAsync(manifestDescriptor, stream, cancellationToken).ConfigureAwait(false);
+ }
+
+ if (_storageOptions.DualWrite.Enabled && !string.IsNullOrWhiteSpace(_storageOptions.DualWrite.MirrorBucket))
+ {
+ await using var mirrorStream = new MemoryStream(manifestBytes, writable: false);
+ var mirrorDescriptor = manifestDescriptor with { Bucket = _storageOptions.DualWrite.MirrorBucket! };
+ await _objectStore.PutAsync(mirrorDescriptor, mirrorStream, cancellationToken).ConfigureAwait(false);
+ }
+
+ var nowUtc = generatedAt.UtcDateTime;
+ var artifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.SurfaceManifest, manifestDigest);
+ var manifestDocumentRecord = new ArtifactDocument
+ {
+ Id = artifactId,
+ Type = ArtifactDocumentType.SurfaceManifest,
+ Format = ArtifactDocumentFormat.SurfaceManifestJson,
+ MediaType = "application/vnd.stellaops.surface.manifest+json",
+ BytesSha256 = manifestDigest,
+ SizeBytes = manifestBytes.Length,
+ Immutable = true,
+ RefCount = 1,
+ CreatedAtUtc = nowUtc,
+ UpdatedAtUtc = nowUtc,
+ TtlClass = "surface.manifest"
+ };
+
+ await _artifactRepository.UpsertAsync(manifestDocumentRecord, cancellationToken).ConfigureAwait(false);
+
+ var link = new LinkDocument
+ {
+ Id = CatalogIdFactory.CreateLinkId(LinkSourceType.Image, manifestDocument.ImageDigest ?? request.ScanId, artifactId),
+ FromType = LinkSourceType.Image,
+ FromDigest = manifestDocument.ImageDigest ?? request.ScanId,
+ ArtifactId = artifactId,
+ CreatedAtUtc = nowUtc
+ };
+
+ await _linkRepository.UpsertAsync(link, cancellationToken).ConfigureAwait(false);
+
+ var manifestUri = BuildCasUri(_storageOptions.ObjectStore.BucketName, manifestKey);
+ _logger.LogInformation("Published surface manifest {Manifest} for image {ImageDigest}.", artifactId, manifestDocument.ImageDigest);
+
+ return new SurfaceManifestPublishResult(
+ ManifestDigest: manifestDigest,
+ ManifestUri: manifestUri,
+ ArtifactId: artifactId,
+ Document: manifestDocument);
+ }
+
+ private async Task StorePayloadAsync(SurfaceManifestPayload payload, string tenant, CancellationToken cancellationToken)
+ {
+ var digest = ComputeDigest(payload.Content.Span);
+ var key = ArtifactObjectKeyBuilder.Build(
+ payload.ArtifactType,
+ payload.ArtifactFormat,
+ digest,
+ _storageOptions.ObjectStore.RootPrefix);
+
+ await using (var stream = new MemoryStream(payload.Content.ToArray(), writable: false))
+ {
+ var descriptor = new ArtifactObjectDescriptor(
+ _storageOptions.ObjectStore.BucketName,
+ key,
+ Immutable: true,
+ RetainFor: _storageOptions.ObjectStore.ComplianceRetention);
+
+ await _objectStore.PutAsync(descriptor, stream, cancellationToken).ConfigureAwait(false);
+
+ if (_storageOptions.DualWrite.Enabled && !string.IsNullOrWhiteSpace(_storageOptions.DualWrite.MirrorBucket))
+ {
+ await using var mirrorStream = new MemoryStream(payload.Content.ToArray(), writable: false);
+ var mirrorDescriptor = descriptor with { Bucket = _storageOptions.DualWrite.MirrorBucket! };
+ await _objectStore.PutAsync(mirrorDescriptor, mirrorStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return new SurfaceManifestArtifact
+ {
+ Kind = payload.Kind,
+ Uri = BuildCasUri(_storageOptions.ObjectStore.BucketName, key),
+ Digest = digest,
+ MediaType = payload.MediaType,
+ Format = MapFormat(payload.ArtifactFormat),
+ SizeBytes = payload.Content.Length,
+ View = payload.View,
+ Storage = new SurfaceManifestStorage
+ {
+ Bucket = _storageOptions.ObjectStore.BucketName,
+ ObjectKey = key,
+ SizeBytes = payload.Content.Length,
+ ContentType = payload.MediaType
+ },
+ Metadata = payload.Metadata
+ };
+ }
+
+ private static string BuildCasUri(string bucket, string key)
+ {
+ var normalizedKey = string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim().TrimStart('/');
+ return $"cas://{bucket}/{normalizedKey}";
+ }
+
+ private static string MapFormat(ArtifactDocumentFormat format)
+ => format switch
+ {
+ ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace.ndjson",
+ ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph",
+ ArtifactDocumentFormat.ComponentFragmentJson => "layer.fragments",
+ ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest",
+ ArtifactDocumentFormat.CycloneDxJson => "cdx-json",
+ ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf",
+ ArtifactDocumentFormat.SpdxJson => "spdx-json",
+ ArtifactDocumentFormat.BomIndex => "bom-index",
+ ArtifactDocumentFormat.DsseJson => "dsse-json",
+ _ => format.ToString().ToLowerInvariant()
+ };
+
+ private static string ComputeDigest(ReadOnlySpan content)
+ {
+ Span hash = stackalloc byte[32];
+ SHA256.HashData(content, hash);
+ return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
+ }
+
+ private static string ComputeDigest(byte[] content)
+ => ComputeDigest(content.AsSpan());
+
+ private static string NormalizeDigest(string digest)
+ {
+ if (string.IsNullOrWhiteSpace(digest))
+ {
+ return string.Empty;
+ }
+
+ var trimmed = digest.Trim();
+ return trimmed.Contains(':', StringComparison.Ordinal)
+ ? trimmed
+ : $"sha256:{trimmed}";
+ }
+}
diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs
new file mode 100644
index 000000000..d2e18caf0
--- /dev/null
+++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using StellaOps.Scanner.Core.Contracts;
+using StellaOps.Scanner.EntryTrace;
+using StellaOps.Scanner.EntryTrace.Serialization;
+using StellaOps.Scanner.Surface.FS;
+
+namespace StellaOps.Scanner.Worker.Processing.Surface;
+
+internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private readonly SurfaceManifestPublisher _publisher;
+ private readonly ILogger _logger;
+ private readonly string _componentVersion;
+
+ public SurfaceManifestStageExecutor(
+ SurfaceManifestPublisher publisher,
+ ILogger logger)
+ {
+ _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
+ }
+
+ public string StageName => ScanStageNames.ComposeArtifacts;
+
+ public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var payloads = CollectPayloads(context);
+ if (payloads.Count == 0)
+ {
+ _logger.LogDebug("No surface payloads available for job {JobId}; skipping manifest publish.", context.JobId);
+ return;
+ }
+
+ var request = new SurfaceManifestRequest(
+ ScanId: context.ScanId,
+ ImageDigest: ResolveImageDigest(context),
+ Attempt: context.Lease.Attempt,
+ Metadata: context.Lease.Metadata,
+ Payloads: payloads,
+ Component: "scanner.worker",
+ Version: _componentVersion,
+ WorkerInstance: Environment.MachineName);
+
+ var result = await _publisher.PublishAsync(request, cancellationToken).ConfigureAwait(false);
+ context.Analysis.Set(ScanAnalysisKeys.SurfaceManifest, result);
+ _logger.LogInformation("Surface manifest stored for job {JobId} with digest {Digest}.", context.JobId, result.ManifestDigest);
+ }
+
+ private List CollectPayloads(ScanJobContext context)
+ {
+ var payloads = new List();
+
+ if (context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out var graph) && graph is not null)
+ {
+ var graphJson = EntryTraceGraphSerializer.Serialize(graph);
+ payloads.Add(new SurfaceManifestPayload(
+ ArtifactDocumentType.SurfaceEntryTrace,
+ ArtifactDocumentFormat.EntryTraceGraphJson,
+ Kind: "entrytrace.graph",
+ MediaType: "application/json",
+ Content: Encoding.UTF8.GetBytes(graphJson),
+ Metadata: new Dictionary
+ {
+ ["artifact"] = "entrytrace.graph",
+ ["nodes"] = graph.Nodes.Length.ToString(CultureInfoInvariant),
+ ["edges"] = graph.Edges.Length.ToString(CultureInfoInvariant)
+ }));
+ }
+
+ if (context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceNdjson, out ImmutableArray ndjson) && !ndjson.IsDefaultOrEmpty)
+ {
+ var builder = new StringBuilder();
+ for (var i = 0; i < ndjson.Length; i++)
+ {
+ builder.Append(ndjson[i]);
+ if (!ndjson[i].EndsWith('\n'))
+ {
+ builder.Append('\n');
+ }
+ }
+
+ payloads.Add(new SurfaceManifestPayload(
+ ArtifactDocumentType.SurfaceEntryTrace,
+ ArtifactDocumentFormat.EntryTraceNdjson,
+ Kind: "entrytrace.ndjson",
+ MediaType: "application/x-ndjson",
+ Content: Encoding.UTF8.GetBytes(builder.ToString())));
+ }
+
+ var fragments = context.Analysis.GetLayerFragments();
+ if (!fragments.IsDefaultOrEmpty && fragments.Length > 0)
+ {
+ var fragmentsJson = JsonSerializer.Serialize(fragments, JsonOptions);
+ payloads.Add(new SurfaceManifestPayload(
+ ArtifactDocumentType.SurfaceLayerFragment,
+ ArtifactDocumentFormat.ComponentFragmentJson,
+ Kind: "layer.fragments",
+ MediaType: "application/json",
+ Content: Encoding.UTF8.GetBytes(fragmentsJson),
+ View: "inventory"));
+ }
+
+ return payloads;
+ }
+
+ private static string ResolveImageDigest(ScanJobContext context)
+ {
+ static bool TryGet(IReadOnlyDictionary metadata, string key, out string value)
+ {
+ if (metadata.TryGetValue(key, out var found) && !string.IsNullOrWhiteSpace(found))
+ {
+ value = found.Trim();
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ var metadata = context.Lease.Metadata;
+ if (TryGet(metadata, "image.digest", out var digest) ||
+ TryGet(metadata, "imageDigest", out digest) ||
+ TryGet(metadata, "scanner.image.digest", out digest))
+ {
+ return digest;
+ }
+
+ return context.ScanId;
+ }
+
+ private static readonly IFormatProvider CultureInfoInvariant = System.Globalization.CultureInfo.InvariantCulture;
+}
diff --git a/src/Scanner/StellaOps.Scanner.Worker/Program.cs b/src/Scanner/StellaOps.Scanner.Worker/Program.cs
index be845f993..ad945c71f 100644
--- a/src/Scanner/StellaOps.Scanner.Worker/Program.cs
+++ b/src/Scanner/StellaOps.Scanner.Worker/Program.cs
@@ -18,7 +18,9 @@ using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
+using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Storage.Extensions;
+using StellaOps.Scanner.Storage;
var builder = Host.CreateApplicationBuilder(args);
@@ -52,6 +54,9 @@ var connectionString = storageSection.GetValue("Mongo:ConnectionString")
if (!string.IsNullOrWhiteSpace(connectionString))
{
builder.Services.AddScannerStorage(storageSection);
+ builder.Services.AddSingleton, ScannerStorageSurfaceSecretConfigurator>();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
}
builder.Services.TryAddSingleton();
diff --git a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md
index 9c179643a..40f5b3e38 100644
--- a/src/Scanner/StellaOps.Scanner.Worker/TASKS.md
+++ b/src/Scanner/StellaOps.Scanner.Worker/TASKS.md
@@ -3,7 +3,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCAN-REPLAY-186-002 | TODO | Scanner Worker Guild | REPLAY-CORE-185-001 | Enforce deterministic analyzer execution when consuming replay input bundles, emit layer Merkle metadata, and author `docs/modules/scanner/deterministic-execution.md` summarising invariants from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Replay mode analyzers pass determinism tests; new doc merged; integration fixtures updated. |
-| SCANNER-SURFACE-01 | DOING (2025-11-02) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review. | Integration tests prove cache entries exist; telemetry counters exported. |
+| SCANNER-SURFACE-01 | DOING (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.
2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.
2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence. | Integration tests prove cache entries exist; telemetry counters exported. |
| SCANNER-ENV-01 | TODO (2025-11-06) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.
2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running.
2025-11-05 14:55Z: Extending helper usage into cache/secrets configuration, updating worker validator wiring, and drafting docs/tests for new Surface.Env outputs.
2025-11-06 17:05Z: README/design docs updated with warning catalogue; startup logging guidance captured for ops runbooks.
2025-11-06 07:45Z: Helm/Compose env profiles (dev/stage/prod/airgap/mirror) now seed `SCANNER_SURFACE_*` defaults to keep worker cache roots aligned with Surface.Env helpers.
2025-11-06 07:55Z: Paused; pending automation tracked via `DEVOPS-OPENSSL-11-001/002` and Surface.Env test fixtures. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. |
> 2025-11-05 19:18Z: Bound `SurfaceCacheOptions` root directory to resolved Surface.Env settings and added unit coverage around the configurator.
-| SCANNER-SECRETS-01 | DOING (2025-11-02) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
+| SCANNER-SECRETS-01 | DOING (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.
2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.
2025-11-06: Continuing to replace legacy registry credential plumbing and extend rotation metrics/fixtures.
2025-11-06 21:35Z: Introduced `ScannerStorageSurfaceSecretConfigurator` mapping `cas-access` secrets into storage options plus unit coverage. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs
index 888e1bc62..12470fcb9 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/Core/Internal/LanguageAnalyzerSurfaceCache.cs
@@ -4,6 +4,8 @@ using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.FS;
+public readonly record struct LanguageAnalyzerSurfaceCacheEntry(LanguageAnalyzerResult Result, bool IsHit);
+
public sealed class LanguageAnalyzerSurfaceCache
{
private const string CacheNamespace = "scanner/lang/analyzers";
@@ -24,6 +26,17 @@ public sealed class LanguageAnalyzerSurfaceCache
string fingerprint,
Func> factory,
CancellationToken cancellationToken)
+ {
+ var entry = await GetOrCreateEntryAsync(logger, analyzerId, fingerprint, factory, cancellationToken).ConfigureAwait(false);
+ return entry.Result;
+ }
+
+ public async ValueTask GetOrCreateEntryAsync(
+ ILogger logger,
+ string analyzerId,
+ string fingerprint,
+ Func> factory,
+ CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(factory);
@@ -62,7 +75,7 @@ public sealed class LanguageAnalyzerSurfaceCache
fingerprint);
result = await factory(cancellationToken).ConfigureAwait(false);
- return result;
+ return new LanguageAnalyzerSurfaceCacheEntry(result, false);
}
if (cacheHit)
@@ -82,7 +95,7 @@ public sealed class LanguageAnalyzerSurfaceCache
fingerprint);
}
- return result;
+ return new LanguageAnalyzerSurfaceCacheEntry(result, cacheHit);
}
private static ReadOnlyMemory Serialize(LanguageAnalyzerResult result)
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs
index 5784818c2..74eb9ee69 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Core/Contracts/ScanAnalysisKeys.cs
@@ -15,4 +15,6 @@ public static class ScanAnalysisKeys
public const string EntryTraceGraph = "analysis.entrytrace.graph";
public const string EntryTraceNdjson = "analysis.entrytrace.ndjson";
+
+ public const string SurfaceManifest = "analysis.surface.manifest";
}
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs
index cbe44e3dc..2f05c7989 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Catalog/ArtifactDocument.cs
@@ -2,23 +2,30 @@ using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Scanner.Storage.Catalog;
-public enum ArtifactDocumentType
-{
- LayerBom,
- ImageBom,
- Diff,
- Index,
- Attestation,
-}
-
-public enum ArtifactDocumentFormat
-{
- CycloneDxJson,
- CycloneDxProtobuf,
- SpdxJson,
- BomIndex,
- DsseJson,
-}
+public enum ArtifactDocumentType
+{
+ LayerBom,
+ ImageBom,
+ Diff,
+ Index,
+ Attestation,
+ SurfaceManifest,
+ SurfaceEntryTrace,
+ SurfaceLayerFragment,
+}
+
+public enum ArtifactDocumentFormat
+{
+ CycloneDxJson,
+ CycloneDxProtobuf,
+ SpdxJson,
+ BomIndex,
+ DsseJson,
+ SurfaceManifestJson,
+ EntryTraceNdjson,
+ EntryTraceGraphJson,
+ ComponentFragmentJson,
+}
[BsonIgnoreExtraElements]
public sealed class ArtifactDocument
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs
index 211cfb1a1..95a9e9d9f 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Extensions/ServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@ using System;
using System.Net.Http;
using Amazon;
using Amazon.S3;
+using Amazon.Runtime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -150,14 +151,22 @@ public static class ServiceCollectionExtensions
var options = provider.GetRequiredService>().Value.ObjectStore;
var config = new AmazonS3Config
{
- RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
- ForcePathStyle = options.ForcePathStyle,
- };
-
- if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
- {
- config.ServiceURL = options.ServiceUrl;
- }
+ RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
+ ForcePathStyle = options.ForcePathStyle,
+ };
+
+ if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
+ {
+ config.ServiceURL = options.ServiceUrl;
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
+ {
+ AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
+ ? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
+ : new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
+ return new AmazonS3Client(credentials, config);
+ }
return new AmazonS3Client(config);
}
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs
index 7d88ecead..a1882535b 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ObjectStore/ArtifactObjectKeyBuilder.cs
@@ -33,6 +33,9 @@ public static class ArtifactObjectKeyBuilder
ArtifactDocumentType.ImageBom => ScannerStorageDefaults.ObjectPrefixes.Images,
ArtifactDocumentType.Index => ScannerStorageDefaults.ObjectPrefixes.Indexes,
ArtifactDocumentType.Attestation => ScannerStorageDefaults.ObjectPrefixes.Attestations,
+ ArtifactDocumentType.SurfaceManifest => ScannerStorageDefaults.ObjectPrefixes.SurfaceManifests,
+ ArtifactDocumentType.SurfaceEntryTrace => ScannerStorageDefaults.ObjectPrefixes.SurfaceEntryTrace,
+ ArtifactDocumentType.SurfaceLayerFragment => ScannerStorageDefaults.ObjectPrefixes.SurfaceLayerFragments,
ArtifactDocumentType.Diff => "diffs",
_ => ScannerStorageDefaults.ObjectPrefixes.Images,
};
@@ -44,6 +47,10 @@ public static class ArtifactObjectKeyBuilder
ArtifactDocumentFormat.SpdxJson => "sbom.spdx.json",
ArtifactDocumentFormat.BomIndex => "bom-index.bin",
ArtifactDocumentFormat.DsseJson => "artifact.dsse.json",
+ ArtifactDocumentFormat.SurfaceManifestJson => "surface.manifest.json",
+ ArtifactDocumentFormat.EntryTraceNdjson => "entrytrace.ndjson",
+ ArtifactDocumentFormat.EntryTraceGraphJson => "entrytrace.graph.json",
+ ArtifactDocumentFormat.ComponentFragmentJson => "layer-fragments.json",
_ => "artifact.bin",
};
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs
index 20af262a8..decac0da5 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageDefaults.cs
@@ -26,11 +26,14 @@ public static class ScannerStorageDefaults
public const string Migrations = "schema_migrations";
}
- public static class ObjectPrefixes
- {
- public const string Layers = "layers";
- public const string Images = "images";
- public const string Indexes = "indexes";
- public const string Attestations = "attest";
- }
-}
+ public static class ObjectPrefixes
+ {
+ public const string Layers = "layers";
+ public const string Images = "images";
+ public const string Indexes = "indexes";
+ public const string Attestations = "attest";
+ public const string SurfaceManifests = "surface/manifests";
+ public const string SurfaceEntryTrace = "surface/payloads/entrytrace";
+ public const string SurfaceLayerFragments = "surface/payloads/layer-fragments";
+ }
+}
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs
index 4ef8c7c21..d983f0fa4 100644
--- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/ScannerStorageOptions.cs
@@ -102,6 +102,15 @@ public sealed class ObjectStoreOptions
public TimeSpan? ComplianceRetention { get; set; }
= TimeSpan.FromDays(90);
+ public string? AccessKeyId { get; set; }
+ = null;
+
+ public string? SecretAccessKey { get; set; }
+ = null;
+
+ public string? SessionToken { get; set; }
+ = null;
+
public IDictionary Headers { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
public RustFsOptions RustFs { get; set; } = new();
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs
new file mode 100644
index 000000000..1758f9f84
--- /dev/null
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.FS/SurfaceManifestModels.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Scanner.Surface.FS;
+
+///
+/// Canonical manifest describing surface artefacts produced for a scan.
+///
+public sealed record SurfaceManifestDocument
+{
+ public const string DefaultSchema = "stellaops.surface.manifest@1";
+
+ [JsonPropertyName("schema")]
+ public string Schema { get; init; } = DefaultSchema;
+
+ [JsonPropertyName("tenant")]
+ public string Tenant { get; init; } = string.Empty;
+
+ [JsonPropertyName("imageDigest")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ImageDigest { get; init; }
+ = null;
+
+ [JsonPropertyName("scanId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ScanId { get; init; }
+ = null;
+
+ [JsonPropertyName("generatedAt")]
+ public DateTimeOffset GeneratedAt { get; init; }
+ = DateTimeOffset.UtcNow;
+
+ [JsonPropertyName("source")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SurfaceManifestSource? Source { get; init; }
+ = null;
+
+ [JsonPropertyName("artifacts")]
+ public IReadOnlyList Artifacts { get; init; }
+ = ImmutableArray.Empty;
+}
+
+///
+/// Identifies the producer of the manifest.
+///
+public sealed record SurfaceManifestSource
+{
+ [JsonPropertyName("component")]
+ public string Component { get; init; } = "scanner.worker";
+
+ [JsonPropertyName("version")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Version { get; init; }
+ = null;
+
+ [JsonPropertyName("workerInstance")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? WorkerInstance { get; init; }
+ = null;
+
+ [JsonPropertyName("attempt")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? Attempt { get; init; }
+ = null;
+}
+
+///
+/// Describes a surface artefact referenced by the manifest.
+///
+public sealed record SurfaceManifestArtifact
+{
+ [JsonPropertyName("kind")]
+ public string Kind { get; init; } = string.Empty;
+
+ [JsonPropertyName("uri")]
+ public string Uri { get; init; } = string.Empty;
+
+ [JsonPropertyName("digest")]
+ public string Digest { get; init; } = string.Empty;
+
+ [JsonPropertyName("mediaType")]
+ public string MediaType { get; init; } = string.Empty;
+
+ [JsonPropertyName("format")]
+ public string Format { get; init; } = string.Empty;
+
+ [JsonPropertyName("sizeBytes")]
+ public long SizeBytes { get; init; }
+ = 0;
+
+ [JsonPropertyName("view")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? View { get; init; }
+ = null;
+
+ [JsonPropertyName("storage")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SurfaceManifestStorage? Storage { get; init; }
+ = null;
+
+ [JsonPropertyName("metadata")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public IReadOnlyDictionary? Metadata { get; init; }
+ = null;
+}
+
+///
+/// Storage descriptor for an artefact.
+///
+public sealed record SurfaceManifestStorage
+{
+ [JsonPropertyName("bucket")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Bucket { get; init; }
+ = null;
+
+ [JsonPropertyName("objectKey")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ObjectKey { get; init; }
+ = null;
+
+ [JsonPropertyName("sizeBytes")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public long? SizeBytes { get; init; }
+ = null;
+
+ [JsonPropertyName("contentType")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ContentType { get; init; }
+ = null;
+}
+
+///
+/// Result from publishing a surface manifest.
+///
+public sealed record SurfaceManifestPublishResult(
+ string ManifestDigest,
+ string ManifestUri,
+ string ArtifactId,
+ SurfaceManifestDocument Document);
diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs
new file mode 100644
index 000000000..ee1b9ad8f
--- /dev/null
+++ b/src/Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/CasAccessSecret.cs
@@ -0,0 +1,207 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Text.Json;
+
+namespace StellaOps.Scanner.Surface.Secrets;
+
+public sealed record CasAccessSecret(
+ string Driver,
+ string? Endpoint,
+ string? Region,
+ string? Bucket,
+ string? RootPrefix,
+ string? ApiKey,
+ string? ApiKeyHeader,
+ IReadOnlyDictionary Headers,
+ string? AccessKeyId,
+ string? SecretAccessKey,
+ string? SessionToken,
+ bool? AllowInsecureTls);
+
+public static class SurfaceSecretParser
+{
+ public static CasAccessSecret ParseCasAccessSecret(SurfaceSecretHandle handle)
+ {
+ ArgumentNullException.ThrowIfNull(handle);
+ var payload = handle.AsBytes();
+ if (payload.IsEmpty)
+ {
+ throw new InvalidOperationException("Surface secret payload is empty.");
+ }
+
+ var jsonText = DecodeUtf8(payload);
+ using var document = JsonDocument.Parse(jsonText);
+ var root = document.RootElement;
+
+ string driver = GetString(root, "driver") ?? GetMetadataValue(handle.Metadata, "driver") ?? "s3";
+ string? endpoint = GetString(root, "endpoint") ?? GetMetadataValue(handle.Metadata, "endpoint");
+ string? region = GetString(root, "region") ?? GetMetadataValue(handle.Metadata, "region");
+ string? bucket = GetString(root, "bucket") ?? GetMetadataValue(handle.Metadata, "bucket");
+ string? rootPrefix = GetString(root, "rootPrefix") ?? GetMetadataValue(handle.Metadata, "rootPrefix");
+ string? apiKey = GetString(root, "apiKey") ?? GetMetadataValue(handle.Metadata, "apiKey");
+ string? apiKeyHeader = GetString(root, "apiKeyHeader") ?? GetMetadataValue(handle.Metadata, "apiKeyHeader");
+ string? accessKeyId = GetString(root, "accessKeyId") ?? GetMetadataValue(handle.Metadata, "accessKeyId");
+ string? secretAccessKey = GetString(root, "secretAccessKey") ?? GetMetadataValue(handle.Metadata, "secretAccessKey");
+ string? sessionToken = GetString(root, "sessionToken") ?? GetMetadataValue(handle.Metadata, "sessionToken");
+ bool? allowInsecureTls = GetBoolean(root, "allowInsecureTls");
+
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ PopulateHeaders(root, headers);
+ PopulateMetadataHeaders(handle.Metadata, headers);
+
+ return new CasAccessSecret(
+ driver.Trim(),
+ endpoint?.Trim(),
+ region?.Trim(),
+ bucket?.Trim(),
+ rootPrefix?.Trim(),
+ apiKey?.Trim(),
+ apiKeyHeader?.Trim(),
+ new ReadOnlyDictionary(headers),
+ accessKeyId?.Trim(),
+ secretAccessKey?.Trim(),
+ sessionToken?.Trim(),
+ allowInsecureTls);
+ }
+
+ private static string DecodeUtf8(ReadOnlyMemory payload)
+ {
+ if (payload.IsEmpty)
+ {
+ return string.Empty;
+ }
+
+ try
+ {
+ return Encoding.UTF8.GetString(payload.Span);
+ }
+ catch (DecoderFallbackException ex)
+ {
+ throw new InvalidOperationException("Surface secret payload is not valid UTF-8 JSON.", ex);
+ }
+ }
+
+ private static string? GetString(JsonElement element, string propertyName)
+ {
+ if (TryGetPropertyIgnoreCase(element, propertyName, out var property))
+ {
+ return property.ValueKind switch
+ {
+ JsonValueKind.String => property.GetString(),
+ JsonValueKind.Number => property.GetRawText(),
+ JsonValueKind.True => bool.TrueString,
+ JsonValueKind.False => bool.FalseString,
+ _ => null
+ };
+ }
+
+ return null;
+ }
+
+ private static bool? GetBoolean(JsonElement element, string propertyName)
+ {
+ if (TryGetPropertyIgnoreCase(element, propertyName, out var property))
+ {
+ return property.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
+ _ => null
+ };
+ }
+
+ return null;
+ }
+
+ private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement property)
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ property = default;
+ return false;
+ }
+
+ if (element.TryGetProperty(propertyName, out property))
+ {
+ return true;
+ }
+
+ foreach (var candidate in element.EnumerateObject())
+ {
+ if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ property = candidate.Value;
+ return true;
+ }
+ }
+
+ property = default;
+ return false;
+ }
+
+ private static void PopulateHeaders(JsonElement element, IDictionary headers)
+ {
+ if (!TryGetPropertyIgnoreCase(element, "headers", out var headersElement))
+ {
+ return;
+ }
+
+ if (headersElement.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ foreach (var property in headersElement.EnumerateObject())
+ {
+ var value = property.Value.ValueKind switch
+ {
+ JsonValueKind.String => property.Value.GetString(),
+ JsonValueKind.Number => property.Value.GetRawText(),
+ JsonValueKind.True => bool.TrueString,
+ JsonValueKind.False => bool.FalseString,
+ _ => null
+ };
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ headers[property.Name] = value.Trim();
+ }
+ }
+
+ private static void PopulateMetadataHeaders(IReadOnlyDictionary metadata, IDictionary headers)
+ {
+ foreach (var (key, value) in metadata)
+ {
+ if (!key.StartsWith("header:", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var headerName = key["header:".Length..];
+ if (string.IsNullOrWhiteSpace(headerName) || string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ headers[headerName] = value;
+ }
+ }
+
+ private static string? GetMetadataValue(IReadOnlyDictionary metadata, string key)
+ {
+ foreach (var (metadataKey, metadataValue) in metadata)
+ {
+ if (string.Equals(metadataKey, key, StringComparison.OrdinalIgnoreCase))
+ {
+ return metadataValue;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs
new file mode 100644
index 000000000..f7afa336b
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.Surface.Secrets.Tests/CasAccessSecretParserTests.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Text;
+using StellaOps.Scanner.Surface.Secrets;
+using Xunit;
+
+namespace StellaOps.Scanner.Surface.Secrets.Tests;
+
+public sealed class CasAccessSecretParserTests
+{
+ [Fact]
+ public void ParseCasAccessSecret_WithRustFsPayload_ReturnsExpectedValues()
+ {
+ const string json = """
+ {
+ "driver": "rustfs",
+ "endpoint": "https://surface.test.local",
+ "region": "us-gov-west-1",
+ "bucket": "stellaops-surface",
+ "rootPrefix": "scanner",
+ "apiKey": "secret-api-key",
+ "apiKeyHeader": "X-Api-Key",
+ "allowInsecureTls": true,
+ "headers": {
+ "X-Surface-Tenant": "tenant-a"
+ }
+ }
+ """;
+
+ using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json));
+ var secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
+
+ Assert.Equal("rustfs", secret.Driver);
+ Assert.Equal("https://surface.test.local", secret.Endpoint);
+ Assert.Equal("us-gov-west-1", secret.Region);
+ Assert.Equal("stellaops-surface", secret.Bucket);
+ Assert.Equal("scanner", secret.RootPrefix);
+ Assert.Equal("secret-api-key", secret.ApiKey);
+ Assert.Equal("X-Api-Key", secret.ApiKeyHeader);
+ Assert.True(secret.AllowInsecureTls);
+ Assert.Single(secret.Headers);
+ Assert.Equal("tenant-a", secret.Headers["X-Surface-Tenant"]);
+ }
+
+ [Fact]
+ public void ParseCasAccessSecret_UsesMetadataFallback_WhenFieldsMissing()
+ {
+ const string json = @"{ ""driver"": ""s3"" }";
+ var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["endpoint"] = "https://s3.test.local",
+ ["accessKeyId"] = "AKIA123",
+ ["secretAccessKey"] = "s3-secret",
+ ["header:X-Custom"] = "value"
+ };
+
+ using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json), metadata);
+ var secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
+
+ Assert.Equal("s3", secret.Driver);
+ Assert.Equal("https://s3.test.local", secret.Endpoint);
+ Assert.Equal("AKIA123", secret.AccessKeyId);
+ Assert.Equal("s3-secret", secret.SecretAccessKey);
+ Assert.Equal("value", secret.Headers["X-Custom"]);
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs
new file mode 100644
index 000000000..1c2d8797e
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerSurfaceSecretConfiguratorTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using StellaOps.Scanner.Surface.Env;
+using StellaOps.Scanner.Surface.Secrets;
+using StellaOps.Scanner.WebService.Options;
+using StellaOps.Scanner.Storage;
+using Xunit;
+
+namespace StellaOps.Scanner.WebService.Tests;
+
+public sealed class ScannerSurfaceSecretConfiguratorTests
+{
+ [Fact]
+ public void Configure_AppliesCasAccessSecretToArtifactStore()
+ {
+ const string json = """
+ {
+ "driver": "rustfs",
+ "endpoint": "https://surface.api",
+ "bucket": "surface-artifacts",
+ "apiKey": "rust-key",
+ "apiKeyHeader": "X-Surface-Api-Key",
+ "region": "ap-southeast-2"
+ }
+ """;
+
+ using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json));
+ var secretProvider = new StubSecretProvider(handle);
+ var environment = new StubSurfaceEnvironment();
+ var options = new ScannerWebServiceOptions();
+
+ var configurator = new ScannerSurfaceSecretConfigurator(
+ secretProvider,
+ environment,
+ NullLogger.Instance);
+
+ configurator.Configure(options);
+
+ Assert.Equal("rustfs", options.ArtifactStore.Driver);
+ Assert.Equal("https://surface.api", options.ArtifactStore.Endpoint);
+ Assert.Equal("surface-artifacts", options.ArtifactStore.Bucket);
+ Assert.Equal("rust-key", options.ArtifactStore.ApiKey);
+ Assert.Equal("X-Surface-Api-Key", options.ArtifactStore.ApiKeyHeader);
+ Assert.Equal("ap-southeast-2", options.ArtifactStore.Region);
+ }
+
+ [Fact]
+ public void PostConfigure_SynchronizesArtifactStoreToScannerStorageOptions()
+ {
+ var webOptions = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions
+ {
+ ArtifactStore = new ScannerWebServiceOptions.ArtifactStoreOptions
+ {
+ Driver = "rustfs",
+ Endpoint = "https://surface.sync",
+ ApiKey = "sync-key",
+ ApiKeyHeader = "X-Sync",
+ Bucket = "sync-bucket",
+ Region = "us-west-2",
+ RootPrefix = "sync"
+ }
+ });
+
+ var configurator = new ScannerStorageOptionsPostConfigurator(
+ new OptionsMonitorStub(webOptions),
+ NullLogger.Instance);
+
+ var storageOptions = new ScannerStorageOptions();
+ configurator.PostConfigure(Microsoft.Extensions.Options.Options.DefaultName, storageOptions);
+
+ Assert.Equal("rustfs", storageOptions.ObjectStore.Driver);
+ Assert.Equal("https://surface.sync", storageOptions.ObjectStore.RustFs.BaseUrl);
+ Assert.Equal("sync-bucket", storageOptions.ObjectStore.BucketName);
+ Assert.Equal("sync", storageOptions.ObjectStore.RootPrefix);
+ Assert.Equal("us-west-2", storageOptions.ObjectStore.Region);
+ Assert.Equal("sync-key", storageOptions.ObjectStore.RustFs.ApiKey);
+ Assert.Equal("X-Sync", storageOptions.ObjectStore.RustFs.ApiKeyHeader);
+ }
+
+ private sealed class StubSecretProvider : ISurfaceSecretProvider
+ {
+ private readonly SurfaceSecretHandle _handle;
+
+ public StubSecretProvider(SurfaceSecretHandle handle)
+ {
+ _handle = handle;
+ }
+
+ public ValueTask GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(_handle);
+ }
+
+ private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
+ {
+ public StubSurfaceEnvironment()
+ {
+ Settings = new SurfaceEnvironmentSettings(
+ new Uri("https://surface"),
+ "bucket",
+ "region",
+ new DirectoryInfo(Path.GetTempPath()),
+ 256,
+ false,
+ Array.Empty(),
+ new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, true),
+ "tenant",
+ new SurfaceTlsConfiguration(null, null, null));
+ RawVariables = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ public SurfaceEnvironmentSettings Settings { get; }
+
+ public IReadOnlyDictionary RawVariables { get; }
+ }
+
+ private sealed class OptionsMonitorStub : IOptionsMonitor where T : class
+ {
+ private readonly IOptions _options;
+
+ public OptionsMonitorStub(IOptions options)
+ {
+ _options = options;
+ }
+
+ public T CurrentValue => _options.Value;
+
+ public T Get(string? name) => _options.Value;
+
+ public IDisposable OnChange(Action listener) => NullDisposable.Instance;
+
+ private sealed class NullDisposable : IDisposable
+ {
+ public static readonly NullDisposable Instance = new();
+ public void Dispose() { }
+ }
+ }
+}
diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs
new file mode 100644
index 000000000..74957c29b
--- /dev/null
+++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/ScannerStorageSurfaceSecretConfiguratorTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading;
+using Microsoft.Extensions.Logging.Abstractions;
+using StellaOps.Scanner.Storage;
+using StellaOps.Scanner.Surface.Env;
+using StellaOps.Scanner.Surface.Secrets;
+using StellaOps.Scanner.Worker.Options;
+using Xunit;
+
+namespace StellaOps.Scanner.Worker.Tests;
+
+public sealed class ScannerStorageSurfaceSecretConfiguratorTests
+{
+ [Fact]
+ public void Configure_WithCasAccessSecret_AppliesSettings()
+ {
+ const string json = """
+ {
+ "driver": "rustfs",
+ "endpoint": "https://surface.example",
+ "region": "eu-central-1",
+ "bucket": "surface-bucket",
+ "rootPrefix": "scanner",
+ "apiKey": "rustfs-api",
+ "apiKeyHeader": "X-Rustfs-Key",
+ "allowInsecureTls": false
+ }
+ """;
+
+ using var handle = SurfaceSecretHandle.FromBytes(Encoding.UTF8.GetBytes(json));
+ var secretProvider = new StubSecretProvider(handle);
+ var environment = new StubSurfaceEnvironment("tenant-eu");
+
+ var configurator = new ScannerStorageSurfaceSecretConfigurator(
+ secretProvider,
+ environment,
+ NullLogger.Instance);
+
+ var options = new ScannerStorageOptions();
+ configurator.Configure(options);
+
+ Assert.Equal("rustfs", options.ObjectStore.Driver);
+ Assert.Equal("https://surface.example", options.ObjectStore.RustFs.BaseUrl);
+ Assert.Equal("eu-central-1", options.ObjectStore.Region);
+ Assert.Equal("surface-bucket", options.ObjectStore.BucketName);
+ Assert.Equal("scanner", options.ObjectStore.RootPrefix);
+ Assert.Equal("rustfs-api", options.ObjectStore.RustFs.ApiKey);
+ Assert.Equal("X-Rustfs-Key", options.ObjectStore.RustFs.ApiKeyHeader);
+ }
+
+ private sealed class StubSecretProvider : ISurfaceSecretProvider
+ {
+ private readonly SurfaceSecretHandle _handle;
+
+ public StubSecretProvider(SurfaceSecretHandle handle)
+ {
+ _handle = handle;
+ }
+
+ public ValueTask GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult(_handle);
+ }
+
+ private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
+ {
+ public StubSurfaceEnvironment(string tenant)
+ {
+ Settings = new SurfaceEnvironmentSettings(
+ new Uri("https://surface"),
+ "bucket",
+ "region-1",
+ new DirectoryInfo(Path.GetTempPath()),
+ 1024,
+ false,
+ Array.Empty(),
+ new SurfaceSecretsConfiguration("inline", tenant, null, null, null, true),
+ tenant,
+ new SurfaceTlsConfiguration(null, null, null))
+ {
+ CreatedAtUtc = DateTimeOffset.UtcNow
+ };
+ RawVariables = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ public SurfaceEnvironmentSettings Settings { get; }
+
+ public IReadOnlyDictionary RawVariables { get; }
+ }
+}