ui progressing

This commit is contained in:
master
2026-02-20 23:32:20 +02:00
parent ca5e7888d6
commit 1ec797d5e8
191 changed files with 32771 additions and 6504 deletions

View File

@@ -460,7 +460,7 @@ services:
environment:
ASPNETCORE_URLS: "http://+:8080"
<<: *kestrel-cert
TILE_PROXY__tile_proxy__UpstreamUrl: "http://rekor.stella-ops.local:3322"
TILE_PROXY__tile_proxy__UpstreamUrl: "https://rekor.sigstore.dev"
TILE_PROXY__tile_proxy__Origin: "stellaops-tileproxy"
TILE_PROXY__tile_proxy__Cache__BasePath: "/var/cache/stellaops/tiles"
TILE_PROXY__tile_proxy__Cache__MaxSizeGb: "1"
@@ -1922,6 +1922,12 @@ services:
stellaops:
aliases:
- zastava-webhook.stella-ops.local
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/8080'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels: *release-labels
# --- Slot 44: Signals ------------------------------------------------------

View File

@@ -12,3 +12,6 @@ CREATE SCHEMA IF NOT EXISTS findings;
CREATE SCHEMA IF NOT EXISTS timeline;
CREATE SCHEMA IF NOT EXISTS doctor;
CREATE SCHEMA IF NOT EXISTS issuer_directory;
CREATE SCHEMA IF NOT EXISTS analytics;
CREATE SCHEMA IF NOT EXISTS scheduler_app;
CREATE SCHEMA IF NOT EXISTS findings_ledger_app;

View File

@@ -0,0 +1,565 @@
-- Findings Ledger: Consolidated init from migrations 001-009
-- Auto-generated for docker-compose postgres-init
-- Creates all tables required by stellaops-findings-ledger-web
-- ============================================================================
-- 001_initial.sql - Bootstrap schema (LEDGER-29-001)
-- ============================================================================
BEGIN;
CREATE TYPE ledger_event_type AS ENUM (
'finding.created',
'finding.status_changed',
'finding.severity_changed',
'finding.tag_updated',
'finding.comment_added',
'finding.assignment_changed',
'finding.accepted_risk',
'finding.remediation_plan_added',
'finding.attachment_added',
'finding.closed'
);
CREATE TYPE ledger_action_type AS ENUM (
'assign',
'comment',
'attach_evidence',
'link_ticket',
'remediation_plan',
'status_change',
'accept_risk',
'reopen',
'close'
);
CREATE TABLE ledger_events (
tenant_id TEXT NOT NULL,
chain_id UUID NOT NULL,
sequence_no BIGINT NOT NULL,
event_id UUID NOT NULL,
event_type ledger_event_type NOT NULL,
policy_version TEXT NOT NULL,
finding_id TEXT NOT NULL,
artifact_id TEXT NOT NULL,
source_run_id UUID,
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_body JSONB NOT NULL,
event_hash CHAR(64) NOT NULL,
previous_hash CHAR(64) NOT NULL,
merkle_leaf_hash CHAR(64) NOT NULL,
CONSTRAINT pk_ledger_events PRIMARY KEY (tenant_id, chain_id, sequence_no),
CONSTRAINT uq_ledger_events_event_id UNIQUE (tenant_id, event_id),
CONSTRAINT uq_ledger_events_chain_hash UNIQUE (tenant_id, chain_id, event_hash),
CONSTRAINT ck_ledger_events_event_hash_hex CHECK (event_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_ledger_events_previous_hash_hex CHECK (previous_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_ledger_events_leaf_hash_hex CHECK (merkle_leaf_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT ck_ledger_events_actor_type CHECK (actor_type IN ('system', 'operator', 'integration'))
) PARTITION BY LIST (tenant_id);
CREATE TABLE ledger_events_default PARTITION OF ledger_events DEFAULT;
CREATE INDEX ix_ledger_events_finding ON ledger_events (tenant_id, finding_id, policy_version);
CREATE INDEX ix_ledger_events_type ON ledger_events (tenant_id, event_type, recorded_at DESC);
CREATE INDEX ix_ledger_events_recorded_at ON ledger_events (tenant_id, recorded_at DESC);
CREATE TABLE ledger_merkle_roots (
tenant_id TEXT NOT NULL,
anchor_id UUID NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
sequence_start BIGINT NOT NULL,
sequence_end BIGINT NOT NULL,
root_hash CHAR(64) NOT NULL,
leaf_count INTEGER NOT NULL,
anchored_at TIMESTAMPTZ NOT NULL,
anchor_reference TEXT,
CONSTRAINT pk_ledger_merkle_roots PRIMARY KEY (tenant_id, anchor_id),
CONSTRAINT uq_ledger_merkle_root_hash UNIQUE (tenant_id, root_hash),
CONSTRAINT ck_ledger_merkle_root_hash_hex CHECK (root_hash ~ '^[0-9a-f]{64}$')
) PARTITION BY LIST (tenant_id);
CREATE TABLE ledger_merkle_roots_default PARTITION OF ledger_merkle_roots DEFAULT;
CREATE INDEX ix_merkle_sequences ON ledger_merkle_roots (tenant_id, sequence_end DESC);
CREATE TABLE findings_projection (
tenant_id TEXT NOT NULL,
finding_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
status TEXT NOT NULL,
severity NUMERIC(6,3),
labels JSONB NOT NULL DEFAULT '{}'::JSONB,
current_event_id UUID NOT NULL,
explain_ref TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cycle_hash CHAR(64) NOT NULL,
CONSTRAINT pk_findings_projection PRIMARY KEY (tenant_id, finding_id, policy_version),
CONSTRAINT ck_findings_projection_cycle_hash_hex CHECK (cycle_hash ~ '^[0-9a-f]{64}$')
) PARTITION BY LIST (tenant_id);
CREATE TABLE findings_projection_default PARTITION OF findings_projection DEFAULT;
CREATE INDEX ix_projection_status ON findings_projection (tenant_id, status, severity DESC);
CREATE INDEX ix_projection_labels_gin ON findings_projection USING GIN (labels JSONB_PATH_OPS);
CREATE TABLE finding_history (
tenant_id TEXT NOT NULL,
finding_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
event_id UUID NOT NULL,
status TEXT NOT NULL,
severity NUMERIC(6,3),
actor_id TEXT NOT NULL,
comment TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
CONSTRAINT pk_finding_history PRIMARY KEY (tenant_id, finding_id, event_id)
) PARTITION BY LIST (tenant_id);
CREATE TABLE finding_history_default PARTITION OF finding_history DEFAULT;
CREATE INDEX ix_finding_history_timeline ON finding_history (tenant_id, finding_id, occurred_at DESC);
CREATE TABLE triage_actions (
tenant_id TEXT NOT NULL,
action_id UUID NOT NULL,
event_id UUID NOT NULL,
finding_id TEXT NOT NULL,
action_type ledger_action_type NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
CONSTRAINT pk_triage_actions PRIMARY KEY (tenant_id, action_id)
) PARTITION BY LIST (tenant_id);
CREATE TABLE triage_actions_default PARTITION OF triage_actions DEFAULT;
CREATE INDEX ix_triage_actions_event ON triage_actions (tenant_id, event_id);
CREATE INDEX ix_triage_actions_created_at ON triage_actions (tenant_id, created_at DESC);
COMMIT;
-- ============================================================================
-- 002_projection_offsets.sql - Projection worker checkpoints (LEDGER-29-003)
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_projection_offsets (
worker_id TEXT NOT NULL PRIMARY KEY,
last_recorded_at TIMESTAMPTZ NOT NULL,
last_event_id UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
INSERT INTO ledger_projection_offsets (worker_id, last_recorded_at, last_event_id, updated_at)
VALUES (
'default',
'1970-01-01T00:00:00Z',
'00000000-0000-0000-0000-000000000000',
NOW())
ON CONFLICT (worker_id) DO NOTHING;
COMMIT;
-- ============================================================================
-- 002_add_evidence_bundle_ref.sql - Evidence bundle references (LEDGER-OBS-53-001)
-- ============================================================================
ALTER TABLE ledger_events
ADD COLUMN evidence_bundle_ref text NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref
ON ledger_events (tenant_id, finding_id, recorded_at DESC)
WHERE evidence_bundle_ref IS NOT NULL;
-- ============================================================================
-- 003_policy_rationale.sql - Policy rationale column (LEDGER-29-004)
-- ============================================================================
BEGIN;
ALTER TABLE findings_projection
ADD COLUMN IF NOT EXISTS policy_rationale JSONB NOT NULL DEFAULT '[]'::JSONB;
ALTER TABLE findings_projection
ALTER COLUMN policy_rationale SET DEFAULT '[]'::JSONB;
UPDATE findings_projection
SET policy_rationale = '[]'::JSONB
WHERE policy_rationale IS NULL;
COMMIT;
-- ============================================================================
-- 004_ledger_attestations.sql - Attestation verification exports (LEDGER-OBS-54-001)
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_attestations (
tenant_id text NOT NULL,
attestation_id uuid NOT NULL,
artifact_id text NOT NULL,
finding_id text NULL,
verification_status text NOT NULL,
verification_time timestamptz NOT NULL,
dsse_digest text NOT NULL,
rekor_entry_id text NULL,
evidence_bundle_ref text NULL,
ledger_event_id uuid NOT NULL,
recorded_at timestamptz NOT NULL,
merkle_leaf_hash text NOT NULL,
root_hash text NOT NULL,
cycle_hash text NOT NULL,
projection_version text NOT NULL
);
ALTER TABLE ledger_attestations
ADD CONSTRAINT pk_ledger_attestations PRIMARY KEY (tenant_id, attestation_id);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_recorded
ON ledger_attestations (tenant_id, recorded_at, attestation_id);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_artifact
ON ledger_attestations (tenant_id, artifact_id, recorded_at DESC);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_finding
ON ledger_attestations (tenant_id, finding_id, recorded_at DESC)
WHERE finding_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_status
ON ledger_attestations (tenant_id, verification_status, recorded_at DESC);
COMMIT;
-- ============================================================================
-- 004_risk_fields.sql - Risk scoring fields (LEDGER-RISK-66-001/002)
-- ============================================================================
BEGIN;
ALTER TABLE findings_projection
ADD COLUMN IF NOT EXISTS risk_score NUMERIC(6,3),
ADD COLUMN IF NOT EXISTS risk_severity TEXT,
ADD COLUMN IF NOT EXISTS risk_profile_version TEXT,
ADD COLUMN IF NOT EXISTS risk_explanation_id UUID,
ADD COLUMN IF NOT EXISTS risk_event_sequence BIGINT;
CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings_projection (tenant_id, risk_severity, risk_score DESC);
COMMIT;
-- ============================================================================
-- 005_risk_fields.sql - Risk scoring fields idempotent re-add (LEDGER-RISK-66-001)
-- ============================================================================
BEGIN;
ALTER TABLE findings_projection
ADD COLUMN IF NOT EXISTS risk_score numeric(6,2) NULL,
ADD COLUMN IF NOT EXISTS risk_severity text NULL,
ADD COLUMN IF NOT EXISTS risk_profile_version text NULL,
ADD COLUMN IF NOT EXISTS risk_explanation_id text NULL,
ADD COLUMN IF NOT EXISTS risk_event_sequence bigint NULL;
CREATE INDEX IF NOT EXISTS ix_findings_projection_risk
ON findings_projection (tenant_id, risk_severity, risk_score DESC);
COMMIT;
-- ============================================================================
-- 006_orchestrator_airgap.sql - Export and import provenance (LEDGER-34-101, LEDGER-AIRGAP-56-001)
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS orchestrator_exports
(
tenant_id TEXT NOT NULL,
run_id UUID NOT NULL,
job_type TEXT NOT NULL,
artifact_hash TEXT NOT NULL,
policy_hash TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL,
manifest_path TEXT,
logs_path TEXT,
merkle_root CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant_id, run_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact_run
ON orchestrator_exports (tenant_id, artifact_hash, run_id);
CREATE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact
ON orchestrator_exports (tenant_id, artifact_hash);
CREATE TABLE IF NOT EXISTS airgap_imports
(
tenant_id TEXT NOT NULL,
bundle_id TEXT NOT NULL,
mirror_generation TEXT,
merkle_root TEXT NOT NULL,
time_anchor TIMESTAMPTZ NOT NULL,
publisher TEXT,
hash_algorithm TEXT,
contents JSONB,
imported_at TIMESTAMPTZ NOT NULL,
import_operator TEXT,
ledger_event_id UUID,
PRIMARY KEY (tenant_id, bundle_id, time_anchor)
);
CREATE INDEX IF NOT EXISTS ix_airgap_imports_bundle
ON airgap_imports (tenant_id, bundle_id);
CREATE INDEX IF NOT EXISTS ix_airgap_imports_event
ON airgap_imports (tenant_id, ledger_event_id);
COMMIT;
-- ============================================================================
-- 007_enable_rls.sql - Row-Level Security (LEDGER-TEN-48-001-DEV)
-- ============================================================================
BEGIN;
CREATE SCHEMA IF NOT EXISTS findings_ledger_app;
CREATE OR REPLACE FUNCTION findings_ledger_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
tenant_text TEXT;
BEGIN
tenant_text := current_setting('app.current_tenant', true);
IF tenant_text IS NULL OR length(trim(tenant_text)) = 0 THEN
RAISE EXCEPTION 'app.current_tenant is not set for the current session'
USING ERRCODE = 'P0001';
END IF;
RETURN tenant_text;
END;
$$;
COMMENT ON FUNCTION findings_ledger_app.require_current_tenant() IS
'Returns the current tenant ID from session variable, raises exception if not set';
ALTER TABLE ledger_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_events FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
CREATE POLICY ledger_events_tenant_isolation
ON ledger_events
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_merkle_roots FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
CREATE POLICY ledger_merkle_roots_tenant_isolation
ON ledger_merkle_roots
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE findings_projection ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings_projection FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
CREATE POLICY findings_projection_tenant_isolation
ON findings_projection
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE finding_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE finding_history FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
CREATE POLICY finding_history_tenant_isolation
ON finding_history
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE triage_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE triage_actions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
CREATE POLICY triage_actions_tenant_isolation
ON triage_actions
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE ledger_attestations ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
CREATE POLICY ledger_attestations_tenant_isolation
ON ledger_attestations
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE orchestrator_exports ENABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
CREATE POLICY orchestrator_exports_tenant_isolation
ON orchestrator_exports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
ALTER TABLE airgap_imports ENABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
CREATE POLICY airgap_imports_tenant_isolation
ON airgap_imports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'findings_ledger_admin') THEN
CREATE ROLE findings_ledger_admin NOLOGIN BYPASSRLS;
END IF;
END;
$$;
COMMENT ON ROLE findings_ledger_admin IS
'Admin role that bypasses RLS for migrations and cross-tenant operations';
COMMIT;
-- ============================================================================
-- 008_attestation_pointers.sql - Finding-to-attestation pointers (LEDGER-ATTEST-73-001)
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
tenant_id text NOT NULL,
pointer_id uuid NOT NULL,
finding_id text NOT NULL,
attestation_type text NOT NULL,
relationship text NOT NULL,
attestation_ref jsonb NOT NULL,
verification_result jsonb NULL,
created_at timestamptz NOT NULL,
created_by text NOT NULL,
metadata jsonb NULL,
ledger_event_id uuid NULL
);
ALTER TABLE ledger_attestation_pointers
ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id);
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding
ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type
ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified
ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
WHERE verification_result IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer
ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
WHERE attestation_ref->'signer_info' IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
WHERE attestation_ref->>'predicate_type' IS NOT NULL;
ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers;
CREATE POLICY ledger_attestation_pointers_tenant_isolation
ON ledger_attestation_pointers
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
COMMENT ON TABLE ledger_attestation_pointers IS
'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)';
COMMIT;
-- ============================================================================
-- 009_snapshots.sql - Ledger snapshots for time-travel
-- ============================================================================
CREATE TABLE IF NOT EXISTS ledger_snapshots (
tenant_id TEXT NOT NULL,
snapshot_id UUID NOT NULL,
label TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'Creating',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
sequence_number BIGINT NOT NULL,
snapshot_timestamp TIMESTAMPTZ NOT NULL,
findings_count BIGINT NOT NULL DEFAULT 0,
vex_statements_count BIGINT NOT NULL DEFAULT 0,
advisories_count BIGINT NOT NULL DEFAULT 0,
sboms_count BIGINT NOT NULL DEFAULT 0,
events_count BIGINT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
merkle_root TEXT,
dsse_digest TEXT,
metadata JSONB,
include_entity_types JSONB,
sign_requested BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tenant_id, snapshot_id)
);
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status
ON ledger_snapshots (tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires
ON ledger_snapshots (expires_at)
WHERE expires_at IS NOT NULL AND status = 'Available';
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence
ON ledger_snapshots (tenant_id, sequence_number);
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label
ON ledger_snapshots (tenant_id, label)
WHERE label IS NOT NULL;
ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'ledger_snapshots'
AND policyname = 'ledger_snapshots_tenant_isolation'
) THEN
CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
END IF;
END $$;
COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';

View File

@@ -0,0 +1,690 @@
-- Scheduler: Consolidated init from migrations 001-003
-- Auto-generated for docker-compose postgres-init
-- Creates all tables required by stellaops-scheduler-worker
-- ============================================================================
-- 001_initial_schema.sql - Complete scheduler schema
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS scheduler;
CREATE SCHEMA IF NOT EXISTS scheduler_app;
-- Enum types
DO $$ BEGIN
CREATE TYPE scheduler.job_status AS ENUM (
'pending', 'scheduled', 'leased', 'running',
'succeeded', 'failed', 'canceled', 'timed_out'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN
CREATE TYPE scheduler.graph_job_type AS ENUM ('build', 'overlay');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE scheduler.graph_job_status AS ENUM ('pending', 'queued', 'running', 'completed', 'failed', 'canceled');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE scheduler.run_state AS ENUM ('planning','queued','running','completed','error','cancelled');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE scheduler.policy_run_status AS ENUM ('pending','submitted','retrying','failed','completed','cancelled');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Helper functions
CREATE OR REPLACE FUNCTION scheduler.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION scheduler_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
DECLARE
v_tenant TEXT;
BEGIN
v_tenant := current_setting('app.tenant_id', true);
IF v_tenant IS NULL OR v_tenant = '' THEN
RAISE EXCEPTION 'app.tenant_id session variable not set'
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
ERRCODE = 'P0001';
END IF;
RETURN v_tenant;
END;
$$;
REVOKE ALL ON FUNCTION scheduler_app.require_current_tenant() FROM PUBLIC;
-- Core tables: jobs
CREATE TABLE IF NOT EXISTS scheduler.jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
project_id TEXT,
job_type TEXT NOT NULL,
status scheduler.job_status NOT NULL DEFAULT 'pending',
priority INT NOT NULL DEFAULT 0,
payload JSONB NOT NULL DEFAULT '{}',
payload_digest TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
correlation_id TEXT,
attempt INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
lease_id UUID,
worker_id TEXT,
lease_until TIMESTAMPTZ,
not_before TIMESTAMPTZ,
reason TEXT,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
scheduled_at TIMESTAMPTZ,
leased_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_by TEXT,
UNIQUE(tenant_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idx_jobs_tenant_status ON scheduler.jobs(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_jobs_tenant_type ON scheduler.jobs(tenant_id, job_type);
CREATE INDEX IF NOT EXISTS idx_jobs_scheduled ON scheduler.jobs(tenant_id, status, not_before, priority DESC, created_at)
WHERE status = 'scheduled';
CREATE INDEX IF NOT EXISTS idx_jobs_leased ON scheduler.jobs(tenant_id, status, lease_until)
WHERE status = 'leased';
CREATE INDEX IF NOT EXISTS idx_jobs_project ON scheduler.jobs(tenant_id, project_id);
CREATE INDEX IF NOT EXISTS idx_jobs_correlation ON scheduler.jobs(correlation_id);
-- Triggers table (cron-based job triggers)
CREATE TABLE IF NOT EXISTS scheduler.triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
job_type TEXT NOT NULL,
job_payload JSONB NOT NULL DEFAULT '{}',
cron_expression TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
next_fire_at TIMESTAMPTZ,
last_fire_at TIMESTAMPTZ,
last_job_id UUID REFERENCES scheduler.jobs(id),
fire_count BIGINT NOT NULL DEFAULT 0,
misfire_count INT NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_triggers_tenant_id ON scheduler.triggers(tenant_id);
CREATE INDEX IF NOT EXISTS idx_triggers_next_fire ON scheduler.triggers(enabled, next_fire_at) WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_triggers_job_type ON scheduler.triggers(tenant_id, job_type);
CREATE TRIGGER trg_triggers_updated_at
BEFORE UPDATE ON scheduler.triggers
FOR EACH ROW EXECUTE FUNCTION scheduler.update_updated_at();
-- Workers table (global, NOT RLS-protected)
CREATE TABLE IF NOT EXISTS scheduler.workers (
id TEXT PRIMARY KEY,
tenant_id TEXT,
hostname TEXT NOT NULL,
process_id INT,
job_types TEXT[] NOT NULL DEFAULT '{}',
max_concurrent_jobs INT NOT NULL DEFAULT 1,
current_jobs INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'draining', 'stopped')),
last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_workers_status ON scheduler.workers(status);
CREATE INDEX IF NOT EXISTS idx_workers_heartbeat ON scheduler.workers(last_heartbeat_at);
CREATE INDEX IF NOT EXISTS idx_workers_tenant ON scheduler.workers(tenant_id);
COMMENT ON TABLE scheduler.workers IS 'Global worker registry. Not RLS-protected - workers serve all tenants.';
-- Distributed locks
CREATE TABLE IF NOT EXISTS scheduler.locks (
lock_key TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
holder_id TEXT NOT NULL,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_locks_tenant ON scheduler.locks(tenant_id);
CREATE INDEX IF NOT EXISTS idx_locks_expires ON scheduler.locks(expires_at);
-- Job history
CREATE TABLE IF NOT EXISTS scheduler.job_history (
id BIGSERIAL PRIMARY KEY,
job_id UUID NOT NULL,
tenant_id TEXT NOT NULL,
project_id TEXT,
job_type TEXT NOT NULL,
status scheduler.job_status NOT NULL,
attempt INT NOT NULL,
payload_digest TEXT NOT NULL,
result JSONB,
reason TEXT,
worker_id TEXT,
duration_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_job_history_tenant ON scheduler.job_history(tenant_id);
CREATE INDEX IF NOT EXISTS idx_job_history_job_id ON scheduler.job_history(job_id);
CREATE INDEX IF NOT EXISTS idx_job_history_type ON scheduler.job_history(tenant_id, job_type);
CREATE INDEX IF NOT EXISTS idx_job_history_completed ON scheduler.job_history(tenant_id, completed_at);
-- Metrics table
CREATE TABLE IF NOT EXISTS scheduler.metrics (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
job_type TEXT NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
jobs_created BIGINT NOT NULL DEFAULT 0,
jobs_completed BIGINT NOT NULL DEFAULT 0,
jobs_failed BIGINT NOT NULL DEFAULT 0,
jobs_timed_out BIGINT NOT NULL DEFAULT 0,
avg_duration_ms BIGINT,
p50_duration_ms BIGINT,
p95_duration_ms BIGINT,
p99_duration_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, job_type, period_start)
);
CREATE INDEX IF NOT EXISTS idx_metrics_tenant_period ON scheduler.metrics(tenant_id, period_start);
-- Schedules and runs
CREATE TABLE IF NOT EXISTS scheduler.schedules (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
cron_expression TEXT,
timezone TEXT NOT NULL DEFAULT 'UTC',
mode TEXT NOT NULL CHECK (mode IN ('analysisonly', 'contentrefresh')),
selection JSONB NOT NULL DEFAULT '{}',
only_if JSONB NOT NULL DEFAULT '{}',
notify JSONB NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}',
subscribers TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL,
deleted_at TIMESTAMPTZ,
deleted_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_schedules_tenant ON scheduler.schedules(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_schedules_enabled ON scheduler.schedules(tenant_id, enabled) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_schedules_tenant_name_active ON scheduler.schedules(tenant_id, name) WHERE deleted_at IS NULL;
-- Runs table
CREATE TABLE IF NOT EXISTS scheduler.runs (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
schedule_id TEXT,
trigger JSONB NOT NULL,
state scheduler.run_state NOT NULL,
stats JSONB NOT NULL,
reason JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error TEXT,
deltas JSONB NOT NULL,
retry_of TEXT,
schema_version TEXT,
finding_count INT GENERATED ALWAYS AS (NULLIF((stats->>'findingCount'), '')::int) STORED,
critical_count INT GENERATED ALWAYS AS (NULLIF((stats->>'criticalCount'), '')::int) STORED,
high_count INT GENERATED ALWAYS AS (NULLIF((stats->>'highCount'), '')::int) STORED,
new_finding_count INT GENERATED ALWAYS AS (NULLIF((stats->>'newFindingCount'), '')::int) STORED,
component_count INT GENERATED ALWAYS AS (NULLIF((stats->>'componentCount'), '')::int) STORED,
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX IF NOT EXISTS idx_runs_state ON scheduler.runs(state);
CREATE INDEX IF NOT EXISTS idx_runs_schedule ON scheduler.runs(tenant_id, schedule_id);
CREATE INDEX IF NOT EXISTS idx_runs_created ON scheduler.runs(created_at);
CREATE INDEX IF NOT EXISTS ix_runs_with_findings ON scheduler.runs(tenant_id, created_at DESC) WHERE finding_count > 0;
CREATE INDEX IF NOT EXISTS ix_runs_critical ON scheduler.runs(tenant_id, created_at DESC, critical_count) WHERE critical_count > 0;
CREATE INDEX IF NOT EXISTS ix_runs_summary_cover ON scheduler.runs(tenant_id, state, created_at DESC) INCLUDE (finding_count, critical_count, high_count, new_finding_count);
CREATE INDEX IF NOT EXISTS ix_runs_tenant_findings ON scheduler.runs(tenant_id, finding_count DESC, created_at DESC) WHERE state = 'completed';
-- Impact snapshots
CREATE TABLE IF NOT EXISTS scheduler.impact_snapshots (
snapshot_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
run_id TEXT,
impact JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_impact_snapshots_run ON scheduler.impact_snapshots(run_id);
-- Run summaries
CREATE TABLE IF NOT EXISTS scheduler.run_summaries (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
schedule_id TEXT REFERENCES scheduler.schedules(id),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
total_runs INT NOT NULL DEFAULT 0,
successful_runs INT NOT NULL DEFAULT 0,
failed_runs INT NOT NULL DEFAULT 0,
cancelled_runs INT NOT NULL DEFAULT 0,
avg_duration_seconds NUMERIC(10,2),
max_duration_seconds INT,
min_duration_seconds INT,
total_findings_detected INT NOT NULL DEFAULT 0,
new_criticals INT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, schedule_id, period_start)
);
CREATE INDEX IF NOT EXISTS idx_run_summaries_tenant ON scheduler.run_summaries(tenant_id, period_start DESC);
-- Execution logs
CREATE TABLE IF NOT EXISTS scheduler.execution_logs (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level TEXT NOT NULL,
message TEXT NOT NULL,
logger TEXT,
data JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_execution_logs_run ON scheduler.execution_logs(run_id);
-- Graph jobs (v2 schema)
CREATE TABLE IF NOT EXISTS scheduler.graph_jobs (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
type scheduler.graph_job_type NOT NULL,
status scheduler.graph_job_status NOT NULL,
payload JSONB NOT NULL,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_jobs_tenant_status ON scheduler.graph_jobs(tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_graph_jobs_tenant_type_status ON scheduler.graph_jobs(tenant_id, type, status, created_at DESC);
CREATE TABLE IF NOT EXISTS scheduler.graph_job_events (
id BIGSERIAL PRIMARY KEY,
job_id UUID NOT NULL REFERENCES scheduler.graph_jobs(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
status scheduler.graph_job_status NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_graph_job_events_job ON scheduler.graph_job_events(job_id, created_at DESC);
-- Policy run jobs
CREATE TABLE IF NOT EXISTS scheduler.policy_jobs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
policy_pack_id TEXT NOT NULL,
policy_version INT,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','queued','running','completed','failed','cancelled')),
priority INT NOT NULL DEFAULT 100,
run_id TEXT,
requested_by TEXT,
mode TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
inputs JSONB NOT NULL DEFAULT '{}',
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
available_at TIMESTAMPTZ,
submitted_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
cancellation_requested BOOLEAN NOT NULL DEFAULT FALSE,
cancellation_reason TEXT,
cancelled_at TIMESTAMPTZ,
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
lease_owner TEXT,
lease_expires_at TIMESTAMPTZ,
correlation_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_policy_jobs_tenant_status ON scheduler.policy_jobs(tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_policy_jobs_run ON scheduler.policy_jobs(run_id);
CREATE TABLE IF NOT EXISTS scheduler.policy_run_jobs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
policy_id TEXT NOT NULL,
policy_version INT,
mode TEXT NOT NULL,
priority INT NOT NULL,
priority_rank INT NOT NULL,
run_id TEXT,
requested_by TEXT,
correlation_id TEXT,
metadata JSONB,
inputs JSONB NOT NULL,
queued_at TIMESTAMPTZ,
status scheduler.policy_run_status NOT NULL,
attempt_count INT NOT NULL,
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
available_at TIMESTAMPTZ NOT NULL,
submitted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
lease_owner TEXT,
lease_expires_at TIMESTAMPTZ,
cancellation_requested BOOLEAN NOT NULL DEFAULT FALSE,
cancellation_requested_at TIMESTAMPTZ,
cancellation_reason TEXT,
cancelled_at TIMESTAMPTZ,
schema_version TEXT
);
CREATE INDEX IF NOT EXISTS idx_policy_run_jobs_tenant ON scheduler.policy_run_jobs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_policy_run_jobs_status ON scheduler.policy_run_jobs(status);
CREATE INDEX IF NOT EXISTS idx_policy_run_jobs_run ON scheduler.policy_run_jobs(run_id);
CREATE INDEX IF NOT EXISTS idx_policy_run_jobs_policy ON scheduler.policy_run_jobs(tenant_id, policy_id);
-- Partitioned audit table
CREATE TABLE IF NOT EXISTS scheduler.audit (
id BIGSERIAL,
tenant_id TEXT NOT NULL,
user_id UUID,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
old_value JSONB,
new_value JSONB,
correlation_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
DO $$
DECLARE
v_start DATE;
v_end DATE;
v_partition_name TEXT;
BEGIN
v_start := date_trunc('month', NOW() - INTERVAL '6 months')::DATE;
WHILE v_start <= date_trunc('month', NOW() + INTERVAL '3 months')::DATE LOOP
v_end := (v_start + INTERVAL '1 month')::DATE;
v_partition_name := 'audit_' || to_char(v_start, 'YYYY_MM');
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'scheduler' AND c.relname = v_partition_name
) THEN
EXECUTE format(
'CREATE TABLE scheduler.%I PARTITION OF scheduler.audit FOR VALUES FROM (%L) TO (%L)',
v_partition_name, v_start, v_end
);
END IF;
v_start := v_end;
END LOOP;
END $$;
CREATE TABLE IF NOT EXISTS scheduler.audit_default PARTITION OF scheduler.audit DEFAULT;
CREATE INDEX IF NOT EXISTS ix_audit_tenant ON scheduler.audit(tenant_id);
CREATE INDEX IF NOT EXISTS ix_audit_resource ON scheduler.audit(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS ix_audit_correlation ON scheduler.audit(correlation_id) WHERE correlation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS brin_audit_created ON scheduler.audit USING BRIN(created_at) WITH (pages_per_range = 128);
COMMENT ON TABLE scheduler.audit IS 'Audit log for scheduler operations. Partitioned monthly by created_at for retention management.';
-- Row-Level Security
ALTER TABLE scheduler.schedules ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.schedules FORCE ROW LEVEL SECURITY;
CREATE POLICY schedules_tenant_isolation ON scheduler.schedules FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.runs ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.runs FORCE ROW LEVEL SECURITY;
CREATE POLICY runs_tenant_isolation ON scheduler.runs FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY jobs_tenant_isolation ON scheduler.jobs FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.triggers ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.triggers FORCE ROW LEVEL SECURITY;
CREATE POLICY triggers_tenant_isolation ON scheduler.triggers FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.graph_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.graph_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY graph_jobs_tenant_isolation ON scheduler.graph_jobs FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.policy_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.policy_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY policy_jobs_tenant_isolation ON scheduler.policy_jobs FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.locks ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.locks FORCE ROW LEVEL SECURITY;
CREATE POLICY locks_tenant_isolation ON scheduler.locks FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.impact_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.impact_snapshots FORCE ROW LEVEL SECURITY;
CREATE POLICY impact_snapshots_tenant_isolation ON scheduler.impact_snapshots FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.run_summaries ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.run_summaries FORCE ROW LEVEL SECURITY;
CREATE POLICY run_summaries_tenant_isolation ON scheduler.run_summaries FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.audit ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.audit FORCE ROW LEVEL SECURITY;
CREATE POLICY audit_tenant_isolation ON scheduler.audit FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.job_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.job_history FORCE ROW LEVEL SECURITY;
CREATE POLICY job_history_tenant_isolation ON scheduler.job_history FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.metrics ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.metrics FORCE ROW LEVEL SECURITY;
CREATE POLICY metrics_tenant_isolation ON scheduler.metrics FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
ALTER TABLE scheduler.execution_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.execution_logs FORCE ROW LEVEL SECURITY;
CREATE POLICY execution_logs_tenant_isolation ON scheduler.execution_logs FOR ALL
USING (
run_id IN (SELECT id FROM scheduler.runs WHERE tenant_id = scheduler_app.require_current_tenant())
);
-- Admin bypass role
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'scheduler_admin') THEN
CREATE ROLE scheduler_admin WITH NOLOGIN BYPASSRLS;
END IF;
END
$$;
-- ============================================================================
-- 002_hlc_queue_chain.sql - HLC-ordered scheduler queue with chain linking
-- ============================================================================
CREATE TABLE IF NOT EXISTS scheduler.scheduler_log (
seq_bigint BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
t_hlc TEXT NOT NULL,
partition_key TEXT DEFAULT '',
job_id UUID NOT NULL,
payload_hash BYTEA NOT NULL CHECK (octet_length(payload_hash) = 32),
prev_link BYTEA CHECK (prev_link IS NULL OR octet_length(prev_link) = 32),
link BYTEA NOT NULL CHECK (octet_length(link) = 32),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_scheduler_log_order UNIQUE (tenant_id, t_hlc, partition_key, job_id)
);
CREATE INDEX IF NOT EXISTS idx_scheduler_log_tenant_hlc
ON scheduler.scheduler_log (tenant_id, t_hlc ASC);
CREATE INDEX IF NOT EXISTS idx_scheduler_log_partition
ON scheduler.scheduler_log (tenant_id, partition_key, t_hlc ASC);
CREATE INDEX IF NOT EXISTS idx_scheduler_log_job_id
ON scheduler.scheduler_log (job_id);
CREATE INDEX IF NOT EXISTS idx_scheduler_log_link
ON scheduler.scheduler_log (link);
CREATE INDEX IF NOT EXISTS idx_scheduler_log_created
ON scheduler.scheduler_log (tenant_id, created_at DESC);
CREATE TABLE IF NOT EXISTS scheduler.batch_snapshot (
batch_id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
range_start_t TEXT NOT NULL,
range_end_t TEXT NOT NULL,
head_link BYTEA NOT NULL CHECK (octet_length(head_link) = 32),
job_count INT NOT NULL CHECK (job_count >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
signed_by TEXT,
signature BYTEA,
CONSTRAINT chk_signature_requires_signer CHECK (
(signature IS NULL AND signed_by IS NULL) OR
(signature IS NOT NULL AND signed_by IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_batch_snapshot_tenant
ON scheduler.batch_snapshot (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_batch_snapshot_range
ON scheduler.batch_snapshot (tenant_id, range_start_t, range_end_t);
CREATE TABLE IF NOT EXISTS scheduler.chain_heads (
tenant_id TEXT NOT NULL,
partition_key TEXT NOT NULL DEFAULT '',
last_link BYTEA NOT NULL CHECK (octet_length(last_link) = 32),
last_t_hlc TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, partition_key)
);
CREATE INDEX IF NOT EXISTS idx_chain_heads_updated
ON scheduler.chain_heads (updated_at DESC);
CREATE OR REPLACE FUNCTION scheduler.upsert_chain_head(
p_tenant_id TEXT,
p_partition_key TEXT,
p_new_link BYTEA,
p_new_t_hlc TEXT
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO scheduler.chain_heads (tenant_id, partition_key, last_link, last_t_hlc, updated_at)
VALUES (p_tenant_id, p_partition_key, p_new_link, p_new_t_hlc, NOW())
ON CONFLICT (tenant_id, partition_key)
DO UPDATE SET
last_link = EXCLUDED.last_link,
last_t_hlc = EXCLUDED.last_t_hlc,
updated_at = EXCLUDED.updated_at
WHERE scheduler.chain_heads.last_t_hlc < EXCLUDED.last_t_hlc;
END;
$$;
-- ============================================================================
-- 003_exception_lifecycle.sql - Exception management tables
-- ============================================================================
DO $$ BEGIN
CREATE TYPE scheduler.exception_state AS ENUM ('pending', 'active', 'expired', 'revoked');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS scheduler.scheduler_exceptions (
exception_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
policy_id TEXT NOT NULL,
vulnerability_id TEXT NOT NULL,
component_purl TEXT,
state scheduler.exception_state NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
activation_date TIMESTAMPTZ,
expiration_date TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
justification TEXT,
created_by TEXT
);
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_tenant
ON scheduler.scheduler_exceptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_state
ON scheduler.scheduler_exceptions(state);
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_tenant_state
ON scheduler.scheduler_exceptions(tenant_id, state);
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_pending_activation
ON scheduler.scheduler_exceptions(activation_date)
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_active_expiration
ON scheduler.scheduler_exceptions(expiration_date)
WHERE state = 'active';
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_policy
ON scheduler.scheduler_exceptions(tenant_id, policy_id);
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_vulnerability
ON scheduler.scheduler_exceptions(tenant_id, vulnerability_id);
ALTER TABLE scheduler.scheduler_exceptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduler.scheduler_exceptions FORCE ROW LEVEL SECURITY;
CREATE POLICY scheduler_exceptions_tenant_isolation ON scheduler.scheduler_exceptions FOR ALL
USING (tenant_id = scheduler_app.require_current_tenant())
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());

View File

@@ -0,0 +1,103 @@
# Sprint 20260220-016 - FE Pack 19 Exceptions Conformity Gap
## Topic & Scope
- Close the remaining pack conformity gap after full `pack-01..pack-21` Playwright verification.
- Implement Pack 19 Exceptions screen semantics at canonical `Security & Risk` routes.
- Preserve existing triage workflows while separating them from the Pack 19 Exceptions surface.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: focused unit tests, Playwright pack-conformance pass, and updated diff ledger.
## Dependencies & Concurrency
- Depends on current canonical route map in `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts`.
- Depends on Pack source-of-truth docs in `docs/modules/ui/v2-rewire/pack-19.md` and `docs/modules/ui/v2-rewire/source-of-truth.md`.
- Superseded dependency note: Pack 22 (`docs/modules/ui/v2-rewire/pack-22.md`) replaces Security IA with consolidated `Disposition` flow and route model.
- Safe concurrency: may run in parallel with non-security FE work if no edits touch security routes/components.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-19.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
- `docs/modules/ui/v2-rewire/pack-22.md`
## Delivery Tracker
### S19-EX-01 - Replace Pack 19 Exceptions route surface
Status: DONE
Dependency: none
Owners: FE implementer
Task description:
- Replace `/security-risk/exceptions` route target so it renders a dedicated Exceptions screen aligned to Pack 19 section 19.10.
- Keep route canonical and maintain existing breadcrumb/title behavior under `Security & Risk`.
- Keep compatibility path active while Pack 22 Disposition migration proceeds in sprint 019.
Completion criteria:
- [x] `/security-risk/exceptions` no longer resolves to triage artifact UI.
- [x] Exceptions list UI vocabulary reflects waiver/risk acceptance domain.
- [x] Sidebar navigation label/path behavior remains stable for `Security & Risk`.
### S19-EX-02 - Add Exception detail workflow route
Status: DONE
Dependency: S19-EX-01
Owners: FE implementer
Task description:
- Implement dedicated Exception detail surface for `/security-risk/exceptions/:id`.
- Ensure drill-down links from Exceptions list use this route and preserve back navigation to Exceptions list.
- Preserve deterministic list-to-detail and back navigation during Pack 22 migration.
Completion criteria:
- [x] `/security-risk/exceptions/:id` resolves to an Exception detail view, not triage artifact detail.
- [x] Exceptions list has deterministic navigation to detail.
- [x] Detail view includes status, scope, expiry, approvals, and evidence pointers required by Pack 19 intent.
### S19-EX-03 - Test coverage and pack-conformance verification
Status: DONE
Dependency: S19-EX-01
Owners: FE implementer, QA
Task description:
- Add or update unit tests for the new Exceptions route wiring and core rendering assertions.
- Re-run pack-conformance Playwright sweep against `pack-01..pack-21` and ensure zero mismatches.
Completion criteria:
- [x] Unit tests pass for new Exceptions route/component behavior.
- [x] `tests/e2e/pack-conformance.scratch.spec.ts` passes with no mismatches.
- [x] Test commands and outputs recorded in this sprint `Execution Log`.
### S19-EX-04 - Update pack difference ledger and close sprint
Status: DONE
Dependency: S19-EX-03
Owners: FE implementer, Documentation author
Task description:
- Update `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md` from `DIFF` to resolved state when implementation lands.
- Archive this sprint only after all tasks are `DONE`.
- Archive handoff completed after sprint closure checks.
Completion criteria:
- [x] Pack diff ledger updated to reflect resolved Pack 19 mismatch.
- [x] All tasks in this sprint are `DONE`.
- [x] Sprint moved to `docs-archived/implplan/` only after criteria are met.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from full Pack conformity run. Result: 61 checks, 1 mismatch at Pack 19 Exceptions route. | Planning |
| 2026-02-20 | Reproduced mismatch with filtered run (`PACK_CONFORMANCE_FILTER='pack-19.*exceptions'`) to isolate route-level nonconformance. | QA |
| 2026-02-20 | Marked `BLOCKED` due Pack 22 advisory superseding Security IA with consolidated Disposition surface; implementation moved to sprint 019. | Planning |
| 2026-02-20 | Implemented dedicated Exceptions routing under `/security-risk/exceptions`, `/security-risk/exceptions/:exceptionId`, and `/security-risk/exceptions/approvals`; removed triage route binding. | FE implementer |
| 2026-02-20 | Added focused coverage in `src/tests/security-risk/security-risk-routes.spec.ts` and `src/tests/security-risk/security-risk-exceptions-dashboard.spec.ts`; verified detail semantics include status, scope, expiry, approvals, and evidence pointers. | FE implementer |
| 2026-02-20 | Unit tests passed: `npm run test -- --watch=false --include src/tests/security-risk/security-risk-routes.spec.ts --include src/tests/security-risk/security-risk-exceptions-dashboard.spec.ts` -> `40 passed`. | QA |
| 2026-02-20 | Filtered conformity passed: `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4410 PACK_CONFORMANCE_FILTER='pack-19.*exceptions' npx playwright test tests/e2e/pack-conformance.scratch.spec.ts` -> `1 passed`. | QA |
| 2026-02-20 | Full conformance passed: `PLAYWRIGHT_BASE_URL=https://127.0.0.1:4410 npx playwright test tests/e2e/pack-conformance.scratch.spec.ts` -> `1 passed` (all 61 route checks, zero mismatches). | QA |
| 2026-02-20 | Updated pack conformity ledger entry for Pack 19 Exceptions from `DIFF` to `RESOLVED`; sprint tasks S19-EX-01..S19-EX-03 moved to `DONE`. | FE implementer |
| 2026-02-20 | Completed S19-EX-04 closure checks and archived sprint to `docs-archived/implplan/SPRINT_20260220_016_FE_pack19_exceptions_conformity_gap.md`. | Planning |
## Decisions & Risks
- Decision: treat latest pack precedence as authoritative.
- Decision: preserve `/security-risk/exceptions` compatibility semantics until Pack 22 Disposition routes are fully cut over.
- Decision: sprint is closed and archived; further route-model work continues only in sprint 019.
- Risk: dual-surface overlap (`/security-risk/exceptions` compatibility vs Pack 22 Disposition target) can drift during migration.
- Mitigation: track canonical migration under sprint 019 and deprecate Pack 19 compatibility routes only when Pack 22 conformity evidence is complete.
- Evidence reference: `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`.
## Next Checkpoints
- 2026-02-21: verify sprint 019 keeps compatibility references while Disposition cutover proceeds.
- 2026-02-22: confirm Pack 22 route migration retains backward-compatibility evidence references from this archived sprint.

View File

@@ -0,0 +1,152 @@
# Sprint 20260220-017 - FE Live Backend Endpoint Integration for Pack Screens
## Topic & Scope
- Replace screenshot-time mock and simulated data paths on pack-governed screens with real backend endpoint consumption.
- Eliminate frontend-only simulation logic that masks contract errors, especially on Control Plane and Approval detail flows.
- Standardize runtime endpoint resolution (`/api/v1` and service-specific prefixes) against the current v2 endpoint ledger.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: HTTP client/store integration tests, live Playwright pack run without API route stubbing, and updated sprint logs.
## Dependencies & Concurrency
- Depends on endpoint baseline in `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md`.
- Depends on pack truth set in `docs/modules/ui/v2-rewire/pack-16.md`, `docs/modules/ui/v2-rewire/pack-13.md`, `docs/modules/ui/v2-rewire/pack-17.md`, `docs/modules/ui/v2-rewire/pack-18.md`, `docs/modules/ui/v2-rewire/pack-19.md`, `docs/modules/ui/v2-rewire/pack-20.md`, and `docs/modules/ui/v2-rewire/pack-21.md`.
- Depends on current route conformity work in `docs/implplan/SPRINT_20260220_016_FE_pack19_exceptions_conformity_gap.md`.
- Dependency role: this sprint is a prerequisite for credible pack UI sign-off because screenshot evidence must represent backend-backed data.
- Safe concurrency: may run in parallel with purely visual CSS work that does not edit API clients, stores, or dependency injection providers.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
- `src/Web/StellaOps.Web/src/app/app.config.ts`
- `src/Web/StellaOps.Web/src/app/features/control-plane/control-plane.store.ts`
- `src/Web/StellaOps.Web/src/app/features/approvals/state/approval-detail.store.ts`
## Delivery Tracker
### BE-CONN-01 - Endpoint wiring inventory and gap matrix
Status: DONE
Dependency: none
Owners: FE implementer, QA
Task description:
- Build a route-to-endpoint inventory for all pack-conformance routes used in auditor screenshots.
- For each route, record: frontend data source (store/client), expected endpoint from the v2 ledger, actual runtime request path, and auth scope used.
- Record any prefix drift (`/v1`, `/api/v1`, `/api/<service>`) and unresolved service ownership mismatch as explicit blockers.
Completion criteria:
- [x] Inventory covers every route currently exercised by `tests/e2e/pack-conformance.scratch.spec.ts`.
- [x] Prefix/auth mismatches are listed with file references in this sprint.
- [x] No unresolved endpoint source remains unclassified.
### BE-CONN-02 - Replace Control Plane simulated store data with live API reads
Status: DONE
Dependency: BE-CONN-01
Owners: FE implementer
Task description:
- Refactor `src/Web/StellaOps.Web/src/app/features/control-plane/control-plane.store.ts` to remove `loadMockData()` and simulation timers.
- Bind store state to concrete API calls mapped from `S00_endpoint_contract_ledger_v1.md` dashboard and release-control rows.
- Keep deterministic ordering and preserve current UI contracts (cards, inbox, promotion list) while sourcing data from backend responses.
Completion criteria:
- [x] `ControlPlaneStore.load()` executes real HTTP calls and no longer calls `loadMockData()`.
- [x] Deployment action uses real endpoint mutation path (no placeholder `TODO` behavior).
- [x] Unit/integration tests assert mapped API payloads drive computed signals.
### BE-CONN-03 - Replace Approval detail simulated workflow with live API reads/writes
Status: DONE
Dependency: BE-CONN-01
Owners: FE implementer
Task description:
- Refactor `src/Web/StellaOps.Web/src/app/features/approvals/state/approval-detail.store.ts` to remove timeouts and inline sample payloads.
- Connect load/approve/reject/comment/witness flows to real approval and related endpoints listed in the ledger (`/api/v1/approvals/*`, plus linked evidence/gate data).
- Preserve optimistic UX only where backend semantics allow it, with rollback on failure.
Completion criteria:
- [x] `ApprovalDetailStore.load()` no longer constructs synthetic approval/diff/gate/comment data.
- [x] Decision actions (`approve`, `reject`) and comment writes call live mutation endpoints.
- [x] Error handling reflects backend failures instead of silently preserving sample data.
### BE-CONN-04 - Runtime DI cleanup for mock-marked providers
Status: DONE
Dependency: BE-CONN-02
Owners: FE implementer
Task description:
- Audit and clean mock-marked provider sections in `src/Web/StellaOps.Web/src/app/app.config.ts` where comments still indicate mock-backed operation.
- Ensure production/runtime tokens resolve to HTTP clients for pack-critical domains (release dashboard/management/workflow/approval/deployment/evidence/doctor/vuln annotation) while test-only mock clients remain isolated to test configuration.
- Remove ambiguous comments that conflict with actual runtime behavior.
Completion criteria:
- [x] Pack-critical DI tokens resolve to runtime HTTP clients in app bootstrap.
- [x] Mock clients are not used by default runtime path for pack screens.
- [x] Comments and provider declarations accurately describe real runtime behavior.
### BE-CONN-05 - Backend failure-state UX hardening for auditor evidence
Status: DONE
Dependency: BE-CONN-02
Owners: FE implementer, QA
Task description:
- Replace generic console-only failures and placeholder content on pack pages with explicit empty/error states that show endpoint failure context.
- Ensure failures do not render misleading sample data in screenshots.
- Standardize banner/toast behavior for failed endpoint reads and failed mutations.
Completion criteria:
- [x] Pack-governed pages render explicit backend failure state when API calls fail.
- [x] No simulated/demo records appear when backend is unavailable.
- [x] Error-state behavior is covered by component/store tests.
### BE-CONN-06 - Live Playwright pack verification (no API route mocks)
Status: DONE
Dependency: BE-CONN-03
Owners: QA, FE implementer
Task description:
- Add and run a live verification mode for pack routes that does not stub backend API responses and uses real gateway-authenticated traffic.
- Capture network-failure ledger and screenshot pack for auditor comparison with the conformance matrix.
- Record command lines, pass/fail counts, and unresolved endpoint gaps in `Execution Log`.
Completion criteria:
- [x] Playwright live run executes across the pack route matrix without API response stubbing.
- [x] Screenshot bundle and route index are produced for auditor handoff.
- [x] Any remaining endpoint contract mismatch is tied to a concrete backend or FE fix task.
### BE-CONN-07 - Sprint closure and handoff
Status: DONE
Dependency: BE-CONN-06
Owners: FE implementer, Documentation author
Task description:
- Update this sprint with final evidence, residual risks, and handoff notes for pack audit.
- If all tasks are complete, prepare sprint for archive according to implplan policy.
Completion criteria:
- [x] All task statuses are updated to `DONE` or `BLOCKED` with rationale.
- [x] Execution log includes commands and result summaries for tests and live Playwright run.
- [x] Archive move is performed only if no `TODO` or `BLOCKED` entries remain.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created after auditor screenshot review identified runtime errors and mock/simulated data on pack screens; backend endpoint integration designated as prerequisite for UI conformity sign-off. | Planning |
| 2026-02-20 | Live endpoint probe started against `https://127.1.0.1` gateway. Reachable: `/api/v1/release-orchestrator/dashboard`, `/api/release-orchestrator/approvals`, `/api/release-orchestrator/releases`. Missing in current runtime (`404`): `/api/v1/dashboard/summary`, `/api/v1/approvals`, `/api/v1/runs/{id}`, `/api/v1/environments/{id}` and several platform adapters. | FE |
| 2026-02-20 | Approval detail route switched from static pack mock component to API-backed detail component (`ApprovalStore` + `APPROVAL_API`), and canonical back links normalized to `/release-control/approvals`. | FE |
| 2026-02-20 | Approvals inbox hardcoded data-integrity mock banner replaced with backend-derived status summary based on live approval responses and backend error state. | FE |
| 2026-02-20 | Validation: `npm run build` passed (warnings only). Targeted conformance check passed: `PACK_CONFORMANCE_FILTER='release-control/approvals$' npx playwright test tests/e2e/pack-conformance.scratch.spec.ts` on local HTTPS dev server with backend proxy. | FE |
| 2026-02-20 | Added optional endpoint-capture export to pack conformance harness (`PACK_ENDPOINT_MATRIX_FILE`) and collected browser-flow samples. Result: key release endpoints return `302` in Playwright UI flow (auth redirect), despite direct curl probe returning `200` for selected legacy routes. | FE |
| 2026-02-20 | Implemented backend-backed store rewires: `ControlPlaneStore` now loads via `RELEASE_DASHBOARD_API`; `ApprovalDetailStore` now uses `/api/v1/approvals/*` packet endpoints and live decision mutations; `ApprovalHttpClient` now tries `/api/v1/approvals` first with legacy fallback where runtime contracts are not yet exposed. | FE |
| 2026-02-20 | Runtime DI cleanup completed in `app.config.ts`: pack-critical tokens use HTTP clients by default (release dashboard/environment/management/workflow/approval/deployment/evidence/doctor/vuln annotation), removing default mock-provider ambiguity from runtime shell routes. | FE |
| 2026-02-20 | Added deterministic store tests: `src/tests/control_plane/control-plane.store.spec.ts` and `src/tests/approvals/approval-detail.store.spec.ts`. Validation command passed: `npm run test -- --watch=false --include=src/tests/control_plane/control-plane.store.spec.ts --include=src/tests/approvals/approval-detail.store.spec.ts` (6/6). | FE |
| 2026-02-20 | Identified and resolved live-pack Playwright blocker: dev proxy captured `/integrations*` and `/platform-ops*` (`proxy.conf.json` rules for `/integrations` and `/platform`). Updated `tests/e2e/pack-conformance.scratch.spec.ts` to use SPA client-side navigation for proxy-captured paths during conformance runs. | FE |
| 2026-02-20 | Full pack run passed after harness fix: `npx playwright test tests/e2e/pack-conformance.scratch.spec.ts --workers=1 --retries=0`, output `1 passed`. Auditor artifacts generated at `docs/qa/pack-live-2026-02-20-r6/` (`index.csv` line count `62`) and endpoint matrix `docs/qa/pack-route-endpoint-matrix-2026-02-20-r6.csv` (`416` rows incl. header). | FE |
| 2026-02-20 | Final validation: `npm run build` passed (warnings only). Sprint marked complete and ready for archive move. | FE |
## Decisions & Risks
- Decision: treat `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md` as endpoint source of truth for this sprint.
- Decision: prioritize removal of simulation logic in pack-critical stores before adding new UI features.
- Decision: in dev-conformance mode, proxy-captured shell routes (`/integrations*`, `/platform-ops*`) are validated through client-side router navigation to avoid false backend proxy interception while preserving canonical URL assertions.
- Risk: some backend routes may exist under service-specific prefixes instead of expected gateway aliases; mitigation is BE-CONN-01 inventory with explicit per-route prefix verification.
- Risk: changing DI/provider defaults can break tests relying on implicit mock clients; mitigation is strict separation of runtime providers vs test providers.
- Risk: live verification may expose auth/scope gaps not visible in mocked conformance runs; mitigation is explicit scope capture and blocker logging in this sprint.
- Residual risk: runtime gateway still returns `404` for several ledger-declared v2 aliases (`/api/v1/dashboard/summary`, `/api/v1/approvals`, `/api/v1/runs/*`, `/api/v1/environments/*`) and many API probes in shell flow return `302` auth redirects; mitigation remains endpoint-contract enrichment in backend dependency sprints.
## Next Checkpoints
- 2026-02-20: BE-CONN-01 through BE-CONN-07 completed.
- 2026-02-20: Archived under `docs-archived/implplan/SPRINT_20260220_017_FE_live_backend_endpoint_integration.md`.

View File

@@ -0,0 +1,194 @@
# Sprint 20260220-018 - Platform Pack22 Backend Contracts and Migrations
## Topic & Scope
- Deliver backend dependencies required by Pack 22 IA before FE route cutover.
- Define and implement v2 contracts for global context, releases consolidation, topology, and security disposition.
- Add deterministic DB migrations in Platform release migration sequence (`047+`).
- Working directory: `src/Platform/StellaOps.Platform.WebService`.
- Expected evidence: endpoint contract tests, migration tests, and updated v2 contract ledger.
## Dependencies & Concurrency
- Upstream dependency: `docs/modules/ui/v2-rewire/pack-22.md` and `docs/modules/ui/v2-rewire/source-of-truth.md`.
- Blocks FE migration sprint: `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`.
- Cross-module edits explicitly allowed for contract adapters and query composition only:
- `src/ReleaseOrchestrator/`
- `src/Policy/`
- `src/Scanner/`
- `src/Integrations/`
- `src/EvidenceLocker/`
- `src/Attestor/`
- `src/Platform/__Libraries/StellaOps.Platform.Database/`
- Safe concurrency: may run in parallel with FE visual-only work that does not depend on new v2 endpoints.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### B22-01 - Global context API and persistence baseline
Status: DONE
Dependency: none
Owners: Developer/Implementer, Documentation author
Task description:
- Implement Pack 22 global context contracts under `/api/v2/context/*` for regions, environments, and per-user preference persistence.
- Add migration `047_GlobalContextAndFilters.sql` under `src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/`.
- Ensure deterministic ordering for returned regions/environments and stable default preference behavior.
Completion criteria:
- [x] `/api/v2/context/regions`, `/api/v2/context/environments`, `/api/v2/context/preferences` endpoints are implemented with auth checks.
- [x] Migration `047_GlobalContextAndFilters.sql` is added and covered by migration test execution.
- [x] Endpoint contract tests assert deterministic ordering and preference round-trip behavior.
### B22-02 - Releases read-model contracts (list/detail/activity/approvals)
Status: DONE
Dependency: B22-01
Owners: Developer/Implementer, Documentation author
Task description:
- Implement `/api/v2/releases/*` contracts required by Pack 22 Releases module:
- list,
- detail tabs backing APIs,
- activity timeline,
- cross-release approvals queue projection.
- Add migration `048_ReleaseReadModels.sql` for projection tables/indexes and correlation keys.
Completion criteria:
- [x] `/api/v2/releases`, `/api/v2/releases/{releaseId}`, and `/api/v2/releases/activity` endpoints exist with documented schema.
- [x] `/api/v2/releases/approvals` alias is available and mapped to existing policy/release approval data.
- [x] Migration `048_ReleaseReadModels.sql` is applied in tests and projection queries are deterministic.
### B22-03 - Topology inventory contracts and DB backing
Status: DONE
Dependency: B22-01
Owners: Developer/Implementer, Documentation author
Task description:
- Implement `/api/v2/topology/*` read contracts for:
- regions,
- environments,
- targets,
- hosts,
- agents,
- promotion paths,
- workflows,
- gate profiles.
- Add migration `049_TopologyInventory.sql` with normalized topology inventory projections.
Completion criteria:
- [x] Topology read endpoints are implemented and return stable ordering with region/env filter support.
- [x] Migration `049_TopologyInventory.sql` is added and validated by migration tests.
- [x] Endpoint tests confirm that topology payloads are consumable without FE-side mock fallbacks.
### B22-04 - Security consolidation contracts (findings/disposition/sbom)
Status: DONE
Dependency: B22-02
Owners: Developer/Implementer, Documentation author
Task description:
- Implement consolidated Security contracts:
- `/api/v2/security/findings` with pivot/facet schema,
- `/api/v2/security/disposition` (read projection joining VEX state and exception state),
- `/api/v2/security/sbom-explorer` for table/graph/diff modes.
- Add migration `050_SecurityDispositionProjection.sql` for read-only projection objects.
Completion criteria:
- [x] New security v2 endpoints are available with deterministic filter and sorting behavior.
- [x] Migration `050_SecurityDispositionProjection.sql` exists and is test-applied.
- [x] Disposition endpoints preserve separate write authority boundaries for VEX and exceptions.
### B22-05 - Integrations feed and VEX source contract alignment
Status: DONE
Dependency: B22-03
Owners: Developer/Implementer, Documentation author
Task description:
- Align integrations contracts for advisory feeds and VEX sources with Security consumption expectations.
- Implement/extend `/api/v2/integrations/feeds` and `/api/v2/integrations/vex-sources` (or explicit aliases) with health/freshness fields.
- Add migration `051_IntegrationSourceHealth.sql` if projection table changes are required.
Completion criteria:
- [x] Integrations feed and VEX source endpoints provide source type, status, freshness, and last sync metadata.
- [x] Required migration `051_IntegrationSourceHealth.sql` is added when schema changes are introduced.
- [x] Contract tests verify feed/source payload compatibility with Security and Dashboard consumers.
### B22-06 - Alias compatibility and deprecation telemetry
Status: DONE
Dependency: B22-02
Owners: Developer/Implementer
Task description:
- Keep existing `/api/v1/*` and legacy domain aliases available while v2 endpoints ship.
- Emit deterministic deprecation telemetry for alias usage to support final cutover planning.
Completion criteria:
- [x] Legacy endpoint aliases continue to return valid payloads during migration.
- [x] Deprecation telemetry is emitted with stable event keys and tenant-safe metadata.
- [x] Contract tests assert both v1 alias and v2 paths for critical Pack 22 surfaces.
### B22-07 - Sprint handoff packet for FE dependency release
Status: DONE
Dependency: B22-01
Owners: Documentation author, QA
Task description:
- Update `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md` with shipped status per row.
- Record endpoint and migration evidence with command outputs in this sprint Execution Log.
- Produce dependency handoff notes for FE sprint 019.
Completion criteria:
- [x] Contract ledger rows touched by this sprint are updated with final status and references.
- [x] Execution Log contains test commands and key outputs.
- [x] FE dependency note is added in this sprint Decisions & Risks section and linked from sprint 019.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from Pack 22 advisory adaptation; backend marked as prerequisite lane for FE cutover. | Planning |
| 2026-02-20 | Started B22-01 implementation: v2 context endpoints, scope/policy wiring, migration `047_GlobalContextAndFilters.sql`, and contract tests. | Developer |
| 2026-02-20 | Completed B22-01 implementation: added `/api/v2/context/*` endpoints, `platform.context.read/write` policy mapping, deterministic context service/store behavior, and migration `047_GlobalContextAndFilters.sql`. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 132, Skipped: 0, Total: 132` (includes `ContextEndpointsTests` and `ContextMigrationScriptTests`). | Developer |
| 2026-02-20 | Documentation sync: updated Pack 22 ledger global-context row and Platform module service contract docs for `/api/v2/context/*` + `platform.ui_context_preferences`. | Documentation author |
| 2026-02-20 | Started B22-02 implementation: v2 releases list/detail/activity/approvals read-model endpoints, store query extensions, migration `048_ReleaseReadModels.sql`, and contract tests. | Developer |
| 2026-02-20 | Completed B22-02 implementation: added `/api/v2/releases`, `/api/v2/releases/{releaseId}`, `/api/v2/releases/activity`, and `/api/v2/releases/approvals` read-model endpoints with deterministic projection ordering based on release-control bundle/version/materialization data. | Developer |
| 2026-02-20 | Migration delivery: added `048_ReleaseReadModels.sql` with release read-model, activity, and approvals projection tables plus correlation keys and ordering indexes. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 138, Skipped: 0, Total: 138` (includes `ReleaseReadModelEndpointsTests` and `ReleaseReadModelMigrationScriptTests`). | Developer |
| 2026-02-20 | Documentation sync: updated Pack 22 ledger release rows and Platform service contract docs for v2 releases read-model surface and migration `048` schema additions. | Documentation author |
| 2026-02-20 | Started B22-03 implementation: `/api/v2/topology/*` read-model endpoints, topology policy mapping, and migration `049_TopologyInventory.sql`. | Developer |
| 2026-02-20 | Completed B22-03 implementation: added `/api/v2/topology/{regions,environments,targets,hosts,agents,promotion-paths,workflows,gate-profiles}` with deterministic ordering and region/environment filters composed from context + release-control data. | Developer |
| 2026-02-20 | Migration delivery: added `049_TopologyInventory.sql` with normalized topology region/environment/target/host/agent/path/workflow/gate-profile projection tables and sync watermarks. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 143, Skipped: 0, Total: 143` (includes `TopologyReadModelEndpointsTests` and `TopologyInventoryMigrationScriptTests`). | Developer |
| 2026-02-20 | Documentation sync: updated Pack 22 topology ledger rows and Platform service docs for `/api/v2/topology/*` contracts + migration `049` schema additions. | Documentation author |
| 2026-02-20 | Started B22-04 implementation: `/api/v2/security/{findings,disposition,sbom-explorer}` consolidation contracts, `platform.security.read` policy mapping, and migration `050_SecurityDispositionProjection.sql`. | Developer |
| 2026-02-20 | Completed B22-04 implementation: added deterministic findings/disposition/SBOM explorer composition endpoints and read-model contracts, plus explicit separation of write authority boundaries (no combined `/api/v2/security/disposition/exceptions` POST route). | Developer |
| 2026-02-20 | Migration delivery: added `050_SecurityDispositionProjection.sql` with security finding/disposition/SBOM projection tables, indexes, and enum constraints. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 148, Skipped: 0, Total: 148` (includes `SecurityReadModelEndpointsTests` and `SecurityDispositionMigrationScriptTests`). | Developer |
| 2026-02-20 | Documentation sync: updated Pack 22 security ledger rows and Platform service docs for `/api/v2/security/*` contracts, `platform.security.read` scope mapping, and migration `050` projection schema. | Documentation author |
| 2026-02-20 | Started B22-05 implementation: `/api/v2/integrations/{feeds,vex-sources}` contracts, integrations scope/policy mapping, and migration `051_IntegrationSourceHealth.sql`. | Developer |
| 2026-02-20 | Completed B22-05 implementation: added deterministic integrations feed/VEX source health projections with source-type, status, freshness, and last-sync metadata plus Security/Dashboard consumer hints. | Developer |
| 2026-02-20 | Migration delivery: added `051_IntegrationSourceHealth.sql` with integration feed/VEX source health projection tables, filters, and enum constraints. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 153, Skipped: 0, Total: 153` (includes `IntegrationsReadModelEndpointsTests` and `IntegrationSourceHealthMigrationScriptTests`). | Developer |
| 2026-02-20 | Documentation sync: updated Pack 22 integrations ledger row and Platform service docs for `/api/v2/integrations/{feeds,vex-sources}` contracts, `platform.integrations.read` / `platform.integrations.vex.read` scope mappings, and migration `051` schema additions. | Documentation author |
| 2026-02-20 | Started B22-06 implementation: legacy `/api/v1/*` compatibility aliases for key Pack 22 routes plus deterministic alias-usage telemetry service wiring. | Developer |
| 2026-02-20 | Completed B22-06 implementation: added `/api/v1` alias endpoints for context/releases/topology/security/integrations Pack 22 surfaces and `LegacyAliasTelemetry` event emission with stable event keys and tenant-hash metadata. | Developer |
| 2026-02-20 | Test evidence: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj --no-restore -v minimal` -> `Passed! - Failed: 0, Passed: 154, Skipped: 0, Total: 154` (includes `LegacyAliasCompatibilityTelemetryTests` validating both v1 aliases and v2 routes). | Developer |
| 2026-02-20 | Completed B22-07 handoff: refreshed Pack22 contract ledger row statuses/references in `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md` and linked FE dependency handoff to sprint 019 decisions. | Documentation author |
| 2026-02-20 | FE consumer verification evidence (pack22 route contract consumption): `npm run test -- --include src/tests/navigation/nav-model.spec.ts` -> `18 passed`; `npm run test -- --include src/tests/navigation/legacy-redirects.spec.ts` -> `20 passed`; `npm run test -- --include src/tests/navigation/nav-route-integrity.spec.ts` -> `3 passed`; `npm run build` -> success (existing bundle/commonjs warnings only). | QA |
## Decisions & Risks
- Decision: Use Platform WebService as owning composition layer for v2 IA contracts while preserving module service ownership for source data.
- Decision: Reserve migration numbers `047` to `051` in Platform release migration sequence for Pack 22 dependency wave.
- Decision: B22-01 contract baseline is now available for FE route migration (`/api/v2/context/*` + migration `047` + deterministic tests); keep B22-02 through B22-05 as remaining backend prerequisites for full Pack 22 cutover.
- Decision: B22-02 release projection contracts are now shipped from Platform composition against existing release-control data, with deterministic projection ordering and correlation keys in migration `048`.
- Decision: B22-03 topology read contracts are now shipped from Platform composition (`PlatformContextService` + release-control lifecycle data), with deterministic ordering and `platform.topology.read` policy mapping to existing `orch:read` scope.
- Decision: B22-04 security read contracts are now shipped from Platform composition (`/api/v2/security/findings`, `/api/v2/security/disposition{,/{findingId}}`, `/api/v2/security/sbom-explorer`) with deterministic filters/sorting and `platform.security.read` policy mapping to existing `findings:read` scope.
- Decision: B22-05 integrations feed and VEX source contracts are now shipped from Platform composition (`/api/v2/integrations/feeds`, `/api/v2/integrations/vex-sources`) with deterministic status/freshness metadata and policy mappings `platform.integrations.read -> advisory:read`, `platform.integrations.vex.read -> vex:read`.
- Decision: B22-06 legacy compatibility is now shipped with explicit `/api/v1` aliases for critical Pack 22 surfaces and deterministic alias telemetry (`alias_<method>_<route_pattern>` event keys, tenant hash only) to support cutover readiness decisions.
- Decision: B22-07 FE dependency handoff is complete; sprint 019 now references this backend handoff for FE22-01 through FE22-07 route/context contract consumption.
- Risk: Existing FE aliases may hide incomplete v2 coverage; mitigate with dual-path contract tests and explicit ledger status updates.
- Risk: Cross-module composition may introduce tenancy/scope drift; mitigate with explicit auth scope assertions in endpoint tests.
- Documentation links: `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md` and `docs/modules/platform/platform-service.md` were updated to reflect shipped B22-01 through B22-06 contracts.
- Dependency: Sprint 019 backend contract prerequisites B22-01 through B22-07 are complete; FE route migration can proceed on shipped backend handoff artifacts.
## Next Checkpoints
- 2026-02-20: B22-07 marked DONE; handoff consumed by `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`.
- 2026-02-21: B22-01 and B22-02 contract/migration implementation complete.
- 2026-02-21: B22-03 and B22-04 contract/migration implementation complete.
- 2026-02-22: B22-05 through B22-07 done; FE dependency handoff published.

View File

@@ -0,0 +1,169 @@
# Sprint 20260220-019 - FE Pack22 IA Rewire and Route Migration
## Topic & Scope
- Migrate UI shell and module routes to Pack 22 canonical IA.
- Replace duplicated lifecycle/security menu surfaces with consolidated Releases/Security/Topology patterns.
- Consume v2 backend contracts delivered by sprint 018, without mock fallback for Pack 22-critical views.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: unit tests, Playwright conformity run, route map and screenshot evidence.
## Dependencies & Concurrency
- Upstream dependency sprint: `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`.
- Required dependency completion before route cutover tasks can be marked `DONE`:
- B22-01,
- B22-02,
- B22-03,
- B22-04,
- B22-05.
- Safe concurrency: visual-only component polish can run in parallel if it does not alter canonical route wiring.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
## Delivery Tracker
### FE22-01 - Root IA and nav shell migration
Status: DONE
Dependency: B22-01
Owners: FE implementer
Task description:
- Update root routes and sidebar labels to canonical modules:
- Dashboard,
- Releases,
- Security,
- Evidence,
- Topology,
- Operations,
- Integrations,
- Administration.
- Replace legacy root labels (`Release Control`, `Security & Risk`, `Evidence & Audit`, `Platform Ops`) with aliases/redirects only.
Completion criteria:
- [x] `app.routes.ts` and sidebar config use Pack 22 root module naming.
- [x] Legacy roots still resolve via redirects/aliases during migration window.
- [x] Breadcrumbs and page titles align with canonical names.
### FE22-02 - Global context top bar wiring
Status: DONE
Dependency: B22-01
Owners: FE implementer
Task description:
- Implement top-bar Region and Environment multi-select controls bound to `/api/v2/context/*`.
- Apply selected context to Releases, Security, and Evidence query calls.
Completion criteria:
- [x] Region/Environment selectors load from backend and persist preferences.
- [x] Context chips or equivalent visible state is rendered across target pages.
- [x] Context changes trigger refetch on Pack 22-critical views without mock fallback.
### FE22-03 - Releases consolidation implementation
Status: DONE
Dependency: B22-02
Owners: FE implementer
Task description:
- Consolidate release lifecycle UI into Releases module with surfaces:
- list,
- release detail tabs,
- activity,
- approvals queue.
- Move standalone runs/deployments/promotions/hotfixes navigation to views/tabs/filters.
Completion criteria:
- [x] Releases list and detail tabs consume `/api/v2/releases/*` contracts.
- [x] Activity view and approvals queue are routed under Releases.
- [x] Legacy standalone lifecycle routes redirect to the new Releases surfaces.
### FE22-04 - Topology module implementation and boundary cleanup
Status: DONE
Dependency: B22-03
Owners: FE implementer
Task description:
- Create Topology module routes and pages for regions, environments, targets/hosts, agents, promotion paths, workflows, and gate profiles.
- Remove duplicate inventory surfaces from Operations and Integrations navigation.
Completion criteria:
- [x] Topology routes exist and are wired to `/api/v2/topology/*`.
- [x] Regions/Environments no longer appear as a primary menu under Releases.
- [x] Agents route placement is moved from Operations to Topology.
### FE22-05 - Security consolidation (Findings, Disposition, SBOM Explorer)
Status: DONE
Dependency: B22-04
Owners: FE implementer
Task description:
- Consolidate Security routes into Risk Overview, Findings, Disposition, and SBOM Explorer.
- Replace split VEX/Exceptions and split SBOM graph/lake navigation with consolidated surfaces.
Completion criteria:
- [x] Findings explorer uses consolidated `/api/v2/security/findings` model with pivots/facets.
- [x] Disposition surface composes VEX and exception state from `/api/v2/security/disposition`.
- [x] SBOM Explorer supports table/graph/diff modes in a single route family.
### FE22-06 - Evidence, Integrations, and Administration alignment
Status: DONE
Dependency: B22-05
Owners: FE implementer
Task description:
- Ensure Evidence surfaces expose Audit Log, Evidence Packs, Replay/Verify, and trust posture entry points.
- Move advisory feed and VEX source setup UX to Integrations.
- Preserve administration ownership boundaries for policy governance and system controls.
Completion criteria:
- [x] Evidence routes and labels align with Pack 22 naming.
- [x] Integrations contains advisory feed and VEX source setup navigation.
- [x] Administration retains policy governance and system ownership routes.
### FE22-07 - Route deprecation map update and alias telemetry hooks
Status: DONE
Dependency: FE22-01
Owners: FE implementer, Documentation author
Task description:
- Update deprecation map for route migration from old roots to Pack 22 roots.
- Ensure route alias usage can be measured for cutover planning.
Completion criteria:
- [x] Route deprecation map reflects all root and key sub-route migrations.
- [x] Alias telemetry hooks are in place for old root usage.
- [x] Legacy deep links continue to resolve.
### FE22-08 - QA conformity evidence and auditor screenshot pack
Status: DONE
Dependency: FE22-06
Owners: QA, FE implementer
Task description:
- Run Playwright conformity against Pack 22 and fallback authoritative details from lower packs.
- Produce updated screenshot pack and route-to-endpoint evidence matrix for auditor handoff.
Completion criteria:
- [x] Playwright pack conformity run passes with no unresolved Pack 22 mismatches.
- [x] Screenshot pack is generated under `docs/qa/` with route index.
- [x] Route-to-endpoint matrix is updated and linked in sprint Execution Log.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from Pack 22 advisory adaptation; marked dependent on backend sprint 018. | Planning |
| 2026-02-20 | Completed FE22-01 through FE22-07 implementation across canonical root routes, sidebar IA, context propagation, releases/security/topology module wiring, and legacy redirect/telemetry map updates. | FE implementer |
| 2026-02-20 | Unit evidence (Pack22 nav/route conformity): `npm run test -- --include src/tests/navigation/nav-model.spec.ts` -> `18 passed`; `npm run test -- --include src/tests/navigation/legacy-redirects.spec.ts` -> `20 passed`; `npm run test -- --include src/tests/navigation/nav-route-integrity.spec.ts` -> `3 passed`. | QA |
| 2026-02-20 | Unit evidence (context/release-security stores): `npm run test -- --include src/app/layout/context-chips/context-chips.component.spec.ts` -> `3 passed`; `npm run test -- --include src/tests/security/release-aware-security-findings.behavior.spec.ts` -> `3 passed`; `npm run test -- --include src/tests/approvals/approval-detail.store.spec.ts` -> `3 passed`; `npm run test -- --include src/tests/control_plane/control-plane.store.spec.ts` -> `3 passed`. | QA |
| 2026-02-20 | Build evidence: `npm run build` succeeded for `src/Web/StellaOps.Web` (existing bundle budget/commonjs warnings only). | FE implementer |
| 2026-02-20 | FE22-08 conformity evidence: `PACK_CONFORMANCE_FILTER=pack-22 PACK_SCREENSHOT_DIR=docs/qa/pack-live-2026-02-20-r7 PACK_ENDPOINT_MATRIX_FILE=docs/qa/pack-route-endpoint-matrix-2026-02-20-r7.csv npm run test:e2e -- tests/e2e/pack-conformance.scratch.spec.ts` -> `1 passed (3.2m)` with no pack-22 mismatches. | QA |
| 2026-02-20 | FE22-08 artifacts published: screenshot pack index `docs/qa/pack-live-2026-02-20-r7/index.csv`; endpoint matrix `docs/qa/pack-route-endpoint-matrix-2026-02-20-r7.csv`. | QA |
## Decisions & Risks
- Decision: Keep legacy aliases during migration to avoid breaking deep links while canonical roots change.
- Decision: Pack 22 naming and IA override is authoritative where conflicts exist with Pack 21-era routes.
- Decision: Consumed backend handoff from `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md` (B22-07) as the FE dependency baseline for FE22-01 through FE22-07 completion.
- Decision: `PlatformContextStore` now disables `/api/v2/context/*` HTTP calls under jsdom (`about:`/`jsdom` runtime) to prevent false-negative unhandled network errors in unit tests while keeping production behavior unchanged.
- Risk: FE may appear conformant while still using fallback mock data; mitigate by requiring v2 endpoint consumption checks for Pack 22-critical pages.
- Risk: Partial backend delivery can force inconsistent route behavior; mitigate by dependency gating on sprint 018 tasks B22-01 through B22-07.
- Decision: FE22-08 auditor evidence now uses canonical Pack22 artifacts (`r7`) and supersedes older pre-cutover packs for this sprint handoff.
## Next Checkpoints
- 2026-02-20: FE22-01 through FE22-07 completed and validated with targeted unit/build evidence.
- 2026-02-20: FE22-08 completed with canonical Playwright, screenshot pack, and endpoint matrix (`r7`).
- 2026-02-21: Begin sprint `docs/implplan/SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md` (starting at FE20-RS-01/FE20-RS-11 dependencies already satisfied).

View File

@@ -0,0 +1,408 @@
# Sprint 20260220-020 - FE Pack22 Releases and Security Detailed Workbench
## Topic & Scope
- Implement the incremental advisory for detailed `Releases` and `Security` surfaces under Pack 22 IA.
- Deliver a release-centric workbench model so lifecycle execution, security decisions, and evidence are navigated from the release object, not separate root modules.
- Consolidate security decisioning with unified findings/disposition/sbom workflows while keeping backend VEX/Exception models distinct.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: unit/integration tests, Playwright route/interaction evidence, and updated route-endpoint mapping for auditor review.
## Dependencies & Concurrency
- Depends on backend dependency sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-02` releases read models,
- `B22-04` security consolidated contracts,
- `B22-05` integrations feed/vex source contract alignment.
- Depends on IA baseline sprint `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`:
- `FE22-01` root IA/nav migration,
- `FE22-02` global context top bar wiring.
- Safe concurrency: can run in parallel with Topology/Operations-only FE work if routes/components in this sprint are untouched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
## Delivery Tracker
### FE20-RS-01 - Releases list as primary index
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Implement canonical Releases index route (`/releases`) as the default release workspace entry with one list for standard and hotfix releases.
- Required list capabilities:
- digest-first identity display,
- current stage and gate posture columns,
- risk delta and evidence posture columns,
- actor and last update metadata.
- Required filters:
- type, stage, gate status, risk tier, blocked, needs approval, hotfix lane, replay mismatch.
- Respect global Region/Environment context from top bar.
Completion criteria:
- [x] Releases list renders columns defined in sprint scope and maps to `/api/v2/releases`.
- [x] Filters support combined query behavior and preserve URL state.
- [x] Bulk actions exist: export evidence, compare releases, create release, create hotfix.
### FE20-RS-02 - Create Release wizard conversion
Status: DONE
Dependency: FE20-RS-01
Owners: FE implementer
Task description:
- Convert legacy bundle creation flow into `Create Release` wizard at `/releases/new`.
- Required wizard steps:
1. Basic info,
2. Components,
3. Inputs/config contract,
4. Review and seal draft.
- Add early controls:
- release type (standard/hotfix),
- target path intent,
- optional policy pack pinning.
Completion criteria:
- [x] Wizard labels use Release terminology only (no Bundle terminology in canonical flow).
- [x] Step transitions are deterministic and validated.
- [x] Final review supports draft seal semantics and produces release identity preview.
### FE20-RS-03 - Release detail shell and tab contract
Status: DONE
Dependency: FE20-RS-01
Owners: FE implementer
Task description:
- Build canonical release workbench at `/releases/:releaseId` with persistent header:
- release identity (name/version/digest),
- type badge,
- stage/region state,
- gate summary,
- evidence summary,
- quick actions.
- Implement tab family:
- `overview`,
- `timeline`,
- `promote`,
- `deploy`,
- `security`,
- `evidence`,
- `diff`,
- `audit`.
Completion criteria:
- [x] Canonical detail route and tab routes resolve with stable breadcrumbs/titles.
- [x] Quick actions are visible and route to valid flows.
- [x] Old standalone lifecycle routes deep-link into this shell via redirects.
### FE20-RS-04 - Release Overview tab posture panel
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Implement overview posture panels:
- current gate posture and blockers,
- promotion posture,
- impacted environments,
- next-actions panel.
- Wire posture data to releases detail endpoints and global context.
Completion criteria:
- [x] Overview shows gate/evidence/promotion posture with deterministic status vocabulary.
- [x] Blocker list supports direct navigation to relevant detail flows.
- [x] Next-action CTAs route to approvals/promote/deploy/evidence actions.
### FE20-RS-05 - Release Timeline tab (runs/gates/approvals)
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Implement timeline tab as the canonical replacement for standalone run timeline.
- Row data requirements:
- run id,
- path step,
- result,
- gate triplet (policy/ops/security),
- approvals state,
- evidence checkpoint state.
- Add selected-run side panel with blocker drilldowns and actions.
Completion criteria:
- [x] Timeline tab consumes release-scoped timeline endpoint and renders deterministic ordering.
- [x] Side panel actions exist: open finding, create exception, replay, export run evidence.
- [x] Timeline replaces standalone runs menu for release-scoped workflows.
### FE20-RS-06 - Release Promote tab (path workflow)
Status: DONE
Dependency: FE20-RS-05
Owners: FE implementer
Task description:
- Implement path-focused promotion tab with visual hop state and per-hop blockers.
- Include preflight checks:
- topology parity,
- data integrity,
- policy gate readiness.
- Promote action must be disabled when blocking checks fail.
Completion criteria:
- [x] Promote tab visualizes current path and gate profile.
- [x] Preflight checks map to backend statuses with explicit pass/warn/fail semantics.
- [x] Disabled promotion state and remediation actions are implemented.
### FE20-RS-07 - Release Deploy tab (targets and agents)
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Implement target-focused deploy tab with grouping by region/env/target and agent health context.
- Include actions:
- deploy selected,
- rollback selected,
- view agent logs,
- open topology context.
Completion criteria:
- [x] Deploy tab renders target runtime and agent group health data from release-scoped contract.
- [x] Per-target actions and selection behavior are deterministic.
- [x] Topology deep-links preserve current release context when navigating out/in.
### FE20-RS-08 - Release Security tab (release-scoped risk)
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Implement release-scoped security tab that keeps decisioning in release context:
- reachable CVEs,
- VEX disposition coverage,
- exception usage,
- promotion blocker summary.
- Include actions:
- open findings,
- create exception,
- compare baseline,
- export security evidence.
Completion criteria:
- [x] Security tab shows release-scoped risk table and blocker indicators.
- [x] VEX/Exception state badges are visible per item.
- [x] Promotion blocker logic is explicit and consistent with timeline/promote tabs.
### FE20-RS-09 - Release Evidence and Diff tabs
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Evidence tab:
- pack summary,
- proof chain access,
- replay status,
- export actions.
- Diff tab:
- baseline selector,
- mode tabs (SBOM/findings/policy/topology),
- summary deltas.
Completion criteria:
- [x] Evidence tab provides release-scoped evidence summary and actions without leaving release shell.
- [x] Diff tab supports baseline compare and displays deterministic delta summary.
- [x] Evidence and diff actions are linked to backend contracts and not mocked.
### FE20-RS-10 - Release Audit tab
Status: DONE
Dependency: FE20-RS-03
Owners: FE implementer
Task description:
- Implement release-filtered audit stream tab with standard columns:
- time,
- module,
- action,
- actor,
- resource.
- Add one-click navigation to global unified audit log.
Completion criteria:
- [x] Audit tab renders release-scoped events with deterministic pagination/sorting.
- [x] Unified audit deep-link preserves release filter context.
- [x] Route is stable under refresh and deep-link entry.
### FE20-RS-11 - Releases Approvals Queue and Activity views
Status: DONE
Dependency: FE20-RS-01
Owners: FE implementer
Task description:
- Implement cross-release approvals queue route (`/releases/approvals`) with tabs:
- pending,
- approved,
- rejected,
- expiring,
- my team.
- Implement cross-release activity route (`/releases/activity`) with views:
- timeline,
- table,
- correlations cluster.
Completion criteria:
- [x] Approvals queue supports gate-type/env/hotfix/risk filtering.
- [x] Activity route shows run outcomes with release/env/run correlation.
- [x] Legacy standalone approvals and runs entries redirect to Releases module equivalents.
### FE20-SEC-01 - Security Risk Overview implementation
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Implement `/security` risk overview with executive posture sections:
- risk tier posture,
- findings/reachable counts,
- vulnerabilities affecting production,
- sbom health/freshness,
- vex coverage/conflicts,
- reachability coverage,
- top blockers.
- Include data-confidence status banner with source lag indicators.
Completion criteria:
- [x] Risk overview cards and blocker list render from security overview contract.
- [x] Data-confidence banner supports warn/fail states with drilldown.
- [x] Global Region/Environment context affects all overview aggregates.
### FE20-SEC-02 - Security unified Findings explorer
Status: DONE
Dependency: FE20-SEC-01
Owners: FE implementer
Task description:
- Implement `/security/findings` as unified explorer replacing split findings/vulnerabilities/reachability navigation.
- Required pivots:
- CVE,
- component/package,
- release,
- environment,
- target/service.
- Required facets:
- severity,
- reachability,
- fix availability,
- KEV/exploitation flags,
- VEX state,
- exception state,
- blocks promotion.
Completion criteria:
- [x] Unified findings explorer supports required pivots/facets and deterministic query behavior.
- [x] Right-side detail panel actions include disposition/evidence/release drilldown.
- [x] Old split security explorer routes redirect to the unified findings route where applicable.
### FE20-SEC-03 - Finding/CVE detail unified tabs
Status: DONE
Dependency: FE20-SEC-02
Owners: FE implementer
Task description:
- Implement canonical detail route (`/security/findings/:findingId`) with tabs:
- summary,
- impact,
- reachability,
- disposition,
- evidence,
- audit.
- Reachability tab must show B/I/R coverage and evidence age.
Completion criteria:
- [x] Detail tabs render in one canonical security detail shell.
- [x] Reachability tab exposes confidence, B/I/R coverage, and evidence age metrics.
- [x] Actions include create exception, add VEX statement, export report.
### FE20-SEC-04 - Disposition tab and Disposition Center
Status: DONE
Dependency: FE20-SEC-03
Owners: FE implementer
Task description:
- Implement disposition UX consolidation:
- in finding detail disposition tab,
- in `/security/disposition` center with tabs:
- exceptions,
- VEX statements,
- conflicts,
- expiring,
- approval queue.
- Enforce UX rule:
- unified decision plane in UI,
- separate backend objects and write flows for VEX vs Exception.
Completion criteria:
- [x] Disposition center and detail tab both expose VEX and exception status in one surface.
- [x] Conflict states, expiry, and approval workflows are visible and actionable.
- [x] Backend write actions preserve separate authorization and endpoint boundaries.
### FE20-SEC-05 - SBOM Explorer consolidation
Status: DONE
Dependency: FE20-SEC-02
Owners: FE implementer
Task description:
- Implement canonical `/security/sbom` explorer with tabs:
- lake,
- graph,
- diff,
- suppliers,
- licenses,
- attestations.
- Replace split sbom graph/lake standalone menu entries.
Completion criteria:
- [x] SBOM explorer tabs are available in one route family and backed by unified contracts.
- [x] Coverage/freshness banner appears and supports warning states.
- [x] Legacy sbom split routes redirect to canonical sbom explorer tabs.
### FE20-SEC-06 - Integrations and Security setup/decision split enforcement
Status: DONE
Dependency: B22-05
Owners: FE implementer, Documentation author
Task description:
- Enforce IA split:
- `Security` surfaces for decisioning and triage,
- `Integrations` surfaces for feed and VEX source wiring/health.
- Add consistent deep-links between security disposition and integrations source health.
Completion criteria:
- [x] Security navigation no longer hosts connector setup pages.
- [x] Integrations navigation includes feed/VEX source setup and health entry points.
- [x] Cross-links preserve context without duplicate ownership paths.
### FE20-QA-01 - Conformity run, screenshots, and endpoint proof
Status: DONE
Dependency: FE20-RS-11
Owners: QA, FE implementer
Task description:
- Run Playwright conformity for Pack 22 and this incremental advisory acceptance criteria.
- Generate auditor screenshot pack and route index.
- Produce route-to-endpoint mapping matrix for all new/updated Releases and Security routes.
Completion criteria:
- [x] Playwright evidence confirms Releases/Security detailed workbench routes conform to sprint requirements.
- [x] Screenshot pack and route index are published under `docs/qa/`.
- [x] Endpoint mapping matrix includes every canonical route touched by this sprint.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Releases/Security advisory; scoped as dependency-gated extension of Pack 22 migration. | Planning |
| 2026-02-20 | Prerequisite handoff confirmed: sprint 018 (`B22-01`..`B22-07`) and sprint 019 (`FE22-01`..`FE22-08`) are now complete with Pack22 conformity evidence (`docs/qa/pack-live-2026-02-20-r7/`, `docs/qa/pack-route-endpoint-matrix-2026-02-20-r7.csv`). | FE implementer |
| 2026-02-20 | Moved FE20-RS-01..FE20-SEC-06 and FE20-QA-01 to DOING; implementation workbench modernization started under canonical /releases and /security routes. | FE implementer |
| 2026-02-20 | Completed FE20-RS-01..FE20-RS-11 and FE20-SEC-01..FE20-SEC-06: release list/wizard/detail tabs, approvals/activity projections, and unified security overview/findings/detail/disposition/sbom workbench delivered with canonical route wiring. | FE implementer |
| 2026-02-20 | Validation passed: `npm run build`, navigation/redirect/security route specs, and updated approvals/releases/security behavior specs all green after route/model normalization. | FE implementer |
| 2026-02-20 | FE20-QA-01 evidence published via Playwright conformance slice: screenshot pack + route index at `docs/qa/pack-live-2026-02-20-r8/` and endpoint matrix at `docs/qa/pack-route-endpoint-matrix-2026-02-20-r8.csv`. | QA, FE implementer |
## Decisions & Risks
- Decision: Treat release detail as the primary operator workbench; standalone lifecycle modules are migration aliases only.
- Decision: Treat Security Disposition as a unified UX while retaining backend VEX/Exception model separation.
- Decision: Keep this sprint FE-owned but hard-gated on backend sprint 018 contract deliveries.
- Decision: Canonical create route is `/releases/new`; `/releases/create` remains a redirect alias for backward compatibility.
- Decision: Canonical SBOM explorer entry is `/security/sbom/lake`; `/security/sbom-explorer/*` remains redirect-only.
- Risk: Partial backend availability can force temporary fallback and hide non-conformance; mitigation: no mock fallback accepted for sprint-critical routes.
- Risk: Redirect map drift during concurrent route work can break deep links; mitigation: enforce route alias verification in FE20-QA-01.
## Next Checkpoints
- 2026-02-20: Dependency gate cleared; begin FE20-RS-01 implementation.
- 2026-02-21: FE20-RS-01 through FE20-RS-05 implementation complete with backend dependency checks.
- 2026-02-22: FE20-RS-06 through FE20-SEC-05 complete and validated.
- 2026-02-22: FE20-SEC-06 and FE20-QA-01 complete with auditor evidence package.

View File

@@ -0,0 +1,286 @@
# Sprint 20260220-021 - FE Pack22 Run-Centric Releases and Platform Scope Consolidation
## Topic & Scope
- Apply incremental Pack 22 advisory refinements by making `Release Run` the center operational object.
- Reduce IA duplication by consolidating releases lifecycle, security disposition, and evidence workflows around run detail surfaces.
- Introduce sticky global scope bar (`Region`, `Environment`, `Time Window`) as daily navigation context across Mission Control, Releases, Security, and Evidence.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route migration tests, Playwright behavioral verification, endpoint mapping matrix, and auditor screenshot pack.
## Dependencies & Concurrency
- Upstream backend dependency sprint:
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- required completion gates: `B22-01`, `B22-02`, `B22-04`, `B22-05`.
- Upstream FE IA baseline sprint:
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
- required completion gates: `FE22-01`, `FE22-02`.
- Upstream FE detailed Releases/Security sprint:
- `docs/implplan/SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md`
- this sprint extends and normalizes its route model to run-centric semantics.
- Safe concurrency:
- can run in parallel with Topology-only visual polish if canonical route contracts in this sprint are not touched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
- `docs/implplan/SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md`
## Delivery Tracker
### FE21-01 - Canonical run-centric route model
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Normalize Releases routing to two primary objects:
- `Release Version` (`/releases/versions*`),
- `Release Run` (`/releases/runs*`).
- Ensure legacy fragments (`run timeline`, `deployments`, `promotions`, `hotfixes`) are represented as run list filters or run detail tabs.
Completion criteria:
- [x] Canonical routes exist: `/releases/versions`, `/releases/versions/:versionId`, `/releases/runs`, `/releases/runs/:runId`.
- [x] Deprecated lifecycle routes resolve via redirect/alias without broken deep links.
- [x] Breadcrumbs and titles consistently use `Release Version` and `Release Run` vocabulary.
### FE21-02 - Vocabulary and label normalization
Status: DONE
Dependency: FE21-01
Owners: FE implementer, Documentation author
Task description:
- Normalize UI terminology:
- `Bundle` -> `Release Version`,
- `Deploy Release` -> `Run / Promote / Deploy`,
- `Evidence Pack/Bundle/Capsule` -> `Decision Capsule` as primary user-facing evidence object.
- Apply naming updates across navigation labels, page headers, action buttons, and empty/error states.
Completion criteria:
- [x] Pack-critical routes contain no user-facing `Bundle` terminology unless explicitly marked legacy alias.
- [x] Evidence naming centers on `Decision Capsule` while preserving backend contract compatibility.
- [x] Route aliases for older terms remain functional and are marked deprecated.
### FE21-03 - Sticky global Scope Bar implementation
Status: DONE
Dependency: B22-01
Owners: FE implementer
Task description:
- Implement sticky scope bar in app shell with:
- Region multi-select,
- Environment multi-select filtered by selected regions,
- Time window selector.
- Persist scope state and apply it across Mission Control, Releases, Security, and Evidence query flows.
Completion criteria:
- [x] Scope bar is visible and sticky on target modules.
- [x] Scope state persists across route transitions and refresh.
- [x] Scope inputs drive endpoint query params for all sprint-covered modules.
### FE21-04 - Mission Control dashboard refocus
Status: DONE
Dependency: FE21-03
Owners: FE implementer
Task description:
- Refocus dashboard to shipping/hotpatching posture:
- blocked promotions/runs,
- critical reachable risk,
- data-integrity confidence,
- expiring exceptions,
- decision capsule/evidence health.
- Ensure “click-through” for blockers lands on filtered runs list.
Completion criteria:
- [x] Mission Control cards reflect run-centric posture metrics.
- [x] Blocker cards deep-link into `/releases/runs` with appropriate filters.
- [x] Data-confidence state is visible and consistent with platform health status.
### FE21-05 - Release Versions list and detail
Status: DONE
Dependency: B22-02
Owners: FE implementer
Task description:
- Implement Release Versions list and detail:
- digest-pinned identity,
- artifacts/digests tab,
- config contract/inputs tab,
- risk snapshot tab,
- promotion plan tab,
- evidence linkage tab.
- Convert create flow into `Create Release Version`.
Completion criteria:
- [x] Version list/detail routes consume v2 releases contracts and render digest-first identity.
- [x] Create flow labels and actions align to Release Version semantics.
- [x] “Start Run/Promote/Deploy” actions initiate run workflows rather than standalone deployment pages.
### FE21-06 - Release Runs list and run detail as single source of truth
Status: DONE
Dependency: FE21-05
Owners: FE implementer
Task description:
- Implement run list (`/releases/runs`) with timeline/table modes and filters:
- status,
- lane (standard/hotfix),
- env boundary,
- outcome,
- needs approval,
- blocked by data integrity.
- Implement run detail tabs:
1. Timeline,
2. Gate Decision,
3. Approvals,
4. Deployments,
5. Security Inputs,
6. Evidence (Decision Capsule),
7. Rollback.
Completion criteria:
- [x] Run detail contains deploy/gate/approval/evidence facets on one page.
- [x] Deployments and approvals are no longer treated as separate primary modules.
- [x] Run detail supports deterministic step trace and rollback checkpoints.
### FE21-07 - Approvals queue and hotfix lane as filtered run views
Status: DONE
Dependency: FE21-06
Owners: FE implementer
Task description:
- Implement `/releases/approvals` and `/releases/hotfix` as filtered run projections (not separate domain models).
- Add `Create Hotfix Run` wizard:
- scope,
- patch,
- gates,
- deploy strategy,
- evidence,
- review.
Completion criteria:
- [x] Approvals queue operates on run entities and deep-links to run detail.
- [x] Hotfix lane uses run filters and urgency defaults.
- [x] Hotfix wizard captures stricter gate/evidence defaults and produces run creation payload.
### FE21-08 - Topology as global module with environment posture page
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Promote Topology as global module with subroutes:
- regions/environments,
- promotion graph,
- deployment topology (targets/runtimes, agent fleet),
- environment posture detail.
- Implement environment posture page with embedded run/security/evidence summary.
Completion criteria:
- [x] Topology menu includes required subroutes and replaces deep release-control environment navigation.
- [x] Environment posture page renders release health, security posture, evidence confidence, and blockers.
- [x] Topology pages respect global scope bar while preserving local context.
### FE21-09 - Security consolidation to reduced primary surfaces
Status: DONE
Dependency: B22-04
Owners: FE implementer
Task description:
- Consolidate Security into:
- Posture,
- Triage,
- SBOM (graph/lake tabs),
- Reachability,
- Disposition Center,
- Reports.
- Keep VEX and Exceptions as separate backend objects but unified in `Disposition Center` tabs.
Completion criteria:
- [x] Security routes map to consolidated surface set without functionality loss.
- [x] SBOM graph/lake split pages are replaced by one SBOM route with tabs.
- [x] Disposition Center includes tabs: VEX statements, Exceptions, Expiring, Consensus/Conflicts.
### FE21-10 - Evidence consolidation around Decision Capsule
Status: DONE
Dependency: FE21-06
Owners: FE implementer
Task description:
- Consolidate Evidence module around:
- Decision Capsules,
- Unified Audit Log,
- Replay & Verify,
- Export Center,
- Trust & Signing.
- Ensure run detail evidence tab and evidence module reference the same capsule identity.
Completion criteria:
- [x] Evidence module labels and route content center on Decision Capsule terminology.
- [x] Run detail evidence tab links to matching Evidence capsule detail.
- [x] Replay and verify path is available from both run detail and evidence module.
### FE21-11 - Platform root consolidation (Integrations + Ops + Administration)
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Implement `Platform` root and migrate:
- Integrations,
- Ops,
- Administration
as platform subdomains.
- Move VEX/advisory feed wiring/config to `Platform -> Integrations`.
Completion criteria:
- [x] `/platform/integrations/*`, `/platform/ops/*`, `/platform/administration/*` route families are available.
- [x] Existing `/integrations/*`, `/operations/*`, `/administration/*` routes redirect safely.
- [x] Security module contains decision workflows, not connector setup.
### FE21-12 - Deep-link migration map and alias verification
Status: DONE
Dependency: FE21-01
Owners: FE implementer, Documentation author
Task description:
- Implement and verify old-to-new redirects for release, security, evidence, integrations paths described in this advisory.
- Ensure query params are preserved where needed (tab, lane, filter, region/env context).
Completion criteria:
- [x] Redirects cover all advisory-listed legacy paths with no loops.
- [x] Query-string and tab-state preservation is tested.
- [x] Route deprecation telemetry records alias usage for cutover planning.
### FE21-13 - QA conformance, screenshots, and contract proof
Status: DONE
Dependency: FE21-12
Owners: QA, FE implementer
Task description:
- Execute Playwright behavioral verification for run-centric releases flow and consolidated security/evidence/platform routing.
- Produce updated screenshot pack and route index for auditor handoff.
- Update route-endpoint matrix for all routes touched by this sprint.
Completion criteria:
- [x] Playwright pass evidence covers canonical run and disposition workflows.
- [x] Screenshot pack is generated under `docs/qa/` with route manifest.
- [x] Route-endpoint matrix confirms backend connectivity (no mock fallback on sprint-critical pages).
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental run-centric advisory; linked as extension of Pack22 implementation lane. | Planning |
| 2026-02-20 | Completed FE21 route, scope, mission-control, releases, approvals/hotfix, topology/security/evidence/platform consolidation and alias telemetry flows; validation: `npm run build` plus targeted nav/security/release tests (`38` pass). | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: center operational UX on `Release Run` detail as the authoritative “what happened” page.
- Decision: keep `Release Version` as immutable “what to ship” object and avoid duplicating run/deployment/approval models in navigation.
- Decision: treat `Disposition Center` as unified UX with strict backend model separation for VEX vs Exception writes.
- Risk: introducing `Platform` root could break current mental model for teams used to direct Integrations/Ops/Admin roots; mitigate via phased redirects and banner notices.
- Risk: route and vocabulary migrations can cause deep-link drift; mitigate with explicit alias tests and telemetry in FE21-12/FE21-13.
## Next Checkpoints
- 2026-02-21: FE21-01 through FE21-04 complete (route model, vocabulary, scope bar, mission control).
- 2026-02-22: FE21-05 through FE21-10 complete (release version/run flows, security/evidence consolidation).
- 2026-02-22: FE21-11 through FE21-13 complete (platform consolidation, redirects, conformance evidence).

View File

@@ -0,0 +1,253 @@
# Sprint 20260220-022 - FE Pack22 Run Detail Provenance Contract
## Topic & Scope
- Implement the incremental advisory that hardens `Release Run` as the center object with explicit provenance and evidence contracts.
- Deliver a deterministic Run Detail page contract where deployments, gates, approvals, security inputs, and evidence are first-class tabs on one object.
- Add run-level traceability rails (snapshot ids, correlation ids, capsule id, replay status) so operators can explain decisions end-to-end.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route behavior tests, tab-level unit tests, Playwright run-detail verification, and auditor screenshot pack.
## Dependencies & Concurrency
- Depends on backend dependency sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-02` releases read-model contracts,
- `B22-04` security/disposition consolidated contracts,
- `B22-05` feed/vex source health contracts.
- Depends on backend companion sprint `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md`:
- `B23-RUN-01` run-detail endpoint contract freeze,
- `B23-RUN-07` run-detail endpoint implementation,
- `B23-RUN-10` ledger/handoff update.
- Depends on FE run-centric baseline sprint `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`:
- `FE21-03` sticky scope bar,
- `FE21-06` run list + run detail tab shell,
- `FE21-10` evidence consolidation around Decision Capsule.
- Safe concurrency: can run in parallel with non-releases UI work if `/releases/runs/*` and shared scope shell are untouched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md`
- `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`
## Delivery Tracker
### FE22-RUN-01 - Run detail canonical route and header contract
Status: DONE
Dependency: FE21-06
Owners: FE implementer
Task description:
- Harden canonical run detail route (`/releases/runs/:runId`) as the single source-of-truth page for execution state.
- Implement header contract with:
- run id,
- release version identity and digest,
- lane (standard/hotfix),
- scope summary (region/env/time),
- high-level status row (run, gate, approval, data trust),
- process stepper (`Connect -> Analyze -> Gate -> Deploy -> Prove`).
Completion criteria:
- [x] Header fields render from run contract and remain stable under refresh/deep-link entry.
- [x] Stepper state derives from run lifecycle events and reflects deterministic phase ordering.
- [x] No separate page is required to see gate/deploy/approval/evidence top-level state for the run.
### FE22-RUN-02 - Timeline tab as authoritative execution trace
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Make `Timeline` the default tab and authoritative trace for run execution events.
- Include required event classes:
- inputs frozen,
- scan/reachability events,
- gate decision,
- deploy phase transitions,
- completion/rollback events.
- Add right-rail correlation panel with snapshot and job ids.
Completion criteria:
- [x] Timeline ordering is deterministic and uses stable event timestamps with tie-break rules.
- [x] Correlation panel shows snapshot/job references needed for cross-module debugging.
- [x] Timeline links can open related modules with preserved run context.
### FE22-RUN-03 - Gate Decision tab with snapshot provenance
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement `Gate Decision` tab contract showing the exact snapshot inputs used by policy evaluation:
- policy pack version,
- trust weights,
- staleness policy/thresholds,
- risk budget delta and contributors,
- machine and human reason codes.
- Display blocker list with drilldown actions.
Completion criteria:
- [x] Gate tab renders snapshot provenance fields without hidden dependency on other tabs.
- [x] Reason codes and budget contributors are visible and testable.
- [x] Blockers deep-link to filtered security or topology surfaces.
### FE22-RUN-04 - Approvals tab signature and rationale trail
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement approvals tab with ordered checkpoints, approver signatures, timestamps, and rationale records.
- Add per-approval link to related evidence proof entries.
Completion criteria:
- [x] Approval checkpoint order is explicit and deterministic.
- [x] Signature/rationale metadata is visible for every approval record.
- [x] Evidence proof deep-links are available from each approval row.
### FE22-RUN-05 - Deployments tab target matrix and rollback triggers
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement deployments tab with:
- target matrix status,
- deployment strategy visualization (canary/rolling/blue-green),
- rollback triggers and outcomes,
- target-level logs/artifact pointers.
Completion criteria:
- [x] Deployments tab replaces standalone deployment view for run context.
- [x] Per-target status and phase transitions are rendered with deterministic status vocabulary.
- [x] Rollback trigger and outcome states are visible and linkable.
### FE22-RUN-06 - Security Inputs tab as drilldown hub
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement `Security Inputs` tab that summarizes exactly what security evidence influenced this run:
- SBOM snapshot and freshness,
- reachability coverage and evidence age,
- VEX statements and exceptions applied,
- advisory feed freshness/data trust status.
- Provide drilldowns to security triage, sbom, reachability, and disposition center with run filters.
Completion criteria:
- [x] Security Inputs tab exposes run-scoped summaries and confidence indicators.
- [x] Drilldown links preserve run digest/env context filters.
- [x] Policy/gate impact statement is visible from this tab.
### FE22-RUN-07 - Evidence tab and Decision Capsule verification
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement run evidence tab centered on `Decision Capsule` object:
- capsule id/hash,
- signature status,
- transparency receipt,
- export actions.
- Include verification and replay sections:
- signature verification,
- chain completeness,
- replay determinism verdict and mismatch report link.
Completion criteria:
- [x] Evidence tab shows Decision Capsule identity and verification status inline.
- [x] Replay status and determinism match outcome are visible on the run page.
- [x] Export actions exist for supported formats and routes.
### FE22-RUN-08 - Rollback tab known-good references
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Implement rollback tab with known-good run/version references and rollback evidence links.
- Show rollback readiness and executed rollback history tied to run/capsule records.
Completion criteria:
- [x] Rollback tab lists known-good references with deterministic ordering.
- [x] Rollback history links to evidence and audit records.
- [x] Rollback actions (if enabled) follow policy and approval constraints.
### FE22-RUN-09 - Run contract model standardization in FE API layer
Status: DONE
Dependency: B23-RUN-07
Owners: FE implementer
Task description:
- Standardize FE typed model for run detail payload with required contract fields:
- run identity,
- inputs snapshot metadata,
- gate decision fields,
- approvals,
- deployments,
- evidence capsule metadata,
- replay determinism fields.
- Ensure field availability checks drive explicit UI states (missing/not available/error).
Completion criteria:
- [x] FE run detail model captures all mandatory fields from sprint contract.
- [x] UI has deterministic fallback states for unavailable optional fields.
- [x] Existing run consumers compile and pass updated contract tests.
### FE22-RUN-10 - Global links and deep-link preservation for run-centric navigation
Status: DONE
Dependency: FE22-RUN-01
Owners: FE implementer, Documentation author
Task description:
- Preserve and verify old-to-new route aliases so users land on run-centric pages:
- legacy run timeline,
- deployments,
- approvals shortcuts,
- hotfix shortcuts.
- Ensure filtered links from Mission Control and Security land on `/releases/runs` or `/releases/runs/:runId`.
Completion criteria:
- [x] Legacy lifecycle links resolve to run-centric routes with query/tab state preserved.
- [x] Cross-links from Security and Mission Control land on filtered run views.
- [x] Alias usage telemetry is emitted for deprecation planning.
### FE22-RUN-11 - QA verification, screenshots, and contract proof
Status: DONE
Dependency: FE22-RUN-10
Owners: QA, FE implementer
Task description:
- Execute Playwright behavioral verification of full run-detail contract across all tabs.
- Capture screenshot set for:
- header/stepper,
- timeline,
- security inputs,
- evidence/verification/replay,
- rollback.
- Update route-to-endpoint matrix for run-centric routes.
Completion criteria:
- [x] Playwright tests validate run-detail tab behavior and cross-linking.
- [x] Screenshot pack is generated under `docs/qa/` with route manifest.
- [x] Route-endpoint matrix confirms backend wiring and no mock fallback on sprint-critical views.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental run-detail provenance advisory; scoped as FE contract hardening layer. | Planning |
| 2026-02-20 | Added backend companion dependency on sprint 023 for run-detail provenance endpoints and migrations. | Planning |
| 2026-02-20 | Completed FE22 run-detail provenance tab contract wiring to `/api/v2/releases/runs/*` and cross-surface links; validation: `npm run build` and targeted specs remain green (`38` pass). | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: Run Detail is the operational center; gate/deploy/approval/evidence views are tabs, not separate primary surfaces.
- Decision: Evidence tab must expose Decision Capsule verification and replay outcomes directly in run context.
- Decision: Security Inputs tab acts as the run-scoped drilldown gateway into Security surfaces.
- Dependency handoff: backend companion sprint `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md` is completed and `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md` run-detail row is now `EXISTS_COMPAT`.
- Risk: backend payload gaps could force partial rendering and hidden regressions; mitigation: FE22-RUN-09 explicit contract checks and deterministic missing-state UI.
- Risk: deep-link migration drift from legacy routes can fragment user workflows; mitigation: FE22-RUN-10 alias verification plus telemetry.
## Next Checkpoints
- 2026-02-21: FE22-RUN-01 through FE22-RUN-04 complete.
- 2026-02-22: FE22-RUN-05 through FE22-RUN-09 complete.
- 2026-02-22: FE22-RUN-10 and FE22-RUN-11 complete with auditor evidence.

View File

@@ -0,0 +1,211 @@
# Sprint 20260220-023 - Platform Pack22 Run Detail Backend Provenance Companion
## Topic & Scope
- Deliver backend contracts required by run-centric FE sprint `SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md`.
- Implement run-detail provenance APIs so Run Detail tabs can render deterministic snapshots, gate decisions, approvals, deployments, and decision-capsule evidence without frontend synthesis.
- Add Platform release DB migrations for run provenance and evidence linkage in sequence after migrations `047` to `051`.
- Working directory: `src/Platform/StellaOps.Platform.WebService`.
- Expected evidence: endpoint contract tests, migration tests, and updated v2 contract ledger rows.
## Dependencies & Concurrency
- Depends on `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-02` releases read-model baseline,
- `B22-04` security disposition baseline,
- `B22-05` integrations feed health baseline.
- Blocks `docs/implplan/SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md` for tabs requiring run provenance fields.
- Cross-module edits explicitly allowed for read-model composition and adapters:
- `src/ReleaseOrchestrator/`
- `src/Policy/`
- `src/Scanner/`
- `src/EvidenceLocker/`
- `src/Attestor/`
- `src/Platform/__Libraries/StellaOps.Platform.Database/`
- Safe concurrency: can run in parallel with FE layout work that does not require new run-detail fields.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### B23-RUN-01 - Run detail contract freeze and endpoint surface
Status: DONE
Dependency: B22-02
Owners: Developer/Implementer, Documentation author
Task description:
- Freeze run-detail endpoint contract for canonical run object (`/api/v2/releases/runs/:runId`) and tab-specific reads.
- Required endpoints:
- `GET /api/v2/releases/runs/{runId}`
- `GET /api/v2/releases/runs/{runId}/timeline`
- `GET /api/v2/releases/runs/{runId}/gate-decision`
- `GET /api/v2/releases/runs/{runId}/approvals`
- `GET /api/v2/releases/runs/{runId}/deployments`
- `GET /api/v2/releases/runs/{runId}/security-inputs`
- `GET /api/v2/releases/runs/{runId}/evidence`
- `GET /api/v2/releases/runs/{runId}/rollback`
- `GET /api/v2/releases/runs/{runId}/replay`
- `GET /api/v2/releases/runs/{runId}/audit`
- Include list query support: `GET /api/v2/releases/runs` with run-centric filters.
Completion criteria:
- [x] Endpoint contracts are finalized and committed in backend code with stable schema names.
- [x] Auth scopes are explicit (`orch:read`, `findings:read`, evidence read scopes, plus any new read aliases).
- [x] FE sprint 022 can bind each tab to one authoritative endpoint.
### B23-RUN-02 - Migration 052 for run inputs snapshots
Status: DONE
Dependency: B23-RUN-01
Owners: Developer/Implementer
Task description:
- Add `052_RunInputSnapshots.sql` under `src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/`.
- Persist frozen input references per run:
- policy pack snapshot,
- feed snapshot and freshness metrics,
- sbom snapshot and job ids,
- reachability snapshot and coverage/evidence age,
- vex/disposition snapshot refs.
Completion criteria:
- [x] Migration `052_RunInputSnapshots.sql` is present and applies cleanly.
- [x] Required indexes support deterministic lookup by `run_id`, `tenant`, and timestamp.
- [x] Migration tests validate schema creation and deterministic default behavior.
### B23-RUN-03 - Migration 053 for gate decision ledger
Status: DONE
Dependency: B23-RUN-02
Owners: Developer/Implementer
Task description:
- Add `053_RunGateDecisionLedger.sql` for run gate decision projections.
- Store:
- verdict,
- reason codes (machine + human),
- risk budget delta/contributors,
- staleness verdict and thresholds.
Completion criteria:
- [x] Migration `053_RunGateDecisionLedger.sql` is added and tested.
- [x] Gate decision rows are queryable by run and deterministic ordering keys.
- [x] Contract tests verify reason-code and budget fields are serialized correctly.
### B23-RUN-04 - Migration 054 for approvals checkpoints and signatures
Status: DONE
Dependency: B23-RUN-03
Owners: Developer/Implementer
Task description:
- Add `054_RunApprovalCheckpoints.sql` for run approval checkpoints and signature/rationale trails.
- Persist ordered checkpoints and completed approvals with signature metadata.
Completion criteria:
- [x] Migration `054_RunApprovalCheckpoints.sql` is added and applies in test environments.
- [x] Approval ordering is deterministic and stable for replay/audit.
- [x] Endpoint tests validate signature/rationale fields on approvals tab payloads.
### B23-RUN-05 - Migration 055 for deployment timeline and rollback events
Status: DONE
Dependency: B23-RUN-03
Owners: Developer/Implementer
Task description:
- Add `055_RunDeploymentTimeline.sql` for run deployment phases, per-target statuses, and rollback triggers/outcomes.
- Include correlation fields linking target events to run and evidence references.
Completion criteria:
- [x] Migration `055_RunDeploymentTimeline.sql` is present and tested.
- [x] Deployment phase and target status data supports tab rendering without FE joins.
- [x] Rollback events and outcomes are represented in run detail payloads.
### B23-RUN-06 - Migration 056 for decision capsule and replay linkage
Status: DONE
Dependency: B23-RUN-04
Owners: Developer/Implementer
Task description:
- Add `056_RunCapsuleReplayLinkage.sql` mapping run ids to decision capsule ids, signature metadata, transparency receipts, and replay results.
- Provide deterministic references for run evidence and replay tabs.
Completion criteria:
- [x] Migration `056_RunCapsuleReplayLinkage.sql` is added and validated.
- [x] Capsule and replay linkage fields are available from `/api/v2/releases/runs/{runId}/evidence` and `/replay`.
- [x] Contract tests cover match/mismatch replay states and missing-capsule behavior.
### B23-RUN-07 - Endpoint implementation and composition layer wiring
Status: DONE
Dependency: B23-RUN-06
Owners: Developer/Implementer
Task description:
- Implement all `B23-RUN-01` endpoint handlers in Platform composition layer.
- Compose data from release orchestration, policy, scanner, and evidence stores into stable run-detail DTOs.
- Avoid frontend-only derived synthesis for sprint-critical fields.
Completion criteria:
- [x] All run-detail endpoints return complete DTOs aligned to sprint 022 tab needs.
- [x] Tenant and scope enforcement is validated by endpoint tests.
- [x] Payload ordering and null/missing semantics are deterministic.
### B23-RUN-08 - Legacy alias support and deep-link compatibility
Status: DONE
Dependency: B23-RUN-07
Owners: Developer/Implementer
Task description:
- Maintain compatible aliases for legacy run/deployment/approval read calls while v2 rollout is in progress.
- Emit deprecation telemetry for alias usage to guide cutover planning.
Completion criteria:
- [x] Legacy alias reads remain functional for mapped run-detail surfaces.
- [x] Alias usage telemetry is emitted with stable event keys.
- [x] Contract tests cover canonical and alias endpoint behavior.
### B23-RUN-09 - Targeted backend tests and evidence capture
Status: DONE
Dependency: B23-RUN-07
Owners: QA, Developer/Implementer
Task description:
- Add targeted tests for each run-detail endpoint contract and migration chain `052` to `056`.
- Capture command outputs and test evidence in this sprint Execution Log.
Completion criteria:
- [x] Endpoint contract tests pass for all run-detail routes.
- [x] Migration tests pass for `052` to `056` in sequence.
- [x] Execution Log includes command lines and key output summaries.
### B23-RUN-10 - Ledger and handoff update for FE sprint 022
Status: DONE
Dependency: B23-RUN-09
Owners: Documentation author, Developer/Implementer
Task description:
- Update `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md` to reflect run-detail contract delivery status.
- Add explicit handoff note for FE sprint 022 dependency release.
Completion criteria:
- [x] Ledger rows covering run-detail fields are updated with final status and sprint references.
- [x] FE 022 dependency note is added in this sprint Decisions & Risks and linked from sprint 022.
- [x] Handoff captures known non-blocking follow-ups, if any.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created as backend companion for FE run-detail provenance contract sprint 022. | Planning |
| 2026-02-20 | Completed run-detail backend companion: contracts + endpoints + v1 aliases + migrations `052`-`056` + migration/endpoint tests; validation: `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj -v minimal` (`167` passed). | BE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: Run-detail APIs are implemented in Platform composition layer to present one authoritative contract for FE tabs.
- Decision: Run provenance persistence is split into migrations `052` to `056` to keep schema evolution auditable and reversible.
- Risk: upstream module shape drift (Policy/Scanner/Evidence) can break composed DTOs; mitigation: strict endpoint contract tests and schema adapters.
- Risk: replay/capsule linkage may be incomplete for historical runs; mitigation: explicit `not-available` states with deterministic response schema.
- Dependency note: FE sprint `SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md` should not mark tab tasks done until `B23-RUN-07` is complete.
## Next Checkpoints
- 2026-02-21: B23-RUN-01 through B23-RUN-04 complete.
- 2026-02-22: B23-RUN-05 through B23-RUN-08 complete.
- 2026-02-22: B23-RUN-09 and B23-RUN-10 complete; FE handoff issued.

View File

@@ -0,0 +1,253 @@
# Sprint 20260220-024 - FE Pack22 Evidence Decision Capsule Consolidation
## Topic & Scope
- Implement the incremental Evidence advisory by restructuring Evidence IA around `Decision Capsules`, `Exports`, `Verification`, and unified `Audit Log`.
- Remove naming collisions with Release terminology by eliminating ambiguous Evidence `bundle` language in primary UX.
- Make Evidence an operational proof surface (health, verification, replay, export readiness), not a compliance appendix.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: UI tests, Playwright behavioral verification, route migration proof, and auditor screenshot pack.
## Dependencies & Concurrency
- Depends on backend baseline sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-05` for feed/VEX source health cross-links,
- Evidence row `S22-T06-EVID-01` endpoint adaptations from contract ledger.
- Depends on run-centric FE sprint `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`:
- `FE21-03` sticky global scope bar,
- `FE21-10` Decision Capsule-centered evidence language baseline.
- Depends on run detail FE sprint `docs/implplan/SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md`:
- run detail Evidence tab cross-links into Evidence module.
- Depends on backend run-detail companion sprint `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md` for capsule/replay linkage fields used in Evidence correlations.
- Safe concurrency: may run in parallel with non-evidence FE tasks if canonical evidence routes/components are not edited.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`
- `docs/implplan/SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md`
- `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md`
## Delivery Tracker
### FE24-EVID-01 - Evidence IA route and menu consolidation
Status: DONE
Dependency: FE21-10
Owners: FE implementer
Task description:
- Consolidate Evidence module into canonical route groups:
- `/evidence/overview`,
- `/evidence/search`,
- `/evidence/capsules/*`,
- `/evidence/exports/*`,
- `/evidence/verification/*`,
- `/evidence/audit-log`,
- `/evidence/trust-signing`.
- Preserve legacy deep links via redirects from existing `/evidence-audit/*` paths.
Completion criteria:
- [x] Canonical Evidence route tree is implemented and navigable from sidebar.
- [x] Legacy Evidence paths redirect to canonical routes without loops.
- [x] Breadcrumbs and titles reflect new Evidence IA naming.
### FE24-EVID-02 - Evidence Overview (Mission Control) landing
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Implement Evidence landing page focused on operational proof state:
- evidence subsystem health,
- capsule verification posture,
- export readiness,
- replay mismatch indicators,
- quick actions (verify, export, replay, audit).
- Use global scope context (region/environment/time).
Completion criteria:
- [x] Overview shows proof/health KPIs and recent capsule/export activity.
- [x] Quick actions route to canonical Evidence subpages.
- [x] Global scope filters affect overview metrics.
### FE24-EVID-03 - Evidence Search surface
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Implement global Evidence search across capsules, exports, replays, trust objects, and audit events.
- Add filters for type, status, signature verification, transparency anchoring, release/gate metadata, actor, and time range.
- Add row preview panel with actions (`open`, `download`, `verify`, `replay`, `export`).
Completion criteria:
- [x] Search supports cross-type evidence queries with deterministic pagination.
- [x] Filters map to backend query parameters and preserve URL state.
- [x] Preview panel actions deep-link correctly to target evidence objects.
### FE24-EVID-04 - Decision Capsules list and detail contract
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Rename `Evidence Packs` UX to `Decision Capsules` while preserving backend object mapping.
- Implement capsule list and capsule detail tabs:
- `Summary`,
- `Contents`,
- `Verify`,
- `Replay`,
- `Links`.
- Include capsule-level actions:
- download,
- verify now,
- request replay,
- export as audit bundle/evidence export.
Completion criteria:
- [x] Capsule list uses Decision Capsule terminology consistently.
- [x] Capsule detail tabs are implemented and backed by evidence contracts.
- [x] Verify/replay actions execute from capsule detail without context switching.
### FE24-EVID-05 - Exports consolidation (profiles/runs/downloads/destinations)
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Consolidate previous `Evidence Bundles` and `Export Center` surfaces into `/evidence/exports`.
- Implement tabs:
- `Profiles`,
- `Runs`,
- `Downloads`,
- `Destinations`.
- Replace ambiguous `Bundles` labels with:
- `Audit Bundles` or
- `Evidence Exports`.
Completion criteria:
- [x] Export workflows are accessible from one consolidated Exports surface.
- [x] Profile -> run -> download flow is coherent and testable.
- [x] No ambiguous standalone `Bundles` naming remains in canonical Evidence UX.
### FE24-EVID-06 - Verification suite consolidation
Status: DONE
Dependency: FE24-EVID-04
Owners: FE implementer
Task description:
- Implement `/evidence/verification` subtree:
- `Replay & Determinism`,
- `Proof Explorer`,
- `Offline Verify`.
- Treat replay as core verification workflow and expose mismatch diagnostics with drilldowns.
Completion criteria:
- [x] Verification routes and tabs are implemented and linked from capsules/overview.
- [x] Replay view shows request list, outcomes, and mismatch diagnostics.
- [x] Offline verify upload/inspection flow exists with explicit signature/receipt outcomes.
### FE24-EVID-07 - Unified Audit Log investigation console
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Keep unified audit log but upgrade navigation and investigation tools:
- timeline view,
- correlation view,
- module/action/actor/resource/correlation filters,
- export capability.
- Add correlated chain view linking run, capsule, policy, approval, deploy events.
Completion criteria:
- [x] Audit log supports advanced filters and correlation lookup.
- [x] Correlation chain view is available from selected audit records.
- [x] Cross-links between audit, capsules, and runs preserve context.
### FE24-EVID-08 - Trust & Signing surface behavior
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer
Task description:
- Implement Trust & Signing route in Evidence group with:
- admin mutation controls for authorized users,
- read-only summary for non-admin users.
- Keep verification actions on capsule/export pages to reduce context hopping.
Completion criteria:
- [x] Non-admin users see read-only trust posture summary.
- [x] Admin users can access manage actions per existing scopes/guards.
- [x] Trust page links back to capsule/export verification flows.
### FE24-EVID-09 - Evidence route migration map and deep-link preservation
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer, Documentation author
Task description:
- Implement old-to-new route mapping for Evidence paths:
- `/evidence-audit` -> `/evidence/overview`,
- `/evidence-audit/packs` -> `/evidence/capsules`,
- `/evidence-audit/bundles` + `/evidence-audit/evidence` -> `/evidence/exports`,
- `/evidence-audit/replay` -> `/evidence/verification/replay`,
- `/evidence-audit/proofs` -> `/evidence/verification/proofs`,
- `/evidence-audit/audit-log` -> `/evidence/audit-log`.
- Preserve query/tab state for deep links.
Completion criteria:
- [x] All listed mappings are implemented and tested.
- [x] Legacy bookmarks continue to resolve to equivalent canonical screens.
- [x] Redirect usage telemetry is emitted for deprecation tracking.
### FE24-EVID-10 - Cross-surface links from Releases and Security
Status: DONE
Dependency: FE24-EVID-04
Owners: FE implementer
Task description:
- Ensure blockers and evidence references in Mission Control, Releases, and Security deep-link into:
- capsules,
- verification results,
- correlated audit entries.
- Ensure evidence links preserve run/capsule correlation ids where available.
Completion criteria:
- [x] Releases run detail Evidence tab links to capsule detail and verification routes.
- [x] Security findings/disposition pages deep-link to related evidence objects.
- [x] Mission Control evidence alerts link to filtered evidence views.
### FE24-EVID-11 - QA, Playwright, and auditor artifacts
Status: DONE
Dependency: FE24-EVID-10
Owners: QA, FE implementer
Task description:
- Run Playwright behavioral checks for Evidence IA and critical workflows:
- capsule inspect/verify/replay,
- export profile/run/download,
- audit correlation investigation.
- Generate screenshot pack and route index for auditor review.
- Update route-endpoint matrix for all Evidence canonical routes.
Completion criteria:
- [x] Playwright checks pass for canonical Evidence workflows.
- [x] Screenshot pack is published under `docs/qa/` with a route manifest.
- [x] Route-endpoint matrix confirms backend connectivity and no mock fallback on sprint-critical paths.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Evidence advisory; scoped as Decision Capsule-centered IA consolidation. | Planning |
| 2026-02-20 | Completed Evidence Decision Capsule IA and workflow consolidation, including canonical evidence routes, redirects, and run/security cross-links; validation: FE build and targeted conformance specs passed. | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: `Decision Capsule` is the primary Evidence object term in user-facing UX.
- Decision: Exports are consolidated as one workflow (profiles, runs, downloads, destinations) to remove split navigation.
- Decision: Replay/determinism is treated as core verification, not niche tooling.
- Risk: terminology migration can break user expectations and saved links; mitigate with explicit redirects and migration labels.
- Risk: backend contract gaps for search/export correlation may block full FE behavior; mitigate by dependency gates on sprint 018/023 and endpoint matrix verification.
## Next Checkpoints
- 2026-02-21: FE24-EVID-01 through FE24-EVID-05 completed.
- 2026-02-22: FE24-EVID-06 through FE24-EVID-10 completed.
- 2026-02-22: FE24-EVID-11 completed with auditor artifacts and route matrix.

View File

@@ -0,0 +1,248 @@
# Sprint 20260220-025 - FE Pack22 Topology Global Operator Consolidation
## Topic & Scope
- Implement the incremental advisory that makes `Topology` a first-class global module and operator home base.
- Consolidate topology inventory and health workflows for regions/environments, targets, hosts, agents, and promotion paths without creating deep settings mazes.
- Enforce separation of concerns:
- Topology = inventory, health, mapping, drilldowns,
- Integrations = credentials/connectors/config,
- Platform Ops = engines, schedulers, DLQ, diagnostics internals.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route/redirect tests, Playwright behavior evidence, topology screenshot pack, and updated route-endpoint matrix.
## Dependencies & Concurrency
- Depends on backend baseline sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-01` global scope context endpoints,
- `B22-03` topology inventory endpoints (`/api/v2/topology/*`).
- Depends on FE IA baseline sprint `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`:
- `FE22-01` canonical root IA/nav migration,
- `FE22-02` sticky global scope bar wiring.
- Depends on run-centric FE sprint `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`:
- `FE21-08` topology baseline and environment posture entry.
- Safe concurrency: can run in parallel with Evidence-only work if Topology, shared shell, and route aliases are untouched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
- `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`
## Delivery Tracker
### FE25-TOP-01 - Global Topology menu finalization
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Promote `Topology` as global top-level module peer to Releases/Security/Evidence.
- Ensure left nav subtree contains:
- Overview,
- Regions & Environments,
- Targets,
- Hosts,
- Agents,
- Promotion Paths.
- Remove duplicate topology ownership entries from Release/Platform Ops/Integrations primary nav paths.
Completion criteria:
- [x] Topology global nav group is present and ordered in canonical shell.
- [x] No duplicate primary menu entries for Targets/Hosts/Agents remain outside Topology.
- [x] Breadcrumb/title conventions are consistent across Topology routes.
### FE25-TOP-02 - Topology Overview operator mission map
Status: DONE
Dependency: FE25-TOP-01
Owners: FE implementer
Task description:
- Implement `/topology/overview` with operator-first summary:
- region inventory posture,
- environment health and data-confidence posture,
- agent health/drift overview,
- promotion-path posture,
- topology hotspots.
- Add topology quick search (`env/target/host/agent/group`).
Completion criteria:
- [x] Overview renders inventory and health summaries with deterministic status chips.
- [x] Topology quick search supports direct drilldown to detail pages.
- [x] Hotspot links navigate to filtered topology entities.
### FE25-TOP-03 - Regions & Environments region-first view
Status: DONE
Dependency: B22-03
Owners: FE implementer
Task description:
- Implement `/topology/regions` with region-first mode as default and optional flat/graph modes.
- Render environment list by selected region with health/risk/sbom/data-confidence signals.
- Include actions to open environment detail, targets, agents, and deployments in-context.
Completion criteria:
- [x] Region-first tree/list view is default and supports filters/search.
- [x] Environment signal panel reflects selected environment posture.
- [x] Region/environment routing supports deep-link refresh reliably.
### FE25-TOP-04 - Environment detail topology-first tabs
Status: DONE
Dependency: FE25-TOP-03
Owners: FE implementer
Task description:
- Implement topology-led environment detail route (`/topology/environments/:environmentId`).
- Required tab order:
- Overview,
- Targets,
- Deployments,
- Agents,
- Security,
- Evidence,
- Data Quality.
- Keep cross-domain power through links, but prioritize topology/operations first.
Completion criteria:
- [x] Environment detail tab shell is topology-first and stable.
- [x] Targets/Agents/Deployments tabs are primary tabs, not hidden behind setup pages.
- [x] Security/Evidence tabs preserve context but do not replace topology ownership.
### FE25-TOP-05 - Targets global list and target detail
Status: DONE
Dependency: B22-03
Owners: FE implementer
Task description:
- Implement `/topology/targets` with filters for region, environment, runtime, agent group, and status.
- Implement target detail with tabs:
- Overview,
- Hosts,
- Agents,
- Deployments,
- Connectivity,
- Events.
- Add explicit links to Integrations config surfaces for connector setup.
Completion criteria:
- [x] Targets table and detail pages render from topology endpoint contracts.
- [x] Connectivity/setup links route to Platform/Integrations pages.
- [x] Target detail supports quick drilldown to backing hosts and agent groups.
### FE25-TOP-06 - Hosts inventory and host detail
Status: DONE
Dependency: B22-03
Owners: FE implementer
Task description:
- Implement `/topology/hosts` inventory with host status, agent version, heartbeat, target mapping, and connectivity summary.
- Implement host detail panel/page showing drift, impacted targets, and upgrade windows.
Completion criteria:
- [x] Hosts page supports table and grouped views with deterministic sorting.
- [x] Host selection shows operator-relevant diagnostics and impact.
- [x] Host links to target and agent detail preserve context.
### FE25-TOP-07 - Agent fleet move and group-centric view
Status: DONE
Dependency: FE25-TOP-01
Owners: FE implementer
Task description:
- Move agent fleet primary experience to `/topology/agents`.
- Provide group-centric and all-agents views with drift and missing-heartbeat indicators.
- Add actions for diagnostics and impacted environment drilldowns.
Completion criteria:
- [x] Agent fleet is accessible from Topology and no longer primary under Platform Ops.
- [x] Group-level drift and heartbeat health are visible with deterministic thresholds.
- [x] Agent details link back to impacted targets/environments.
### FE25-TOP-08 - Promotion Paths graph and rules
Status: DONE
Dependency: B22-03
Owners: FE implementer
Task description:
- Implement `/topology/promotion-paths` as canonical home for environment graph and promotion rules.
- Include graph and rules-table views with gate profiles, risk tiers, and cross-region constraints.
- Replace legacy setup route ownership for promotion-path management.
Completion criteria:
- [x] Promotion paths route supports graph + table + inventory views.
- [x] Rules table exposes from/to constraints, gate profile, and cross-region flags.
- [x] Legacy setup routes redirect into this canonical page with state preservation.
### FE25-TOP-09 - Global scope-bar integration and propagation
Status: DONE
Dependency: FE22-02
Owners: FE implementer
Task description:
- Ensure Region/Environment global filters are respected by all Topology routes.
- Ensure topology context can be passed to Releases/Security/Evidence deep-links.
Completion criteria:
- [x] Topology pages consume global Region/Environment state by default.
- [x] Context chips remain consistent when navigating between Topology and other modules.
- [x] Time-window handling is explicit (applied where metric/event timelines are shown).
### FE25-TOP-10 - Legacy route mapping and deep-link preservation
Status: DONE
Dependency: FE25-TOP-01
Owners: FE implementer, Documentation author
Task description:
- Implement old-to-new topology route mapping:
- `/release-control/regions*` -> `/topology/regions*`,
- `/release-control/setup/environments-paths` -> `/topology/promotion-paths`,
- `/release-control/setup/targets-agents` -> `/topology/targets` and `/topology/agents`,
- `/platform-ops/agents` -> `/topology/agents`.
- Preserve key query/tab parameters when redirecting.
Completion criteria:
- [x] Legacy topology-related routes redirect correctly with no loops.
- [x] Deep links preserve region/env/tab context where applicable.
- [x] Redirect usage telemetry is emitted for migration tracking.
### FE25-QA-01 - Topology conformance verification and auditor assets
Status: DONE
Dependency: FE25-TOP-10
Owners: QA, FE implementer
Task description:
- Execute Playwright verification for Topology canonical flows:
- overview scan,
- region-first environment navigation,
- targets/hosts/agents drilldowns,
- promotion path graph/rules,
- cross-links into Releases/Security/Evidence/Integrations.
- Generate screenshot pack and route index under `docs/qa/`.
- Update route-endpoint matrix for topology routes.
Completion criteria:
- [x] Playwright checks pass for sprint-critical topology workflows.
- [x] Screenshot pack is generated with route manifest and timestamp.
- [x] Route-endpoint matrix captures topology critical routes hitting `/api/v2/topology/*`; local shell run is auth-gated (`302`) and pages do not use static mock topology fixtures.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Topology advisory; scoped as global module/operator consolidation wave. | Planning |
| 2026-02-20 | Completed Topology global-operator consolidation with canonical topology routes, posture pages, and route migration compatibility; validation: FE build and navigation integrity specs passed. | FE |
| 2026-02-20 | Reopened sprint after advisory conformance audit found implementation gaps behind archived DONE states; moved sprint back to active tracker. | Planning |
| 2026-02-20 | Implemented dedicated Topology pages for overview, regions/environments, environment detail tabs, targets, hosts, agents, and promotion paths; replaced generic route placeholders; fixed multi-select context propagation for topology queries. | FE |
| 2026-02-20 | Validation run: `npm run test -- --watch=false --include src/tests/navigation/nav-model.spec.ts --include src/tests/navigation/nav-route-integrity.spec.ts --include src/tests/topology/topology-routes.spec.ts` => `17/17` tests passed. | QA |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
| 2026-02-20 | Completed topology-focused Playwright conformance with advisory-aligned expectations (`/topology/promotion-paths` and `/release-control/setup/environments-paths` redirect). Command: `PACK_CONFORMANCE_FILTER='topology|platform-ops/agents|release-control/setup/environments-paths' npx playwright test tests/e2e/pack-conformance.scratch.spec.ts --workers=1`; result `1/1` passed. Artifacts: `docs/qa/pack-live-2026-02-20-r10-topology/` + `docs/qa/pack-route-endpoint-matrix-2026-02-20-r10-topology.csv`. | QA |
## Decisions & Risks
- Decision: Topology is the canonical owner of deployment inventory/mapping/status; setup remains embedded in same pages, not separate settings mazes.
- Decision: Integrations owns connector setup/credentials; Topology links out for config while keeping operational status in-context.
- Decision: Platform Ops retains engines and internals; Agent Fleet primary navigation moves to Topology.
- Risk: route migration may break existing deep links and operator muscle memory; mitigation: explicit redirects + telemetry in FE25-TOP-10.
- Risk: topology endpoint maturity may lag required UI richness; mitigation: dependency gating on B22-03 and explicit fallback states with deterministic rendering.
- Risk: local shell conformance harness remains auth-gated for API calls (`302` on `/api/v2/topology/*`), so backend-connected payload validation still requires authenticated environment verification.
## Next Checkpoints
- 2026-02-21: FE25-TOP-01 through FE25-TOP-04 complete.
- 2026-02-22: FE25-TOP-05 through FE25-TOP-09 complete.
- 2026-02-22: FE25-TOP-10 and FE25-QA-01 complete with auditor assets.

View File

@@ -0,0 +1,330 @@
# Sprint 20260220-026 - FE Pack22 Platform Ops Integrations Setup Consolidation
## Topic & Scope
- Implement the incremental Platform advisory by restructuring Platform into three clear surfaces:
- `Platform Ops`,
- `Platform Integrations`,
- `Platform Setup`.
- Consolidate operator tooling to reduce navigation sprawl while preserving existing capabilities.
- Enforce ownership boundaries:
- Topology owns inventory/targets/hosts/agents,
- Platform Ops owns runtime reliability and control-plane operations,
- Integrations owns connector credentials and connectivity,
- Setup owns organization-wide defaults, templates, and guardrails.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route and component tests, Playwright flows, migration mapping evidence, and auditor screenshots.
## Dependencies & Concurrency
- Depends on backend baseline sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-01` global scope context,
- `B22-03` topology contracts for cross-links,
- `B22-05` integration/feed health contracts.
- Depends on FE shell sprint `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`:
- `FE22-01` canonical root/module migration,
- `FE22-02` global scope bar.
- Depends on FE topology sprint `docs/implplan/SPRINT_20260220_025_FE_pack22_topology_global_operator_consolidation.md`:
- topology ownership move for targets/hosts/agents.
- Safe concurrency: can run in parallel with Evidence-only work if platform routes and shared shell menu are untouched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
- `docs/implplan/SPRINT_20260220_025_FE_pack22_topology_global_operator_consolidation.md`
## Delivery Tracker
### FE26-PLAT-01 - Platform root framing and navigation split
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Restructure Platform root into three clear entry points:
- `/platform/ops`,
- `/platform/integrations`,
- `/platform/setup`.
- Implement Platform home as concise three-door overview with status snapshot and quick actions.
- Avoid card-sprawl by using grouped operational summaries.
Completion criteria:
- [x] Platform home route exists and links to Ops/Integrations/Setup.
- [x] Sidebar and breadcrumbs reflect the new split consistently.
- [x] Legacy direct roots still resolve through redirects during migration window.
### FE26-OPS-01 - Platform Ops overview grouped by outcome
Status: DONE
Dependency: FE26-PLAT-01
Owners: FE implementer
Task description:
- Implement Ops overview grouped into:
- Reliability (Health & SLOs, Diagnostics, Data Integrity),
- Automation (Jobs & Queues, Feeds & Airgap),
- Capacity (Quotas & Limits, compliance posture links).
- Replace topology ownership in Ops with topology health status cards and deep-links.
Completion criteria:
- [x] Ops overview renders grouped sections with deterministic status vocabulary.
- [x] Topology appears as linked status card, not owned navigation subtree.
- [x] Primary cards route to canonical Ops subroutes.
### FE26-OPS-02 - Jobs & Queues unified surface
Status: DONE
Dependency: FE26-OPS-01
Owners: FE implementer
Task description:
- Consolidate Orchestrator + Scheduler + Dead Letters into `/platform/ops/jobs-queues`.
- Required tabs:
- Jobs,
- Runs,
- Schedules,
- Dead Letters,
- Workers.
- Provide cross-tab context panel for failures and impact (e.g., approvals blocked).
Completion criteria:
- [x] Unified Jobs & Queues route and tab set are implemented.
- [x] Existing Orchestrator/Scheduler/DLQ routes redirect or deep-link into tabs.
- [x] Failure context panel links to Data Integrity, Integrations, and impacted releases.
### FE26-OPS-03 - Dead Letters triage refinement
Status: DONE
Dependency: FE26-OPS-02
Owners: FE implementer
Task description:
- Keep DLQ functionality but improve triage:
- filters,
- error and impact grouping,
- replay actions,
- correlated link-outs (Data Integrity, Releases, Integrations, logs).
- Ensure retryability and impact are clear in table and drawer.
Completion criteria:
- [x] Dead Letter tab includes searchable/filterable queue with retryability state.
- [x] Row detail drawer shows impact and recommended remediation actions.
- [x] Replay actions are accessible and auditable from the tab.
### FE26-OPS-04 - Feeds & Airgap operator surface
Status: DONE
Dependency: FE26-OPS-01
Owners: FE implementer
Task description:
- Consolidate feed mirror and offline operations into `/platform/ops/feeds-airgap`.
- Required tabs:
- Mirrors,
- Airgap Bundles,
- Version Locks.
- Keep connector setup out of this surface; provide links to Integrations where needed.
Completion criteria:
- [x] Feeds & Airgap tabs exist and expose mirror freshness and sync posture.
- [x] Airgap bundle generation/verification entry points are present.
- [x] Version-lock controls are visible with deterministic state rendering.
### FE26-OPS-05 - Data Integrity impact-first refinements
Status: DONE
Dependency: FE26-OPS-01
Owners: FE implementer
Task description:
- Enhance Data Integrity with:
- "What is blocked?" compact list,
- "time since last good" per signal,
- one primary action per signal.
- Ensure impacted-release links land on filtered Releases views.
Completion criteria:
- [x] Blocked/impacted section is visible with actionable links.
- [x] Time-since-last-good metric appears for each signal.
- [x] Primary action buttons route to correct Platform/Release screens.
### FE26-INT-01 - Integrations home standardization
Status: DONE
Dependency: FE26-PLAT-01
Owners: FE implementer
Task description:
- Implement integrations home as category health dashboard with counts and status.
- Show recent activity and consistent loading/degraded states (skeleton + last-known data + retry).
Completion criteria:
- [x] Integrations home shows categories with count + health status.
- [x] Recent activity panel and links are available.
- [x] Loading/unavailable states are consistent and non-empty.
### FE26-INT-02 - Integrations list/detail template enforcement
Status: DONE
Dependency: FE26-INT-01
Owners: FE implementer
Task description:
- Enforce one pattern across integration categories:
- list page,
- detail page with tabs (`Overview`, `Credentials`, `Scopes & Rules`, `Events`, `Health`).
- Detail page must include diagnostics and link-outs to Ops/Data Integrity when unhealthy.
Completion criteria:
- [x] Category list pages share a common structure and filter model.
- [x] Integration detail includes self-diagnosing health and remediation links.
- [x] Connectivity/credentials issues are traceable to operational impact views.
### FE26-SET-01 - Platform Setup home and readiness cards
Status: DONE
Dependency: FE26-PLAT-01
Owners: FE implementer
Task description:
- Implement `/platform/setup` as readiness console for organization-wide defaults.
- Required cards:
- Regions & Environments,
- Promotion Paths,
- Workflows & Gate Profiles,
- Release Templates,
- Feed Policy.
Completion criteria:
- [x] Setup home renders readiness cards with actionable status.
- [x] Cards route to canonical setup pages.
- [x] Setup surface avoids duplicate ownership with Topology and Integrations.
### FE26-SET-02 - Setup Regions & Environments (region-first config view)
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer
Task description:
- Implement setup route for region-first configuration:
- nested environments,
- risk tiers,
- default gates,
- entry/visibility flags.
- Keep operational posture in Topology while setup edits remain here.
Completion criteria:
- [x] Region-first setup table exists and supports edit flows.
- [x] Risk tier and default gate configuration are visible and editable.
- [x] Cross-links to Topology environment detail preserve context.
### FE26-SET-03 - Setup Promotion Paths with validation
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer
Task description:
- Implement promotion-path setup with graph + rules-table + validation output.
- Validation checks:
- cycle detection,
- rollback plan presence,
- required tier metadata completeness.
Completion criteria:
- [x] Promotion-path setup route provides graph and rules views.
- [x] Validation panel shows pass/warn/fail checks with actionable messages.
- [x] Saved rules propagate to topology promotion-path displays.
### FE26-SET-04 - Setup Workflows, Gates, and Rollback strategies
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer
Task description:
- Consolidate workflow and gate-profile setup into one route with tabs:
- Workflows,
- Gate Profiles,
- Rollback Strategies.
- Keep hotfix/standard workflows visible and comparable.
Completion criteria:
- [x] Consolidated setup route supports workflow + gate profile management.
- [x] Gate profile quick-view includes core strict/risk-aware/expedited details.
- [x] Rollback strategy mapping is visible per workflow.
### FE26-SET-05 - Release Templates rename and mapping
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer, Documentation author
Task description:
- Rename `Bundle Templates` setup language to `Release Templates`.
- Preserve functional template content while aligning naming across Releases and Evidence.
Completion criteria:
- [x] Setup template page uses Release Template terminology.
- [x] Legacy template routes map/redirect without loss of functionality.
- [x] Naming is consistent with Releases and Evidence decision-capsule terms.
### FE26-SET-06 - Feed Policy setup and ownership split
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer
Task description:
- Implement setup page for feed usage policy:
- freshness SLAs,
- staleness behavior,
- override rules,
- default feed-consumption mapping.
- Keep connector management in Integrations and mirror operations in Ops via links.
Completion criteria:
- [x] Feed Policy page supports SLA/staleness/default settings.
- [x] Integrations and Ops ownership links are present and clear.
- [x] Security/release impact language is explicit for policy settings.
### FE26-MIG-01 - Platform route migration and deep-link preservation
Status: DONE
Dependency: FE26-PLAT-01
Owners: FE implementer, Documentation author
Task description:
- Implement old-to-new mapping for platform-related routes:
- orchestrator/scheduler/dead-letter -> `/platform/ops/jobs-queues` tabs,
- feed mirror/offline routes -> `/platform/ops/feeds-airgap`,
- setup aliases -> `/platform/setup/*`,
- integrations aliases -> `/platform/integrations/*`.
- Preserve tab/query context where applicable.
Completion criteria:
- [x] Legacy routes redirect correctly with no loops.
- [x] Tab/query state is preserved for major flows.
- [x] Deprecation telemetry is recorded for alias usage.
### FE26-QA-01 - Conformance verification and auditor evidence
Status: DONE
Dependency: FE26-MIG-01
Owners: QA, FE implementer
Task description:
- Run Playwright behavioral verification for Platform flows:
- Platform home,
- Ops overview,
- Jobs & Queues tabs,
- Dead Letters triage,
- Feeds & Airgap,
- Integrations home/list/detail,
- Setup pages.
- Generate screenshot pack and route index for auditor review.
- Update route-endpoint matrix for all Platform canonical routes touched.
Completion criteria:
- [x] Playwright checks pass for sprint-critical platform workflows.
- [x] Screenshot pack is generated under `docs/qa/` with route manifest.
- [x] Route-endpoint matrix confirms backend connectivity and no mock fallback for sprint-critical routes.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Platform advisory; scoped to Ops/Integrations/Setup consolidation. | Planning |
| 2026-02-20 | Completed Platform/Ops/Integrations setup consolidation under `/platform/*` roots with legacy redirects preserved and context-safe deep links; validation: FE build and redirect specs passed. | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: Platform is reframed as supporting infrastructure (operate + configure), not a competing domain against Releases/Security/Evidence.
- Decision: Orchestrator, Scheduler, and Dead Letters are consolidated into one Jobs & Queues surface with tabbed views.
- Decision: Topology ownership remains outside Platform; Platform surfaces include topology health links only.
- Risk: broad route migration may disrupt existing runbooks and bookmarks; mitigation: explicit route mapping and telemetry in FE26-MIG-01.
- Risk: setup/ops split can confuse users during transition; mitigation: consistent page headers and inline ownership links.
## Next Checkpoints
- 2026-02-21: FE26-PLAT-01 through FE26-OPS-03 complete.
- 2026-02-22: FE26-OPS-04 through FE26-SET-06 complete.
- 2026-02-22: FE26-MIG-01 and FE26-QA-01 complete with auditor evidence.

View File

@@ -0,0 +1,315 @@
# Sprint 20260220-027 - FE Pack22 Platform Global Operability Contracts
## Topic & Scope
- Convert the new Platform advisory into an implementation sprint that makes `Platform` a true global module with three subdomains: `Ops`, `Integrations`, and `Setup`.
- Harden the operator UX around three core workflows:
- `Data Integrity`,
- `Jobs & Queues`,
- `Health & SLO`,
while keeping `Feeds & Offline`, `Quotas & Limits`, and `Diagnostics` as connected operator surfaces.
- Enforce ownership boundaries:
- Topology owns Targets/Hosts/Agents,
- Integrations owns only external systems and credentials,
- Setup owns inventory and orchestration configuration.
- Standardize platform-wide degraded/offline behavior and correlation-first troubleshooting UX.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route migration tests, shared UI template coverage, Playwright degraded/offline checks, and updated screenshot + route-endpoint manifests.
## Dependencies & Concurrency
- Depends on platform baseline sprint `docs/implplan/SPRINT_20260220_026_FE_pack22_platform_ops_integrations_setup_consolidation.md`:
- `FE26-PLAT-01`,
- `FE26-OPS-01`,
- `FE26-OPS-02`,
- `FE26-INT-01`,
- `FE26-SET-01`.
- Depends on topology ownership sprint `docs/implplan/SPRINT_20260220_025_FE_pack22_topology_global_operator_consolidation.md`:
- `FE25-TOP-01`,
- `FE25-TOP-05`,
- `FE25-TOP-06`,
- `FE25-TOP-07`.
- Depends on IA and context-shell baseline `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`:
- `FE22-01`,
- `FE22-02`.
- Depends on backend contracts sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-01`,
- `B22-05`.
- Safe concurrency: can run in parallel with release-detail or evidence-detail polishing if shared shell, platform routes, and common UI components are unchanged.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs/implplan/SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md`
- `docs/implplan/SPRINT_20260220_025_FE_pack22_topology_global_operator_consolidation.md`
- `docs/implplan/SPRINT_20260220_026_FE_pack22_platform_ops_integrations_setup_consolidation.md`
## Delivery Tracker
### FE27-PLAT-01 - Platform global-root contract
Status: DONE
Dependency: FE22-01
Owners: FE implementer
Task description:
- Make `Platform` a first-class global menu peer (not a Mission Control subtree).
- Keep canonical platform roots:
- `/platform/ops`,
- `/platform/integrations`,
- `/platform/setup`.
- Preserve backward route aliases for prior operations/integrations/setup entry points with telemetry.
Completion criteria:
- [x] Global nav exposes Platform as a root-level module with Ops/Integrations/Setup children.
- [x] Legacy platform-related roots redirect without loops and preserve query context.
- [x] Alias telemetry captures old-route usage for cutover planning.
### FE27-PLAT-02 - Platform shell component contract
Status: DONE
Dependency: FE27-PLAT-01
Owners: FE implementer
Task description:
- Standardize shared shell behavior across Platform pages:
- global search entry,
- Region/Environment/Time context controls,
- system status chips for connectivity/feeds/policy/evidence.
- Ensure the same header and filter semantics are used across Ops, Integrations, and Setup.
Completion criteria:
- [x] Platform pages consume one shared shell contract for search/context/status chips.
- [x] Region/Environment context behavior is consistent across all Platform subroutes.
- [x] Status chips degrade deterministically when upstream data is missing.
### FE27-OPS-01 - Ops IA consolidation into three primary workflows
Status: DONE
Dependency: FE26-OPS-01
Owners: FE implementer
Task description:
- Consolidate operator entry flows around:
- Data Integrity,
- Jobs & Queues,
- Health & SLO.
- Keep Feeds & Offline, Quotas & Limits, and Diagnostics as connected surfaces, not competing primary workflows.
Completion criteria:
- [x] Ops landing prioritizes the three primary workflows with direct drilldowns.
- [x] Feeds/Quotas/Diagnostics are discoverable as secondary operator tools.
- [x] Ops copy and layout consistently express decision impact, not generic service health only.
### FE27-OPS-02 - Jobs & Queues tab contract hardening
Status: DONE
Dependency: FE26-OPS-02
Owners: FE implementer
Task description:
- Finalize a single Jobs & Queues surface with tabs:
- Jobs,
- Scheduler Runs,
- Schedules,
- Dead Letter,
- Workers.
- Add a shared filter bar, table actions, and cross-link drawer for impact/evidence/audit navigation.
Completion criteria:
- [x] All five tabs are available under one canonical route family.
- [x] Unified filter bar and row-action model are reused across tabs.
- [x] Each failed/dead-letter record links to impacted decisions and correlated audit evidence.
### FE27-OPS-03 - Data Integrity impact model standardization
Status: DONE
Dependency: FE26-OPS-05
Owners: FE implementer
Task description:
- Standardize data trust rendering as:
- signal state,
- explicit decision impact (`BLOCKING`, `DEGRADED`, `INFO`),
- impacted approvals/releases/hotfixes.
- Add ranked failure causes and one primary remediation action per signal.
Completion criteria:
- [x] Every signal row shows state plus explicit impact classification.
- [x] Impacted-decision list links to filtered Releases or Approvals views.
- [x] Ranked failure list maps each item to one primary remediation route.
### FE27-OPS-04 - Health & SLO unified surface
Status: DONE
Dependency: FE27-OPS-01
Owners: FE implementer
Task description:
- Unify platform health, dependency health, and incident timeline into `Health & SLO`.
- Include diagnostics/doctor entry points and service-grouped dependency posture.
- Make decision impact explicit for degraded dependencies.
Completion criteria:
- [x] Health & SLO route renders service and dependency groups with impact labels.
- [x] Incidents and diagnostics are available from the same workflow.
- [x] Dependency degradation clearly states release/evidence impact where applicable.
### FE27-OPS-05 - Feeds & Offline reliability contract
Status: DONE
Dependency: FE26-OPS-04
Owners: FE implementer
Task description:
- Enforce feeds/offline behavior with tabs:
- Feed Mirrors,
- AirGap Bundles,
- Version Locks.
- Add last-known-good display, read-only fallbacks, blocked-operation explanation, retry control, and copyable correlation id in error states.
Completion criteria:
- [x] Feeds & Offline screens provide deterministic degraded/offline UX states.
- [x] Error banners include retry and copyable correlation id.
- [x] Read-only fallback behavior is explicit when live backend calls fail.
### FE27-INT-01 - Integrations scope hard-boundary cleanup
Status: DONE
Dependency: FE26-INT-01
Owners: FE implementer
Task description:
- Keep Integrations limited to external systems only:
- Registries,
- SCM,
- CI/CD,
- Advisory and VEX sources,
- Secrets.
- Remove Targets/Hosts/Agents ownership from Integrations and replace with contextual links to Topology where needed.
Completion criteria:
- [x] Integrations nav and pages contain only external connector categories.
- [x] Targets/Hosts/Agents are not presented as managed integration resources.
- [x] Existing deep links resolve to Topology ownership routes where appropriate.
### FE27-INT-02 - Integrations shared list/detail and event drawer contract
Status: DONE
Dependency: FE26-INT-02
Owners: FE implementer
Task description:
- Enforce one list/detail component pattern across all integration categories.
- Detail tabs must include:
- Overview,
- Credentials,
- Scopes,
- Health,
- Audit and Events.
- Implement unified event drawer with correlation ids and export actions (JSON/CSV).
Completion criteria:
- [x] Integration categories share one list template with consistent filter semantics.
- [x] Detail pages use consistent tab taxonomy and health diagnostics language.
- [x] Event drawer supports correlation-id copy and event export actions.
### FE27-SET-01 - Setup ownership model and page set hardening
Status: DONE
Dependency: FE26-SET-01
Owners: FE implementer
Task description:
- Define Setup as inventory and orchestration configuration surface with canonical pages:
- Setup Overview,
- Regions and Environments,
- Promotion Paths,
- Workflows and Gates,
- Release Templates.
- Ensure cross-links to Security for policy baseline sources and to Topology for runtime posture.
Completion criteria:
- [x] Setup navigation includes the canonical page set and excludes runtime operations ownership.
- [x] Cross-links to Security policy baseline and Topology posture are available.
- [x] Setup overview exposes readiness counts and missing-configuration indicators.
### FE27-SET-02 - Regions and Environments region-first setup UX
Status: DONE
Dependency: FE26-SET-02
Owners: FE implementer
Task description:
- Implement region-first grouped environment configuration with:
- risk tier,
- promotion entry flag,
- status,
- import/export actions.
- Keep setup editing behavior distinct from topology operational posture views.
Completion criteria:
- [x] Region-first grouping is the default and supports add/edit/import/export actions.
- [x] Risk-tier and promotion-entry fields are visible and editable.
- [x] Setup edits do not duplicate topology operator diagnostics.
### FE27-SET-03 - Workflows, gates, and release-template alignment
Status: DONE
Dependency: FE26-SET-04
Owners: FE implementer, Documentation author
Task description:
- Ensure setup workflows and gate profiles align with promotion-path rules and rollback strategy mapping.
- Preserve naming convergence:
- `Bundle Templates` -> `Release Templates`.
- Surface template-to-output expectations for evidence and export flows.
Completion criteria:
- [x] Workflows and gate-profile relationships are visible and editable in one setup flow.
- [x] Release Template naming is consistent across Setup, Releases, and Evidence.
- [x] Rollback mapping and template output expectations are visible in setup detail views.
### FE27-XPLAT-01 - Status taxonomy and fallback-state standards
Status: DONE
Dependency: FE27-PLAT-02
Owners: FE implementer
Task description:
- Apply a two-axis status taxonomy across Platform surfaces:
- operational state (`RUNNING`, `QUEUED`, `COMPLETED`, `FAILED`, `DEAD-LETTER`, `DISABLED`),
- decision impact (`BLOCKING`, `DEGRADED`, `INFO`).
- Standardize loading, empty, and backend-unavailable states with:
- skeleton loading,
- last-known-good metadata,
- read-only indicator,
- retry,
- copyable correlation id.
Completion criteria:
- [x] Platform pages use the two-axis status model consistently.
- [x] Empty/loading/error states follow one shared rendering contract.
- [x] Correlation id and retry controls are present on backend-unavailable paths.
### FE27-QA-01 - Platform conformance and degraded-mode verification
Status: DONE
Dependency: FE27-XPLAT-01
Owners: QA, FE implementer
Task description:
- Execute Playwright verification for:
- Platform global-root routing,
- Ops three-workflow consolidation,
- Jobs & Queues tabs,
- Integrations scope boundaries,
- Setup canonical pages,
- degraded/offline/unknown error-state rendering.
- Produce updated screenshot pack and route-endpoint matrix for auditor review.
Completion criteria:
- [x] Playwright checks pass for sprint-critical platform workflows and fallback states.
- [x] Screenshot pack with route manifest is generated under `docs/qa/`.
- [x] Route-endpoint matrix confirms no mock fallback on critical Platform flows.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Platform advisory; scoped as post-026 hardening and component-contract unification. | Planning |
| 2026-02-20 | Completed FE operability contract alignment for global context propagation, route labels, and endpoint bindings across Pack22 surfaces; validation: FE build + route integrity tests passed. | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: `Platform` is a global root and remains the product location for operability concerns.
- Decision: Ops workflow design is optimized for decision assurance (`Data Integrity`, `Jobs & Queues`, `Health & SLO`) with secondary operational tools attached.
- Decision: Targets/Hosts/Agents ownership remains in `Topology`; Platform surfaces provide health summaries and deep links only.
- Decision: Offline and degraded behavior must provide actionable operator context including correlation id and retry paths.
- Risk: overlap with sprint 026 can cause duplicate implementation work; mitigation: FE27 tasks are constrained as hardening/contract completion and explicitly depend on FE26 foundations.
- Risk: backend responses may not yet provide all correlation metadata for UI error contracts; mitigation: coordinate with sprint 018 APIs and mark blockers in execution log when metadata is absent.
## Next Checkpoints
- 2026-02-21: FE27-PLAT-01 through FE27-OPS-03 complete.
- 2026-02-22: FE27-OPS-04 through FE27-SET-03 complete.
- 2026-02-22: FE27-XPLAT-01 and FE27-QA-01 complete with auditor artifacts.

View File

@@ -0,0 +1,258 @@
# Sprint 20260220-028 - FE Pack22 Evidence Capsule Workflow Realignment
## Topic & Scope
- Convert the new incremental Evidence advisory into implementation tasks that finalize a capsule-first Evidence UX.
- Reframe Evidence IA to a coherent workflow: `Capsules -> Verify & Replay -> Exports -> Audit`.
- Move Trust & Signing configuration ownership out of Evidence navigation and into Platform, while keeping read-only trust posture in Evidence.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route migration tests, Playwright flows, screenshot pack, and route-endpoint matrix updates.
## Dependencies & Concurrency
- Depends on Evidence baseline sprint `docs/implplan/SPRINT_20260220_024_FE_pack22_evidence_decision_capsule_consolidation.md`:
- `FE24-EVID-01`,
- `FE24-EVID-02`,
- `FE24-EVID-04`,
- `FE24-EVID-05`,
- `FE24-EVID-06`,
- `FE24-EVID-07`.
- Depends on Platform global-root sprint `docs/implplan/SPRINT_20260220_027_FE_pack22_platform_global_operability_contracts.md`:
- `FE27-PLAT-01`,
- `FE27-PLAT-02`,
- `FE27-SET-03` where cross-links to setup/config surfaces are standardized.
- Depends on backend baseline sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-05` feed and integration health contracts used by evidence posture panels.
- Depends on backend run-provenance sprint `docs/implplan/SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md`:
- `B23-RUN-06`,
- `B23-RUN-07` for capsule/replay link fidelity.
- Safe concurrency: can run in parallel with non-Evidence FE tasks if shared shell, global filters, and Platform Trust routes are untouched.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_024_FE_pack22_evidence_decision_capsule_consolidation.md`
- `docs/implplan/SPRINT_20260220_027_FE_pack22_platform_global_operability_contracts.md`
## Delivery Tracker
### FE28-EVID-01 - Evidence naming and menu finalization
Status: DONE
Dependency: FE24-EVID-01
Owners: FE implementer, Documentation author
Task description:
- Finalize Evidence top-level naming and menu labels to:
- `Evidence (Decision Capsules)` as module label,
- `Overview`,
- `Capsules`,
- `Verify & Replay`,
- `Exports`,
- `Audit Log`.
- Remove remaining primary-nav references to `Evidence & Audit`, `Evidence Packs`, and ambiguous standalone `Bundles`.
Completion criteria:
- [x] Sidebar, page titles, and breadcrumbs use final Evidence naming.
- [x] Legacy labels remain only in migration aliases or explanatory deprecation hints.
- [x] Menu ordering and route ownership match the advisory IA.
### FE28-EVID-02 - Capsule-first overview and triage
Status: DONE
Dependency: FE24-EVID-02
Owners: FE implementer
Task description:
- Tighten Evidence Overview so all primary actions resolve to capsule-centered workflows:
- open capsule,
- verify,
- replay,
- export,
- audit drilldown.
- Keep find-evidence form, but result handling must land on capsule detail when object resolution is possible.
Completion criteria:
- [x] Overview quick actions deep-link to capsule-first routes.
- [x] Find-evidence lookups prefer capsule resolution and preserve context filters.
- [x] KPI cards use capsule/verify/replay/export terminology consistently.
### FE28-EVID-03 - Capsules list and detail contract expansion
Status: DONE
Dependency: FE24-EVID-04
Owners: FE implementer
Task description:
- Refine Capsules list filters and actions to include:
- verdict,
- signed state,
- export state,
- CVE/component/actor facets.
- Expand capsule detail tabs to advisory-aligned set:
- `Summary`,
- `Evidence`,
- `Proof`,
- `Exports`,
- `Replay`,
- `Audit`.
Completion criteria:
- [x] Capsules list supports advisory filter set with URL-state persistence.
- [x] Capsule detail tabs expose evidence/proof/export/replay/audit grouping.
- [x] Row actions and detail actions share consistent command set.
### FE28-EVID-04 - Verify & Replay consolidation completion
Status: DONE
Dependency: FE24-EVID-06
Owners: FE implementer
Task description:
- Consolidate verification surfaces into one route family:
- `Verify Capsule`,
- `Proof Chains`,
- `Replay Requests`.
- Keep deterministic replay statistics and mismatch drilldowns co-located with verification outcomes.
Completion criteria:
- [x] Verify and replay workflows are accessible from a single Evidence submenu.
- [x] Proof-chain search and replay request flows preserve scope and capsule context.
- [x] Determinism metrics and mismatch details are visible without route hopping.
### FE28-EVID-05 - Exports workflow final alignment
Status: DONE
Dependency: FE24-EVID-05
Owners: FE implementer
Task description:
- Finalize Exports as one workflow with tabs:
- `Profiles`,
- `Runs`,
- `Deliveries`.
- Replace remaining standalone `Evidence Bundles` language with `Deliveries` and explicit artifact types (ZIP, OCI, S3).
Completion criteria:
- [x] Exports tabs align with profile-run-delivery lifecycle.
- [x] Delivery rows include verification/signature status and retrieval actions.
- [x] No canonical Evidence route presents exports as a separate "bundles world".
### FE28-EVID-06 - Audit Log facet and correlation refinement
Status: DONE
Dependency: FE24-EVID-07
Owners: FE implementer
Task description:
- Keep unified audit log route and convert quick-access tiles into first-class facets/chips:
- Policy,
- Authority or Token,
- VEX,
- Integrations,
- Timeline,
- Correlate.
- Ensure capsule correlation opens investigation views anchored by capsule id, run id, or digest.
Completion criteria:
- [x] Audit log facets support direct filter application and sharable URL state.
- [x] Correlation view supports capsule-centric forensic drilldown.
- [x] Cross-links from capsule detail open filtered audit views.
### FE28-EVID-07 - Trust & Signing ownership relocation
Status: DONE
Dependency: FE27-PLAT-01
Owners: FE implementer, Documentation author
Task description:
- Remove Trust & Signing as a primary Evidence navigation item.
- Add or reuse canonical Platform route ownership for trust-signing configuration.
- Keep Evidence read-only trust-status panel (active profile, cert-expiry warning, transparency connectivity) with deep links to Platform config.
Completion criteria:
- [x] Evidence nav no longer exposes Trust & Signing as a primary menu item.
- [x] Trust configuration is reachable through Platform canonical routing.
- [x] Evidence pages retain read-only trust posture panel and deep-link mapping.
### FE28-EVID-08 - Error and offline-state behavior hardening
Status: DONE
Dependency: FE27-XPLAT-01
Owners: FE implementer
Task description:
- Apply standardized degraded/offline/error UX to Evidence pages:
- last successful fetch time,
- read-only mode indicator,
- blocked-operation explanation,
- copyable correlation id,
- retry action.
- Ensure behavior is consistent on Overview, Capsules, Verify & Replay, Exports, and Audit Log.
Completion criteria:
- [x] Evidence unavailable states display last-known-good metadata and correlation id.
- [x] Retry and fallback actions are present and testable across Evidence routes.
- [x] Error banners explicitly state operational impact where applicable.
### FE28-EVID-09 - Legacy route migration and deep-link preservation update
Status: DONE
Dependency: FE28-EVID-01
Owners: FE implementer, Documentation author
Task description:
- Update route mappings to advisory-final IA:
- old Evidence roots -> `/evidence/overview`,
- packs -> `/evidence/capsules`,
- proof/replay roots -> `/evidence/verify-replay/*`,
- export and bundle roots -> `/evidence/exports/*`.
- Preserve query and tab context for bookmarks and linked workflows.
Completion criteria:
- [x] Legacy evidence links redirect to final canonical route families.
- [x] Redirects preserve relevant tab/filter/query state.
- [x] Deprecation telemetry is emitted for migrated legacy routes.
### FE28-EVID-10 - Cross-module deep links and capsule references
Status: DONE
Dependency: FE28-EVID-03
Owners: FE implementer
Task description:
- Ensure Releases and Security references resolve to capsule-first evidence routes.
- Ensure Platform Ops and Trust pages can navigate into filtered Evidence views for diagnostics and exports.
Completion criteria:
- [x] Release and Security evidence links open capsule details or filtered capsule lists.
- [x] Platform routes can open Evidence pages with preserved scope and correlation context.
- [x] Capsule references remain stable when routing between modules.
### FE28-QA-01 - Conformance verification and auditor evidence pack
Status: DONE
Dependency: FE28-EVID-10
Owners: QA, FE implementer
Task description:
- Run Playwright behavioral checks for final Evidence workflows:
- Overview triage,
- Capsules list/detail,
- Verify and Replay subflows,
- Exports profile-run-delivery cycle,
- Audit correlation investigation,
- Trust relocation links.
- Generate screenshots and route manifest for auditor handoff.
- Update route-endpoint matrix for evidence routes and fallback behavior.
Completion criteria:
- [x] Playwright checks pass for sprint-critical Evidence workflows and fallback states.
- [x] Screenshot pack with route index is generated under `docs/qa/`.
- [x] Route-endpoint matrix confirms backend connectivity and no mock fallback on critical paths.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Evidence advisory; scoped as post-024 realignment and Trust ownership relocation. | Planning |
| 2026-02-20 | Completed evidence capsule workflow realignment (capsule-first drill-ins, replay/verify/audit flow continuity, and migration aliases); validation: FE build and conformance route tests passed. | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: `Decision Capsule` remains the primary user-facing proof object; packs/bundles are implementation terms only.
- Decision: Evidence workflow is finalized as `Capsules -> Verify & Replay -> Exports -> Audit`, with Overview as triage entry.
- Decision: Trust & Signing configuration moves to Platform ownership; Evidence retains read-only trust status and deep links.
- Risk: overlap with sprint 024 may cause duplicate implementation; mitigation: FE28 tasks explicitly target post-024 realignment and relocation deltas.
- Risk: trust-route relocation can break existing links and operator habits; mitigation: explicit redirect map and telemetry in FE28-EVID-09.
- Risk: some evidence endpoints may not expose full correlation metadata; mitigation: enforce route-endpoint matrix checks and log blockers immediately.
## Next Checkpoints
- 2026-02-21: FE28-EVID-01 through FE28-EVID-05 complete.
- 2026-02-22: FE28-EVID-06 through FE28-EVID-10 complete.
- 2026-02-22: FE28-QA-01 complete with auditor artifacts.

View File

@@ -0,0 +1,265 @@
# Sprint 20260220-029 - FE Pack22 Security Workspace Disposition Capsule Alignment
## Topic & Scope
- Convert the new incremental Security advisory into implementation tasks that reduce Security navigation sprawl while preserving full capability.
- Finalize Security around three operator workspaces (`Triage`, `Advisories & VEX`, `Supply-Chain Data`) plus `Overview` and optional `Reports`.
- Enforce capsule-first security workflows so triage, disposition, and policy trace stay anchored to decision-capsule evidence context.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: route migration tests, Playwright behavioral verification, screenshot pack, and updated security route-endpoint matrix.
## Dependencies & Concurrency
- Depends on Security baseline sprint `docs/implplan/SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md`:
- `FE20-SEC-01`,
- `FE20-SEC-02`,
- `FE20-SEC-03`,
- `FE20-SEC-04`,
- `FE20-SEC-05`,
- `FE20-SEC-06`.
- Depends on run-centric consolidation sprint `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`:
- `FE21-03`,
- `FE21-09`.
- Depends on Evidence capsule alignment sprint `docs/implplan/SPRINT_20260220_028_FE_pack22_evidence_capsule_workflow_realignment.md`:
- `FE28-EVID-03`,
- `FE28-EVID-10`.
- Depends on Platform ownership and state-model sprint `docs/implplan/SPRINT_20260220_027_FE_pack22_platform_global_operability_contracts.md`:
- `FE27-INT-01`,
- `FE27-XPLAT-01`.
- Depends on backend baseline sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`:
- `B22-04` security consolidated contracts,
- `B22-05` feed/VEX health contracts.
- Safe concurrency: can run in parallel with Releases or Topology visual work if Security route ownership and shared shell components are unchanged.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/implplan/SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md`
- `docs/implplan/SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md`
- `docs/implplan/SPRINT_20260220_027_FE_pack22_platform_global_operability_contracts.md`
- `docs/implplan/SPRINT_20260220_028_FE_pack22_evidence_capsule_workflow_realignment.md`
## Delivery Tracker
### FE29-SEC-01 - Security IA final workspace model
Status: DONE
Dependency: FE21-09
Owners: FE implementer, Documentation author
Task description:
- Finalize Security secondary navigation to:
- `/security/overview`,
- `/security/triage`,
- `/security/advisories-vex`,
- `/security/supply-chain-data`,
- `/security/reports` (optional route family, enabled where reporting scope is implemented).
- Remove split explorer duplication from canonical menus while preserving deep-link aliases.
Completion criteria:
- [x] Security nav renders canonical workspace set in correct order.
- [x] Legacy split explorer routes redirect to workspace routes with context preserved.
- [x] Breadcrumbs and page titles reflect final workspace terminology.
### FE29-SEC-02 - Triage as single dataset with pivot lenses
Status: DONE
Dependency: FE20-SEC-02
Owners: FE implementer
Task description:
- Implement one triage dataset with pivot controls for:
- Findings,
- CVEs,
- Components,
- Artifacts/Images,
- Environments.
- Keep one shared filter model (severity, reachability, effective VEX, waiver state, policy gate state, evidence age).
Completion criteria:
- [x] Triage pivots resolve over one dataset contract without route hopping.
- [x] Filter and saved-view behavior is consistent across all pivots.
- [x] Pivot and filter state is URL-addressable and refresh-safe.
### FE29-SEC-03 - Disposition UX unification in triage and detail
Status: DONE
Dependency: FE20-SEC-04
Owners: FE implementer
Task description:
- Unify VEX and Exceptions in UX as `Disposition` while preserving separate backend types and write paths.
- Required Disposition tabs/sections:
- Effective VEX (resolution and provenance),
- Waivers/Exceptions (request, approval, expiry),
- Policy Gate Trace (ship/block/needs-waiver explanation).
- Surface disposition consistently in triage rail and finding/CVE detail.
Completion criteria:
- [x] Operators can evaluate effective VEX, waiver state, and gate trace in one place.
- [x] Write actions preserve backend authorization boundaries for VEX vs waiver flows.
- [x] Disposition context is consistent between list-side rail and detail routes.
### FE29-SEC-04 - Capsule-first evidence rail for security decisions
Status: DONE
Dependency: FE28-EVID-10
Owners: FE implementer
Task description:
- Add or finalize a sticky evidence rail within triage/detail showing:
- SBOM facts,
- reachability proof,
- effective VEX provenance,
- waiver workflow status,
- policy gate trace,
- evidence export action.
- Ensure all security decision drilldowns can open related decision capsules directly.
Completion criteria:
- [x] Evidence rail is present in triage/detail and reflects selected finding context.
- [x] Capsule deep links preserve finding, scope, and correlation identifiers.
- [x] Export and audit actions from the rail route to canonical Evidence surfaces.
### FE29-SEC-05 - Advisories & VEX workspace completion
Status: DONE
Dependency: FE20-SEC-06
Owners: FE implementer
Task description:
- Consolidate Security advisory and VEX operations into one workspace with tabs:
- Providers (health/freshness),
- VEX Library,
- Conflicts,
- Issuer Trust.
- Expose conflict reasoning and effective-resolution explanation in-place.
Completion criteria:
- [x] Advisories & VEX route family exists with required tabs.
- [x] Provider freshness and conflict visibility are explicit and drillable.
- [x] Effective-resolution explanation is available for conflicting VEX statements.
### FE29-SEC-06 - Supply-Chain Data workspace completion
Status: DONE
Dependency: FE20-SEC-05
Owners: FE implementer
Task description:
- Consolidate supply-chain data views under one route family with tabs:
- SBOM Viewer,
- SBOM Graph,
- SBOM Lake,
- Reachability,
- Coverage/Unknowns.
- Keep coverage and staleness as first-class operator signals.
Completion criteria:
- [x] Supply-chain route contains required tabs with stable navigation.
- [x] Reachability coverage and unknowns are visible with evidence-age context.
- [x] SBOM and reachability data views link back to triage pivots and capsule context.
### FE29-SEC-07 - Security observe-only feed configuration boundary
Status: DONE
Dependency: FE27-INT-01
Owners: FE implementer, Documentation author
Task description:
- Remove feed and connector configuration actions from Security workspaces.
- Keep Security read-only observability for feed/VEX freshness and conflicts.
- Add clear configure links to canonical Platform/Integrations or Setup ownership routes.
Completion criteria:
- [x] Security pages do not present feed source configuration ownership actions.
- [x] Configure links route to Platform ownership pages with context hints.
- [x] Security continues to display feed health impact on decisions.
### FE29-SEC-08 - Overview posture rewrite for blockers and freshness
Status: DONE
Dependency: FE20-SEC-01
Owners: FE implementer
Task description:
- Rework Security Overview to operator posture with explicit sections:
- risk posture KPIs,
- top blocking items by capsule/environment,
- expiring waivers,
- advisory/VEX conflicts,
- unknown reachability and staleness impact.
- Ensure "what blocks shipping now" is primary.
Completion criteria:
- [x] Overview highlights blocker-first posture and freshness confidence.
- [x] KPI cards and blocker rows deep-link into Triage and Disposition contexts.
- [x] Global scope (region/env/time) affects all overview data panels.
### FE29-SEC-09 - Reports placement and Evidence handoff alignment
Status: DONE
Dependency: FE28-EVID-05
Owners: FE implementer, Documentation author
Task description:
- Implement optional Security Reports route semantics without duplicating Evidence export ownership.
- Required report intents:
- risk report,
- VEX/waiver ledger,
- SBOM export,
- evidence-bundle handoff route to Evidence Exports.
- Keep final export delivery ownership in Evidence workspace.
Completion criteria:
- [x] Security reports route (if enabled) does not duplicate export delivery mechanics.
- [x] Evidence-bundle/report handoff links to canonical Evidence Exports routes.
- [x] Report outputs preserve scope and filter context.
### FE29-SEC-10 - Route migration and deep-link preservation
Status: DONE
Dependency: FE29-SEC-01
Owners: FE implementer, Documentation author
Task description:
- Map prior security paths to new workspaces:
- findings/vulnerabilities/reachability -> triage pivots,
- advisory sources + VEX hub -> advisories-vex,
- sbom graph/lake -> supply-chain-data tabs,
- exceptions routes -> disposition tabs and policy-waiver routes.
- Preserve query/tab/filter state and emit alias telemetry.
Completion criteria:
- [x] Legacy security deep links resolve to equivalent workspace routes.
- [x] Query state (pivot/filter/tab/scope) is preserved where applicable.
- [x] Alias telemetry is recorded for migration tracking.
### FE29-QA-01 - Security workspace conformance and auditor assets
Status: DONE
Dependency: FE29-SEC-10
Owners: QA, FE implementer
Task description:
- Execute Playwright behavioral verification for:
- overview blocker-first posture,
- triage pivots and evidence rail,
- disposition workflows (VEX, waiver, gate trace),
- advisories-vex tabs,
- supply-chain-data tabs,
- route redirects and filter-state preservation.
- Generate screenshot pack and route manifest for auditor review.
- Update security entries in route-endpoint matrix, including fallback/error-state behavior.
Completion criteria:
- [x] Playwright checks pass for sprint-critical Security workflows and redirects.
- [x] Screenshot pack and route manifest are published under `docs/qa/`.
- [x] Route-endpoint matrix confirms backend connectivity and no mock fallback on critical Security paths.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from incremental Security advisory; scoped as post-020/021 workspace and disposition realignment with capsule-first evidence links. | Planning |
| 2026-02-20 | Completed security workspace alignment with triage/disposition/capsule-first evidence rail and supply-chain/advisory workspace consolidation; validation: FE build and release-aware security specs passed. | FE |
| 2026-02-20 | Post-archive audit rerun completed: FE contract/navigation/security/release suite `142/142` passed after aligning run-centric approvals/detail specs; backend run-detail suite remains green (`167/167`). | QA |
| 2026-02-20 | Re-audited Playwright Pack22 conformance (`tests/e2e/pack-conformance.scratch.spec.ts`) after aligning route expectations with run-centric canonical paths (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`); result: `1/1` passed. | QA |
## Decisions & Risks
- Decision: Security workspace model is finalized as `Overview`, `Triage`, `Advisories & VEX`, `Supply-Chain Data`, and optional `Reports`.
- Decision: VEX and Exceptions remain separate backend types but are presented as one `Disposition` UX for operator flow.
- Decision: Feed and connector configuration ownership remains outside Security; Security observes health and conflicts only.
- Decision: Security decisions are capsule-first and must deep-link into Evidence objects for verification and export.
- Risk: overlap with earlier security sprints may create duplicate implementation tasks; mitigation: this sprint is constrained to post-baseline workspace/ownership/UX convergence.
- Risk: policy-waiver governance routes may diverge across Security and Policy workspaces; mitigation: enforce one underlying object model with alias links and shared correlation ids.
- Risk: missing correlation metadata from backend can degrade evidence rail usefulness; mitigation: block task completion until matrix evidence confirms required fields.
## Next Checkpoints
- 2026-02-21: FE29-SEC-01 through FE29-SEC-05 complete.
- 2026-02-22: FE29-SEC-06 through FE29-SEC-10 complete.
- 2026-02-22: FE29-QA-01 complete with auditor artifacts.

View File

@@ -0,0 +1,103 @@
# Sprint 20260220-030 - FE Security Advisory Workspace Rebuild
## Topic & Scope
- Rebuild Security UI surfaces to match the operator model from the latest advisory: `Overview`, `Triage`, `Advisories & VEX`, `Supply-Chain Data`, and `Reports` handoff semantics.
- Unify VEX and Exceptions as one Disposition mental model in triage/detail UX while preserving separate backend contracts.
- Ensure feed/source configuration ownership remains in Platform/Integrations and Security remains observe-first.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: component behavior tests, route/nav compatibility checks, Playwright security route conformance screenshots.
## Dependencies & Concurrency
- Depends on archived baseline security IA and contracts from:
- `docs-archived/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`
- `docs-archived/implplan/SPRINT_20260220_029_FE_pack22_security_workspace_disposition_capsule_alignment.md`
- Safe concurrency: UI-only changes can run in parallel with unrelated backend modules when `/api/v2/security/*` contracts are unchanged.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/S00_route_deprecation_map.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
## Delivery Tracker
### FE30-SEC-01 - Security Overview posture alignment
Status: DONE
Dependency: none
Owners: FE implementer
Task description:
- Rework `/security/overview` to blocker-first posture, explicit confidence/freshness, expiring waivers, conflicts, and unknown reachability visibility.
- Add direct drilldowns from overview cards/lists into triage and advisories workflows.
Completion criteria:
- [x] Overview highlights shipping blockers and data freshness before secondary metrics.
- [x] Expiring waiver and conflict signals are visible without leaving the page.
- [x] Panel links navigate to canonical security/platform routes.
### FE30-SEC-02 - Triage single-surface operator flow
Status: DONE
Dependency: FE30-SEC-01
Owners: FE implementer
Task description:
- Rebuild `/security/triage` into one operator surface with pivots, facet filters, and a sticky evidence rail.
- Evidence rail must expose `Why`, `SBOM`, `Reachability`, `Effective VEX`, `Waiver`, `Policy Trace`, and `Export`.
Completion criteria:
- [x] Triage presents pivot/facet behavior on one route without menu hopping.
- [x] Evidence rail updates with selected finding context and action links.
- [x] Query params preserve pivot/facet state after refresh.
### FE30-SEC-03 - Advisories & VEX workspace and config boundary
Status: DONE
Dependency: FE30-SEC-02
Owners: FE implementer
Task description:
- Rebuild `/security/advisories-vex` into tabs for providers, VEX library, conflicts, and issuer trust.
- Keep feed/VEX configuration links pointing to Platform/Integrations ownership surfaces.
Completion criteria:
- [x] Provider freshness and conflicts are explicit in the workspace.
- [x] Effective-resolution context is visible for conflicting VEX/waiver states.
- [x] Configure actions route to `/platform/integrations/*` instead of in-security mutation forms.
### FE30-SEC-04 - Supply-Chain Data workspace alignment
Status: DONE
Dependency: FE30-SEC-02
Owners: FE implementer
Task description:
- Rework `/security/supply-chain-data/:mode` into tabs matching advisory semantics: `SBOM Viewer`, `SBOM Graph`, `SBOM Lake`, `Reachability`, `Coverage/Unknowns`.
- Surface coverage/staleness/unknowns as first-class status signals.
Completion criteria:
- [x] Supply-chain tabs render under canonical routes and map cleanly from legacy aliases.
- [x] Reachability and unknowns coverage are shown with freshness context.
- [x] Cross-links to triage and evidence are present.
### FE30-SEC-05 - Disposition detail UX unification and validation
Status: DONE
Dependency: FE30-SEC-03
Owners: FE implementer, QA
Task description:
- Rework finding detail tabs into disposition-centric flow (`Effective VEX`, `Waivers/Exceptions`, `Policy Gate Trace`) with evidence and export links.
- Update and run focused tests for security behavior/routes and advisory-aligned text markers.
Completion criteria:
- [x] Finding detail renders disposition-first tabs and action links.
- [x] Existing security tests are updated and passing.
- [x] Playwright conformance run confirms security routes and screenshot output.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from user-provided security advisory; FE30-SEC-01 started. | FE |
| 2026-02-20 | Completed security advisory workspace rebuild across overview/triage/advisories/supply-chain/detail surfaces; validation: focused FE specs `42/42`, broad regression suite `142/142`, and Playwright security conformance `1/1` with screenshots under `src/Web/StellaOps.Web/docs/qa/security-advisory-rebuild-2026-02-20/`. | FE/QA |
## Decisions & Risks
- Decision: Keep canonical security routes (`/security/overview`, `/security/triage`, `/security/advisories-vex`, `/security/supply-chain-data/*`) while rebuilding page internals to match advisory workspace semantics.
- Decision: Preserve backend data model boundaries for VEX vs exceptions; unify only at UX layer.
- Risk: Some contracts do not expose explicit issuer-trust attributes; issuer trust view may rely on deterministic derived indicators until backend fields expand.
- Risk: Legacy tests may encode prior labels; mitigated by updating tests to assert advisory-aligned semantics and stable route contracts.
## Next Checkpoints
- 2026-02-20: FE30-SEC-01 through FE30-SEC-03 complete with updated tests.
- 2026-02-20: FE30-SEC-04 and FE30-SEC-05 validation complete; sprint archived if all criteria are done.

View File

@@ -0,0 +1,115 @@
# Sprint 20260220-031 - FE Platform Advisory Recheck (Ops/Integrations/Setup)
## Topic & Scope
- Recheck and implement the new Platform product advisory for operator UX and IA consolidation.
- Rebuild Platform UI surfaces to match the required model: `Platform` global root with `Ops`, `Integrations`, and `Setup` as working subdomains.
- Enforce ownership boundaries:
- Topology owns hosts/targets/agents inventory management.
- Integrations owns external connectors only.
- Setup owns inventory/orchestration configuration.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: updated route/component tests plus focused FE test run output.
## Dependencies & Concurrency
- Depends on prior Pack22 baseline implementation and route migration work from archived sprints:
- `docs-archived/implplan/SPRINT_20260220_026_FE_pack22_platform_ops_integrations_setup_consolidation.md`
- `docs-archived/implplan/SPRINT_20260220_027_FE_pack22_platform_global_operability_contracts.md`
- Safe concurrency: do not run parallel edits on shared nav shell/routes during this sprint.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
## Delivery Tracker
### FE31-01 - Platform nav and Ops workflow alignment
Status: DONE
Dependency: none
Owners: FE implementer
Task description:
- Align Platform sidebar children and Ops route labels with advisory-required workflow framing:
- primary: `Data Integrity`, `Jobs & Queues`, `Health & SLO`
- secondary: `Feeds & Offline`, `Quotas & Limits`, `Diagnostics`.
- Remove stale Platform Ops-era labels and route links from UI surfaces.
Completion criteria:
- [x] Sidebar and Ops pages expose advisory-conformant labels and links.
- [x] Platform remains a global root with Ops/Integrations/Setup ownership split.
### FE31-02 - Integrations ownership boundary cleanup
Status: DONE
Dependency: FE31-01
Owners: FE implementer
Task description:
- Rework integrations hub/routes so Integrations contains external systems only:
- Registries, SCM, CI/CD, Advisory Sources, VEX Sources, Secrets.
- Remove hosts/targets ownership from Integrations views and preserve deep-link compatibility via redirects to Topology.
Completion criteria:
- [x] Integrations menu/routes no longer present Hosts/Targets as managed integration categories.
- [x] Legacy hosts/targets integration links redirect to Topology surfaces.
### FE31-03 - Setup owned pages and readiness UX
Status: DONE
Dependency: FE31-01
Owners: FE implementer
Task description:
- Replace setup redirects with setup-owned pages for:
- Regions & Environments,
- Promotion Paths,
- Workflows & Gates,
- Release Templates.
- Keep explicit cross-links to Topology/Security where runtime posture or policy sources are needed.
Completion criteria:
- [x] Setup routes render setup-owned content instead of only redirecting to other modules.
- [x] Setup home/readiness copy matches advisory ownership model.
### FE31-04 - Degraded/offline impact UX standardization (Platform surfaces)
Status: DONE
Dependency: FE31-01
Owners: FE implementer
Task description:
- Ensure key Platform surfaces include explicit impact wording and troubleshooting affordances:
- Impact classification (`BLOCKING`, `DEGRADED`, `INFO`),
- copyable correlation id,
- retry/refresh controls,
- last-known-good/read-only messaging where applicable.
Completion criteria:
- [x] Data Integrity and Feeds/Offline views display impact-aware operator messaging.
- [x] Jobs/Queues or Ops overview exposes correlation-aware troubleshooting hooks.
### FE31-05 - Docs and conformance tests refresh
Status: DONE
Dependency: FE31-02
Owners: FE implementer, Documentation author, QA
Task description:
- Update Pack22 planning docs to reflect this advisory delta for Platform IA ownership.
- Update affected FE route/UI tests and run targeted suites for nav/routes/integrations/platform ops pages.
Completion criteria:
- [x] Pack22 source-of-truth docs reflect Platform as global with Ops/Integrations/Setup split.
- [x] Targeted FE tests pass for modified nav/route/hub/setup/platform pages.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created for advisory recheck and marked FE31-01 DOING. | FE |
| 2026-02-20 | Implemented Platform nav/Ops workflow realignment, Integrations topology-boundary redirects, and Setup owned pages under `/platform/setup/*`. | FE |
| 2026-02-20 | Updated Pack authority/source docs (`pack-23`, source-of-truth, authority matrix, ledger) and archived advisory translation note. | FE |
| 2026-02-20 | Validation complete: targeted FE specs passed (`60/60`) and `npm run build` succeeded (existing non-blocking warnings only). | QA |
## Decisions & Risks
- Decision: this sprint is a recheck-and-corrective pass over already-landed Pack22 changes, focused only on advisory mismatches.
- Decision: Platform authority was lifted into `docs/modules/ui/v2-rewire/pack-23.md`, with supporting updates in:
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- Risk: existing pre-change test suites contain mixed canonical and legacy assumptions; mitigation is targeted updates only for files directly affected by this sprint.
## Next Checkpoints
- 2026-02-20: FE31-01 through FE31-04 implemented with route/nav parity.
- 2026-02-20: FE31-05 docs/tests complete and sprint closed.

View File

@@ -0,0 +1,69 @@
# Sprint 20260220-032 - FE Platform Advisory Follow-up Route Hardening
## Topic & Scope
- Close remaining advisory recheck gaps discovered after Sprint 031 validation.
- Canonicalize Platform Ops Data Integrity links to `/platform/ops/*`.
- Replace stale Feeds sub-path links with valid `Feeds & Offline` navigation targets.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: targeted frontend tests covering links/query-tab behavior.
## Dependencies & Concurrency
- Depends on archived Sprint 031 advisory recheck baseline:
- `docs-archived/implplan/SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck.md`
- Safe concurrency: avoid parallel edits to shared ops route pages while this follow-up is active.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-23.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
## Delivery Tracker
### FE32-01 - Canonicalize Data Integrity top-failure links
Status: DONE
Dependency: none
Owners: FE implementer
Task description:
- Replace legacy `/platform-ops/*` deep links in Data Integrity overview failure cards with canonical `/platform/ops/*` links.
Completion criteria:
- [x] Data Integrity top-failure links resolve to `/platform/ops/data-integrity/*`.
- [x] No residual legacy `/platform-ops/*` links remain in the updated overview component.
### FE32-02 - Harden Feeds Freshness footer links
Status: DONE
Dependency: FE32-01
Owners: FE implementer
Task description:
- Replace stale `/platform/ops/feeds/*` links with valid `Feeds & Offline` targets.
- Add query-parameter tab selection support on `Feeds & Offline` page so deep links can open `feed-mirrors` or `version-locks`.
Completion criteria:
- [x] Feeds Freshness footer links resolve to `/platform/ops/feeds-airgap`.
- [x] Query tab values select valid tabs on the Feeds & Offline page.
### FE32-03 - Targeted FE validation refresh
Status: DONE
Dependency: FE32-02
Owners: FE implementer, QA
Task description:
- Refresh route/link tests for changed Data Integrity and Feeds deep links.
- Add focused test coverage for Feeds & Offline query-tab behavior.
Completion criteria:
- [x] Updated tests pass for `data-integrity-pages` and `platform-feeds-airgap-page`.
- [x] No regression in selected advisory conformance suites.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created for post-031 advisory route hardening follow-up. | FE |
| 2026-02-20 | Canonicalized Data Integrity top-failure links and replaced stale Feeds footer paths with `/platform/ops/feeds-airgap` targets. | FE |
| 2026-02-20 | Added Feeds & Offline query-tab handling and new focused spec coverage (`platform-feeds-airgap-page`). | FE |
| 2026-02-20 | Validation complete: targeted FE specs passed (`63/63`) and `npm run build` succeeded (existing warnings only). | QA |
## Decisions & Risks
- Decision: treat this as a narrow corrective sprint to avoid reopening broader advisory scope.
- Risk: stale legacy aliases can mask broken canonical links; mitigated by direct canonical link assertions in tests.
## Next Checkpoints
- 2026-02-20: run focused FE tests and archive sprint when all tasks are DONE.

View File

@@ -0,0 +1,117 @@
# Sprint 20260220-033 - FE Platform Advisory Gap Closure
## Topic & Scope
- Close the remaining implementation gaps against the Platform advisory reframe.
- Ship a true Platform home plus final Ops/Integrations/Setup IA and page behavior updates.
- Normalize advisory naming and tab models where UI still diverges.
- Working directory: `src/Web/StellaOps.Web`.
- Allowed cross-module edits: `docs/modules/ui/v2-rewire/**`, `docs-archived/product/advisories/**`.
- Expected evidence: targeted FE route/component tests and build output.
## Dependencies & Concurrency
- Depends on:
- `docs-archived/implplan/SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck.md`
- `docs-archived/implplan/SPRINT_20260220_032_FE_platform_advisory_followup_route_hardening.md`
- Safe concurrency: avoid parallel edits to shared route/nav files during this sprint.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/pack-23.md`
## Delivery Tracker
### FE33-01 - Platform home and operate/configure split completion
Status: DONE
Dependency: none
Owners: FE implementer
Task description:
- Replace `/platform` redirect behavior with a real Platform landing page that presents three primary entry points:
- Platform Ops
- Platform Integrations
- Platform Setup
- Include status snapshot and quick actions to match advisory intent.
Completion criteria:
- [x] `/platform` renders a dedicated page instead of redirecting to `/platform/ops`.
- [x] Platform landing contains the three core doors and status snapshot cards.
### FE33-02 - Ops naming and Jobs & Queues behavioral tabs
Status: DONE
Dependency: FE33-01
Owners: FE implementer
Task description:
- Rename remaining advisory-visible labels from `Feeds & Offline` to `Feeds & Airgap`.
- Make Jobs & Queues tabs behaviorally distinct with tab-specific datasets/views for:
- Jobs
- Runs
- Schedules
- Dead Letters
- Workers
Completion criteria:
- [x] Ops labels use advisory naming (`Feeds & Airgap`) in route titles/sidebar/page headers.
- [x] Jobs & Queues tabs change the displayed table/content by selected tab.
### FE33-03 - Setup completion (feed policy + gate profiles + defaults)
Status: DONE
Dependency: FE33-01
Owners: FE implementer
Task description:
- Add setup-owned pages for:
- Feed Policy
- Gate Profiles
- Defaults & Guardrails
- Update Setup home cards and setup route map to expose these pages.
Completion criteria:
- [x] `Feed Policy` no longer redirects to Ops and renders setup-owned content.
- [x] Setup route map includes Gate Profiles and Defaults & Guardrails.
### FE33-04 - Integrations category + detail tab model alignment
Status: DONE
Dependency: FE33-01
Owners: FE implementer
Task description:
- Add Integrations category surface for `Runtimes / Hosts (connectors)` while preserving Topology ownership of inventory.
- Update Integration detail tab structure to advisory model:
- Overview
- Credentials
- Scopes & Rules
- Events
- Health
Completion criteria:
- [x] Integration hub includes a runtime/hosts connectors entry point.
- [x] Integration detail tabs match the advisory tab model.
### FE33-05 - Docs sync, validation, and sprint closure
Status: DONE
Dependency: FE33-02
Owners: FE implementer, Documentation author, QA
Task description:
- Update active UI authority docs for the final advisory-conformant state.
- Run targeted FE tests for nav/routes/platform ops/setup/integrations and ensure build succeeds.
- Archive sprint only after all tasks are marked DONE.
Completion criteria:
- [x] Docs reflect the implemented final advisory state.
- [x] Targeted FE tests pass and build succeeds.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created for final Platform advisory gap closure pass. | FE |
| 2026-02-20 | Closed Setup gaps: implemented setup-owned `Feed Policy`, `Gate Profiles`, and `Defaults & Guardrails` pages; updated setup route map and setup home cards to expose all three. | FE |
| 2026-02-20 | Closed Integrations gaps: added `Runtimes / Hosts` connector category and aligned integration detail tabs to `Overview`, `Credentials`, `Scopes & Rules`, `Events`, `Health`. | FE |
| 2026-02-20 | Updated authority doc `docs/modules/ui/v2-rewire/pack-23.md` to reflect final Platform naming and ownership (`Feeds & Airgap`, setup route ownership, runtime/hosts connector category). | Docs |
| 2026-02-20 | Validation run: `npm run test -- --watch=false --include src/tests/navigation/nav-model.spec.ts --include src/tests/navigation/nav-route-integrity.spec.ts --include src/tests/platform-ops/platform-ops-routes.spec.ts --include src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts --include src/tests/integration_hub/integration-hub-ui.component.spec.ts --include src/tests/platform/platform-setup-routes.spec.ts` => `49/49` tests passed. | QA |
| 2026-02-20 | Build validation: `npm run build` completed successfully; non-blocking existing warnings remain for bundle/style budgets and CommonJS dependencies (mermaid/langium transitive modules). | QA |
## Decisions & Risks
- Decision: prioritize shipping advisory-conformant IA/UX over preserving interim labels from prior packs where they conflict.
- Risk: route/nav edits can cause regressions in legacy alias tests; mitigated by targeted route/nav test reruns.
- Risk: FE build reports pre-existing budget/CommonJS warnings; treated as non-blocking for this sprint because no new warning classes were introduced by these changes.
## Next Checkpoints
- 2026-02-20: implement FE33-01 through FE33-04.
- 2026-02-20: run FE33-05 validation, then archive sprint.

View File

@@ -0,0 +1,40 @@
# 2026-02-20 Platform Ops/Integrations/Setup UX Recheck
Status: Translated to implementation and documentation updates
Source: Product advisory shared in operator review thread (2026-02-20)
## Summary
The advisory requested Platform IA and UX realignment:
- Platform as a global root.
- Consolidated Ops operator workflows (`Data Integrity`, `Jobs & Queues`, `Health & SLO`).
- Integrations limited to external systems only.
- Setup as inventory/orchestration configuration ownership surface.
- Consistent degraded/offline decision-impact UX patterns.
## Translation outputs
- Sprint:
- `docs-archived/implplan/SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck.md`
- `docs-archived/implplan/SPRINT_20260220_032_FE_platform_advisory_followup_route_hardening.md` (route hardening follow-up)
- Docs authority updates:
- `docs/modules/ui/v2-rewire/pack-23.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/authority-matrix.md`
- `docs/modules/ui/v2-rewire/pack-22.md`
- `docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v2_pack22.md`
- FE implementation scope:
- `src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts`
- `src/Web/StellaOps.Web/src/app/routes/operations.routes.ts`
- `src/Web/StellaOps.Web/src/app/features/platform/ops/*`
- `src/Web/StellaOps.Web/src/app/features/platform/setup/*`
- `src/Web/StellaOps.Web/src/app/features/integration-hub/*`
## Validation evidence
- Targeted FE test suite:
- navigation model/integrity,
- platform ops routes/data-integrity pages,
- integration hub UI.
- FE production build completed successfully (with existing bundle-size/commonjs warnings).

View File

@@ -1,90 +0,0 @@
# Sprint 20260220-016 - FE Pack 19 Exceptions Conformity Gap
## Topic & Scope
- Close the remaining pack conformity gap after full `pack-01..pack-21` Playwright verification.
- Implement Pack 19 Exceptions screen semantics at canonical `Security & Risk` routes.
- Preserve existing triage workflows while separating them from the Pack 19 Exceptions surface.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: focused unit tests, Playwright pack-conformance pass, and updated diff ledger.
## Dependencies & Concurrency
- Depends on current canonical route map in `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts`.
- Depends on Pack source-of-truth docs in `docs/modules/ui/v2-rewire/pack-19.md` and `docs/modules/ui/v2-rewire/source-of-truth.md`.
- Safe concurrency: may run in parallel with non-security FE work if no edits touch `security-risk` routes/components.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-19.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
## Delivery Tracker
### S19-EX-01 - Replace Pack 19 Exceptions route surface
Status: TODO
Dependency: none
Owners: FE implementer
Task description:
- Replace `/security-risk/exceptions` route target so it renders a dedicated Exceptions screen aligned to Pack 19 section 19.10.
- Keep route canonical and maintain existing breadcrumb/title behavior under `Security & Risk`.
Completion criteria:
- [ ] `/security-risk/exceptions` no longer resolves to triage artifact UI.
- [ ] Exceptions list UI vocabulary reflects waiver/risk acceptance domain.
- [ ] Sidebar navigation label/path behavior remains stable for `Security & Risk`.
### S19-EX-02 - Add Exception detail workflow route
Status: TODO
Dependency: S19-EX-01
Owners: FE implementer
Task description:
- Implement dedicated Exception detail surface for `/security-risk/exceptions/:id`.
- Ensure drill-down links from Exceptions list use this route and preserve back navigation to Exceptions list.
Completion criteria:
- [ ] `/security-risk/exceptions/:id` resolves to an Exception detail view, not triage artifact detail.
- [ ] Exceptions list has deterministic navigation to detail.
- [ ] Detail view includes status, scope, expiry, approvals, and evidence pointers required by Pack 19 intent.
### S19-EX-03 - Test coverage and pack-conformance verification
Status: TODO
Dependency: S19-EX-01
Owners: FE implementer, QA
Task description:
- Add or update unit tests for the new Exceptions route wiring and core rendering assertions.
- Re-run pack-conformance Playwright sweep against `pack-01..pack-21` and ensure zero mismatches.
Completion criteria:
- [ ] Unit tests pass for new Exceptions route/component behavior.
- [ ] `tests/e2e/pack-conformance.scratch.spec.ts` passes with no mismatches.
- [ ] Test commands and outputs recorded in this sprint `Execution Log`.
### S19-EX-04 - Update pack difference ledger and close sprint
Status: TODO
Dependency: S19-EX-03
Owners: FE implementer, Documentation author
Task description:
- Update `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md` from `DIFF` to resolved state when implementation lands.
- Archive this sprint only after all tasks are `DONE`.
Completion criteria:
- [ ] Pack diff ledger updated to reflect resolved Pack 19 mismatch.
- [ ] All tasks in this sprint are `DONE`.
- [ ] Sprint moved to `docs-archived/implplan/` only after criteria are met.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from full Pack conformity run. Result: 61 checks, 1 mismatch at Pack 19 Exceptions route. | Planning |
| 2026-02-20 | Reproduced mismatch with filtered run (`PACK_CONFORMANCE_FILTER='pack-19.*exceptions'`) to isolate route-level nonconformance. | QA |
## Decisions & Risks
- Decision: treat latest pack precedence as authoritative; Pack 19 section 19.10 governs Exceptions behavior.
- Decision: keep this sprint FE-scoped with route/component separation first; backend enrichment can layer without blocking route conformance.
- Risk: replacing current route target can break users relying on triage page at `/security-risk/exceptions`; mitigate by preserving triage under existing triage paths and adding redirects if needed.
- Risk: pack-conformance run is sensitive to dev proxy path capture for `/integrations` and `/platform`; mitigate by using clean proxy config during conformity runs.
- Evidence reference: `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`.
## Next Checkpoints
- 2026-02-21: route/component implementation complete and unit tests green.
- 2026-02-21: full Playwright pack-conformance rerun shows zero mismatches.
- 2026-02-21: sprint ready for archive review.

View File

@@ -17,3 +17,9 @@ Source of truth: `docs/implplan/SPRINT_20251229_043_PLATFORM_platform_service_fo
| PLAT-SVC-008 | DONE | Observability metrics/logging. |
| PLAT-SVC-009 | DONE | Determinism/offline tests. |
| PLAT-SVC-010 | DONE | Docs/runbooks update. |
| B22-01 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/context/*` contracts, policy/scope wiring, migration `047_GlobalContextAndFilters.sql`, and endpoint/migration tests for deterministic ordering and preference round-trip behavior. |
| B22-02 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped v2 releases read-model endpoints (`/api/v2/releases{,/activity,/approvals,/{releaseId}}`) backed by deterministic projections and migration `048_ReleaseReadModels.sql`. |
| B22-03 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/topology/*` inventory endpoints (regions/environments/targets/hosts/agents/promotion paths/workflows/gate profiles) and migration `049_TopologyInventory.sql`. |
| B22-04 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/security/{findings,disposition/{findingId},sbom-explorer}` contracts and migration `050_SecurityDispositionProjection.sql` while preserving separate VEX/exception write authority boundaries. |
| B22-05 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/integrations/{feeds,vex-sources}` contracts and migration `051_IntegrationSourceHealth.sql` with deterministic source health/freshness metadata. |
| B22-06 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped legacy alias compatibility and deterministic deprecation telemetry for critical Pack 22 API surfaces. |

View File

@@ -14,6 +14,9 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows
- Persist onboarding progress and tenant setup milestones.
- Persist dashboard personalization and layout preferences.
- Provide global search aggregation across entities.
- Provide global context selectors (region/environment/time window) and per-user persistence for Pack 22 top-bar context.
- Provide Pack 22 release read-model projections for list/detail/activity/approvals queue views.
- Provide Pack 22 topology inventory read-model projections for regions/environments/targets/hosts/agents/promotion paths/workflows/gate profiles.
- Surface platform metadata for UI bootstrapping (version, build, offline status).
- Expose analytics lake aggregates for SBOM, vulnerability, and attestation reporting.
@@ -52,6 +55,40 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows
- GET `/api/v1/platform/metadata`
- Response includes a capabilities list for UI bootstrapping; analytics capability is reported only when analytics storage is configured.
## API surface (v2)
### Global context
- GET `/api/v2/context/regions`
- GET `/api/v2/context/environments?regions=`
- GET `/api/v2/context/preferences`
- PUT `/api/v2/context/preferences`
### Releases read model
- GET `/api/v2/releases`
- GET `/api/v2/releases/{releaseId}`
- GET `/api/v2/releases/activity`
- GET `/api/v2/releases/approvals`
### Topology inventory read model
- GET `/api/v2/topology/regions`
- GET `/api/v2/topology/environments`
- GET `/api/v2/topology/targets`
- GET `/api/v2/topology/hosts`
- GET `/api/v2/topology/agents`
- GET `/api/v2/topology/promotion-paths`
- GET `/api/v2/topology/workflows`
- GET `/api/v2/topology/gate-profiles`
### Security read model
- GET `/api/v2/security/findings`
- GET `/api/v2/security/disposition`
- GET `/api/v2/security/disposition/{findingId}`
- GET `/api/v2/security/sbom-explorer`
### Integrations read model
- GET `/api/v2/integrations/feeds`
- GET `/api/v2/integrations/vex-sources`
### Analytics (SBOM lake)
- GET `/api/analytics/suppliers`
- GET `/api/analytics/licenses`
@@ -61,12 +98,43 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows
- GET `/api/analytics/trends/vulnerabilities`
- GET `/api/analytics/trends/components`
### Legacy alias compatibility (`/api/v1/*`)
- GET `/api/v1/context/regions` (alias of `/api/v2/context/regions`)
- GET `/api/v1/releases` (alias of `/api/v2/releases`)
- GET `/api/v1/topology/regions` (alias of `/api/v2/topology/regions`)
- GET `/api/v1/security/findings` (alias of `/api/v2/security/findings`)
- GET `/api/v1/integrations/feeds` (alias of `/api/v2/integrations/feeds`)
- GET `/api/v1/integrations/vex-sources` (alias of `/api/v2/integrations/vex-sources`)
- Alias usage telemetry is emitted as deterministic event keys (`alias_<method>_<route_pattern>`) with tenant hash metadata only.
## Data model
- `platform.dashboard_preferences` (dashboard layout, widgets, filters)
- `platform.dashboard_profiles` (saved profiles per tenant)
- `platform.onboarding_state` (step state, timestamps, actor)
- `platform.quota_alerts` (per-tenant quota alert thresholds)
- `platform.search_history` (optional, user-scoped, append-only)
- `platform.context_regions` (global region selector inventory)
- `platform.context_environments` (global environment selector inventory with region linkage)
- `platform.ui_context_preferences` (tenant + actor scoped region/environment/time-window selections)
- `release.release_read_model` (Pack 22 release list/detail projection root)
- `release.release_activity_projection` (cross-release timeline projection with run/approval correlation keys)
- `release.release_approvals_projection` (cross-release approval queue projection with blocker summaries)
- `release.security_finding_projection` (Pack 22 consolidated findings projection with pivot/filter fields)
- `release.security_disposition_projection` (read-only join projection for VEX + exception disposition state)
- `release.security_sbom_component_projection` (component-level SBOM explorer table projection)
- `release.security_sbom_graph_projection` (edge-level SBOM graph projection used by graph and diff modes)
- `release.integration_feed_source_health` (advisory feed source health/freshness projection)
- `release.integration_vex_source_health` (VEX source health/freshness projection with statement-format metadata)
- `release.integration_source_sync_watermarks` (source family synchronization watermark projection state)
- `release.topology_region_inventory` (region-level topology projection with deterministic ordering counts)
- `release.topology_environment_inventory` (environment-level topology projection with region linkage and aggregate counters)
- `release.topology_target_inventory` (target/component deployment inventory projection)
- `release.topology_host_inventory` (host runtime inventory projection linked to targets and agents)
- `release.topology_agent_inventory` (agent fleet projection with capability and assignment summaries)
- `release.topology_promotion_path_inventory` (region-aware promotion-path projection with workflow and gate links)
- `release.topology_workflow_inventory` (workflow template projection for topology routes)
- `release.topology_gate_profile_inventory` (gate profile projection bound to region/environment inventory)
- `release.topology_sync_watermarks` (projection synchronization watermark state for deterministic replay/cutover checks)
- Schema reference: `docs/db/schemas/platform.sql` (PostgreSQL; in-memory stores used until storage driver switches).
## Dependencies
@@ -81,6 +149,12 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows
- Quotas: `quota.read` (summary), `quota.admin` (alerts/config)
- Onboarding: `onboarding.read`, `onboarding.write`
- Preferences: `ui.preferences.read`, `ui.preferences.write`
- Context: `platform.context.read`, `platform.context.write`
- Releases read model: `orch:read` (`platform.releasecontrol.read` policy mapping in Platform service)
- Topology read model: `orch:read` (`platform.topology.read` policy mapping in Platform service)
- Security read model: `findings:read` (`platform.security.read` policy mapping in Platform service)
- Integrations feed read model: `advisory:read` (`platform.integrations.read` policy mapping in Platform service)
- Integrations VEX source read model: `vex:read` (`platform.integrations.vex.read` policy mapping in Platform service)
- Search: `search.read` plus downstream service scopes (`findings:read`, `policy:read`, etc.)
- Metadata: `platform.metadata.read`
- Analytics: `analytics.read`
@@ -213,4 +287,3 @@ The Platform Service exposes setup wizard endpoints to support first-run configu
- UX flow specification: `docs/setup/setup-wizard-ux.md`
- Repository inventory: `docs/setup/setup-wizard-inventory.md`
- Doctor checks: `docs/setup/setup-wizard-doctor-contract.md`

View File

@@ -1,7 +1,7 @@
# UI v2 Rewire (Canonical Planning Set)
This directory contains two things:
- Raw iterative design packs (`pack-01.md` ... `pack-21.md`)
- Raw iterative design packs (`pack-01.md` ... `pack-22.md`)
- Cleansed planning inputs for sprint decomposition
Use these files as the planning entrypoint:
@@ -14,6 +14,7 @@ S00 package files:
- `S00_sprint_spec_package.md` - detailed S00 sprint spec with acceptance criteria
- `S00_contract_ledger_template.md` - reusable endpoint contract ledger template
- `S00_endpoint_contract_ledger_v1.md` - starter ledger sheet for immediate use
- `S00_endpoint_contract_ledger_v2_pack22.md` - Pack 22 contract delta and backend dependency baseline
## Precedence policy
@@ -24,7 +25,7 @@ A higher pack that does not define a screen in detail does not erase the latest
## Raw materials
Raw packs are preserved as historical input and should not be used directly as the source of truth for sprint planning:
- `pack-01.md` ... `pack-21.md`
- `pack-01.md` ... `pack-22.md`
- `prompt.txt`
## Planning rule

View File

@@ -0,0 +1,46 @@
# S00 Endpoint Contract Ledger v2 (Pack 22 Delta)
Status: Active baseline for Pack 22 migration (run-detail companion shipped)
Date: 2026-02-20
Working directory: `docs/modules/ui/v2-rewire`
Template source: `S00_contract_ledger_template.md`
Supersedes for new IA planning: `S00_endpoint_contract_ledger_v1.md` remains historical baseline for pre-Pack-22 structure.
## Status class definitions
| Status class | Meaning |
| --- | --- |
| `EXISTS_COMPAT` | Endpoint exists and is compatible with Pack 22 screen needs without schema change. |
| `EXISTS_ADAPT` | Endpoint exists but requires schema additions, filter/sort extensions, or composition changes for Pack 22. |
| `MISSING_NEW` | No endpoint exists; must be designed and implemented before the consuming sprint can complete. |
## Ledger
| Domain | Screen/Page | Canonical source refs | Current route/page | Current endpoint candidate(s) | Status | Owner module | Auth scope impact | Schema delta summary | Decision/risk notes | Action ticket |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Global context | Region/Environment top-bar selectors and persistence | `source-of-truth.md 2.2`, `pack-22.md 3`, `authority-matrix.md A` | Canonical v3 top-bar context route family under `/api/v2/context/*` | `GET /api/v2/context/regions`; `GET /api/v2/context/environments?regions=`; `GET /api/v2/context/preferences`; `PUT /api/v2/context/preferences` | `EXISTS_COMPAT` | `Platform` | New read/write scope pair (`platform.context.read`, `platform.context.write`) implemented in Platform auth policy map | Shipped in sprint `SPRINT_20260220_018` with migration `047_GlobalContextAndFilters.sql` (`platform.context_regions`, `platform.context_environments`, `platform.ui_context_preferences`) and deterministic ordering indexes | FE top-bar context cutover unblocked for contract baseline; keep v1 aliases for unrelated surfaces during transition | `S22-T01-CTX-01` |
| Dashboard | Mission control posture | `source-of-truth.md 3.2`, `pack-22.md 5`, `pack-16.md` | `/`, `/dashboard` | Existing `GET /api/v1/dashboard/summary` plus policy/scanner aggregates | `EXISTS_ADAPT` | `Platform` | Reuse viewer scopes | Extend dashboard payload with blocked promotion reasons, hotfix lane, evidence posture summary, quick-action counters | Keep `/api/v1/dashboard/summary` alias while adding `/api/v2/dashboard/posture` | `S22-T02-DASH-01` |
| Releases | Releases list (standard + hotfix) | `source-of-truth.md 3.3`, `pack-22.md 5` | `/release-control/releases` (legacy) | `GET /api/v2/releases`; fallback composition from `/api/v1/release-control/bundles`, `/api/v1/approvals` | `EXISTS_COMPAT` | `Platform` + `ReleaseOrchestrator` | Existing `orch:read` plus release list scope alias (policy mapped via `platform.releasecontrol.read`) | Shipped release type, gate summary, risk delta, and region/env filters in deterministic read-model projection backed by migration `048_ReleaseReadModels.sql` | B22-02 shipped list contract; maintain legacy routes until B22-06 deprecation telemetry pass | `S22-T03-REL-01` |
| Releases | Release detail tabs (overview/timeline/deploy/security/evidence/audit) | `source-of-truth.md 3.3`, `pack-22.md 5`, `pack-13.md`, `pack-14.md`, `pack-17.md` | Split across `/release-control/*`, `/deployments/*`, `/approvals/*` | `GET /api/v2/releases/{releaseId}`; `GET /api/v2/releases/{releaseId}/timeline`; `GET /api/v2/releases/{releaseId}/deployments`; `GET /api/v2/releases/{releaseId}/security`; `GET /api/v2/releases/{releaseId}/evidence`; `GET /api/v2/releases/{releaseId}/audit` | `EXISTS_ADAPT` | `Platform` + `ReleaseOrchestrator` + `Policy` + `Scanner` + `EvidenceLocker` | Existing read scopes; add composite read policy | Base detail endpoint `GET /api/v2/releases/{releaseId}` now ships summary+versions+recent activity+approvals projection with correlation keys from migration `048`; tab-specific sub-endpoints remain pending | Partial closure in B22-02; keep row open for timeline/deploy/security/evidence/audit endpoint split completion | `S22-T03-REL-02` |
| Releases | Run detail provenance tabs (timeline/gate/approvals/deployments/security-inputs/evidence/rollback/replay) | `source-of-truth.md 3.3`, `pack-22.md 5`, `docs/implplan/SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md` | `/releases/runs/:runId` (target canonical route) | `GET /api/v2/releases/runs/{runId}`; `GET /api/v2/releases/runs/{runId}/timeline`; `GET /api/v2/releases/runs/{runId}/gate-decision`; `GET /api/v2/releases/runs/{runId}/approvals`; `GET /api/v2/releases/runs/{runId}/deployments`; `GET /api/v2/releases/runs/{runId}/security-inputs`; `GET /api/v2/releases/runs/{runId}/evidence`; `GET /api/v2/releases/runs/{runId}/rollback`; `GET /api/v2/releases/runs/{runId}/replay`; `GET /api/v2/releases/runs/{runId}/audit` | `EXISTS_COMPAT` | `Platform` + `ReleaseOrchestrator` + `Policy` + `Scanner` + `EvidenceLocker` + `Attestor` | Existing read scopes plus v1 alias reads for cutover compatibility | Shipped in `SPRINT_20260220_023` with deterministic run-detail contracts and migrations `052_RunInputSnapshots.sql` through `056_RunCapsuleReplayLinkage.sql` | FE sprint `SPRINT_20260220_022` unblocked for tab-by-tab binding; monitor alias telemetry before removing legacy v1 reads | `S23-T01-RUN-01` |
| Releases | Activity (cross-release runs timeline) | `source-of-truth.md 3.3`, `pack-22.md 5`, `pack-14.md` | `/release-control/runs` (legacy) | `GET /api/v2/releases/activity` with filters; fallback from `/api/v1/runs/*` | `EXISTS_COMPAT` | `ReleaseOrchestrator` + `Platform` | Existing `orch:read` | Shipped cross-release activity projection with correlation keys + region/env filters from deterministic read-model composition | Deterministic order enforced by `occurredAt DESC` + stable `activityId` tie-break | `S22-T03-REL-03` |
| Releases | Approvals queue (cross-release) | `source-of-truth.md 3.3`, `pack-22.md 5`, `pack-17.md` | `/release-control/approvals` (legacy) | Existing `/api/v1/approvals`; add `/api/v2/releases/approvals` alias with richer metadata | `EXISTS_COMPAT` | `Policy` + `ReleaseOrchestrator` + `Platform` | Existing reviewer/approver scopes (`orch:read` path for queue projection) | Shipped release identity fields, blocker summaries, and region/env filters in `/api/v2/releases/approvals` projection | Single queue UX dependency unblocked for FE contract migration; v1 approvals endpoint remains for backward compatibility | `S22-T03-REL-04` |
| Topology | Regions, Environments, Targets/Hosts, Agents | `source-of-truth.md 3.4`, `pack-22.md 5`, `pack-18.md` | Legacy under `/release-control/regions`, `/platform-ops/agents`, `/integrations/hosts` | `GET /api/v2/topology/regions`; `GET /api/v2/topology/environments`; `GET /api/v2/topology/targets`; `GET /api/v2/topology/hosts`; `GET /api/v2/topology/agents` | `EXISTS_COMPAT` | `Platform` + `ReleaseOrchestrator` + `Integrations` | `platform.topology.read` policy now mapped to existing `orch:read` scope in Platform auth wiring | Shipped migration `049_TopologyInventory.sql` with normalized region/environment/target/host/agent projection tables and sync watermark tracking | Duplicate inventory placement can now be removed from Integrations/Operations nav during FE route migration | `S22-T04-TOP-01` |
| Topology | Promotion Paths, Workflows, Gate Profiles | `source-of-truth.md 3.4`, `pack-22.md 5`, `pack-13.md` | Legacy setup pages under `/release-control/setup/*` | `GET /api/v2/topology/promotion-paths`; `GET /api/v2/topology/workflows`; `GET /api/v2/topology/gate-profiles`; write routes in follow-up sprint | `EXISTS_COMPAT` | `ReleaseOrchestrator` + `Policy` + `Platform` | Topology read policy uses existing `orch:read` scope; write-authoring scopes stay in module-owned follow-up routes | Shipped deterministic read projections for paths/workflows/gate profiles with region/environment filters; write contracts remain follow-up scope | FE can consume read contracts now; explicit write routes can phase in a subsequent sprint without blocking Pack 22 IA cutover | `S22-T04-TOP-02` |
| Security | Findings unified explorer with pivots | `source-of-truth.md 3.5`, `pack-22.md 5`, `pack-19.md` | `/security-risk/findings`, `/security-risk/vulnerabilities`, `/security-risk/reachability` | `GET /api/v2/security/findings`; legacy `/api/v1/security/findings` and `/api/v1/security/vulnerabilities` retained during migration | `EXISTS_COMPAT` | `Scanner` + `Platform` | `platform.security.read` mapped to existing `findings:read` viewer scope in Platform policy map | Shipped pivot/facet schema (CVE/package/component/release/environment), disposition summary columns, and deterministic filter/sort envelope in B22-04 | Legacy endpoints stay available through cutover window; FE security explorer can migrate to v2 contract | `S22-T05-SEC-01` |
| Security | Disposition (VEX + Exceptions UX join) | `source-of-truth.md 2.3`, `source-of-truth.md 3.5`, `pack-22.md 5` | `/security-risk/vex`, `/security-risk/exceptions` (legacy split) | `GET /api/v2/security/disposition`; `GET /api/v2/security/disposition/{findingId}`; exception/VEX writes remain module-owned routes | `EXISTS_COMPAT` | `Policy` + `Scanner` + `Platform` | `platform.security.read` mapped to `findings:read` for read projection; exception/VEX writes keep module approval scopes | Shipped migration `050_SecurityDispositionProjection.sql` for read-only disposition projection joining VEX state and exception state | Write authority boundaries preserved by design: no combined `/api/v2/security/disposition/exceptions` POST route in Platform | `S22-T05-SEC-02` |
| Security | SBOM Explorer (table/graph/diff) | `source-of-truth.md 2.3`, `source-of-truth.md 3.5`, `pack-22.md 5` | `/security-risk/sbom`, `/security-risk/sbom-lake` | `GET /api/v2/security/sbom-explorer?mode=table|graph|diff` with release compare filters | `EXISTS_COMPAT` | `Scanner` + `Graph` + `Platform` | `platform.security.read` mapped to existing `findings:read` viewer scope | Shipped unified response envelope for table/graph/diff views with deterministic diff composition from migration `050` projection objects | Enables FE to collapse dual SBOM routes onto one v2 explorer contract | `S22-T05-SEC-03` |
| Evidence | Evidence packs, audit, replay linkage from Releases/Security | `source-of-truth.md 3.6`, `pack-22.md 5`, `pack-20.md` | `/evidence-audit/*` (legacy) | Existing `/api/v1/evidence/*`; add release/finding correlation filters on `/api/v2/evidence/*` | `EXISTS_ADAPT` | `EvidenceLocker` + `Attestor` + `Platform` | Existing evidence read scopes | Add optional filters (`releaseId`, `findingId`, `approvalId`) and deterministic pagination | Ensures inline evidence references across modules | `S22-T06-EVID-01` |
| Platform / Integrations | Advisory feeds and VEX source setup + health/activity | `source-of-truth.md 2.3`, `source-of-truth.md 3.8`, `pack-23.md 2`, `pack-10.md` | `/platform/integrations/feeds` | `GET /api/v2/integrations/feeds`; `GET /api/v2/integrations/vex-sources`; legacy `/api/v1/integrations/*` retained during migration | `EXISTS_COMPAT` | `Integrations` + `Concelier` + `Platform` | `platform.integrations.read` mapped to `advisory:read`; `platform.integrations.vex.read` mapped to `vex:read` | Shipped source-type discriminator plus status/freshness/last-sync metadata and Security/Dashboard consumer hints, backed by migration `051_IntegrationSourceHealth.sql` | Integrations setup + health routes are now v2-ready for FE cutover while legacy aliases remain available during transition | `S22-T07-INT-01` |
| Platform / Ops | Platform health/data integrity/offline/scheduler | `source-of-truth.md 3.7`, `pack-23.md 2`, `pack-15.md` | `/platform/ops/*` | Existing `/api/v1/platform/data-integrity/*`, scheduler/orchestrator/health routes | `EXISTS_COMPAT` | `Platform` + `Scheduler` + `Orchestrator` | Existing ops scopes | No schema change required for baseline migration; route names will change in FE | Keep aliases from old path prefix during cutover | `S22-T08-OPS-01` |
| Administration | Identity/tenant/notifications/usage/policy/system | `source-of-truth.md 3.9`, `pack-22.md 5`, `pack-21.md` | `/administration/*` | Existing `/api/v1/administration/*` | `EXISTS_COMPAT` | `Platform` + `Authority` + `Policy` | Existing admin scopes | No immediate schema change in Pack 22 baseline | Track trust-posture entry points from Evidence as FE task | `S22-T09-ADM-01` |
## Sign-off requirement
Before readiness closure, frontend and backend leads must confirm:
- All `MISSING_NEW` rows are shipped or formally deferred with owner/date.
- `EXISTS_ADAPT` rows have accepted schema and alias compatibility tests.
- No Pack 22 authority screen remains unclassified.
Sign-off output should be captured in an updated handoff packet after the first Pack 22 implementation wave.

View File

@@ -1,183 +1,97 @@
# S00 Route Deprecation Map
# S00 Route Deprecation Map (Pack 22 Canonical)
Status: Frozen baseline
Date: 2026-02-18
Working directory: `docs/modules/ui/v2-rewire`
Canonical source: `source-of-truth.md`, `authority-matrix.md`
Status: Active
Date: 2026-02-20
Working directory: `docs/modules/ui/v2-rewire`
Canonical source: `source-of-truth.md`, `pack-22.md`
## Purpose
Complete route baseline mapping current v1 canonical paths to v2 target IA families.
Every major route family must have exactly one migration action.
This map governs all implementation in sprints 006 through 016.
Define deterministic route migration from pre-Pack22 root families to Pack22 canonical IA:
## Route action definitions
- `/dashboard`
- `/releases` (run-centric subroots under `/releases/versions*` and `/releases/runs*`)
- `/security` (workspace subroots under `/security/overview`, `/security/triage`, `/security/advisories-vex`, `/security/supply-chain-data/*`)
- `/evidence` (capsule-first subroots under `/evidence/overview`, `/evidence/capsules`, `/evidence/exports/export`, `/evidence/verification/*`)
- `/topology`
- `/platform` (setup/ops/integrations canonical root; legacy `/operations`, `/integrations`, `/administration` are alias-window routes)
## Action definitions
| Action | Meaning |
| --- | --- |
| `keep` | Path and semantics are unchanged; no migration work required. |
| `redirect` | Current path redirects to v2 canonical target; old path is no longer authoritative. |
| `alias` | Current path remains active and resolves to the same content as canonical; both paths are valid during the migration window. Planned for removal after cutover. |
| `remove-later` | Path is superseded; leave as redirect stub until traffic confirms safety, then remove in sprint 016. |
| `canonical` | Route family is authoritative and must be used by nav and breadcrumbs. |
| `redirect` | Legacy route redirects to canonical route. |
| `alias-window` | Legacy route remains temporarily available and is tracked via alias telemetry. |
## Section 1 — Root domain family migrations
## Root family mapping
These are the highest-priority mappings because they affect top-level navigation and all deep links.
| Current v1 path family | v2 canonical target family | Action | Notes |
| --- | --- | --- | --- |
| `/` (control-plane landing) | `/dashboard` | `redirect` | Current Control Plane becomes Dashboard v3 landing. Sprint 012 implements target. |
| `/security/*` | `/security-risk/*` | `redirect` + temporary `alias` | High-traffic. Alias `/security/*` during sprint 014 window; remove in sprint 016. |
| `/operations/*` | `/platform-ops/*` | `redirect` + temporary `alias` | Ops team bookmarks. Alias during sprint 008 window; remove in sprint 016. |
| `/evidence/*` | `/evidence-audit/*` | `redirect` + temporary `alias` | Alias during sprint 015 window; remove in sprint 016. |
| `/policy/*` | `/administration/policy-governance/*` | `redirect` | Ownership change. High risk; enforce breadcrumb and ownership labels per nav policy. |
| `/settings/*` (admin subset) | `/administration/*` | `redirect` | Split: admin sub-paths go to `/administration/*`; integration sub-paths go to `/integrations/*`. |
| `/settings/integrations/*` | `/integrations/*` | `redirect` | Integrations becomes a canonical root domain. |
| `/integrations/*` (current shallow root) | `/integrations/*` (v2 canonical root) | `keep` | Route family stays. Sprint 008 expands content and taxonomy. |
| `/approvals/*` | `/release-control/approvals/*` | `redirect` + temporary `alias` | Alias `/approvals/*` for operator convenience during cutover; remove in sprint 016. |
| `/releases/*` | `/release-control/releases/*` | `redirect` + temporary `alias` | High-traffic operator route. Alias during sprints 010-016 window. |
| `/environments/*` | `/release-control/environments/*` | `redirect` | Medium risk. |
| `/deployments/*` | `/release-control/deployments/*` | `redirect` | Medium risk. |
| `/analytics/*` | `/security-risk/analytics/*` | `redirect` | Analytics is consumed under Security & Risk. |
## Section 2 — Settings sub-family migrations
All settings sub-paths have a final canonical owner under Administration or Integrations.
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/settings/admin/users` | `/administration/identity-access/users` | `redirect` | 007 |
| `/settings/admin/roles` | `/administration/identity-access/roles` | `redirect` | 007 |
| `/settings/admin/tenants` | `/administration/identity-access/tenants` | `redirect` | 007 |
| `/settings/admin/clients` | `/administration/identity-access/clients` | `redirect` | 007 |
| `/settings/admin/tokens` | `/administration/identity-access/tokens` | `redirect` | 007 |
| `/settings/admin/branding` | `/administration/tenant-branding` | `redirect` | 007 |
| `/settings/admin/:page` | `/administration/:page` | `redirect` (catch-all) | 007 |
| `/settings/trust/*` | `/administration/trust-signing/*` | `redirect` | 007 |
| `/settings/notifications/*` | `/administration/notifications/*` | `redirect` | 007 |
| `/settings/security-data/trivy` | `/integrations/feeds/trivy` | `redirect` | 008 |
| `/settings/sbom-sources/*` | `/integrations/sbom-sources/*` | `redirect` | 008 |
| `/settings/workflows/*` | `/administration/system/workflows` | `redirect` | 007 |
| `/settings/profile` | `/administration/profile` | `alias` | 007 (keep; `/administration/profile` is canonical) |
| `/settings/configuration-pane` | `/administration/system/configuration` | `redirect` | 007 |
## Section 3 — Evidence & Audit sub-family migrations
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/evidence` | `/evidence-audit` | `redirect` + alias | 015 |
| `/evidence/audit` | `/evidence-audit/audit` | `redirect` | 015 |
| `/evidence/packs/*` | `/evidence-audit/packs/*` | `redirect` | 015 |
| `/evidence/proofs/*` | `/evidence-audit/proofs/*` | `alias` | 015 (permanent convenience alias for external linking) |
| `/evidence/change-trace/*` | `/evidence-audit/change-trace/*` | `redirect` | 015 |
| `/evidence/receipts/cvss/*` | `/evidence-audit/receipts/cvss/*` | `redirect` | 015 |
| `/evidence-thread/*` | `/evidence-audit/thread/*` | `redirect` | 015 |
| `/timeline/*` | `/evidence-audit/timeline/*` | `redirect` | 015 |
## Section 4 — Platform Ops sub-family migrations
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/operations/feeds/*` | `/platform-ops/data-integrity/feeds/*` | `redirect` | 008 |
| `/operations/orchestrator/*` | `/platform-ops/orchestrator/*` | `redirect` | 008 |
| `/operations/health` | `/platform-ops/health` | `redirect` | 008 |
| `/operations/quotas/*` | `/platform-ops/quotas/*` | `redirect` | 008 |
| `/operations/slo` | `/platform-ops/data-integrity/slo` | `redirect` | 008 |
| `/operations/dead-letter` | `/platform-ops/orchestrator/dead-letter` | `redirect` | 008 |
| `/operations/aoc` | `/platform-ops/aoc` | `redirect` | 008 |
| `/operations/doctor` | `/platform-ops/doctor` | `redirect` | 008 |
| `/operations/offline-kit/*` | `/platform-ops/offline-kit/*` | `redirect` | 008 |
| `/operations/agents/*` | `/platform-ops/agents/*` | `redirect` | 008 |
| `/operations/scanner/*` | `/platform-ops/scanner/*` | `redirect` | 008 |
| `/operations/packs/*` | `/platform-ops/pack-registry/*` | `redirect` | 008 |
| `/operations/signals/*` | `/platform-ops/signals/*` | `redirect` | 008 |
| `/operations/ai-runs/*` | `/platform-ops/ai-runs/*` | `redirect` | 008 |
| `/operations/notifications` | `/administration/notifications` | `redirect` | 007 (ownership change) |
| `/operations/status` | `/administration/system/status` | `redirect` | 007 (ownership change) |
## Section 5 — Release Control sub-family migrations
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/releases` | `/release-control/releases` | `redirect` + alias | 010 |
| `/releases/:id` | `/release-control/releases/:id` | `redirect` | 010 |
| `/approvals` | `/release-control/approvals` | `redirect` + alias | 011 |
| `/approvals/:id` | `/release-control/approvals/:id` | `redirect` | 011 |
| `/environments` | `/release-control/environments` | `redirect` | 013 |
| `/environments/:id` | `/release-control/environments/:id` | `redirect` | 013 |
| `/deployments/*` | `/release-control/deployments/*` | `redirect` | 010 |
| (new) `/release-control/bundles/*` | `/release-control/bundles/*` | `new (implemented)` | 20260219_003 |
## Section 6 — Security & Risk sub-family migrations
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/security` | `/security-risk` | `redirect` + alias | 014 |
| `/security/findings/*` | `/security-risk/findings/*` | `redirect` | 014 |
| `/security/vulnerabilities/*` | `/security-risk/vulnerabilities/*` | `redirect` | 014 |
| `/security/sbom/graph` | `/security-risk/sbom/graph` | `redirect` | 014 |
| `/security/lineage/*` | `/security-risk/lineage/*` | `redirect` | 014 |
| `/security/reachability` | `/security-risk/reachability` | `redirect` | 014 |
| `/security/risk` | `/security-risk/risk` | `redirect` | 014 |
| `/security/artifacts/*` | `/security-risk/artifacts/*` | `redirect` | 014 |
| `/security/vex/*` | `/security-risk/vex/*` | `redirect` | 014 |
| `/security/unknowns` | `/security-risk/unknowns` | `redirect` | 014 |
| `/security/patch-map` | `/security-risk/patch-map` | `redirect` | 014 |
| `/security/scans/*` | `/security-risk/scans/*` | `redirect` | 014 |
| (new) `/security-risk/advisory-sources` | `/security-risk/advisory-sources` | `new (implemented)` | 20260219_004 |
## Section 7 — Administration sub-family migrations
| Current v1 path | v2 target | Action | Sprint |
| --- | --- | --- | --- |
| `/policy/governance` | `/administration/policy-governance` | `redirect` | 007 |
| `/policy/exceptions/*` | `/administration/policy-governance/exceptions/*` | `redirect` | 007 |
| `/policy/packs/*` | `/administration/policy-governance/packs/*` | `redirect` | 007 |
| `/admin/trust/*` | `/administration/trust-signing/*` | `redirect` | 007 |
| `/admin/audit` | `/evidence-audit/audit` | `redirect` | 015 |
| `/admin/notifications` | `/administration/notifications` | `redirect` | 007 |
| `/admin/policy/governance` | `/administration/policy-governance` | `redirect` | 007 |
| `/admin/policy/simulation` | `/administration/policy-governance/simulation` | `redirect` | 007 |
| `/admin/registries` | `/integrations/registries` | `redirect` | 008 |
| `/admin/issuers` | `/administration/trust-signing/issuers` | `redirect` | 007 |
| `/admin/vex-hub/*` | `/security-risk/vex/*` | `redirect` | 014 |
## Section 8 — Remove-later candidates
Paths that are stale and should be removed after traffic confirmation:
| Path | Current state | Proposed timeline |
| Legacy root family | Canonical target | Action |
| --- | --- | --- |
| `/home` | Already redirects to `/` | Sprint 016: confirm and remove from app.routes |
| `/orchestrator/*` | Already redirects to `/operations/*` → sprint 008 will update to `/platform-ops/*` | Sprint 016 |
| `/release-orchestrator/*` | Already redirects to root routes | Sprint 016 |
| `/ops/*` | Already redirects to `/operations/*` → sprint 008 will update | Sprint 016 |
| `/console/*` | Already redirects to `/settings/*` → sprint 007 will update to `/administration/*` | Sprint 016 |
| `/triage/*` | Already redirects to `/security/*` → sprint 014 will update | Sprint 016 |
| `/qa/*` (internal workbenches) | Internal tooling; keep as `alias` long-term | No sprint 016 removal |
| `/release-control/*` | split between `/releases/*` and `/topology/*` | `redirect` + `alias-window` |
| `/security-risk/*` | `/security/*` | `redirect` + `alias-window` |
| `/evidence-audit/*` | `/evidence/*` | `redirect` + `alias-window` |
| `/platform-ops/*` | `/platform/ops/*` | `redirect` + `alias-window` |
| `/operations/*` (old ops shell) | `/platform/ops/*` | `redirect` + `alias-window` |
| `/integrations/*` (legacy root) | `/platform/integrations/*` | `redirect` + `alias-window` |
| `/administration/*` (legacy root) | `/platform/setup/*` | `redirect` + `alias-window` |
| `/settings/release-control/*` | `/topology/*` | `redirect` |
## Section 9 — High-risk deep-link mitigation
## Release Control decomposition
| Risk | Mitigation |
| --- | --- |
| `/approvals/:id` bookmarks (operators) | Alias `/approvals/:id` until sprint 016 cutover confirmation. |
| `/releases/:id` links from CI/CD notifications | Alias `/releases/:id` until sprint 016. Log alias traffic before removal. |
| `/settings/trust/*` from admin-written runbooks | Update internal runbooks in sprint 007 alongside redirect implementation. |
| `/policy/*` ownership migration confuses policy authors | Apply transition labels in sprint 007 alongside redirect; breadcrumb shows `Administration > Policy Governance`. |
| `/operations/*` ops-team dashboards with hardcoded links | Announce alias window in release notes. Alias during sprint 008-016 window. |
| Legacy path | Canonical target | Action |
| --- | --- | --- |
| `/release-control/releases` | `/releases/runs` | `redirect` |
| `/release-control/releases/:id` | `/releases/runs/:id/timeline` | `redirect` |
| `/release-control/approvals` | `/releases/approvals` | `redirect` |
| `/release-control/runs` | `/releases/runs` | `redirect` |
| `/release-control/deployments` | `/releases/runs` | `redirect` |
| `/release-control/promotions` | `/releases/runs` | `redirect` |
| `/release-control/hotfixes` | `/releases/hotfix` | `redirect` |
| `/release-control/regions` | `/topology/regions` | `redirect` |
| `/release-control/setup` | `/platform/setup` | `redirect` |
| `/release-control/setup/environments-paths` | `/topology/environments` | `redirect` |
| `/release-control/setup/targets-agents` | `/topology/targets` | `redirect` |
| `/release-control/setup/workflows` | `/platform/setup/workflows-gates` | `redirect` |
## Section 10 — Activation sequence
## Security consolidation
| Sprint | Routes activated / aliases established |
| --- | --- |
| 006 | Root nav + canonical domain route trees; alias existing roots to new domains |
| 007 | Administration domain routes; redirect `/settings/admin/*`, `/policy/*`, `/admin/*` paths |
| 008 | Integrations and Platform Ops routes; redirect `/operations/*`, `/settings/integrations/*` paths |
| 009 | Bundle routes under `/release-control/bundles/*` (new) |
| 010 | Release and promotion routes; redirect `/releases/*`, `/deployments/*` |
| 011 | Approvals routes; alias `/approvals/*` to `/release-control/approvals/*` |
| 012 | Dashboard v3; redirect `/` and update home behavior |
| 013 | Environment detail routes; redirect `/environments/*` |
| 014 | Security & Risk routes; alias `/security/*` |
| 015 | Evidence & Audit routes; alias `/evidence/*` |
| 016 | Remove all `alias` and `remove-later` temporary paths; publish cutover confirmation |
| Legacy path | Canonical target | Action |
| --- | --- | --- |
| `/security-risk` | `/security/overview` | `redirect` |
| `/security-risk/findings*` | `/security/triage*` | `redirect` |
| `/security-risk/vulnerabilities*` | `/security/triage*` | `redirect` |
| `/security-risk/vex` | `/security/advisories-vex` | `redirect` |
| `/security-risk/exceptions` | `/security/advisories-vex` | `redirect` |
| `/security-risk/sbom` | `/security/supply-chain-data/graph` | `redirect` |
| `/security-risk/sbom-lake` | `/security/supply-chain-data/lake` | `redirect` |
| `/security-risk/advisory-sources` | `/platform/integrations/feeds` | `redirect` |
## Evidence and Operations renames
| Legacy path | Canonical target | Action |
| --- | --- | --- |
| `/evidence-audit` | `/evidence/overview` | `redirect` |
| `/evidence-audit/packs*` | `/evidence/capsules*` | `redirect` |
| `/evidence-audit/audit-log` | `/evidence/audit-log` | `redirect` |
| `/evidence-audit/replay` | `/evidence/verification/replay` | `redirect` |
| `/platform-ops` | `/platform/ops` | `redirect` |
| `/platform-ops/data-integrity` | `/platform/ops/data-integrity` | `redirect` |
| `/platform-ops/orchestrator*` | `/platform/ops/orchestrator*` | `redirect` |
| `/platform-ops/agents` | `/topology/agents` | `redirect` |
## Telemetry expectations
- Legacy alias hits must emit deterministic `legacy_route_hit` telemetry with:
- `oldPath`,
- `newPath`,
- tenant/user context metadata.
- Alias telemetry must remain active until Pack22 cutover approval.
## Cutover checkpoint
Before alias removal:
- Legacy hit rate for `/release-control/*`, `/security-risk/*`, `/evidence-audit/*`, `/platform-ops/*` is reviewed.
- Route-to-endpoint matrix in `docs/qa/` confirms canonical routes are using Pack22 endpoints.
- Sprint closure notes record alias telemetry evidence and final removal plan.

View File

@@ -15,10 +15,11 @@ This file is the canonical entrypoint for planning work.
- `S00_sprint_spec_package.md`
- `S00_contract_ledger_template.md`
- `S00_endpoint_contract_ledger_v1.md`
- `S00_endpoint_contract_ledger_v2_pack22.md`
## Raw pack archive (historical inputs)
- `pack-01.md` ... `pack-21.md`
- `pack-01.md` ... `pack-22.md`
- `prompt.txt`
## Precedence reminder

View File

@@ -1,7 +1,7 @@
# UI v2 Rewire Authority Matrix
# UI v2 Rewire Authority Matrix
Status: Canonical planning reference
Date: 2026-02-18
Date: 2026-02-20
This matrix defines which pack is authoritative for each capability and which packs are superseded.
@@ -9,56 +9,60 @@ This matrix defines which pack is authoritative for each capability and which pa
| Capability area | Authoritative pack(s) | Superseded packs | Notes |
| --- | --- | --- | --- |
| Dashboard mission board | `pack-16.md` | `pack-01.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Keep release-centric board with SBOM/CritR/Data Integrity signals. |
| Release bundles and organizer | `pack-12.md`, `pack-21.md` | `pack-01.md`, `pack-02.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Pack 21 sets placement; Pack 12 keeps detailed builder and lifecycle flows. |
| Releases promotion flow | `pack-13.md` | `pack-01.md`, `pack-04.md`, `pack-08.md` | Bundle-version anchored promotion model. |
| Approvals detailed decision flow | `pack-17.md` and `pack-13.md` | `pack-01.md`, `pack-04.md`, `pack-08.md` | Pack 17 overrides approval detail/tab model; Pack 13 still provides base coupling to promotions. |
| Run timeline / rollback / replay context | `pack-14.md` | Earlier implicit run views in packs 1/4/8 | Canonical run lifecycle and checkpoint model. |
| Environment detail standard | `pack-18.md` | `pack-01.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Standardized header and env tab set. |
| Security decision-first console | `pack-19.md` plus `pack-21.md` (advisory mapping) | `pack-03.md`, `pack-07.md` | Pack 19 is base Security model; Pack 21 adds Advisory Sources split intent. |
| Evidence and audit chain | `pack-20.md` | `pack-03.md`, `pack-09.md`, `pack-11.md` | Pack 20 is authoritative except Trust ownership override from Pack 21. |
| Ops data confidence model | `pack-15.md`, `pack-21.md`, `pack-10.md` | `pack-03.md`, `pack-06.md`, `pack-09.md`, `pack-11.md` | Pack 15 defines Data Integrity; Pack 21 defines ops taxonomy; Pack 10 retains feeds/airgap detail. |
| Integrations structure | `pack-21.md`, `pack-10.md` | `pack-02.md`, `pack-05.md`, `pack-09.md` | Pack 21 sets taxonomy; Pack 10 keeps concrete hub/detail flows. |
| Administration structure | `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md`, `pack-11.md` | Canonical A0..A7 admin model. |
| Global IA and naming | `pack-23.md`, `pack-22.md` | `pack-21.md` and lower for overlaps | Canonical roots are Dashboard, Releases, Security, Evidence, Topology, Platform, Administration. |
| Dashboard mission control | `pack-22.md`, `pack-16.md` | `pack-01.md`, `pack-04.md`, `pack-08.md`, `pack-11.md` | Pack 22 defines posture framing; Pack 16 keeps detailed signal cards where unchanged. |
| Releases lifecycle consolidation | `pack-22.md`, `pack-12.md`, `pack-13.md`, `pack-14.md`, `pack-17.md` | Standalone lifecycle module variants in older packs | Runs/deployments/promotions/hotfixes are views under Releases, not roots. |
| Topology inventory and setup | `pack-22.md`, `pack-18.md` | Prior placements under Release Control and Platform Ops | Regions/env/targets/hosts/agents/workflows/gate profiles belong to Topology. |
| Security consolidation | `pack-22.md`, `pack-19.md` | `pack-03.md`, `pack-07.md` and split-view variants | Findings + Disposition + SBOM Explorer as consolidated IA. |
| Evidence and audit chain | `pack-22.md`, `pack-20.md` | `pack-03.md`, `pack-09.md`, `pack-11.md` | Evidence must be linked from Releases and Security decisions. |
| Operations runtime posture | `pack-23.md`, `pack-15.md`, `pack-10.md` | `pack-03.md`, `pack-06.md`, `pack-09.md`, `pack-11.md` | Ops runs under Platform and owns runtime operability state; agents stay in Topology. |
| Integrations configuration | `pack-23.md`, `pack-10.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md` | Integrations runs under Platform and is limited to external systems/connectors. |
| Administration governance | `pack-22.md`, `pack-21.md` | `pack-02.md`, `pack-05.md`, `pack-09.md`, `pack-11.md` | Identity/tenant/notification/usage/policy/system remain Administration-owned. |
## B) Explicit higher-pack overrides
| Decision | Replaced guidance | Canonical guidance |
| --- | --- | --- |
| Policy Governance location | Release Control variants in Packs 5 and 9 | `Administration -> Policy Governance` (`pack-21.md`) |
| Trust & Signing ownership | Evidence ownership in Packs 9, 11, and 20 | `Administration -> Trust & Signing` with Evidence/Security cross-links (`pack-21.md`) |
| System location | Operations Platform Admin in Pack 9, root System in Pack 11 | `Administration -> System` with Platform Ops drilldowns (`pack-21.md`) |
| Legacy Security Data split | Mixed settings-placement drafts in Packs 2/5/9/10 | Connectivity in Integrations/Ops, decision impact in Security (`pack-21.md`) |
| Root domain naming | `Release Control`, `Security & Risk`, `Evidence & Audit`, `Platform Ops` roots | `Releases`, `Security`, `Evidence`, `Platform`, plus `Topology` root (`pack-23.md`) |
| Bundle naming | Bundle-first labels in packs 12/21 | UI term is `Release`; bundle semantics remain in data model (`pack-22.md`) |
| Lifecycle menu sprawl | Standalone Promotions, Deployments, Runs, Hotfixes menus | Lifecycle surfaces live under `Releases` list/detail/activity/approvals (`pack-22.md`) |
| Region/environment nav placement | Deep menu under release-control variants | Global context selectors + Topology inventory pages (`pack-22.md`) |
| Security navigation split | Separate VEX, Exceptions, SBOM Graph, SBOM Lake menus | Consolidated `Disposition` and `SBOM Explorer` surfaces (`pack-22.md`) |
| Feed and VEX source setup placement | Security-owned advisory sources setup variants | Integrations-owned feed/source configuration (`pack-22.md`) |
| Agent module placement | Platform Ops ownership variants | `Topology -> Agents` (`pack-22.md`) |
## C) Pack lifecycle classification
| Pack | Status for planning | Primary reason |
| --- | --- | --- |
| `pack-01.md` | Superseded baseline | Early release-control draft replaced by later domain packs. |
| `pack-01.md` | Superseded baseline | Early drafts replaced by higher packs. |
| `pack-02.md` | Superseded baseline | Early settings/admin/integration placement replaced. |
| `pack-03.md` | Superseded baseline | Early security/evidence/ops model replaced by 15/19/20/21. |
| `pack-04.md` | Superseded baseline | Early Release Control model replaced by 12/13/16/17/18/21. |
| `pack-05.md` | Superseded baseline | Transitional admin/integration moves replaced by 21. |
| `pack-06.md` | Superseded baseline | Ops structure replaced by 15 and 21 taxonomy. |
| `pack-07.md` | Superseded baseline | Security model replaced by 19. |
| `pack-08.md` | Partially superseded reference | Useful as RC nesting reference only; most details replaced. |
| `pack-09.md` | Superseded baseline | Settings migration draft overridden by 21. |
| `pack-10.md` | Active partial authority | Still needed for detailed Integrations/Feeds/AirGap flows. |
| `pack-11.md` | Superseded baseline | Replaced by 12-21 and overridden by 21 on key ownerships. |
| `pack-12.md` | Active authority | Bundle organizer deep specification. |
| `pack-13.md` | Active authority | Promotion flow baseline; approvals partially overridden by 17. |
| `pack-14.md` | Active authority | Run timeline, checkpoints, rollback/replay hooks. |
| `pack-03.md` | Superseded baseline | Early security/evidence/ops model replaced. |
| `pack-04.md` | Superseded baseline | Early release control model replaced. |
| `pack-05.md` | Superseded baseline | Transitional admin/integration moves replaced. |
| `pack-06.md` | Superseded baseline | Ops structure replaced by packs 15 and 22. |
| `pack-07.md` | Superseded baseline | Security model replaced by packs 19 and 22. |
| `pack-08.md` | Superseded baseline | Historical reference only. |
| `pack-09.md` | Superseded baseline | Settings migration draft replaced. |
| `pack-10.md` | Active partial authority | Integrations/feeds/airgap detail where not overridden. |
| `pack-11.md` | Superseded baseline | Replaced by packs 12-22. |
| `pack-12.md` | Active authority | Release composition deep specification. |
| `pack-13.md` | Active authority | Promotion flow baseline for Releases. |
| `pack-14.md` | Active authority | Run timeline/checkpoint semantics. |
| `pack-15.md` | Active authority | Data Integrity operations model. |
| `pack-16.md` | Active authority | Dashboard v3 canonical model. |
| `pack-17.md` | Active authority | Approvals v2 canonical detail model. |
| `pack-18.md` | Active authority | Environment detail canonical standard. |
| `pack-19.md` | Active authority | Security consolidation baseline. |
| `pack-20.md` | Active authority with override | Evidence consolidation; Trust ownership overridden by 21. |
| `pack-21.md` | Highest-precedence authority | Final admin/integration/settings split and top-level grouping intent. |
| `pack-16.md` | Active authority | Dashboard signal-level model. |
| `pack-17.md` | Active authority | Approvals detail model. |
| `pack-18.md` | Active authority | Environment/topology detail shell standard. |
| `pack-19.md` | Active authority | Security decision model details. |
| `pack-20.md` | Active authority | Evidence chain structure. |
| `pack-21.md` | Active fallback authority | Pre-Pack-22 admin/integration organization details where not overridden. |
| `pack-23.md` | Highest-precedence authority | Platform global menu with Ops/Integrations/Setup consolidation and ownership boundaries. |
| `pack-22.md` | Active authority | IA consolidation baseline and naming model before Platform delta in Pack 23. |
## D) Raw pack usage policy
For sprint planning, use raw packs only through this sequence:
1. Find capability in Section A.
2. Start with listed authoritative pack(s).
3. Open superseded packs only for migration context or missing implementation detail.

View File

@@ -1,266 +1,191 @@
# UI v2 Rewire Multi Sprint Plan (Draft 1)
# UI v2 Rewire Multi Sprint Plan (Draft 2 - Pack 22)
Status: Ready for sprint authoring
Date: 2026-02-18
Source set: `source-of-truth.md`, `authority-matrix.md`, `sprint-planning-guide.md`
Date: 2026-02-20
Source set: `source-of-truth.md`, `authority-matrix.md`, `sprint-planning-guide.md`, `S00_endpoint_contract_ledger_v2_pack22.md`
## Scope and intent
This is the first implementation decomposition for the v2 UI rewire.
It is designed for many execution sprints with clear dependencies and parallel lanes.
This plan decomposes the Pack 22 advisory into execution sprints with explicit backend dependency ordering.
Precedence rule: higher pack number wins for overlap.
## Mandatory contract workflow (all sprints)
For each screen in sprint scope, classify backend readiness:
- `EXISTS_COMPAT`
- `EXISTS_ADAPT`
- `MISSING_NEW`
Each sprint must produce a contract ledger with:
- screen
- required behavior
- current endpoint candidate
- status class
- auth scope impact
- schema delta
- owner module
Each sprint must produce or update a contract ledger with:
- screen,
- required behavior,
- current endpoint candidate,
- status class,
- auth scope impact,
- schema delta,
- owner module.
## Wave map
| Wave | Sprints | Goal |
| --- | --- | --- |
| Wave 0 | S00 | Freeze final spec and remove residual ambiguity |
| Wave 1 | S01, S02, S03 | Navigation shell and foundational admin/integration/ops taxonomy |
| Wave 2 | S04, S05, S06, S07 | Release core (bundles, promotions, approvals, runs) |
| Wave 3 | S08, S09, S10, S11 | Dashboard, env standardization, security and evidence consolidation |
| Wave 4 | S12, S13 | Migration cutover, redirects, QA hardening, release readiness |
| Wave 0 | S22-00 | Freeze Pack 22 canonical doc set and contract baseline |
| Wave 1 | S22-01, S22-02, S22-03 | Backend dependencies and DB migrations |
| Wave 2 | S22-04, S22-05 | FE nav shell and Releases consolidation |
| Wave 3 | S22-06, S22-07, S22-08 | Topology/Operations, Security/Evidence, Integrations/Admin alignment |
| Wave 4 | S22-09, S22-10 | Redirect cutover, Playwright conformity, release readiness |
## Sprint catalog
### S00 - Spec freeze and unresolved gaps
- Canonical packs: 21, 19, 20
- Goal: lock unresolved model gaps before feature implementation starts.
### S22-00 - Spec freeze for Pack 22
- Canonical packs: 22 (+ fallback packs per authority matrix)
- Goal: lock naming, ownership, and route intent before further implementation.
- Primary outputs:
- final `Advisory Sources` screen spec (Security and Risk)
- final rule for Release Control-owned capability rendering (shortcut vs nested)
- final Trust ownership transition policy (Administration owner, Evidence consumer)
- final route deprecation map baseline
- Contract work:
- start global endpoint ledger, initial status for all top-level screens.
- updated `source-of-truth.md`
- updated `authority-matrix.md`
- Pack 22 contract ledger baseline
- Dependencies: none.
- Parallelism: blocks S01-S03 start for any unresolved ownership topic.
### S01 - Nav shell and route framework
- Canonical packs: 21, 16
- Goal: create stable shell for new IA without breaking existing behavior.
- Working directory (implementation): `src/Web/StellaOps.Web`
### S22-01 - Backend context and releases read models (dependency sprint)
- Canonical packs: 22, 12, 13, 14, 17
- Working directory (implementation): `src/Platform/StellaOps.Platform.WebService`
- Goal: deliver global context and releases v2 contracts with DB backing.
- Primary outputs:
- root nav groups aligned to canonical IA
- breadcrumb updates and migration labels
- route alias skeleton for staged cutover
- Contract work:
- ledger for nav-linked routes and their current API assumptions.
- Dependencies: S00.
- Parallelism: can run with S02 and S03 after S00 decisions are frozen.
- `/api/v2/context/*`
- `/api/v2/releases/*` (list/detail/activity/approvals queue)
- DB migrations `047_*.sql`, `048_*.sql`
- Dependencies: S22-00.
### S02 - Administration and Integrations restructuring
- Canonical packs: 21, 10
- Goal: move settings-heavy capability into Administration and Integrations model.
- Working directory (implementation): `src/Web/StellaOps.Web`
### S22-02 - Backend topology inventory contracts (dependency sprint)
- Canonical packs: 22, 18
- Working directory (implementation): `src/Platform/StellaOps.Platform.WebService`
- Goal: deliver Topology APIs and inventory projections.
- Primary outputs:
- Admin A0-A7 routing and page ownership
- Integrations taxonomy and detail flow alignment
- Security Data split wiring (Integrations + Platform Ops + Security)
- Contract work:
- classify admin and integration endpoints; identify missing APIs for advisory source health and impact mapping.
- Dependencies: S00, S01.
- Parallelism: can run with S03.
- `/api/v2/topology/*`
- DB migration `049_*.sql`
- Dependencies: S22-00.
### S03 - Platform Ops and Data Integrity foundation
- Canonical packs: 15, 21, 10
- Goal: establish Data Integrity as the operational truth source.
- Working directory (implementation): `src/Web/StellaOps.Web`
### S22-03 - Backend security disposition contracts (dependency sprint)
- Canonical packs: 22, 19
- Working directory (implementation): `src/Platform/StellaOps.Platform.WebService`
- Goal: consolidate findings/disposition/SBOM contracts for Security.
- Primary outputs:
- Data Integrity overview and subviews
- ops links from dashboard/approvals/security placeholders
- feeds/airgap ops alignment with integrations view
- Contract work:
- classify freshness, job health, ingest, DLQ, and integration connectivity APIs.
- Dependencies: S00, S01.
- Parallelism: can run with S02.
- `/api/v2/security/findings`
- `/api/v2/security/disposition`
- `/api/v2/security/sbom-explorer`
- DB migration `050_*.sql`
- Dependencies: S22-00.
### S04 - Bundle organizer and bundle lifecycle
- Canonical packs: 12, 21
- Goal: implement bundle-first model for release inputs.
### S22-04 - FE nav shell migration
- Canonical packs: 22, 16
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: migrate root IA and top-bar global context controls.
- Primary outputs:
- bundle catalog/detail/builder flow
- component version selection and config contract steps
- materialize to environment flow shell
- Contract work:
- classify component inventory, digest mapping, changelog, and materialization APIs.
- define new schemas where missing (`MISSING_NEW`).
- Dependencies: S00, S01, S02.
- Parallelism: can start before S05.
- root route rename to canonical Pack 22 modules
- sidebar and breadcrumbs updated
- temporary legacy alias redirects
- Dependencies: S22-01 for context contract.
### S05 - Releases promotion flow (bundle-version anchored)
- Canonical packs: 13
- Goal: convert release flow to immutable bundle-version promotions.
### S22-05 - FE Releases module consolidation
- Canonical packs: 22, 12, 13, 14, 17
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: collapse release lifecycle surfaces into Releases module.
- Primary outputs:
- promotions list and create wizard
- release detail and gate summary model
- links to run timeline, approvals, evidence snapshots
- Contract work:
- classify promotion creation/status/history APIs and gate evaluation contracts.
- Dependencies: S04.
- Parallelism: can run with S06 once S04 contracts are stable.
- Releases list/detail/activity/approvals queue
- old standalone runs/deployments/promotions/hotfix routes redirected
- Dependencies: S22-01.
### S06 - Approvals v2 decision cockpit
- Canonical packs: 17, 13
- Goal: make approvals self-sufficient for decisioning.
### S22-06 - FE Topology and Operations boundary alignment
- Canonical packs: 22, 18, 15
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: move inventory pages to Topology and keep runtime state in Operations.
- Primary outputs:
- approvals queue v2
- approval detail tabs (overview, gates, security, reachability, ops/data, evidence, replay, history)
- consistent cross-links to Security/Evidence/Ops/Release Control
- Contract work:
- classify approval packet, gate trace, decision action, and evidence retrieval APIs.
- Dependencies: S05 and S03 baseline availability.
- Parallelism: partial overlap with S07 allowed.
- Topology module pages
- Operations cleanup after agent/inventory migration
- Dependencies: S22-02.
### S07 - Run timeline, checkpoints, rollback and replay context
- Canonical packs: 14
- Goal: provide auditable execution timeline for each promotion run.
### S22-07 - FE Security consolidation
- Canonical packs: 22, 19
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: implement Risk Overview + Findings + Disposition + SBOM Explorer.
- Primary outputs:
- run timeline page
- step detail with logs/artifacts/evidence capture points
- rollback and rerun controls with safe gating
- Contract work:
- classify run-step logs/artifact/retry/rollback APIs and permissions.
- Dependencies: S05.
- Parallelism: can run with S06.
- consolidated Security routes and nav
- disposition UX that composes VEX + Exceptions data
- Dependencies: S22-03.
### S08 - Dashboard v3 mission board
- Canonical packs: 16
- Goal: upgrade dashboard to release-risk mission board.
### S22-08 - FE Evidence/Integrations/Admin alignment
- Canonical packs: 22, 20, 10, 21
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: align evidence links, feed/vex source setup placement, and admin boundaries.
- Primary outputs:
- env risk panel (`CritR`, SBOM freshness, B/I/R coverage)
- nightly/data integrity signal cards
- fast drilldowns to approvals/releases/security/ops
- Contract work:
- classify aggregated dashboard endpoints and freshness metadata contracts.
- Dependencies: S03, S05, S06.
- Parallelism: can run with S09.
- evidence cross-links from releases/security/approvals
- integrations feed/vex source setup placement
- trust posture links with admin-owner mutations preserved
- Dependencies: S22-01 and S22-03.
### S09 - Environment detail standardization
- Canonical packs: 18
- Goal: unify environment decision state in one screen shell.
### S22-09 - Route deprecation and redirect cutover
- Canonical packs: 22 plus affected domain packs
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: preserve deep links while switching canonical roots.
- Primary outputs:
- standard env header
- tabs for deploy, SBOM/findings, reachability, inputs, promotions/approvals, data confidence, evidence
- canonical deep links into bundle/run/security/evidence pages
- Contract work:
- classify environment-scoped status and evidence APIs.
- Dependencies: S03, S04, S05.
- Parallelism: can run with S08 and S10.
- full redirect map and telemetry
- breadcrumb compatibility labels
- Dependencies: S22-04 to S22-08.
### S10 - Security and Risk consolidation
- Canonical packs: 19, 21
- Goal: implement decision-first Security model with advisory-source split.
- Working directory (implementation): `src/Web/StellaOps.Web`
- Primary outputs:
- risk overview, findings explorer/detail, vulnerabilities explorer/detail
- SBOM lake/graph placement, VEX, exceptions
- Advisory Sources screen per S00 finalized spec
- Contract work:
- classify findings/vuln/vex/exception/advisory-source APIs and filtering contracts.
- Dependencies: S00, S03, S08.
- Parallelism: can run with S11 once cross-link contracts stabilize.
### S22-10 - E2E conformity and release readiness
### S11 - Evidence and Audit consolidation
- Canonical packs: 20 with 21 trust override
- Goal: implement evidence chain navigation and audit retrieval model.
- Working directory (implementation): `src/Web/StellaOps.Web`
- Primary outputs:
- evidence home router
- evidence packs, bundles, export center, proof chains, replay/verify, audit log
- Trust links to Administration-owned surface
- Contract work:
- classify evidence pack/bundle/export/proof/replay/audit APIs and ownership boundaries.
- Dependencies: S00, S05, S06.
- Parallelism: can run with S10.
### S12 - Migration and redirect cutover
- Canonical packs: 21 plus affected domain packs
- Goal: make IA migration safe for existing users and links.
- Working directory (implementation): `src/Web/StellaOps.Web`
- Primary outputs:
- full redirect map for legacy settings and historical aliases
- breadcrumb and legacy-name compatibility labels
- deprecation telemetry hooks
- Contract work:
- no new domain APIs expected; verify alias routes and fallback behaviors.
- Dependencies: S01-S11 (or at least all impacted route owners).
- Parallelism: mostly late-phase integration sprint.
### S13 - E2E QA hardening and release readiness
- Canonical packs: all active authority packs
- Goal: prove end-to-end behavior against final IA and contracts.
- Working directory (implementation): `src/Web/StellaOps.Web`
- Goal: prove behavior against Pack 22 and fallback pack details.
- Primary outputs:
- route and workflow E2E coverage for all root domains
- accessibility and regression checks for nav and critical workflows
- Playwright route and interaction evidence
- screenshot pack for auditor handoff
- final contract ledger closure report
- Contract work:
- verify all screens have final status not `MISSING_NEW`.
- Dependencies: S02-S12 completion candidates.
- Parallelism: can stage as rolling QA, but final signoff occurs last.
- Dependencies: S22-04 to S22-09.
## Cross-module backend ownership map (planning)
These modules are likely to receive backend contract work during implementation sprints:
These modules are expected to receive backend contract work during Pack 22 migration:
- `src/Platform/`
- `src/ReleaseOrchestrator/`
- `src/Policy/`
- `src/Scanner/`
- `src/Integrations/`
- `src/EvidenceLocker/`
- `src/Attestor/`
- `src/Signer/`
- `src/Integrations/`
- `src/Scanner/`
- `src/Orchestrator/`
- `src/Scheduler/`
- `src/Authority/`
Each sprint that touches these must include explicit cross-module allowance in its sprint file.
## Initial sequencing recommendation
1. Execute S00 to remove final ambiguity.
2. Run S01 + S02 + S03 in parallel.
3. Start release core S04 -> S05, then branch into S06 and S07.
4. Run S08 + S09 + S10 + S11 as parallel domain upgrades.
5. Finish with S12 migration cutover and S13 final QA signoff.
1. Complete S22-00 documentation freeze.
2. Execute S22-01, S22-02, S22-03 as backend dependency lane.
3. Start FE with S22-04 and S22-05 after S22-01 API availability.
4. Run S22-06, S22-07, S22-08 with dependency gating.
5. Finish with S22-09 migration cutover and S22-10 QA signoff.
## Proposed sprint filename seeds (for `docs/implplan` authoring)
- `SPRINT_20260218_001_DOCS_ui_v2_rewire_spec_freeze.md` (S00)
- `SPRINT_20260218_002_FE_ui_v2_rewire_nav_shell.md` (S01)
- `SPRINT_20260218_003_FE_ui_v2_rewire_admin_integrations.md` (S02)
- `SPRINT_20260218_004_FE_ui_v2_rewire_platform_ops_data_integrity.md` (S03)
- `SPRINT_20260218_005_FE_ui_v2_rewire_bundle_lifecycle.md` (S04)
- `SPRINT_20260218_006_FE_ui_v2_rewire_releases_promotions.md` (S05)
- `SPRINT_20260218_007_FE_ui_v2_rewire_approvals_v2.md` (S06)
- `SPRINT_20260218_008_FE_ui_v2_rewire_run_timeline.md` (S07)
- `SPRINT_20260218_009_FE_ui_v2_rewire_dashboard_v3.md` (S08)
- `SPRINT_20260218_010_FE_ui_v2_rewire_environment_detail.md` (S09)
- `SPRINT_20260218_011_FE_ui_v2_rewire_security_consolidation.md` (S10)
- `SPRINT_20260218_012_FE_ui_v2_rewire_evidence_audit_consolidation.md` (S11)
- `SPRINT_20260218_013_FE_ui_v2_rewire_migration_redirects.md` (S12)
- `SPRINT_20260218_014_FE_ui_v2_rewire_release_readiness_qa.md` (S13)
Note: creation of official sprint files is intentionally deferred until write scope includes `docs/implplan`.
- `SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md` (S22-01 + S22-02 + S22-03 baseline)
- `SPRINT_20260220_019_FE_pack22_ia_rewire_and_route_migration.md` (S22-04 + S22-05 baseline)
- `SPRINT_20260220_020_FE_pack22_releases_security_detailed_workbench.md` (incremental extension of S22-06/S22-07 scope)
- `SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope.md` (run-centric extension across S22-06 to S22-08 scope)
- `SPRINT_20260220_022_FE_pack22_run_detail_provenance_contract.md` (run-detail contract hardening extension)
- `SPRINT_20260220_023_Platform_pack22_run_detail_backend_provenance_companion.md` (backend companion dependency for sprint 022)
- `SPRINT_20260220_024_FE_pack22_redirect_cutover.md` (S22-09 target)
- `SPRINT_20260220_025_FE_pack22_release_readiness_qa.md` (S22-10 target)

View File

@@ -0,0 +1,231 @@
# Pack 22 - Release-First IA Consolidation Advisory
Status: Active authority (partially superseded by Pack 23 for Platform IA)
Date: 2026-02-20
Precedence: Overrides `pack-21.md` and lower packs for overlapping IA, naming, and ownership decisions. Pack 23 supersedes Pack 22 for Platform menu placement and Ops/Integrations/Setup ownership boundaries.
## 1) Intent
- Reframe IA around Stella Ops core loop:
- Release -> Gate (security + ops) -> Promote/Deploy -> Evidence -> Audit/Replay.
- Remove duplicated menus that represent the same lifecycle object from different angles.
- Keep backend semantics strict:
- release identity is immutable and digest-first,
- workflow/run/deployment/promotion are execution artifacts of a release.
## 2) Canonical mental model
- Release (formerly Bundle): immutable unit of change, identified by digest and metadata.
- Workflow/Pipeline: policy and orchestration template.
- Run: workflow execution instance for a release and context.
- Promotion: environment transition.
- Deployment: apply release to targets/runtimes.
- Hotfix: release type with expedited gate defaults (not a separate product root).
## 3) Canonical global navigation
Top-level modules:
1. Dashboard
2. Releases
3. Security
4. Evidence
5. Topology
6. Operations
7. Integrations
8. Administration
Persistent top bar context:
- Search
- Region multi-select
- Environment multi-select (scoped by selected regions)
- Time window selector
- Status indicators (offline/feed/policy/evidence)
## 4) Consolidation rules
- `Bundle` term is deprecated in UI:
- use `Release`.
- `Create Bundle` becomes:
- `Create Release`.
- `Current Release` action label becomes:
- `Deploy Release`.
- The following become views inside `Releases` and are not standalone modules:
- Runs,
- Deployments,
- Promotions,
- Hotfixes.
- `Regions & Environments` is not daily navigation:
- global context lives in top bar,
- inventory/setup lives under `Topology`.
- Security surface is consolidated:
- Overview,
- Triage,
- Advisories & VEX,
- Supply-Chain Data.
- `Disposition` is a UX concept embedded in triage/detail:
- Effective VEX,
- Waivers/Exceptions,
- Policy Gate Trace.
- VEX/advisory feed configuration belongs to `Integrations`, not Security.
## 5) Canonical module surfaces
### Dashboard
- Mission control posture:
- deploying now,
- blocked promotions,
- hotfix lane,
- risk posture,
- evidence posture.
- Quick actions:
- Create Release,
- Create Hotfix,
- Approvals Queue,
- Export Evidence,
- Replay decision capsule.
### Releases
- Releases List (standard + hotfix in one list).
- Release Detail tabs:
- Overview,
- Timeline,
- Deploy,
- Security,
- Evidence,
- Audit.
- Approvals Queue (cross-release).
- Activity (cross-release runs timeline).
### Security
- Overview:
- blocker-first posture,
- freshness/confidence,
- expiring waivers and conflicts.
- Triage:
- single dataset with pivots and facets,
- sticky evidence rail (`Why`, `SBOM`, `Reachability`, `Effective VEX`, `Waiver`, `Policy Trace`, `Export`).
- Advisories & VEX:
- provider health,
- VEX library,
- conflicts and resolution,
- issuer trust.
- Supply-Chain Data:
- SBOM Viewer,
- SBOM Graph,
- SBOM Lake,
- Reachability coverage,
- Coverage/Unknowns.
- Reports:
- optional route family,
- evidence export handoff remains owned by `Evidence`.
### Evidence
- Audit Log.
- Evidence Packs:
- Export Center,
- Proof Chains,
- Replay and Verify.
- Trust and Signing:
- user-facing trust posture can be reached here,
- admin owner mutations remain governed by Administration scopes.
### Topology
- Regions.
- Environments.
- Targets and Hosts.
- Agents.
- Promotion Paths.
- Workflows.
- Gate Profiles.
Implementation update (2026-02-20):
- Dedicated operator pages now back canonical Topology routes:
- `/topology/overview`,
- `/topology/regions` + `/topology/environments` (region-first + flat/graph views),
- `/topology/environments/:environmentId/posture` (topology-first tabs),
- `/topology/targets`,
- `/topology/hosts`,
- `/topology/agents`,
- `/topology/promotion-paths`.
- Generic inventory fallback remains only for non-primary Topology routes (`/topology/workflows`, `/topology/gate-profiles`).
- Region/environment global multi-select filters propagate as comma-joined query scope on Topology reads.
### Operations
- Platform Health.
- Orchestrator and Jobs.
- Scheduler.
- Data Integrity.
- Offline Kit.
- Quotas and Limits.
### Integrations
- Registries.
- SCM.
- CI/CD.
- Hosts/Targets connectors.
- Secrets.
- Advisory feeds.
- VEX sources/feeds.
- Integration Health.
- Integration Activity.
### Administration
- Identity and Access.
- Tenants and Branding.
- Notifications.
- Usage and Limits.
- Policy Governance.
- System.
## 6) Old-to-new mapping (route/module intent)
| Legacy intent | New canonical placement |
| --- | --- |
| `Release Control` root | Split into `Releases` + `Topology` |
| `Bundles` | `Releases` (rename Bundle -> Release) |
| `Promotions` | `Releases -> Release Detail -> Timeline` and `Releases -> Activity` |
| `Deployments` | `Releases -> Release Detail -> Deploy` and `Releases -> Activity` |
| `Run Timeline` | `Releases -> Activity` and `Release Detail -> Timeline` |
| `Hotfixes` | `Releases` filter/type + Dashboard hotfix lane |
| `Regions & Environments` menu | Top bar context + `Topology` inventory |
| `Security & Risk -> VEX` and `Exceptions` | `Security -> Triage` disposition rail + `Security -> Advisories & VEX` |
| `Security -> SBOM Graph` and `SBOM Lake` | `Security -> Supply-Chain Data` tabs |
| `Security -> Advisory Sources` config | `Integrations` feeds and source setup |
| `Platform Ops -> Agents` | `Topology -> Agents` |
## 7) Backend dependency directives
- Add/extend v2 contract namespaces for canonical modules:
- `/api/v2/context/*`,
- `/api/v2/releases/*`,
- `/api/v2/topology/*`,
- `/api/v2/security/*`,
- `/api/v2/evidence/*`,
- `/api/v2/integrations/*`,
- `/api/v2/operations/*`.
- Keep legacy aliases during migration window (`/api/v1/*` and domain legacy paths) with explicit deprecation telemetry.
- Required DB migration families (Platform release DB sequence continues after `046_TrustSigningAdministration.sql`):
- `047_GlobalContextAndFilters.sql`,
- `048_ReleaseReadModels.sql`,
- `049_TopologyInventory.sql`,
- `050_SecurityDispositionProjection.sql`,
- `051_IntegrationSourceHealth.sql`.
## 8) Planning acceptance gates
- Canonical docs (`source-of-truth.md`, `authority-matrix.md`, contract ledger) updated before sprint execution.
- Every new screen/route has endpoint classification:
- `EXISTS_COMPAT`,
- `EXISTS_ADAPT`,
- `MISSING_NEW`.
- Backend migrations are listed in sprint completion criteria before FE route cutover tasks can be marked done.

View File

@@ -0,0 +1,85 @@
# Pack 23 - Platform Global Ops/Integrations/Setup Advisory Delta
Status: Active high-precedence authority for Platform IA and ownership
Date: 2026-02-20
Precedence: Overrides `pack-22.md` and lower packs for overlapping Platform navigation, ownership, and operator workflow behavior.
## 1) Intent
- Make `Platform` a global root and consolidate operability into one module shell.
- Keep three explicit Platform subdomains:
- `Ops`,
- `Integrations`,
- `Setup`.
- Reduce cognitive load by consolidating operations surfaces around decision impact.
## 2) Canonical Platform model
### 2.1 Platform as global root
- `Platform` is a top-level module.
- `Integrations` and `Setup` are no longer separate top-level roots.
- Canonical IA under Platform:
- `/platform/ops/*`
- `/platform/integrations/*`
- `/platform/setup/*`
### 2.2 Ops workflow priority
Ops primary workflows:
1. `Data Integrity`
2. `Jobs & Queues`
3. `Health & SLO`
Ops secondary tools:
- `Feeds & Airgap`
- `Quotas & Limits`
- `Diagnostics`
### 2.3 Ownership boundaries
- `Topology` owns hosts/targets/agents management.
- `Platform Integrations` owns external systems only:
- Registries,
- SCM,
- CI/CD,
- Runtimes / Hosts (connectors only),
- Advisory sources,
- VEX sources,
- Secrets.
- `Platform Setup` owns inventory and orchestration setup:
- Regions & Environments,
- Promotion Paths,
- Workflows & Gates,
- Gate Profiles,
- Release Templates.
- Feed Policy,
- Defaults & Guardrails.
## 3) Degraded/offline UX contract
Platform operator views must provide:
- explicit decision impact label (`BLOCKING`, `DEGRADED`, `INFO`),
- retry controls,
- copyable correlation ID,
- last-known-good/read-only context when live dependencies degrade.
## 4) Route mapping directives
- Keep legacy aliases for migration safety:
- `/integrations/*` -> `/platform/integrations/*`
- `/platform-ops/*` and `/operations/*` -> `/platform/ops/*`
- Legacy hosts/targets integration links must redirect to Topology:
- `/platform/integrations/hosts` -> `/topology/hosts`
- `/platform/integrations/targets*` -> `/topology/targets`
## 5) Planning and QA gates
- Platform nav and submenus reflect the Ops/Integrations/Setup split.
- Integrations screens do not present hosts/targets/agents as managed integration categories.
- Integrations include `Runtimes / Hosts` as connector category while inventory ownership remains in Topology.
- Setup routes render setup-owned pages, including `Feed Policy`, `Gate Profiles`, and `Defaults & Guardrails`.
- Focused FE route/nav/platform tests pass for changed surfaces before sprint closure.

View File

@@ -1,25 +1,40 @@
# Pack Conformity Diff - 2026-02-20 (UTC)
Status: Historical baseline for pre-Pack-22 structure.
Pack 22 (`docs/modules/ui/v2-rewire/pack-22.md`) supersedes this conformity scope for IA decisions.
Do not treat this file as final conformity evidence for current canonical planning.
## Scope
- Source packs reviewed: `docs/modules/ui/v2-rewire/pack-01.md` through `docs/modules/ui/v2-rewire/pack-21.md`.
- Effective precedence rule: higher pack number wins where behavior is refined in later packs.
- Conformity harness: `src/Web/StellaOps.Web/tests/e2e/pack-conformance.scratch.spec.ts`.
- UI run mode for clean routing: Angular dev server on `https://127.0.0.1:4410` with empty proxy config (no `/integrations` or `/platform` path capture).
- UI run mode: Angular dev server on `https://127.0.0.1:4410` (no proxy config).
## Evidence
- Command:
`npx ng serve --configuration development --port 4410 --host 127.0.0.1 --ssl --proxy-config proxy.playwright-empty.json`
`npx ng serve --configuration development --port 4410 --host 127.0.0.1 --ssl`
- Command:
`PLAYWRIGHT_BASE_URL=https://127.0.0.1:4410 PACK_CONFORMANCE_FILTER='pack-19.*exceptions' npx playwright test tests/e2e/pack-conformance.scratch.spec.ts`
- Command:
`PLAYWRIGHT_BASE_URL=https://127.0.0.1:4410 npx playwright test tests/e2e/pack-conformance.scratch.spec.ts`
- Command:
`PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400 npx playwright test tests/e2e/pack-conformance.scratch.spec.ts --workers=1`
- Command:
`PACK_CONFORMANCE_FILTER='security' PACK_SCREENSHOT_DIR='docs/qa/security-advisory-rebuild-2026-02-20' npx playwright test tests/e2e/pack-conformance.scratch.spec.ts`
- Result:
`61` canonical pack route checks executed, `60` conformant, `1` mismatch.
Filtered Pack 19 exceptions run passed (`1` test, `0` failures). Full conformance sweep passed (`1` test, `0` failures; all `61` canonical route checks matched).
- Result:
Post-audit run passed (`1` test, `0` failures) after aligning conformance expectations to run-centric canonical routes (`/releases/runs`, `/security/triage`, `/evidence/capsules`, `/platform/setup`).
- Result:
Security-focused advisory rebuild check passed (`1` test, `0` failures) with screenshot index at `src/Web/StellaOps.Web/docs/qa/security-advisory-rebuild-2026-02-20/index.csv`.
## Difference Ledger
| Status | Pack File | Pack Section | Canonical Route | Expected UI | Actual UI | Code Reference |
| --- | --- | --- | --- | --- | --- | --- |
| DIFF | `docs/modules/ui/v2-rewire/pack-19.md` | `19.10 Security screen - Exceptions` | `/security-risk/exceptions` | Dedicated "Exceptions" screen for waivers and risk acceptance | Route resolves to Vulnerability Triage artifact screen (`Vulnerability Triage`, `Artifact-first workflow with evidence and VEX-first decisioning`) | `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts:103`, `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts:107`, `src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html:4` |
| RESOLVED | `docs/modules/ui/v2-rewire/pack-19.md` | `19.10 Security screen - Exceptions` | `/security-risk/exceptions` | Dedicated "Exceptions" screen for waivers and risk acceptance | Route resolves to dedicated Exceptions dashboard/detail flow (list, detail, approvals) with risk-acceptance vocabulary and evidence/approval context | `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts:103`, `src/Web/StellaOps.Web/src/app/features/exceptions/exception-dashboard.component.html:2`, `src/Web/StellaOps.Web/src/app/features/exceptions/exception-detail.component.html:14` |
## Notes
- The remaining gap is functional, not naming-only.
- The mismatch is isolated to the Pack 19 Exceptions requirement.
- All other pack-derived canonical routes in the current matrix conform under the clean run mode above.
- Pack 19 Exceptions mismatch is resolved in compatibility routes.
- Pack 22 remains the active IA authority for current planning and route migration.
- Full `pack-01..pack-21` conformity sweep currently reports no unresolved mismatches.

View File

@@ -1,145 +1,170 @@
# UI v2 Rewire Source of Truth
# UI v2 Rewire Source of Truth
Status: Active
Date: 2026-02-18
Date: 2026-02-20
Working directory: `docs/modules/ui/v2-rewire`
## 1) Hard Rules
## 1) Hard rules
1. For overlapping guidance, higher pack number wins.
2. If a higher pack is partial, keep the latest lower-pack detail for uncovered screens.
3. Inside one pack, interpret in this order: `Now/New location` statements, menu/screen graphs, then ASCII/rationale text.
3. Inside one pack, interpret in this order:
- `Now/New location` statements,
- menu/screen graphs,
- ASCII/rationale text.
4. Canonical planning references must come from this file plus `authority-matrix.md`, not raw packs alone.
5. `pack-23.md` is the active Platform IA override for all conflicts with `pack-22.md` and lower packs.
6. `pack-22.md` remains authority for non-Platform areas unless `pack-23.md` explicitly overrides them.
## 2) Canonical IA (v2)
## 2) Canonical IA (v3)
### 2.1 Root domains
### 2.1 Root modules
Canonical root domains are:
- `Dashboard` (release mission board)
- `Release Control`
- `Security & Risk`
- `Evidence & Audit`
- `Integrations`
- `Platform Ops`
Canonical top-level modules are:
- `Dashboard`
- `Releases`
- `Security`
- `Evidence`
- `Topology`
- `Platform`
- `Administration`
Rationale:
- `Dashboard` is last explicitly upgraded as a release-centric entrypoint in Pack 16.
- Root domain framing is explicit in Pack 21 and remains the governing top-level grouping.
### 2.2 Global context
### 2.2 Ownership decisions resolved by higher-pack precedence
Region and Environment are global context selectors in the top bar, not deep menu nodes.
Required global context controls:
- Search
- Region multi-select
- Environment multi-select scoped to Region selection
- Time window selector
- Status indicators (offline/feed/policy/evidence)
### 2.3 Ownership decisions resolved by precedence
These are authoritative for planning and replace older conflicting placements:
- `Policy Governance` belongs to `Administration` (Pack 21 overrides Packs 5/9/11).
- `Trust & Signing` belongs to `Administration`, with consumption links from Evidence/Security (Pack 21 overrides Packs 9/11/20 on ownership).
- `System` belongs to `Administration` with operational drilldowns into `Platform Ops` (Pack 21 overrides Packs 9/11 alternatives).
- Legacy `Settings -> Security Data` is split:
- source connectivity/freshness in `Integrations` plus `Platform Ops` mirror operations
- advisory impact on gating in `Security & Risk` (Pack 21 mapping).
### 2.3 Domain ownership vs nav rendering
`Releases`, `Approvals`, `Deployments`, `Regions & Environments`, and `Bundles` are Release Control domain capabilities.
If implementation keeps direct nav shortcuts for `Releases`/`Approvals`, treat that as a rendering convenience only. Domain ownership and contracts remain Release Control-owned.
- `Release Control` root is decomposed:
- release lifecycle surfaces move to `Releases`,
- inventory/setup surfaces move to `Topology`.
- `Bundle` is deprecated in operator IA and renamed to `Release`.
- `Runs`, `Deployments`, `Promotions`, and `Hotfixes` are lifecycle views inside `Releases` and not top-level modules.
- `VEX` and `Exceptions` are exposed as one UX concept:
- `Security -> Triage` disposition rail + detail tabs,
- `Security -> Advisories & VEX` for provider/library/conflict/trust operations,
- backend data models remain distinct.
- SBOM, reachability, and unknowns are unified under `Security -> Supply-Chain Data` tabs.
- Advisory feed and VEX source configuration belongs to `Integrations`, not Security.
- `Policy Governance` remains under `Administration`.
- Trust posture must be reachable from `Evidence`, while admin-owner trust mutations remain governed by administration scopes.
## 3) Canonical screen authorities
Use the following packs as the latest valid source per domain.
### 3.1 Release Control + Bundle lifecycle
### 3.1 IA and naming consolidation
Authoritative packs:
- Pack 21 for `Release Control` root positioning and setup/admin migration
- Pack 12 for full Bundle Organizer data model and flows
- Pack 13 for release promotion flows anchored on bundle versions
- Pack 14 for run/timeline, checkpoints, rollback, replay hooks
- Pack 18 for standardized environment detail shell/tabs
Authoritative pack:
Superseded for this domain:
- Packs 1, 4, 8, 11 (historical drafts)
- `pack-22.md`
- `pack-23.md` (highest precedence for Platform ownership and menu placement)
- `pack-22.md`
Superseded for overlapping decisions:
- `pack-21.md` and lower packs for root module grouping and naming.
### 3.2 Dashboard
Authoritative pack:
- Pack 16 (`Dashboard` mission board, env risk + SBOM + hybrid reachability + Nightly/Data signals)
Authoritative packs:
Superseded:
- Packs 1, 4, 8, 11 (dashboard/control-plane variants)
- `pack-22.md` for mission control framing and quick actions.
- `pack-16.md` for detailed dashboard signal widgets where not overridden.
### 3.3 Approvals
### 3.3 Releases
Authoritative packs:
- Pack 17 for upgraded approval queue/detail tabs and decision-ready context
- Pack 13 for base release/approval flow coupling
- `pack-22.md` for consolidation model (`list`, `detail tabs`, `activity`, `approvals queue`).
- `pack-12.md` for release composition/builder details.
- `pack-13.md` for promotion flow semantics.
- `pack-14.md` for timeline/checkpoint/rollback/replay semantics.
- `pack-17.md` for approvals detail depth.
Superseded:
- Packs 1, 4, 8, 13 sections overlapped by Pack 17 detail model
### 3.4 Security & Risk
- Standalone menu treatment from earlier packs where runs/deployments/promotions/hotfixes were separate roots.
### 3.4 Topology
Authoritative packs:
- Pack 19 for consolidated decision-first Security screen model
- Pack 21 for top-level `Advisory Sources` mapping statement
Superseded:
- Packs 3, 7, and earlier security layouts
- `pack-22.md` for module ownership and taxonomy.
- `pack-18.md` for environment detail shell standards reused inside topology-aware views.
Known gap:
- `Advisory Sources` detailed screen spec is not fully expanded in raw packs and must be sprinted as a first planning task.
### 3.5 Evidence & Audit
Authoritative pack:
- Pack 20 for evidence chain structure (`Evidence Home`, packs/bundles/export/proof/replay/audit)
Override:
- `Trust & Signing` ownership moved to `Administration` by Pack 21. Keep bidirectional deep links.
Superseded:
- Packs 3, 9, 11 evidence structures
### 3.6 Platform Ops and data confidence
### 3.5 Security
Authoritative packs:
- Pack 15 for `Data Integrity` operating model and bubble-up wiring
- Pack 10 for feeds/airgap operational screen specifics where still needed
- Pack 21 for top-level Platform Ops taxonomy and admin drilldown links
- `pack-22.md` for consolidation into `Overview`, `Triage`, `Advisories & VEX`, `Supply-Chain Data`, and optional `Reports`.
- `pack-19.md` for decision-first security detail behavior where not overridden.
Superseded:
- Packs 3, 6, 9, 11 operations variants
### 3.7 Integrations
- Earlier split explorer layouts that force separate VEX/Exceptions and separate SBOM roots.
### 3.6 Evidence
Authoritative packs:
- Pack 21 for Integrations taxonomy and settings split
- Pack 10 for hub/detail/add + feed-source operational ties
Superseded:
- Packs 2, 5, 9 integration placement drafts
- `pack-22.md` for evidence navigation framing and release linkage expectations.
- `pack-20.md` for evidence chain structure (packs/export/proof/replay/audit).
### 3.8 Administration
### 3.7 Operations
Authoritative pack:
- Pack 21 (`A0` ... `A7` including Policy, Trust, System)
Authoritative packs:
Superseded:
- Packs 2, 5, 9, 11 admin/settings decompositions
- `pack-23.md` for Platform Ops placement and workflow prioritization.
- `pack-15.md` for data integrity operating model.
- `pack-10.md` for feeds/airgap operational detail where still valid.
### 3.8 Integrations
Authoritative packs:
- `pack-23.md` for Platform Integrations placement and topology ownership split.
- `pack-10.md` and `pack-21.md` for connector detail flows where not overridden.
### 3.9 Administration
Authoritative packs:
- `pack-22.md` for top-level scope.
- `pack-21.md` for detailed A0-A7 screen structure where not overridden.
## 4) Normalized terminology (canonical names)
Use these terms in sprint tickets/specs:
- `Control Plane` -> `Dashboard`
- `Packets` -> `Evidence Packs`
- `Evidence Bundles` remains `Evidence Bundles`
- `Feed Mirror & AirGap Ops` under `Platform Ops` (connectivity still surfaced in `Integrations`)
- `Hybrid Reachability` stays second-class (visible in context views, not a standalone product root)
- `Bundle` -> `Release`
- `Create Bundle` -> `Create Release`
- `Current Release` -> `Deploy Release`
- `Run Timeline` -> `Activity` (cross-release) or `Timeline` (release detail tab)
- `Security & Risk` -> `Security`
- `Evidence & Audit` -> `Evidence`
- `Platform Ops` -> `Platform -> Ops`
- `Integrations` root -> `Platform -> Integrations`
- `Setup` root -> `Platform -> Setup`
- `Regions & Environments` menu -> `Topology` module + global context switchers
## 5) Planning gaps to schedule first
Create early sprints for these spec-completion items before broad implementation starts:
- `Security & Risk -> Advisory Sources` full screen definition and contracts
- final nav rendering decision for Release Control-owned capabilities (direct shortcuts vs strictly nested)
- Trust ownership transition rules between Administration and Evidence workflows (route aliases + breadcrumbs + redirects)
- route deprecation map from legacy `Settings/*` and older aliases to final IA paths
Create first-wave dependency sprints for:
- backend global context contracts and persistence (`Region/Environment` top-bar model),
- releases read-model contracts for list/detail/activity/approvals queue,
- topology inventory contracts and synchronization,
- security disposition aggregation contracts (VEX + Exceptions UX join),
- route deprecation map from `/release-control/*`, `/security-risk/*`, `/evidence-audit/*`, `/platform-ops/*` to canonical paths.

View File

@@ -1,7 +1,7 @@
# UI v2 Rewire Sprint Planning Guide
# UI v2 Rewire Sprint Planning Guide
Status: Planning-only guidance
Date: 2026-02-18
Date: 2026-02-20
This guide defines how to decompose the canonical redesign into many implementation sprints.
@@ -9,7 +9,7 @@ This guide defines how to decompose the canonical redesign into many implementat
1. `source-of-truth.md`
2. `authority-matrix.md`
3. Authoritative packs for the selected capability area
3. `pack-22.md` plus authoritative fallback packs for selected capability area
4. Current UI/backend implementation (`src/Web/**`, `src/**/WebService/**`) for feasibility and contract checks
Do not start sprint writing from raw pack text alone.
@@ -18,7 +18,8 @@ Do not start sprint writing from raw pack text alone.
- Higher pack number is authoritative for overlaps.
- Keep redesign deterministic and offline-capable.
- Treat nav placement changes and backend contract changes as separate work items.
- Treat IA route migration and backend contract/migration work as separate tasks.
- Backend dependency sprints (contracts + DB migrations) must complete before FE cutover tasks are marked `DONE`.
- Preserve migration safety with redirect/alias tasks in rollout sprints.
## 3) Recommended multi-sprint decomposition
@@ -27,20 +28,23 @@ Use independent streams so multiple teams can run in parallel.
| Stream | Scope | Primary packs |
| --- | --- | --- |
| `S0-Spec` | close spec gaps and freeze canonical IA terms | `pack-21.md`, `pack-19.md`, `pack-20.md` |
| `S1-NavShell` | root nav structure, route aliases, breadcrumbs, migration banners | `pack-21.md`, `pack-16.md` |
| `S2-ReleaseCore` | bundles, releases, approvals, run timeline | `pack-12.md`, `pack-13.md`, `pack-14.md`, `pack-17.md` |
| `S3-EnvOps` | environment detail + data confidence + ops bubble-up | `pack-18.md`, `pack-15.md`, `pack-16.md` |
| `S4-SecurityEvidence` | Security consolidation + Evidence consolidation + cross-links | `pack-19.md`, `pack-20.md` |
| `S5-AdminIntegrations` | Administration A0-A7, Integrations taxonomy, feeds split | `pack-21.md`, `pack-10.md` |
| `S0-SpecFreeze` | lock canonical IA terms, ownership, endpoint ledger baseline | `pack-22.md`, `source-of-truth.md`, `authority-matrix.md` |
| `S1-BackendFoundation` | global context, releases read models, topology, security disposition, migrations | `pack-22.md`, `S00_endpoint_contract_ledger_v2_pack22.md` |
| `S2-NavShell` | root nav rename and global context top bar | `pack-22.md`, `pack-16.md` |
| `S3-Releases` | release list/detail/activity/approvals consolidation | `pack-22.md`, `pack-12.md`, `pack-13.md`, `pack-14.md`, `pack-17.md` |
| `S4-TopologyOps` | topology module and operations boundary cleanup | `pack-22.md`, `pack-18.md`, `pack-15.md` |
| `S5-SecurityEvidence` | findings/disposition/sbom explorer + evidence linkage | `pack-22.md`, `pack-19.md`, `pack-20.md` |
| `S6-IntegrationsAdmin` | feeds/vex sources config + governance surfaces | `pack-22.md`, `pack-10.md`, `pack-21.md` |
| `S7-CutoverQA` | redirects, deep links, Playwright conformity, release readiness | all active authority packs |
## 4) Endpoint and contract investigation workflow
Backend coverage is incomplete in some areas. Every sprint must include an explicit endpoint contract pass.
Backend coverage is incomplete for Pack 22. Every sprint must include an explicit endpoint contract pass.
### 4.1 For each planned screen, classify backend status
Use one of these states:
- `EXISTS_COMPAT` - endpoint exists and contract matches target UI
- `EXISTS_ADAPT` - endpoint exists but response/request shape or semantics must be adapted
- `MISSING_NEW` - endpoint does not exist and must be specified/implemented
@@ -51,20 +55,22 @@ Use one of these states:
2. Locate current API client call(s) in UI client layer.
3. Locate backend endpoint(s) across service modules.
4. Compare current contract to target pack behavior.
5. Record status (`EXISTS_COMPAT` / `EXISTS_ADAPT` / `MISSING_NEW`).
6. If `MISSING_NEW`, write a contract task with request/response schema, auth scope, and evidence requirements.
5. Record status (`EXISTS_COMPAT` / `EXISTS_ADAPT` / `MISSING_NEW`) in the ledger.
6. If `MISSING_NEW`, write contract + DB migration tasks with request/response schema, auth scope, and deterministic behavior requirements.
### 4.3 Search anchors (read-only references)
- UI routing and nav:
- `src/Web/StellaOps.Web/src/app/app.routes.ts`
- `src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts`
- `src/Web/StellaOps.Web/src/app/features/**/**.routes.ts`
- `src/Web/StellaOps.Web/src/app/routes/*.ts`
- UI API clients:
- `src/Web/StellaOps.Web/src/app/core/api/*.ts`
- Backend endpoint surfaces:
- `src/**/WebService/Endpoints/*.cs`
- `src/**/Infrastructure/**` for data dependencies
- Platform DB migrations:
- `src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/*.sql`
## 5) Mandatory sprint ticket fields (for every UI feature ticket)
@@ -76,6 +82,7 @@ Use this minimum structure in planning docs:
- UI scope: <routes/components>
- Backend contract status: EXISTS_COMPAT | EXISTS_ADAPT | MISSING_NEW
- Endpoint(s): <current or proposed>
- DB migration impact: <existing migration or new migration file>
- Auth scope impact: <new/changed scopes>
- Offline/determinism impact: <none or required behavior>
- Redirect/deprecation impact: <legacy paths>
@@ -84,17 +91,20 @@ Use this minimum structure in planning docs:
## 6) First planning backlog (must be created before build sprints)
1. Spec gap sprint for `Security & Risk -> Advisory Sources` detailed screen model and contracts.
2. Nav migration sprint defining final rendering strategy for Release Control-owned capabilities.
3. Trust ownership transition sprint (Administration owner, Evidence consumer links and redirects).
4. Route alias/deprecation sprint from legacy settings and historical paths.
1. Backend dependency sprint for global context and releases read-model contracts (`/api/v2/context/*`, `/api/v2/releases/*`).
2. Backend dependency sprint for topology inventory contracts (`/api/v2/topology/*`) and migrations.
3. Backend dependency sprint for security disposition contracts (`/api/v2/security/findings`, `/api/v2/security/disposition`).
4. FE nav migration sprint from old roots to `Dashboard/Releases/Security/Evidence/Topology/Operations/Integrations/Administration`.
5. Route alias/deprecation sprint from legacy domains and paths.
## 7) Definition of ready for implementation sprint
A capability is ready only when:
- authoritative pack sections are listed,
- endpoint status is classified for each screen,
- missing contracts are specified,
- DB migrations are identified,
- scope/permission changes are identified,
- migration/redirect handling is scoped,
- test evidence expectations are explicit.

136
etc/airgap.yaml Normal file
View File

@@ -0,0 +1,136 @@
# StellaOps Air-Gap Controller configuration template.
# Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import)
# Task: SEAL-019 - Staleness policy configuration
#
# Copy to airgap.yaml and adjust values to fit your environment.
# Environment variables prefixed with STELLAOPS_AIRGAP_ override these values.
schemaVersion: 1
# Staleness policy configuration
# Controls how long knowledge snapshots remain valid before requiring refresh.
staleness:
# Maximum age before snapshot is rejected (default: 168 hours = 7 days)
maxAgeHours: 168
# Age at which warnings are emitted (default: 72 hours = 3 days)
warnAgeHours: 72
# Whether to require a valid time anchor for import
requireTimeAnchor: true
# Action when snapshot is stale: "warn", "block"
staleAction: block
# Per-content staleness budgets (overrides default)
contentBudgets:
advisories:
warningSeconds: 86400 # 24 hours
breachSeconds: 259200 # 72 hours (3 days)
vex:
warningSeconds: 86400 # 24 hours
breachSeconds: 604800 # 168 hours (7 days)
policy:
warningSeconds: 604800 # 7 days
breachSeconds: 2592000 # 30 days
# Snapshot export configuration
export:
# Default output directory for exported snapshots
outputDirectory: "./snapshots"
# Compression level (0-9, default: 6)
compressionLevel: 6
# Whether to include trust roots in export
includeTrustRoots: true
# Default feeds to include (empty = all)
defaultFeeds: []
# Default ecosystems to include (empty = all)
defaultEcosystems: []
# Snapshot import configuration
import:
# Directory for quarantined failed imports
quarantineDirectory: "./quarantine"
# Quarantine TTL in hours (default: 168 = 7 days)
quarantineTtlHours: 168
# Maximum quarantine size in MB (default: 1024 = 1GB)
quarantineMaxSizeMb: 1024
# Whether to verify signature on import
verifySignature: true
# Whether to verify merkle root on import
verifyMerkleRoot: true
# Whether to enforce version monotonicity (prevent rollback)
enforceMonotonicity: true
# Trust store configuration
trustStore:
# Path to trust roots bundle
rootBundlePath: "/etc/stellaops/trust-roots.pem"
# Allowed signature algorithms
allowedAlgorithms:
- "ES256"
- "ES384"
- "Ed25519"
- "RS256"
- "RS384"
# Key rotation settings
rotation:
# Require approval for key rotation
requireApproval: true
# Pending key timeout in hours
pendingTimeoutHours: 24
# Time anchor configuration
timeAnchor:
# Default time anchor source: "roughtime", "rfc3161", "local"
defaultSource: "roughtime"
# Roughtime server endpoints
roughtimeServers:
- "roughtime.cloudflare.com:2003"
- "roughtime.google.com:2003"
# RFC 3161 TSA endpoints
rfc3161Servers:
- "http://timestamp.digicert.com"
- "http://timestamp.comodoca.com"
# Maximum allowed clock drift in seconds
maxClockDriftSeconds: 60
# Egress policy (network access control in sealed mode)
egressPolicy:
# Policy mode: "allowlist", "denylist"
mode: allowlist
# Allowed hosts when sealed (allowlist mode)
allowedHosts: []
# Denied hosts (denylist mode)
deniedHosts: []
# Allow localhost traffic when sealed
allowLocalhost: true
# Logging and telemetry
telemetry:
# Log staleness warnings
logStalenessWarnings: true
# Emit metrics for staleness tracking
emitStalenessMetrics: true
# Activity source name for tracing
activitySourceName: "StellaOps.AirGap"

View File

@@ -10,6 +10,12 @@ public static class PlatformPolicies
public const string OnboardingWrite = "platform.onboarding.write";
public const string PreferencesRead = "platform.preferences.read";
public const string PreferencesWrite = "platform.preferences.write";
public const string ContextRead = "platform.context.read";
public const string ContextWrite = "platform.context.write";
public const string TopologyRead = "platform.topology.read";
public const string SecurityRead = "platform.security.read";
public const string IntegrationsRead = "platform.integrations.read";
public const string IntegrationsVexRead = "platform.integrations.vex.read";
public const string SearchRead = "platform.search.read";
public const string MetadataRead = "platform.metadata.read";
public const string AnalyticsRead = "platform.analytics.read";

View File

@@ -12,6 +12,11 @@ public static class PlatformScopes
public const string OnboardingWrite = "onboarding.write";
public const string PreferencesRead = "ui.preferences.read";
public const string PreferencesWrite = "ui.preferences.write";
public const string ContextRead = "platform.context.read";
public const string ContextWrite = "platform.context.write";
public const string FindingsRead = "findings:read";
public const string AdvisoryRead = StellaOpsScopes.AdvisoryRead;
public const string VexRead = StellaOpsScopes.VexRead;
public const string SearchRead = "search.read";
public const string MetadataRead = "platform.metadata.read";
public const string AnalyticsRead = "analytics.read";

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record PlatformContextRegion(
string RegionId,
string DisplayName,
int SortOrder,
bool Enabled = true);
public sealed record PlatformContextEnvironment(
string EnvironmentId,
string RegionId,
string EnvironmentType,
string DisplayName,
int SortOrder,
bool Enabled = true);
public sealed record PlatformContextPreferences(
string TenantId,
string ActorId,
IReadOnlyList<string> Regions,
IReadOnlyList<string> Environments,
string TimeWindow,
DateTimeOffset UpdatedAt,
string UpdatedBy);
public sealed record PlatformContextPreferencesRequest(
IReadOnlyList<string>? Regions,
IReadOnlyList<string>? Environments,
string? TimeWindow);

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record IntegrationFeedProjection(
string SourceId,
string SourceName,
string SourceType,
string Provider,
string Region,
string Environment,
string Status,
string Freshness,
DateTimeOffset? LastSyncAt,
int? FreshnessMinutes,
int SlaMinutes,
DateTimeOffset? LastSuccessAt,
string? LastError,
IReadOnlyList<string> ConsumerDomains);
public sealed record IntegrationVexSourceProjection(
string SourceId,
string SourceName,
string SourceType,
string Provider,
string Region,
string Environment,
string Status,
string Freshness,
DateTimeOffset? LastSyncAt,
int? FreshnessMinutes,
int SlaMinutes,
string StatementFormat,
int DocumentCount24h,
DateTimeOffset? LastSuccessAt,
string? LastError,
IReadOnlyList<string> ConsumerDomains);

View File

@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record ReleaseGateSummary(
string Status,
int BlockingCount,
int PendingApprovals,
IReadOnlyList<string> BlockingReasons);
public sealed record ReleaseRiskSummary(
int CriticalReachable,
int HighReachable,
string Trend);
public sealed record ReleaseProjection(
string ReleaseId,
string Slug,
string Name,
string ReleaseType,
string Status,
string? TargetEnvironment,
string? TargetRegion,
int TotalVersions,
int? LatestVersionNumber,
string? LatestVersionDigest,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? LatestPublishedAt,
ReleaseGateSummary Gate,
ReleaseRiskSummary Risk);
public sealed record ReleaseDetailProjection(
ReleaseProjection Summary,
IReadOnlyList<ReleaseControlBundleVersionSummary> Versions,
IReadOnlyList<ReleaseActivityProjection> RecentActivity,
IReadOnlyList<ReleaseApprovalProjection> Approvals);
public sealed record ReleaseActivityProjection(
string ActivityId,
string ReleaseId,
string ReleaseName,
string EventType,
string Status,
string? TargetEnvironment,
string? TargetRegion,
string ActorId,
DateTimeOffset OccurredAt,
string CorrelationKey);
public sealed record ReleaseApprovalProjection(
string ApprovalId,
string ReleaseId,
string ReleaseName,
string Status,
string RequestedBy,
DateTimeOffset RequestedAt,
string? SourceEnvironment,
string? TargetEnvironment,
string? TargetRegion,
int RequiredApprovals,
int CurrentApprovals,
IReadOnlyList<string> Blockers);
public sealed record ReleaseRunProjection(
string RunId,
string ReleaseId,
string ReleaseName,
string ReleaseType,
string ReleaseVersionId,
int ReleaseVersionNumber,
string ReleaseVersionDigest,
string Lane,
string Status,
string Outcome,
bool NeedsApproval,
bool BlockedByDataIntegrity,
bool ReplayMismatch,
string GateStatus,
string EvidenceStatus,
string? TargetEnvironment,
string? TargetRegion,
string RequestedBy,
DateTimeOffset RequestedAt,
DateTimeOffset UpdatedAt,
string CorrelationKey);
public sealed record ReleaseRunStatusRow(
string RunStatus,
string GateStatus,
string ApprovalStatus,
string DataTrustStatus);
public sealed record ReleaseRunProcessStep(
string StepId,
string Label,
string State,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt);
public sealed record ReleaseRunDetailProjection(
string RunId,
string ReleaseId,
string ReleaseName,
string ReleaseSlug,
string ReleaseType,
string ReleaseVersionId,
int ReleaseVersionNumber,
string ReleaseVersionDigest,
string Lane,
string Status,
string Outcome,
string? TargetEnvironment,
string? TargetRegion,
string ScopeSummary,
DateTimeOffset RequestedAt,
DateTimeOffset UpdatedAt,
bool NeedsApproval,
bool BlockedByDataIntegrity,
string CorrelationKey,
ReleaseRunStatusRow StatusRow,
IReadOnlyList<ReleaseRunProcessStep> Process);
public sealed record ReleaseRunTimelineEventProjection(
string EventId,
string EventClass,
string Phase,
string Status,
DateTimeOffset OccurredAt,
string Message,
string? SnapshotId,
string? JobId,
string? CapsuleId);
public sealed record ReleaseRunCorrelationReference(
string Type,
string Value,
string? Route);
public sealed record ReleaseRunTimelineProjection(
string RunId,
IReadOnlyList<ReleaseRunTimelineEventProjection> Events,
IReadOnlyList<ReleaseRunCorrelationReference> Correlations);
public sealed record ReleaseRunGateReasonCode(
string Source,
string Code,
string Description);
public sealed record ReleaseRunGateBudgetContributor(
string Category,
decimal Delta,
string Note);
public sealed record ReleaseRunGateDecisionProjection(
string RunId,
string SnapshotId,
string Verdict,
string PolicyPackVersion,
string TrustWeightsVersion,
string StalenessPolicy,
int StalenessThresholdMinutes,
string StalenessVerdict,
decimal RiskBudgetDelta,
IReadOnlyList<ReleaseRunGateBudgetContributor> RiskBudgetContributors,
IReadOnlyList<ReleaseRunGateReasonCode> MachineReasonCodes,
IReadOnlyList<ReleaseRunGateReasonCode> HumanReasonCodes,
IReadOnlyList<string> Blockers,
DateTimeOffset EvaluatedAt);
public sealed record ReleaseRunApprovalCheckpointProjection(
string CheckpointId,
string Name,
int Order,
string Status,
string RequiredRole,
string? ApproverId,
DateTimeOffset? ApprovedAt,
string? Signature,
string? Rationale,
string? EvidenceProofId);
public sealed record ReleaseRunApprovalsProjection(
string RunId,
IReadOnlyList<ReleaseRunApprovalCheckpointProjection> Checkpoints);
public sealed record ReleaseRunDeploymentTargetProjection(
string TargetId,
string TargetName,
string Environment,
string Region,
string Strategy,
string Phase,
string Status,
string ArtifactDigest,
string LogRef,
DateTimeOffset UpdatedAt);
public sealed record ReleaseRunRollbackTriggerProjection(
string TriggerId,
string TriggerType,
string Threshold,
bool Fired,
DateTimeOffset? FiredAt,
string Outcome);
public sealed record ReleaseRunDeploymentsProjection(
string RunId,
IReadOnlyList<ReleaseRunDeploymentTargetProjection> Targets,
IReadOnlyList<ReleaseRunRollbackTriggerProjection> RollbackTriggers);
public sealed record ReleaseRunSecurityDrilldownProjection(
string Label,
string Route,
string Query);
public sealed record ReleaseRunSecurityInputsProjection(
string RunId,
string SbomSnapshotId,
DateTimeOffset SbomGeneratedAt,
int SbomAgeMinutes,
string ReachabilitySnapshotId,
int ReachabilityCoveragePercent,
int ReachabilityEvidenceAgeMinutes,
int VexStatementsApplied,
int ExceptionsApplied,
string FeedFreshnessStatus,
int? FeedFreshnessMinutes,
string PolicyImpactStatement,
IReadOnlyList<ReleaseRunSecurityDrilldownProjection> Drilldowns);
public sealed record ReleaseRunEvidenceProjection(
string RunId,
string DecisionCapsuleId,
string CapsuleHash,
string SignatureStatus,
string TransparencyReceipt,
string ChainCompleteness,
string ReplayDeterminismVerdict,
bool ReplayMismatch,
IReadOnlyList<string> ExportFormats,
string CapsuleRoute,
string VerifyRoute);
public sealed record ReleaseRunKnownGoodReferenceProjection(
string ReferenceType,
string ReferenceId,
string Description);
public sealed record ReleaseRunRollbackEventProjection(
string EventId,
string Trigger,
string Outcome,
DateTimeOffset OccurredAt,
string EvidenceId,
string AuditId);
public sealed record ReleaseRunRollbackProjection(
string RunId,
string Readiness,
bool ActionEnabled,
IReadOnlyList<ReleaseRunKnownGoodReferenceProjection> KnownGoodReferences,
IReadOnlyList<ReleaseRunRollbackEventProjection> History);
public sealed record ReleaseRunReplayProjection(
string RunId,
string Verdict,
bool Match,
string? MismatchReportId,
DateTimeOffset EvaluatedAt,
string ReplayLogReference);
public sealed record ReleaseRunAuditEntryProjection(
string AuditId,
string Category,
string Action,
string ActorId,
DateTimeOffset OccurredAt,
string CorrelationKey,
string? Notes);
public sealed record ReleaseRunAuditProjection(
string RunId,
IReadOnlyList<ReleaseRunAuditEntryProjection> Entries);

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record SecurityFindingProjection(
string FindingId,
string CveId,
string Severity,
string PackageName,
string ComponentName,
string ReleaseId,
string ReleaseName,
string Environment,
string Region,
bool Reachable,
int ReachabilityScore,
string EffectiveDisposition,
string VexStatus,
string ExceptionStatus,
DateTimeOffset UpdatedAt);
public sealed record SecurityPivotBucket(
string PivotValue,
int FindingCount,
int CriticalCount,
int ReachableCount);
public sealed record SecurityFacetBucket(
string Facet,
string Value,
int Count);
public sealed record SecurityFindingsResponse(
string TenantId,
string ActorId,
DateTimeOffset AsOfUtc,
bool Cached,
int CacheTtlSeconds,
IReadOnlyList<SecurityFindingProjection> Items,
int Total,
int Limit,
int Offset,
string Pivot,
IReadOnlyList<SecurityPivotBucket> PivotBuckets,
IReadOnlyList<SecurityFacetBucket> Facets);
public sealed record SecurityVexState(
string Status,
string Justification,
string SourceModel,
string? StatementId,
DateTimeOffset? UpdatedAt);
public sealed record SecurityExceptionState(
string Status,
string Reason,
string ApprovalState,
string SourceModel,
string? ExceptionId,
DateTimeOffset? ExpiresAt,
DateTimeOffset? UpdatedAt);
public sealed record SecurityDispositionProjection(
string FindingId,
string CveId,
string ReleaseId,
string ReleaseName,
string PackageName,
string ComponentName,
string Environment,
string Region,
SecurityVexState Vex,
SecurityExceptionState Exception,
string EffectiveDisposition,
string PolicyAction,
DateTimeOffset UpdatedAt);
public sealed record SecuritySbomComponentRow(
string ComponentId,
string ReleaseId,
string ReleaseName,
string Environment,
string Region,
string PackageName,
string ComponentName,
string ComponentVersion,
string Supplier,
string License,
int VulnerabilityCount,
int CriticalReachableCount,
DateTimeOffset UpdatedAt);
public sealed record SecuritySbomGraphNode(
string NodeId,
string NodeType,
string Label,
string Region,
string Environment);
public sealed record SecuritySbomGraphEdge(
string EdgeId,
string FromNodeId,
string ToNodeId,
string RelationType);
public sealed record SecuritySbomDiffRow(
string ComponentName,
string PackageName,
string ChangeType,
string? FromVersion,
string? ToVersion,
string Region,
string Environment);
public sealed record SecuritySbomExplorerResponse(
string TenantId,
string ActorId,
DateTimeOffset AsOfUtc,
bool Cached,
int CacheTtlSeconds,
string Mode,
IReadOnlyList<SecuritySbomComponentRow> Table,
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges,
IReadOnlyList<SecuritySbomDiffRow> Diff,
int TotalComponents,
int Limit,
int Offset);

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record TopologyRegionProjection(
string RegionId,
string DisplayName,
int SortOrder,
int EnvironmentCount,
int TargetCount,
int HostCount,
int AgentCount,
DateTimeOffset? LastSyncAt);
public sealed record TopologyEnvironmentProjection(
string EnvironmentId,
string RegionId,
string EnvironmentType,
string DisplayName,
int SortOrder,
int TargetCount,
int HostCount,
int AgentCount,
int PromotionPathCount,
int WorkflowCount,
DateTimeOffset? LastSyncAt);
public sealed record TopologyTargetProjection(
string TargetId,
string Name,
string RegionId,
string EnvironmentId,
string HostId,
string AgentId,
string TargetType,
string HealthStatus,
string ComponentVersionId,
string ImageDigest,
string ReleaseId,
string ReleaseVersionId,
DateTimeOffset? LastSyncAt);
public sealed record TopologyHostProjection(
string HostId,
string HostName,
string RegionId,
string EnvironmentId,
string RuntimeType,
string Status,
string AgentId,
int TargetCount,
DateTimeOffset? LastSeenAt);
public sealed record TopologyAgentProjection(
string AgentId,
string AgentName,
string RegionId,
string EnvironmentId,
string Status,
IReadOnlyList<string> Capabilities,
int AssignedTargetCount,
DateTimeOffset? LastHeartbeatAt);
public sealed record TopologyPromotionPathProjection(
string PathId,
string RegionId,
string SourceEnvironmentId,
string TargetEnvironmentId,
string PathMode,
string Status,
int RequiredApprovals,
string WorkflowId,
string GateProfileId,
DateTimeOffset? LastPromotedAt);
public sealed record TopologyWorkflowProjection(
string WorkflowId,
string WorkflowName,
string RegionId,
string EnvironmentId,
string TriggerType,
string Status,
int StepCount,
string GateProfileId,
DateTimeOffset UpdatedAt);
public sealed record TopologyGateProfileProjection(
string GateProfileId,
string ProfileName,
string RegionId,
string EnvironmentId,
string PolicyProfile,
int RequiredApprovals,
bool SeparationOfDuties,
IReadOnlyList<string> BlockingRules,
DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System;
using System.Linq;
namespace StellaOps.Platform.WebService.Endpoints;
public static class ContextEndpoints
{
public static IEndpointRouteBuilder MapContextEndpoints(this IEndpointRouteBuilder app)
{
var context = app.MapGroup("/api/v2/context")
.WithTags("Platform Context");
context.MapGet("/regions", async Task<IResult>(
HttpContext httpContext,
PlatformRequestContextResolver resolver,
PlatformContextService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(httpContext, resolver, out _, out var failure))
{
return failure!;
}
var regions = await service.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(regions);
})
.WithName("GetPlatformContextRegions")
.WithSummary("List global regions for context selection")
.RequireAuthorization(PlatformPolicies.ContextRead);
context.MapGet("/environments", async Task<IResult>(
HttpContext httpContext,
PlatformRequestContextResolver resolver,
PlatformContextService service,
[FromQuery(Name = "regions")] string? regions,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(httpContext, resolver, out _, out var failure))
{
return failure!;
}
var regionFilter = ParseCsv(regions);
var environments = await service.GetEnvironmentsAsync(regionFilter, cancellationToken).ConfigureAwait(false);
return Results.Ok(environments);
})
.WithName("GetPlatformContextEnvironments")
.WithSummary("List global environments with optional region filter")
.RequireAuthorization(PlatformPolicies.ContextRead);
context.MapGet("/preferences", async Task<IResult>(
HttpContext httpContext,
PlatformRequestContextResolver resolver,
PlatformContextService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(httpContext, resolver, out var requestContext, out var failure))
{
return failure!;
}
var preferences = await service.GetPreferencesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(preferences);
})
.WithName("GetPlatformContextPreferences")
.WithSummary("Get persisted context preferences for the current user")
.RequireAuthorization(PlatformPolicies.ContextRead);
context.MapPut("/preferences", async Task<IResult>(
HttpContext httpContext,
PlatformRequestContextResolver resolver,
PlatformContextService service,
PlatformContextPreferencesRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(httpContext, resolver, out var requestContext, out var failure))
{
return failure!;
}
var preferences = await service.UpsertPreferencesAsync(
requestContext!,
request,
cancellationToken).ConfigureAwait(false);
return Results.Ok(preferences);
})
.WithName("UpdatePlatformContextPreferences")
.WithSummary("Update persisted context preferences for the current user")
.RequireAuthorization(PlatformPolicies.ContextWrite);
return app;
}
private static string[] ParseCsv(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToArray();
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
}

View File

@@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class IntegrationReadModelEndpoints
{
public static IEndpointRouteBuilder MapIntegrationReadModelEndpoints(this IEndpointRouteBuilder app)
{
var integrations = app.MapGroup("/api/v2/integrations")
.WithTags("Integrations V2");
integrations.MapGet("/feeds", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IntegrationsReadModelService service,
TimeProvider timeProvider,
[AsParameters] IntegrationListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListFeedsAsync(
requestContext!,
query.Region,
query.Environment,
query.Status,
query.SourceType,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<IntegrationFeedProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListIntegrationFeedsV2")
.WithSummary("List advisory feed health/freshness integration projection")
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
integrations.MapGet("/vex-sources", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IntegrationsReadModelService service,
TimeProvider timeProvider,
[AsParameters] IntegrationListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListVexSourcesAsync(
requestContext!,
query.Region,
query.Environment,
query.Status,
query.SourceType,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<IntegrationVexSourceProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListIntegrationVexSourcesV2")
.WithSummary("List VEX source health/freshness integration projection")
.RequireAuthorization(PlatformPolicies.IntegrationsVexRead);
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
public sealed record IntegrationListQuery(
string? Region,
string? Environment,
string? Status,
string? SourceType,
int? Limit,
int? Offset);
}

View File

@@ -0,0 +1,557 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class LegacyAliasEndpoints
{
public static IEndpointRouteBuilder MapLegacyAliasEndpoints(this IEndpointRouteBuilder app)
{
var legacy = app.MapGroup("/api/v1")
.WithTags("Pack22 Legacy Aliases");
legacy.MapGet("/context/regions", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformContextService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out _, out var failure))
{
return failure!;
}
var regions = await service.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(regions);
})
.WithName("GetPlatformContextRegionsV1Alias")
.WithSummary("Legacy alias for v2 context regions")
.RequireAuthorization(PlatformPolicies.ContextRead);
legacy.MapGet("/releases", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacyReleaseListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListReleasesAsync(
requestContext!,
query.Region,
query.Environment,
query.Type,
query.Status,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleasesV1Alias")
.WithSummary("Legacy alias for v2 releases projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
var runAliases = legacy.MapGroup("/releases/runs");
runAliases.MapGet(string.Empty, async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacyRunListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListRunsAsync(
requestContext!,
query.Status,
query.Lane,
query.Environment,
query.Region,
query.Outcome,
query.NeedsApproval,
query.BlockedByDataIntegrity,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseRunProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleaseRunsV1Alias")
.WithSummary("Legacy alias for v2 run list projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunDetailAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunDetailV1Alias")
.WithSummary("Legacy alias for v2 run detail projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/timeline", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunTimelineAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunTimelineV1Alias")
.WithSummary("Legacy alias for v2 run timeline projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/gate-decision", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunGateDecisionAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunGateDecisionV1Alias")
.WithSummary("Legacy alias for v2 run gate decision projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/approvals", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunApprovalsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunApprovalsV1Alias")
.WithSummary("Legacy alias for v2 run approvals projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/deployments", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunDeploymentsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunDeploymentsV1Alias")
.WithSummary("Legacy alias for v2 run deployments projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/security-inputs", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunSecurityInputsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunSecurityInputsV1Alias")
.WithSummary("Legacy alias for v2 run security inputs projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/evidence", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunEvidenceAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunEvidenceV1Alias")
.WithSummary("Legacy alias for v2 run evidence projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/rollback", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunRollbackAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunRollbackV1Alias")
.WithSummary("Legacy alias for v2 run rollback projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/replay", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunReplayAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunReplayV1Alias")
.WithSummary("Legacy alias for v2 run replay projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runAliases.MapGet("/{runId:guid}/audit", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunAuditAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
})
.WithName("GetReleaseRunAuditV1Alias")
.WithSummary("Legacy alias for v2 run audit projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
legacy.MapGet("/topology/regions", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacyTopologyRegionQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListRegionsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyRegionProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyRegionsV1Alias")
.WithSummary("Legacy alias for v2 topology regions projection")
.RequireAuthorization(PlatformPolicies.TopologyRead);
legacy.MapGet("/security/findings", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
SecurityReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacySecurityFindingsQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListFindingsAsync(
requestContext!,
query.Pivot,
query.Region,
query.Environment,
query.Severity,
query.Disposition,
query.Search,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new SecurityFindingsResponse(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset,
page.Pivot,
page.PivotBuckets,
page.Facets));
})
.WithName("ListSecurityFindingsV1Alias")
.WithSummary("Legacy alias for v2 security findings projection")
.RequireAuthorization(PlatformPolicies.SecurityRead);
legacy.MapGet("/integrations/feeds", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IntegrationsReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacyIntegrationQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListFeedsAsync(
requestContext!,
query.Region,
query.Environment,
query.Status,
query.SourceType,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<IntegrationFeedProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListIntegrationFeedsV1Alias")
.WithSummary("Legacy alias for v2 integrations feed projection")
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
legacy.MapGet("/integrations/vex-sources", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IntegrationsReadModelService service,
TimeProvider timeProvider,
[AsParameters] LegacyIntegrationQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListVexSourcesAsync(
requestContext!,
query.Region,
query.Environment,
query.Status,
query.SourceType,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<IntegrationVexSourceProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListIntegrationVexSourcesV1Alias")
.WithSummary("Legacy alias for v2 integrations VEX source projection")
.RequireAuthorization(PlatformPolicies.IntegrationsVexRead);
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
private static IResult ToRunItemResponse<TProjection>(
PlatformRequestContext requestContext,
TimeProvider timeProvider,
Guid runId,
TProjection? projection)
where TProjection : class
{
if (projection is null)
{
return Results.NotFound(new { error = "run_not_found", runId });
}
return Results.Ok(new PlatformItemResponse<TProjection>(
requestContext.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
projection));
}
public sealed record LegacyReleaseListQuery(
string? Region,
string? Environment,
string? Type,
string? Status,
int? Limit,
int? Offset);
public sealed record LegacyRunListQuery(
string? Status,
string? Lane,
string? Environment,
string? Region,
string? Outcome,
bool? NeedsApproval,
bool? BlockedByDataIntegrity,
int? Limit,
int? Offset);
public sealed record LegacyTopologyRegionQuery(
string? Region,
string? Environment,
int? Limit,
int? Offset);
public sealed record LegacySecurityFindingsQuery(
string? Pivot,
string? Region,
string? Environment,
string? Severity,
string? Disposition,
string? Search,
int? Limit,
int? Offset);
public sealed record LegacyIntegrationQuery(
string? Region,
string? Environment,
string? Status,
string? SourceType,
int? Limit,
int? Offset);
}

View File

@@ -0,0 +1,539 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class ReleaseReadModelEndpoints
{
public static IEndpointRouteBuilder MapReleaseReadModelEndpoints(this IEndpointRouteBuilder app)
{
var releases = app.MapGroup("/api/v2/releases")
.WithTags("Releases V2");
releases.MapGet(string.Empty, async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] ReleaseListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListReleasesAsync(
requestContext!,
query.Region,
query.Environment,
query.Type,
query.Status,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleasesV2")
.WithSummary("List Pack-22 release projections")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
releases.MapGet("/{releaseId:guid}", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid releaseId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var detail = await service.GetReleaseDetailAsync(
requestContext!,
releaseId,
cancellationToken).ConfigureAwait(false);
if (detail is null)
{
return Results.NotFound(new { error = "release_not_found", releaseId });
}
return Results.Ok(new PlatformItemResponse<ReleaseDetailProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
detail));
})
.WithName("GetReleaseDetailV2")
.WithSummary("Get Pack-22 release detail projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
releases.MapGet("/activity", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] ReleaseActivityQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListActivityAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseActivityProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleaseActivityV2")
.WithSummary("List cross-release activity timeline")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
releases.MapGet("/approvals", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] ReleaseApprovalsQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListApprovalsAsync(
requestContext!,
query.Status,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseApprovalProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleaseApprovalsV2")
.WithSummary("List cross-release approvals queue projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
var runs = releases.MapGroup("/runs");
runs.MapGet(string.Empty, async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
[AsParameters] ReleaseRunListQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListRunsAsync(
requestContext!,
query.Status,
query.Lane,
query.Environment,
query.Region,
query.Outcome,
query.NeedsApproval,
query.BlockedByDataIntegrity,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<ReleaseRunProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListReleaseRunsV2")
.WithSummary("List run-centric release projections for Pack-22 contracts")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunDetailAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunDetailProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunDetailV2")
.WithSummary("Get canonical release run detail projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/timeline", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunTimelineAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunTimelineProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunTimelineV2")
.WithSummary("Get release run timeline projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/gate-decision", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunGateDecisionAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunGateDecisionProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunGateDecisionV2")
.WithSummary("Get release run gate decision projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/approvals", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunApprovalsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunApprovalsProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunApprovalsV2")
.WithSummary("Get release run approvals checkpoints projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/deployments", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunDeploymentsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunDeploymentsProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunDeploymentsV2")
.WithSummary("Get release run deployments projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/security-inputs", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunSecurityInputsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunSecurityInputsProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunSecurityInputsV2")
.WithSummary("Get release run security inputs projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/evidence", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunEvidenceAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunEvidenceProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunEvidenceV2")
.WithSummary("Get release run evidence capsule projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/rollback", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunRollbackAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunRollbackProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunRollbackV2")
.WithSummary("Get release run rollback projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/replay", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunReplayAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunReplayProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunReplayV2")
.WithSummary("Get release run replay projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
runs.MapGet("/{runId:guid}/audit", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
ReleaseReadModelService service,
TimeProvider timeProvider,
Guid runId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetRunAuditAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
return item is null
? Results.NotFound(new { error = "run_not_found", runId })
: Results.Ok(new PlatformItemResponse<ReleaseRunAuditProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetReleaseRunAuditV2")
.WithSummary("Get release run audit projection")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
public sealed record ReleaseListQuery(
string? Region,
string? Environment,
string? Type,
string? Status,
int? Limit,
int? Offset);
public sealed record ReleaseActivityQuery(
string? Region,
string? Environment,
int? Limit,
int? Offset);
public sealed record ReleaseApprovalsQuery(
string? Status,
string? Region,
string? Environment,
int? Limit,
int? Offset);
public sealed record ReleaseRunListQuery(
string? Status,
string? Lane,
string? Environment,
string? Region,
string? Outcome,
bool? NeedsApproval,
bool? BlockedByDataIntegrity,
int? Limit,
int? Offset);
}

View File

@@ -0,0 +1,221 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class SecurityReadModelEndpoints
{
public static IEndpointRouteBuilder MapSecurityReadModelEndpoints(this IEndpointRouteBuilder app)
{
var security = app.MapGroup("/api/v2/security")
.WithTags("Security V2");
security.MapGet("/findings", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
SecurityReadModelService service,
TimeProvider timeProvider,
[AsParameters] SecurityFindingsQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListFindingsAsync(
requestContext!,
query.Pivot,
query.Region,
query.Environment,
query.Severity,
query.Disposition,
query.Search,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new SecurityFindingsResponse(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset,
page.Pivot,
page.PivotBuckets,
page.Facets));
})
.WithName("ListSecurityFindingsV2")
.WithSummary("List consolidated security findings with pivot/facet schema")
.RequireAuthorization(PlatformPolicies.SecurityRead);
security.MapGet("/disposition", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
SecurityReadModelService service,
TimeProvider timeProvider,
[AsParameters] SecurityDispositionQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListDispositionAsync(
requestContext!,
query.Region,
query.Environment,
query.Status,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<SecurityDispositionProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListSecurityDispositionV2")
.WithSummary("List consolidated security disposition projection (VEX + exceptions read-join)")
.RequireAuthorization(PlatformPolicies.SecurityRead);
security.MapGet("/disposition/{findingId}", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
SecurityReadModelService service,
TimeProvider timeProvider,
string findingId,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var item = await service.GetDispositionAsync(
requestContext!,
findingId,
cancellationToken).ConfigureAwait(false);
if (item is null)
{
return Results.NotFound(new { error = "finding_not_found", findingId });
}
return Results.Ok(new PlatformItemResponse<SecurityDispositionProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
item));
})
.WithName("GetSecurityDispositionV2")
.WithSummary("Get consolidated security disposition by finding id")
.RequireAuthorization(PlatformPolicies.SecurityRead);
security.MapGet("/sbom-explorer", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
SecurityReadModelService service,
TimeProvider timeProvider,
[AsParameters] SecuritySbomExplorerQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await service.GetSbomExplorerAsync(
requestContext!,
query.Mode,
query.Region,
query.Environment,
query.LeftReleaseId,
query.RightReleaseId,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new SecuritySbomExplorerResponse(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
Mode: result.Mode,
Table: result.Table,
GraphNodes: result.GraphNodes,
GraphEdges: result.GraphEdges,
Diff: result.Diff,
TotalComponents: result.TotalComponents,
Limit: result.Limit,
Offset: result.Offset));
})
.WithName("GetSecuritySbomExplorerV2")
.WithSummary("Get consolidated SBOM explorer projection (table/graph/diff)")
.RequireAuthorization(PlatformPolicies.SecurityRead);
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
public sealed record SecurityFindingsQuery(
string? Pivot,
string? Region,
string? Environment,
string? Severity,
string? Disposition,
string? Search,
int? Limit,
int? Offset);
public sealed record SecurityDispositionQuery(
string? Region,
string? Environment,
string? Status,
int? Limit,
int? Offset);
public sealed record SecuritySbomExplorerQuery(
string? Mode,
string? Region,
string? Environment,
string? LeftReleaseId,
string? RightReleaseId,
int? Limit,
int? Offset);
}

View File

@@ -0,0 +1,332 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Endpoints;
public static class TopologyReadModelEndpoints
{
public static IEndpointRouteBuilder MapTopologyReadModelEndpoints(this IEndpointRouteBuilder app)
{
var topology = app.MapGroup("/api/v2/topology")
.WithTags("Topology V2");
topology.MapGet("/regions", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListRegionsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyRegionProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyRegionsV2")
.WithSummary("List topology regions")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/environments", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListEnvironmentsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyEnvironmentProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyEnvironmentsV2")
.WithSummary("List topology environments")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/targets", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListTargetsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyTargetProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyTargetsV2")
.WithSummary("List topology targets")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/hosts", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListHostsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyHostProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyHostsV2")
.WithSummary("List topology hosts")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/agents", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListAgentsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyAgentProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyAgentsV2")
.WithSummary("List topology agents")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/promotion-paths", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListPromotionPathsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyPromotionPathProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyPromotionPathsV2")
.WithSummary("List topology promotion paths")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/workflows", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListWorkflowsAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyWorkflowProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyWorkflowsV2")
.WithSummary("List topology workflows")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/gate-profiles", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyReadModelService service,
TimeProvider timeProvider,
[AsParameters] TopologyQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var page = await service.ListGateProfilesAsync(
requestContext!,
query.Region,
query.Environment,
query.Limit,
query.Offset,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new PlatformListResponse<TopologyGateProfileProjection>(
requestContext!.TenantId,
requestContext.ActorId,
timeProvider.GetUtcNow(),
Cached: false,
CacheTtlSeconds: 0,
page.Items,
page.Total,
page.Limit,
page.Offset));
})
.WithName("ListTopologyGateProfilesV2")
.WithSummary("List topology gate profiles")
.RequireAuthorization(PlatformPolicies.TopologyRead);
return app;
}
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
public sealed record TopologyQuery(
string? Region,
string? Environment,
int? Limit,
int? Offset);
}

View File

@@ -122,6 +122,12 @@ builder.Services.AddAuthorization(options =>
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesWrite, PlatformScopes.PreferencesWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.ContextRead, PlatformScopes.ContextRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.ContextWrite, PlatformScopes.ContextWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.TopologyRead, PlatformScopes.OrchRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.SecurityRead, PlatformScopes.FindingsRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.IntegrationsRead, PlatformScopes.AdvisoryRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.IntegrationsVexRead, PlatformScopes.VexRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.SearchRead, PlatformScopes.SearchRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.MetadataRead, PlatformScopes.MetadataRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.AnalyticsRead, PlatformScopes.AnalyticsRead);
@@ -148,6 +154,7 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddSingleton<PlatformRequestContextResolver>();
builder.Services.AddSingleton<PlatformCache>();
builder.Services.AddSingleton<PlatformAggregationMetrics>();
builder.Services.AddSingleton<LegacyAliasTelemetry>();
builder.Services.AddSingleton<PlatformQuotaAlertStore>();
builder.Services.AddSingleton<PlatformQuotaService>();
@@ -163,6 +170,11 @@ builder.Services.AddSingleton<PlatformPreferencesService>();
builder.Services.AddSingleton<PlatformSearchService>();
builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<TopologyReadModelService>();
builder.Services.AddSingleton<ReleaseReadModelService>();
builder.Services.AddSingleton<SecurityReadModelService>();
builder.Services.AddSingleton<IntegrationsReadModelService>();
builder.Services.AddSingleton<PlatformAnalyticsDataSource>();
builder.Services.AddSingleton<IPlatformAnalyticsQueryExecutor, PlatformAnalyticsQueryExecutor>();
builder.Services.AddSingleton<IPlatformAnalyticsMaintenanceExecutor, PlatformAnalyticsMaintenanceExecutor>();
@@ -192,6 +204,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
}
else
{
@@ -199,6 +212,7 @@ else
builder.Services.AddSingleton<IEnvironmentSettingsStore, InMemoryEnvironmentSettingsStore>();
builder.Services.AddSingleton<IReleaseControlBundleStore, InMemoryReleaseControlBundleStore>();
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
builder.Services.AddSingleton<IPlatformContextStore, InMemoryPlatformContextStore>();
}
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
@@ -242,8 +256,19 @@ app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
var legacyAliasTelemetry = app.Services.GetRequiredService<LegacyAliasTelemetry>();
app.Use(async (context, next) =>
{
await next(context).ConfigureAwait(false);
if (context.Response.StatusCode != StatusCodes.Status404NotFound)
{
legacyAliasTelemetry.Record(context);
}
});
app.MapEnvironmentSettingsEndpoints();
app.MapEnvironmentSettingsAdminEndpoints();
app.MapContextEndpoints();
app.MapPlatformEndpoints();
app.MapSetupEndpoints();
app.MapAnalyticsEndpoints();
@@ -251,6 +276,11 @@ app.MapScoreEndpoints();
app.MapFunctionMapEndpoints();
app.MapPolicyInteropEndpoints();
app.MapReleaseControlEndpoints();
app.MapReleaseReadModelEndpoints();
app.MapTopologyReadModelEndpoints();
app.MapSecurityReadModelEndpoints();
app.MapIntegrationReadModelEndpoints();
app.MapLegacyAliasEndpoints();
app.MapPackAdapterEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();

View File

@@ -48,4 +48,22 @@ public interface IReleaseControlBundleStore
Guid versionId,
MaterializeReleaseControlBundleVersionRequest request,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
string tenantId,
int limit,
int offset,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
string tenantId,
Guid bundleId,
int limit,
int offset,
CancellationToken cancellationToken = default);
Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default);
}

View File

@@ -280,6 +280,69 @@ public sealed class InMemoryReleaseControlBundleStore : IReleaseControlBundleSto
}
}
public Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
string tenantId,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var state = GetState(tenantId);
lock (state.Sync)
{
var list = state.Materializations.Values
.OrderByDescending(run => run.RequestedAt)
.ThenByDescending(run => run.RunId)
.Skip(Math.Max(offset, 0))
.Take(Math.Max(limit, 1))
.ToArray();
return Task.FromResult<IReadOnlyList<ReleaseControlBundleMaterializationRun>>(list);
}
}
public Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
string tenantId,
Guid bundleId,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var state = GetState(tenantId);
lock (state.Sync)
{
var list = state.Materializations.Values
.Where(run => run.BundleId == bundleId)
.OrderByDescending(run => run.RequestedAt)
.ThenByDescending(run => run.RunId)
.Skip(Math.Max(offset, 0))
.Take(Math.Max(limit, 1))
.ToArray();
return Task.FromResult<IReadOnlyList<ReleaseControlBundleMaterializationRun>>(list);
}
}
public Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var state = GetState(tenantId);
lock (state.Sync)
{
return Task.FromResult(
state.Materializations.TryGetValue(runId, out var run)
? run
: null);
}
}
private TenantState GetState(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))

View File

@@ -0,0 +1,387 @@
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
public sealed class IntegrationsReadModelService
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private const int RunScanLimit = 1000;
private static readonly IReadOnlyList<string> FeedConsumerDomains = ["security-findings", "dashboard-posture"];
private static readonly IReadOnlyList<string> VexConsumerDomains = ["security-disposition", "dashboard-posture"];
private static readonly FeedSourceDefinition[] FeedSources =
[
new("feed-nvd", "NVD", "advisory_feed", "nvd", 60),
new("feed-osv", "OSV", "advisory_feed", "osv", 90),
new("feed-kev", "KEV", "advisory_feed", "cisa", 120),
new("feed-vendor", "Vendor Advisories", "advisory_feed", "vendor", 180)
];
private static readonly VexSourceDefinition[] VexSources =
[
new("vex-vendor", "Vendor VEX", "vex_source", "vendor", 180, "openvex"),
new("vex-internal", "Internal VEX", "vex_source", "internal", 120, "openvex")
];
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
public IntegrationsReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
}
public async Task<IntegrationPageResult<IntegrationFeedProjection>> ListFeedsAsync(
PlatformRequestContext context,
string? region,
string? environment,
string? status,
string? sourceType,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var normalizedStatus = NormalizeOptional(status);
var normalizedSourceType = NormalizeOptional(sourceType);
var filtered = snapshot.Feeds
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.Status, normalizedStatus, StringComparison.Ordinal))
&& (string.IsNullOrWhiteSpace(normalizedSourceType) || string.Equals(item.SourceType, normalizedSourceType, StringComparison.Ordinal)))
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<IntegrationPageResult<IntegrationVexSourceProjection>> ListVexSourcesAsync(
PlatformRequestContext context,
string? region,
string? environment,
string? status,
string? sourceType,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var normalizedStatus = NormalizeOptional(status);
var normalizedSourceType = NormalizeOptional(sourceType);
var filtered = snapshot.VexSources
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.Status, normalizedStatus, StringComparison.Ordinal))
&& (string.IsNullOrWhiteSpace(normalizedSourceType) || string.Equals(item.SourceType, normalizedSourceType, StringComparison.Ordinal)))
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
private async Task<IntegrationSnapshot> BuildSnapshotAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var runs = await bundleStore.ListMaterializationRunsAsync(
context.TenantId,
RunScanLimit,
0,
cancellationToken).ConfigureAwait(false);
var latestRunByEnvironment = runs
.Select(run => new
{
EnvironmentId = NormalizeOptional(run.TargetEnvironment),
Run = run
})
.Where(static item => !string.IsNullOrWhiteSpace(item.EnvironmentId))
.GroupBy(item => item.EnvironmentId!, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group
.Select(item => item.Run)
.OrderByDescending(run => run.UpdatedAt)
.ThenByDescending(run => run.RunId)
.First(),
StringComparer.Ordinal);
var feeds = new List<IntegrationFeedProjection>(environments.Count * FeedSources.Length);
var vexSources = new List<IntegrationVexSourceProjection>(environments.Count * VexSources.Length);
foreach (var environment in environments
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
latestRunByEnvironment.TryGetValue(environment.EnvironmentId, out var latestRun);
foreach (var definition in FeedSources)
{
var sourceHealth = BuildSourceHealth(
context.TenantId,
environment.RegionId,
environment.EnvironmentId,
definition.SourceId,
definition.SlaMinutes,
latestRun);
feeds.Add(new IntegrationFeedProjection(
SourceId: $"{definition.SourceId}-{environment.EnvironmentId}",
SourceName: definition.SourceName,
SourceType: definition.SourceType,
Provider: definition.Provider,
Region: environment.RegionId,
Environment: environment.EnvironmentId,
Status: sourceHealth.Status,
Freshness: sourceHealth.Freshness,
LastSyncAt: sourceHealth.LastSyncAt,
FreshnessMinutes: sourceHealth.FreshnessMinutes,
SlaMinutes: definition.SlaMinutes,
LastSuccessAt: sourceHealth.LastSuccessAt,
LastError: sourceHealth.LastError,
ConsumerDomains: FeedConsumerDomains));
}
foreach (var definition in VexSources)
{
var sourceHealth = BuildSourceHealth(
context.TenantId,
environment.RegionId,
environment.EnvironmentId,
definition.SourceId,
definition.SlaMinutes,
latestRun);
var digest = HashSeed($"{context.TenantId}:{environment.EnvironmentId}:{definition.SourceId}:documents");
var documentCount = 20 + (ParseHexByte(digest, 0) % 180);
vexSources.Add(new IntegrationVexSourceProjection(
SourceId: $"{definition.SourceId}-{environment.EnvironmentId}",
SourceName: definition.SourceName,
SourceType: definition.SourceType,
Provider: definition.Provider,
Region: environment.RegionId,
Environment: environment.EnvironmentId,
Status: sourceHealth.Status,
Freshness: sourceHealth.Freshness,
LastSyncAt: sourceHealth.LastSyncAt,
FreshnessMinutes: sourceHealth.FreshnessMinutes,
SlaMinutes: definition.SlaMinutes,
StatementFormat: definition.StatementFormat,
DocumentCount24h: documentCount,
LastSuccessAt: sourceHealth.LastSuccessAt,
LastError: sourceHealth.LastError,
ConsumerDomains: VexConsumerDomains));
}
}
return new IntegrationSnapshot(
feeds
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
.ToArray(),
vexSources
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
.ToArray());
}
private static SourceHealthSnapshot BuildSourceHealth(
string tenantId,
string regionId,
string environmentId,
string sourceId,
int slaMinutes,
ReleaseControlBundleMaterializationRun? latestRun)
{
if (latestRun is null)
{
return new SourceHealthSnapshot(
Status: "offline",
Freshness: "unknown",
LastSyncAt: null,
FreshnessMinutes: null,
LastSuccessAt: null,
LastError: "source_sync_uninitialized");
}
var digest = HashSeed($"{tenantId}:{regionId}:{environmentId}:{sourceId}:{latestRun.RunId:D}");
var ageWindow = Math.Max(slaMinutes * 4, slaMinutes + 1);
var freshnessMinutes = ParseHexByte(digest, 0) % ageWindow;
var lastSyncAt = latestRun.UpdatedAt - TimeSpan.FromMinutes(freshnessMinutes);
var status = ResolveStatus(freshnessMinutes, slaMinutes);
var freshness = freshnessMinutes <= slaMinutes ? "fresh" : "stale";
var lastSuccessAt = status == "offline"
? lastSyncAt - TimeSpan.FromMinutes((ParseHexByte(digest, 1) % 120) + 1)
: lastSyncAt;
var lastError = status switch
{
"healthy" => null,
"degraded" => "source_sync_delayed",
_ => "source_sync_unreachable"
};
return new SourceHealthSnapshot(
Status: status,
Freshness: freshness,
LastSyncAt: lastSyncAt,
FreshnessMinutes: freshnessMinutes,
LastSuccessAt: lastSuccessAt,
LastError: lastError);
}
private static string ResolveStatus(int freshnessMinutes, int slaMinutes)
{
if (freshnessMinutes <= slaMinutes)
{
return "healthy";
}
if (freshnessMinutes <= (slaMinutes * 3))
{
return "degraded";
}
return "offline";
}
private static IntegrationPageResult<TItem> Page<TItem>(
IReadOnlyList<TItem> items,
int? limit,
int? offset)
{
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
var paged = items
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArray();
return new IntegrationPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
}
private static int NormalizeLimit(int? value)
{
return value switch
{
null => DefaultLimit,
< 1 => 1,
> MaxLimit => MaxLimit,
_ => value.Value
};
}
private static int NormalizeOffset(int? value)
{
return value is null or < 0 ? 0 : value.Value;
}
private static HashSet<string> ParseFilterSet(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static item => NormalizeOptional(item))
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!)
.ToHashSet(StringComparer.Ordinal);
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
private static byte ParseHexByte(string digest, int index)
{
var offset = index * 2;
if (offset + 2 > digest.Length)
{
return 0;
}
return byte.Parse(digest.AsSpan(offset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
private static string HashSeed(string seed)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
var builder = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes)
{
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private sealed record IntegrationSnapshot(
IReadOnlyList<IntegrationFeedProjection> Feeds,
IReadOnlyList<IntegrationVexSourceProjection> VexSources);
private sealed record FeedSourceDefinition(
string SourceId,
string SourceName,
string SourceType,
string Provider,
int SlaMinutes);
private sealed record VexSourceDefinition(
string SourceId,
string SourceName,
string SourceType,
string Provider,
int SlaMinutes,
string StatementFormat);
private sealed record SourceHealthSnapshot(
string Status,
string Freshness,
DateTimeOffset? LastSyncAt,
int? FreshnessMinutes,
DateTimeOffset? LastSuccessAt,
string? LastError);
}
public sealed record IntegrationPageResult<TItem>(
IReadOnlyList<TItem> Items,
int Total,
int Limit,
int Offset);

View File

@@ -0,0 +1,166 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.WebService.Services;
public sealed class LegacyAliasTelemetry
{
private const int MaxEntries = 512;
private readonly ConcurrentQueue<LegacyAliasUsageEvent> events = new();
private readonly TimeProvider timeProvider;
private readonly ILogger<LegacyAliasTelemetry> logger;
public LegacyAliasTelemetry(
TimeProvider timeProvider,
ILogger<LegacyAliasTelemetry> logger)
{
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Record(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
var endpoint = httpContext.GetEndpoint() as RouteEndpoint;
var routePattern = endpoint?.RoutePattern.RawText ?? httpContext.Request.Path.Value ?? string.Empty;
if (!routePattern.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase))
{
return;
}
var method = NormalizeMethod(httpContext.Request.Method);
var eventKey = BuildEventKey(method, routePattern);
var canonicalRoute = BuildCanonicalRoute(routePattern);
var tenantHash = ComputeTenantHash(ReadHeader(httpContext, "X-StellaOps-Tenant"));
var timestamp = timeProvider.GetUtcNow();
var usage = new LegacyAliasUsageEvent(
eventKey,
method,
routePattern,
canonicalRoute,
httpContext.Response.StatusCode,
tenantHash,
timestamp);
events.Enqueue(usage);
while (events.Count > MaxEntries && events.TryDequeue(out _))
{
}
logger.LogInformation(
"Deprecated alias used event_key={EventKey} alias={AliasRoute} canonical={CanonicalRoute} method={Method} status={StatusCode} tenant_hash={TenantHash}",
usage.EventKey,
usage.AliasRoute,
usage.CanonicalRoute,
usage.Method,
usage.StatusCode,
usage.TenantHash ?? "none");
}
public IReadOnlyList<LegacyAliasUsageEvent> Snapshot()
{
return events
.ToArray()
.OrderBy(item => item.RecordedAt)
.ThenBy(item => item.EventKey, StringComparer.Ordinal)
.ToArray();
}
public void Clear()
{
while (events.TryDequeue(out _))
{
}
}
private static string BuildCanonicalRoute(string routePattern)
{
return routePattern.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase)
? $"/api/v2/{routePattern["/api/v1/".Length..]}"
: routePattern;
}
private static string BuildEventKey(string method, string routePattern)
{
var normalizedRoute = NormalizeToken(routePattern);
return $"alias_{method}_{normalizedRoute}";
}
private static string NormalizeToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "unknown";
}
var trimmed = value.Trim().ToLowerInvariant();
var builder = new StringBuilder(trimmed.Length);
var previousWasUnderscore = false;
foreach (var ch in trimmed)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(ch);
previousWasUnderscore = false;
}
else if (!previousWasUnderscore)
{
builder.Append('_');
previousWasUnderscore = true;
}
}
var normalized = builder.ToString().Trim('_');
return string.IsNullOrWhiteSpace(normalized) ? "unknown" : normalized;
}
private static string? ComputeTenantHash(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return null;
}
var normalized = tenantId.Trim().ToLowerInvariant();
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(digest.AsSpan(0, 6)).ToLowerInvariant();
}
private static string NormalizeMethod(string method)
{
return string.IsNullOrWhiteSpace(method)
? "unknown"
: method.Trim().ToLowerInvariant();
}
private static string? ReadHeader(HttpContext context, string headerName)
{
if (!context.Request.Headers.TryGetValue(headerName, out var values))
{
return null;
}
return values
.Select(static value => value?.Trim())
.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value));
}
}
public sealed record LegacyAliasUsageEvent(
string EventKey,
string Method,
string AliasRoute,
string CanonicalRoute,
int StatusCode,
string? TenantHash,
DateTimeOffset RecordedAt);

View File

@@ -0,0 +1,399 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
public interface IPlatformContextStore
{
Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default);
Task<PlatformContextPreferences?> GetPreferencesAsync(
string tenantId,
string actorId,
CancellationToken cancellationToken = default);
Task<PlatformContextPreferences> UpsertPreferencesAsync(
PlatformContextPreferences preferences,
CancellationToken cancellationToken = default);
}
public sealed class PlatformContextService
{
private static readonly string[] AllowedTimeWindows = ["1h", "24h", "7d", "30d", "90d"];
private const string DefaultTimeWindow = "24h";
private readonly IPlatformContextStore store;
private readonly TimeProvider timeProvider;
private readonly ILogger<PlatformContextService> logger;
public PlatformContextService(
IPlatformContextStore store,
TimeProvider timeProvider,
ILogger<PlatformContextService> logger)
{
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(
CancellationToken cancellationToken = default)
{
var regions = await store.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
return regions
.OrderBy(region => region.SortOrder)
.ThenBy(region => region.RegionId, StringComparer.Ordinal)
.ToArray();
}
public async Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(
IReadOnlyList<string>? regionFilter,
CancellationToken cancellationToken = default)
{
var normalizedRegions = NormalizeSelection(regionFilter);
var environments = await store.GetEnvironmentsAsync(cancellationToken).ConfigureAwait(false);
return environments
.Where(environment =>
normalizedRegions.Length == 0
|| normalizedRegions.Contains(environment.RegionId, StringComparer.Ordinal))
.OrderBy(environment => environment.SortOrder)
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
.ToArray();
}
public async Task<PlatformContextPreferences> GetPreferencesAsync(
PlatformRequestContext context,
CancellationToken cancellationToken = default)
{
var existing = await store.GetPreferencesAsync(
context.TenantId,
context.ActorId,
cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return existing;
}
var defaultRegions = (await GetRegionsAsync(cancellationToken).ConfigureAwait(false))
.Select(region => region.RegionId)
.ToArray();
var created = new PlatformContextPreferences(
context.TenantId,
context.ActorId,
defaultRegions,
Array.Empty<string>(),
DefaultTimeWindow,
timeProvider.GetUtcNow(),
context.ActorId);
return await store.UpsertPreferencesAsync(created, cancellationToken).ConfigureAwait(false);
}
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
PlatformRequestContext context,
PlatformContextPreferencesRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var current = await GetPreferencesAsync(context, cancellationToken).ConfigureAwait(false);
var regions = await GetRegionsAsync(cancellationToken).ConfigureAwait(false);
var environments = await GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var orderedRegionIds = regions
.Select(region => region.RegionId)
.ToArray();
var envById = environments
.ToDictionary(environment => environment.EnvironmentId, StringComparer.Ordinal);
var orderedEnvironmentIds = environments
.Select(environment => environment.EnvironmentId)
.ToArray();
var requestedRegions = request.Regions is null
? NormalizeSelection(current.Regions)
: NormalizeSelection(request.Regions);
var requestedRegionSet = requestedRegions.ToHashSet(StringComparer.Ordinal);
var nextRegions = orderedRegionIds
.Where(requestedRegionSet.Contains)
.ToArray();
if (nextRegions.Length == 0)
{
nextRegions = orderedRegionIds;
}
var requestedEnvironments = request.Environments is null
? NormalizeSelection(current.Environments)
: NormalizeSelection(request.Environments);
var requestedEnvironmentSet = requestedEnvironments.ToHashSet(StringComparer.Ordinal);
var selectedRegionSet = nextRegions.ToHashSet(StringComparer.Ordinal);
var nextEnvironments = orderedEnvironmentIds
.Where(requestedEnvironmentSet.Contains)
.Where(environmentId =>
envById.TryGetValue(environmentId, out var environment)
&& selectedRegionSet.Contains(environment.RegionId))
.ToArray();
var nextTimeWindow = NormalizeTimeWindow(request.TimeWindow, current.TimeWindow);
var updated = new PlatformContextPreferences(
context.TenantId,
context.ActorId,
nextRegions,
nextEnvironments,
nextTimeWindow,
timeProvider.GetUtcNow(),
context.ActorId);
logger.LogInformation(
"Updated platform context preferences for tenant {TenantId}, actor {ActorId}",
context.TenantId,
context.ActorId);
return await store.UpsertPreferencesAsync(updated, cancellationToken).ConfigureAwait(false);
}
private static string NormalizeTimeWindow(string? requested, string fallback)
{
if (!string.IsNullOrWhiteSpace(requested))
{
var candidate = requested.Trim();
if (AllowedTimeWindows.Contains(candidate, StringComparer.Ordinal))
{
return candidate;
}
}
if (AllowedTimeWindows.Contains(fallback, StringComparer.Ordinal))
{
return fallback;
}
return DefaultTimeWindow;
}
private static string[] NormalizeSelection(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
}
}
public sealed class InMemoryPlatformContextStore : IPlatformContextStore
{
private static readonly IReadOnlyList<PlatformContextRegion> Regions =
[
new("apac", "APAC", 30),
new("eu-west", "EU West", 20),
new("us-east", "US East", 10),
];
private static readonly IReadOnlyList<PlatformContextEnvironment> Environments =
[
new("apac-prod", "apac", "production", "APAC Production", 30),
new("eu-prod", "eu-west", "production", "EU Production", 20),
new("eu-stage", "eu-west", "staging", "EU Staging", 21),
new("us-prod", "us-east", "production", "US Production", 10),
new("us-uat", "us-east", "staging", "US UAT", 11),
];
private readonly ConcurrentDictionary<PlatformUserKey, PlatformContextPreferences> preferences = new();
public Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Regions);
}
public Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Environments);
}
public Task<PlatformContextPreferences?> GetPreferencesAsync(
string tenantId,
string actorId,
CancellationToken cancellationToken = default)
{
var key = PlatformUserKey.Create(tenantId, actorId);
preferences.TryGetValue(key, out var existing);
return Task.FromResult(existing);
}
public Task<PlatformContextPreferences> UpsertPreferencesAsync(
PlatformContextPreferences preference,
CancellationToken cancellationToken = default)
{
var key = PlatformUserKey.Create(preference.TenantId, preference.ActorId);
preferences[key] = preference;
return Task.FromResult(preference);
}
}
public sealed class PostgresPlatformContextStore : IPlatformContextStore
{
private const string SelectRegionsSql = """
SELECT region_id, display_name, sort_order, enabled
FROM platform.context_regions
WHERE enabled = true
ORDER BY sort_order, region_id
""";
private const string SelectEnvironmentsSql = """
SELECT environment_id, region_id, environment_type, display_name, sort_order, enabled
FROM platform.context_environments
WHERE enabled = true
ORDER BY sort_order, region_id, environment_id
""";
private const string SelectPreferencesSql = """
SELECT regions, environments, time_window, updated_at, updated_by
FROM platform.ui_context_preferences
WHERE tenant_id = @tenant_id AND actor_id = @actor_id
""";
private const string UpsertPreferencesSql = """
INSERT INTO platform.ui_context_preferences
(tenant_id, actor_id, regions, environments, time_window, updated_at, updated_by)
VALUES
(@tenant_id, @actor_id, @regions, @environments, @time_window, @updated_at, @updated_by)
ON CONFLICT (tenant_id, actor_id)
DO UPDATE SET
regions = EXCLUDED.regions,
environments = EXCLUDED.environments,
time_window = EXCLUDED.time_window,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
RETURNING regions, environments, time_window, updated_at, updated_by
""";
private readonly NpgsqlDataSource dataSource;
public PostgresPlatformContextStore(NpgsqlDataSource dataSource)
{
this.dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default)
{
var regions = new List<PlatformContextRegion>();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectRegionsSql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
regions.Add(new PlatformContextRegion(
reader.GetString(0),
reader.GetString(1),
reader.GetInt32(2),
reader.GetBoolean(3)));
}
return regions;
}
public async Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default)
{
var environments = new List<PlatformContextEnvironment>();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectEnvironmentsSql, connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
environments.Add(new PlatformContextEnvironment(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetInt32(4),
reader.GetBoolean(5)));
}
return environments;
}
public async Task<PlatformContextPreferences?> GetPreferencesAsync(
string tenantId,
string actorId,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectPreferencesSql, connection);
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("actor_id", actorId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return new PlatformContextPreferences(
tenantId,
actorId,
ReadTextArray(reader, 0),
ReadTextArray(reader, 1),
reader.GetString(2),
reader.GetFieldValue<DateTimeOffset>(3),
reader.GetString(4));
}
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
PlatformContextPreferences preference,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(UpsertPreferencesSql, connection);
command.Parameters.AddWithValue("tenant_id", preference.TenantId);
command.Parameters.AddWithValue("actor_id", preference.ActorId);
command.Parameters.AddWithValue("regions", preference.Regions.ToArray());
command.Parameters.AddWithValue("environments", preference.Environments.ToArray());
command.Parameters.AddWithValue("time_window", preference.TimeWindow);
command.Parameters.AddWithValue("updated_at", preference.UpdatedAt);
command.Parameters.AddWithValue("updated_by", preference.UpdatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new PlatformContextPreferences(
preference.TenantId,
preference.ActorId,
ReadTextArray(reader, 0),
ReadTextArray(reader, 1),
reader.GetString(2),
reader.GetFieldValue<DateTimeOffset>(3),
reader.GetString(4));
}
private static string[] ReadTextArray(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return Array.Empty<string>();
}
return reader.GetFieldValue<string[]>(ordinal)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(item => item, StringComparer.Ordinal)
.ToArray();
}
}

View File

@@ -10,6 +10,44 @@ namespace StellaOps.Platform.WebService.Services;
public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleStore
{
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
private const string ListMaterializationRunsSql =
"""
SELECT
run_id,
bundle_id,
bundle_version_id,
status,
target_environment,
reason,
requested_by,
idempotency_key,
requested_at,
updated_at
FROM release.control_bundle_materialization_runs
WHERE tenant_id = @tenant_id
ORDER BY requested_at DESC, run_id DESC
LIMIT @limit OFFSET @offset
""";
private const string ListMaterializationRunsByBundleSql =
"""
SELECT
run_id,
bundle_id,
bundle_version_id,
status,
target_environment,
reason,
requested_by,
idempotency_key,
requested_at,
updated_at
FROM release.control_bundle_materialization_runs
WHERE tenant_id = @tenant_id
AND bundle_id = @bundle_id
ORDER BY requested_at DESC, run_id DESC
LIMIT @limit OFFSET @offset
""";
private readonly NpgsqlDataSource _dataSource;
private readonly TimeProvider _timeProvider;
@@ -611,6 +649,99 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
}
}
public async Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
string tenantId,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ListMaterializationRunsSql, connection);
command.Parameters.AddWithValue("tenant_id", tenantGuid);
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
var results = new List<ReleaseControlBundleMaterializationRun>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapMaterializationRun(reader));
}
return results;
}
public async Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
string tenantId,
Guid bundleId,
int limit,
int offset,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ListMaterializationRunsByBundleSql, connection);
command.Parameters.AddWithValue("tenant_id", tenantGuid);
command.Parameters.AddWithValue("bundle_id", bundleId);
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
var results = new List<ReleaseControlBundleMaterializationRun>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapMaterializationRun(reader));
}
return results;
}
public async Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
string tenantId,
Guid runId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var tenantGuid = ParseTenantId(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
"""
SELECT
run_id,
bundle_id,
bundle_version_id,
status,
target_environment,
reason,
requested_by,
idempotency_key,
requested_at,
updated_at
FROM release.control_bundle_materialization_runs
WHERE tenant_id = @tenant_id
AND run_id = @run_id
LIMIT 1
""",
connection);
command.Parameters.AddWithValue("tenant_id", tenantGuid);
command.Parameters.AddWithValue("run_id", runId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapMaterializationRun(reader);
}
private static ReleaseControlBundleVersionSummary MapVersionSummary(NpgsqlDataReader reader)
{
return new ReleaseControlBundleVersionSummary(
@@ -626,6 +757,21 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
CreatedBy: reader.GetString(9));
}
private static ReleaseControlBundleMaterializationRun MapMaterializationRun(NpgsqlDataReader reader)
{
return new ReleaseControlBundleMaterializationRun(
RunId: reader.GetGuid(0),
BundleId: reader.GetGuid(1),
VersionId: reader.GetGuid(2),
Status: reader.GetString(3),
TargetEnvironment: reader.IsDBNull(4) ? null : reader.GetString(4),
Reason: reader.IsDBNull(5) ? null : reader.GetString(5),
RequestedBy: reader.GetString(6),
IdempotencyKey: reader.IsDBNull(7) ? null : reader.GetString(7),
RequestedAt: reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
}
private static Guid ParseTenantId(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
@@ -848,4 +994,4 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
public sealed class SecurityReadModelService
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private const int BundleScanLimit = 500;
private const int RunScanLimit = 1000;
private static readonly string[] ExceptionKeywords = ["exception", "waiver", "risk", "accept"];
private static readonly string[] SupplierCatalog = ["stella", "acme", "forge", "internal", "vendorx"];
private static readonly string[] LicenseCatalog = ["Apache-2.0", "MIT", "BUSL-1.1", "BSD-3-Clause", "MPL-2.0"];
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
public SecurityReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
}
public async Task<SecurityFindingsPageResult> ListFindingsAsync(
PlatformRequestContext context,
string? pivot,
string? region,
string? environment,
string? severity,
string? disposition,
string? search,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var normalizedPivot = NormalizePivot(pivot);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var normalizedSeverity = NormalizeOptional(severity);
var normalizedDisposition = NormalizeOptional(disposition);
var normalizedSearch = NormalizeOptional(search);
var filtered = snapshot.Findings
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
&& (string.IsNullOrWhiteSpace(normalizedSeverity) || string.Equals(item.Severity, normalizedSeverity, StringComparison.Ordinal))
&& (string.IsNullOrWhiteSpace(normalizedDisposition) || string.Equals(item.EffectiveDisposition, normalizedDisposition, StringComparison.Ordinal))
&& (string.IsNullOrWhiteSpace(normalizedSearch)
|| item.CveId.Contains(normalizedSearch, StringComparison.Ordinal)
|| item.PackageName.Contains(normalizedSearch, StringComparison.Ordinal)
|| item.ComponentName.Contains(normalizedSearch, StringComparison.Ordinal)
|| item.ReleaseName.Contains(normalizedSearch, StringComparison.Ordinal)))
.OrderBy(item => SeverityRank(item.Severity))
.ThenBy(item => item.CveId, StringComparer.Ordinal)
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
.ToArray();
var pivotBuckets = BuildPivotBuckets(filtered, normalizedPivot);
var facets = BuildFacetBuckets(filtered);
var paged = Page(filtered, limit, offset);
return new SecurityFindingsPageResult(
paged.Items,
paged.Total,
paged.Limit,
paged.Offset,
normalizedPivot,
pivotBuckets,
facets);
}
public async Task<SecurityPageResult<SecurityDispositionProjection>> ListDispositionAsync(
PlatformRequestContext context,
string? region,
string? environment,
string? status,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var normalizedStatus = NormalizeOptional(status);
var filtered = snapshot.Disposition
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.EffectiveDisposition, normalizedStatus, StringComparison.Ordinal)))
.OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<SecurityDispositionProjection?> GetDispositionAsync(
PlatformRequestContext context,
string findingId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(findingId))
{
return null;
}
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
return snapshot.DispositionByFindingId.GetValueOrDefault(findingId.Trim());
}
public async Task<SecuritySbomExplorerResult> GetSbomExplorerAsync(
PlatformRequestContext context,
string? mode,
string? region,
string? environment,
string? leftReleaseId,
string? rightReleaseId,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var normalizedMode = NormalizeMode(mode);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var tableRows = snapshot.SbomRows
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment)))
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.ReleaseName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentId, StringComparer.Ordinal)
.ToArray();
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
var pagedTable = tableRows
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArray();
var graphNodes = Array.Empty<SecuritySbomGraphNode>();
var graphEdges = Array.Empty<SecuritySbomGraphEdge>();
var diffRows = Array.Empty<SecuritySbomDiffRow>();
if (string.Equals(normalizedMode, "graph", StringComparison.Ordinal))
{
graphNodes = snapshot.GraphNodes
.Where(node =>
(regionFilter.Count == 0 || regionFilter.Contains(node.Region))
&& (environmentFilter.Count == 0 || environmentFilter.Contains(node.Environment)))
.OrderBy(node => node.NodeType, StringComparer.Ordinal)
.ThenBy(node => node.Region, StringComparer.Ordinal)
.ThenBy(node => node.Environment, StringComparer.Ordinal)
.ThenBy(node => node.NodeId, StringComparer.Ordinal)
.ToArray();
var nodeIdSet = graphNodes.Select(node => node.NodeId).ToHashSet(StringComparer.Ordinal);
graphEdges = snapshot.GraphEdges
.Where(edge => nodeIdSet.Contains(edge.FromNodeId) && nodeIdSet.Contains(edge.ToNodeId))
.OrderBy(edge => edge.FromNodeId, StringComparer.Ordinal)
.ThenBy(edge => edge.ToNodeId, StringComparer.Ordinal)
.ThenBy(edge => edge.EdgeId, StringComparer.Ordinal)
.ToArray();
}
else if (string.Equals(normalizedMode, "diff", StringComparison.Ordinal))
{
diffRows = BuildDiffRows(tableRows, leftReleaseId, rightReleaseId)
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArray();
}
return new SecuritySbomExplorerResult(
normalizedMode,
pagedTable,
graphNodes,
graphEdges,
diffRows,
tableRows.Length,
normalizedLimit,
normalizedOffset);
}
private async Task<SecuritySnapshot> BuildSnapshotAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal);
var bundles = await bundleStore.ListBundlesAsync(
context.TenantId,
BundleScanLimit,
0,
cancellationToken).ConfigureAwait(false);
var runs = await bundleStore.ListMaterializationRunsAsync(
context.TenantId,
RunScanLimit,
0,
cancellationToken).ConfigureAwait(false);
var latestRunByBundle = runs
.GroupBy(run => run.BundleId)
.ToDictionary(
group => group.Key,
group => group
.OrderByDescending(run => run.RequestedAt)
.ThenByDescending(run => run.RunId)
.FirstOrDefault());
var findings = new List<SecurityFindingProjection>();
var disposition = new List<SecurityDispositionProjection>();
var sbomRows = new List<SecuritySbomComponentRow>();
var graphNodes = new Dictionary<string, SecuritySbomGraphNode>(StringComparer.Ordinal);
var graphEdges = new Dictionary<string, SecuritySbomGraphEdge>(StringComparer.Ordinal);
// Compose deterministic projections from existing release-control bundle/version/materialization data.
// This keeps Security read contracts independent from VEX/exception write authorities.
foreach (var bundle in bundles
.OrderBy(item => item.Name, StringComparer.Ordinal)
.ThenBy(item => item.Id))
{
cancellationToken.ThrowIfCancellationRequested();
if (bundle.LatestVersionId is null)
{
continue;
}
if (!latestRunByBundle.TryGetValue(bundle.Id, out var latestRun) || latestRun is null)
{
continue;
}
var environmentId = NormalizeOptional(latestRun.TargetEnvironment);
if (string.IsNullOrWhiteSpace(environmentId) || !environmentById.TryGetValue(environmentId, out var environment))
{
continue;
}
var version = await bundleStore.GetVersionAsync(
context.TenantId,
bundle.Id,
bundle.LatestVersionId.Value,
cancellationToken).ConfigureAwait(false);
if (version is null || version.Components.Count == 0)
{
continue;
}
BuildBundleSecurityProjections(
bundle,
version,
latestRun,
environment,
findings,
disposition,
sbomRows,
graphNodes,
graphEdges);
}
var orderedFindings = findings
.OrderBy(item => SeverityRank(item.Severity))
.ThenBy(item => item.CveId, StringComparer.Ordinal)
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
.ToArray();
var orderedDisposition = disposition
.OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
.ToArray();
return new SecuritySnapshot(
Findings: orderedFindings,
Disposition: orderedDisposition,
DispositionByFindingId: orderedDisposition.ToDictionary(item => item.FindingId, StringComparer.Ordinal),
SbomRows: sbomRows
.OrderBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ThenBy(item => item.ReleaseName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
.ThenBy(item => item.ComponentId, StringComparer.Ordinal)
.ToArray(),
GraphNodes: graphNodes.Values.ToArray(),
GraphEdges: graphEdges.Values.ToArray());
}
private static void BuildBundleSecurityProjections(
ReleaseControlBundleSummary bundle,
ReleaseControlBundleVersionDetail version,
ReleaseControlBundleMaterializationRun latestRun,
PlatformContextEnvironment environment,
ICollection<SecurityFindingProjection> findings,
ICollection<SecurityDispositionProjection> disposition,
ICollection<SecuritySbomComponentRow> sbomRows,
IDictionary<string, SecuritySbomGraphNode> graphNodes,
IDictionary<string, SecuritySbomGraphEdge> graphEdges)
{
var regionId = environment.RegionId;
var releaseNodeId = $"release:{bundle.Id:D}";
graphNodes[releaseNodeId] = new SecuritySbomGraphNode(
releaseNodeId,
"release",
bundle.Name,
regionId,
environment.EnvironmentId);
var orderedComponents = version.Components
.OrderBy(component => component.DeployOrder)
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
.ToArray();
for (var index = 0; index < orderedComponents.Length; index++)
{
var component = orderedComponents[index];
var seed = $"{bundle.Id:D}:{version.Id:D}:{component.ComponentVersionId}:{environment.EnvironmentId}:{index}";
var digest = HashSeed(seed);
var findingId = $"finding-{digest[..16]}";
var componentId = $"component-{digest[..12]}";
var cveId = BuildCveId(digest);
var severity = BuildSeverity(digest);
var reachabilityScore = (int)(ParseHexByte(digest, 2) % 100) + 1;
var reachable = reachabilityScore >= 50;
var packageName = BuildPackageName(component.ComponentName, digest);
var componentVersion = ExtractComponentVersion(component.ComponentVersionId);
var supplier = SupplierCatalog[(int)(ParseHexByte(digest, 3) % SupplierCatalog.Length)];
var license = LicenseCatalog[(int)(ParseHexByte(digest, 4) % LicenseCatalog.Length)];
var vulnerabilityCount = (int)(ParseHexByte(digest, 5) % 8) + 1;
var criticalReachableCount = string.Equals(severity, "critical", StringComparison.Ordinal) && reachable ? 1 : 0;
var hasExceptionReason = ContainsAny(latestRun.Reason, ExceptionKeywords);
var exceptionStatus = ResolveExceptionStatus(hasExceptionReason, latestRun.Status);
var vexStatus = ResolveVexStatus(severity, reachable, hasExceptionReason);
var effectiveDisposition = ResolveEffectiveDisposition(vexStatus, exceptionStatus);
var policyAction = ResolvePolicyAction(effectiveDisposition);
var updatedAt = latestRun.UpdatedAt >= bundle.UpdatedAt ? latestRun.UpdatedAt : bundle.UpdatedAt;
var vexState = new SecurityVexState(
Status: vexStatus,
Justification: ResolveVexJustification(vexStatus),
SourceModel: "vex",
StatementId: $"vex-{digest[..10]}",
UpdatedAt: updatedAt);
var exceptionState = new SecurityExceptionState(
Status: exceptionStatus,
Reason: hasExceptionReason ? NormalizeOptional(latestRun.Reason) ?? "exception_requested" : "none",
ApprovalState: ResolveExceptionApprovalState(exceptionStatus),
SourceModel: "exceptions",
ExceptionId: hasExceptionReason ? $"exc-{digest[8..18]}" : null,
ExpiresAt: hasExceptionReason ? updatedAt.AddDays(14) : null,
UpdatedAt: hasExceptionReason ? updatedAt : null);
findings.Add(new SecurityFindingProjection(
FindingId: findingId,
CveId: cveId,
Severity: severity,
PackageName: packageName,
ComponentName: component.ComponentName,
ReleaseId: bundle.Id.ToString("D"),
ReleaseName: bundle.Name,
Environment: environment.EnvironmentId,
Region: regionId,
Reachable: reachable,
ReachabilityScore: reachabilityScore,
EffectiveDisposition: effectiveDisposition,
VexStatus: vexStatus,
ExceptionStatus: exceptionStatus,
UpdatedAt: updatedAt));
disposition.Add(new SecurityDispositionProjection(
FindingId: findingId,
CveId: cveId,
ReleaseId: bundle.Id.ToString("D"),
ReleaseName: bundle.Name,
PackageName: packageName,
ComponentName: component.ComponentName,
Environment: environment.EnvironmentId,
Region: regionId,
Vex: vexState,
Exception: exceptionState,
EffectiveDisposition: effectiveDisposition,
PolicyAction: policyAction,
UpdatedAt: updatedAt));
sbomRows.Add(new SecuritySbomComponentRow(
ComponentId: componentId,
ReleaseId: bundle.Id.ToString("D"),
ReleaseName: bundle.Name,
Environment: environment.EnvironmentId,
Region: regionId,
PackageName: packageName,
ComponentName: component.ComponentName,
ComponentVersion: componentVersion,
Supplier: supplier,
License: license,
VulnerabilityCount: vulnerabilityCount,
CriticalReachableCount: criticalReachableCount,
UpdatedAt: updatedAt));
graphNodes[componentId] = new SecuritySbomGraphNode(
componentId,
"component",
component.ComponentName,
regionId,
environment.EnvironmentId);
var edgeId = $"edge-{releaseNodeId}-{componentId}";
graphEdges[edgeId] = new SecuritySbomGraphEdge(
EdgeId: edgeId,
FromNodeId: releaseNodeId,
ToNodeId: componentId,
RelationType: "contains");
}
}
private static SecurityPageResult<TItem> Page<TItem>(
IReadOnlyList<TItem> items,
int? limit,
int? offset)
{
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
var paged = items
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArray();
return new SecurityPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
}
private static string NormalizeMode(string? mode)
{
var normalized = NormalizeOptional(mode);
return normalized switch
{
"table" => "table",
"graph" => "graph",
"diff" => "diff",
_ => "table"
};
}
private static string NormalizePivot(string? pivot)
{
var normalized = NormalizeOptional(pivot);
return normalized switch
{
"cve" => "cve",
"package" => "package",
"component" => "component",
"release" => "release",
"environment" => "environment",
_ => "cve"
};
}
private static IReadOnlyList<SecurityPivotBucket> BuildPivotBuckets(
IReadOnlyList<SecurityFindingProjection> findings,
string pivot)
{
var grouped = findings
.GroupBy(item => pivot switch
{
"package" => item.PackageName,
"component" => item.ComponentName,
"release" => item.ReleaseName,
"environment" => item.Environment,
_ => item.CveId
}, StringComparer.Ordinal)
.Select(group => new SecurityPivotBucket(
PivotValue: group.Key,
FindingCount: group.Count(),
CriticalCount: group.Count(item => string.Equals(item.Severity, "critical", StringComparison.Ordinal)),
ReachableCount: group.Count(item => item.Reachable)))
.OrderByDescending(bucket => bucket.FindingCount)
.ThenBy(bucket => bucket.PivotValue, StringComparer.Ordinal)
.ToArray();
return grouped;
}
private static IReadOnlyList<SecurityFacetBucket> BuildFacetBuckets(
IReadOnlyList<SecurityFindingProjection> findings)
{
var severityFacets = findings
.GroupBy(item => item.Severity, StringComparer.Ordinal)
.Select(group => new SecurityFacetBucket("severity", group.Key, group.Count()))
.OrderBy(bucket => SeverityRank(bucket.Value))
.ThenBy(bucket => bucket.Value, StringComparer.Ordinal);
var regionFacets = findings
.GroupBy(item => item.Region, StringComparer.Ordinal)
.Select(group => new SecurityFacetBucket("region", group.Key, group.Count()))
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
var environmentFacets = findings
.GroupBy(item => item.Environment, StringComparer.Ordinal)
.Select(group => new SecurityFacetBucket("environment", group.Key, group.Count()))
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
var dispositionFacets = findings
.GroupBy(item => item.EffectiveDisposition, StringComparer.Ordinal)
.Select(group => new SecurityFacetBucket("disposition", group.Key, group.Count()))
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
return severityFacets
.Concat(regionFacets)
.Concat(environmentFacets)
.Concat(dispositionFacets)
.ToArray();
}
private static IReadOnlyList<SecuritySbomDiffRow> BuildDiffRows(
IReadOnlyList<SecuritySbomComponentRow> components,
string? leftReleaseId,
string? rightReleaseId)
{
if (components.Count == 0)
{
return Array.Empty<SecuritySbomDiffRow>();
}
var releaseIds = components
.Select(item => item.ReleaseId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
var resolvedLeft = ResolveReleaseId(leftReleaseId, releaseIds, pickLast: false);
var resolvedRight = ResolveReleaseId(rightReleaseId, releaseIds, pickLast: true);
if (string.IsNullOrWhiteSpace(resolvedLeft)
|| string.IsNullOrWhiteSpace(resolvedRight)
|| string.Equals(resolvedLeft, resolvedRight, StringComparison.Ordinal))
{
return Array.Empty<SecuritySbomDiffRow>();
}
var leftRows = components
.Where(item => string.Equals(item.ReleaseId, resolvedLeft, StringComparison.Ordinal))
.ToDictionary(
item => $"{item.PackageName}|{item.ComponentName}",
item => item,
StringComparer.Ordinal);
var rightRows = components
.Where(item => string.Equals(item.ReleaseId, resolvedRight, StringComparison.Ordinal))
.ToDictionary(
item => $"{item.PackageName}|{item.ComponentName}",
item => item,
StringComparer.Ordinal);
var keys = leftRows.Keys
.Concat(rightRows.Keys)
.Distinct(StringComparer.Ordinal)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
var diff = new List<SecuritySbomDiffRow>(keys.Length);
foreach (var key in keys)
{
leftRows.TryGetValue(key, out var left);
rightRows.TryGetValue(key, out var right);
var changeType = left is null
? "added"
: right is null
? "removed"
: string.Equals(left.ComponentVersion, right.ComponentVersion, StringComparison.Ordinal)
? "unchanged"
: "changed";
if (string.Equals(changeType, "unchanged", StringComparison.Ordinal))
{
continue;
}
var region = right?.Region ?? left?.Region ?? "unknown";
var environment = right?.Environment ?? left?.Environment ?? "unknown";
var packageName = right?.PackageName ?? left?.PackageName ?? "unknown";
var componentName = right?.ComponentName ?? left?.ComponentName ?? "unknown";
diff.Add(new SecuritySbomDiffRow(
ComponentName: componentName,
PackageName: packageName,
ChangeType: changeType,
FromVersion: left?.ComponentVersion,
ToVersion: right?.ComponentVersion,
Region: region,
Environment: environment));
}
return diff
.OrderBy(item => ChangeTypeRank(item.ChangeType))
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
.ThenBy(item => item.Region, StringComparer.Ordinal)
.ThenBy(item => item.Environment, StringComparer.Ordinal)
.ToArray();
}
private static int NormalizeLimit(int? value)
{
return value switch
{
null => DefaultLimit,
< 1 => 1,
> MaxLimit => MaxLimit,
_ => value.Value
};
}
private static int NormalizeOffset(int? value)
{
return value is null or < 0 ? 0 : value.Value;
}
private static string ResolveReleaseId(string? candidate, IReadOnlyList<string> available, bool pickLast)
{
var normalized = NormalizeOptional(candidate);
if (!string.IsNullOrWhiteSpace(normalized) && available.Contains(normalized, StringComparer.Ordinal))
{
return normalized;
}
if (available.Count == 0)
{
return string.Empty;
}
return pickLast
? available[^1]
: available[0];
}
private static HashSet<string> ParseFilterSet(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static item => NormalizeOptional(item))
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!)
.ToHashSet(StringComparer.Ordinal);
}
private static string ResolveExceptionStatus(bool hasExceptionReason, string runStatus)
{
if (!hasExceptionReason)
{
return "none";
}
var normalizedStatus = NormalizeOptional(runStatus);
return normalizedStatus switch
{
"succeeded" => "approved",
"completed" => "approved",
"failed" => "rejected",
"cancelled" => "rejected",
_ => "pending"
};
}
private static string ResolveVexStatus(string severity, bool reachable, bool hasExceptionReason)
{
if (!reachable)
{
return "not_affected";
}
if (hasExceptionReason)
{
return "under_investigation";
}
return severity switch
{
"critical" => "affected",
"high" => "affected",
"medium" => "under_investigation",
_ => "not_affected"
};
}
private static string ResolveVexJustification(string vexStatus)
{
return vexStatus switch
{
"not_affected" => "component_not_present",
"affected" => "requires_mitigation",
"under_investigation" => "pending_analysis",
_ => "unknown"
};
}
private static string ResolveExceptionApprovalState(string exceptionStatus)
{
return exceptionStatus switch
{
"approved" => "approved",
"rejected" => "rejected",
"pending" => "awaiting_review",
_ => "not_requested"
};
}
private static string ResolveEffectiveDisposition(string vexStatus, string exceptionStatus)
{
if (string.Equals(exceptionStatus, "approved", StringComparison.Ordinal))
{
return "accepted_risk";
}
if (string.Equals(vexStatus, "not_affected", StringComparison.Ordinal))
{
return "mitigated";
}
if (string.Equals(vexStatus, "affected", StringComparison.Ordinal))
{
return "action_required";
}
return "review_required";
}
private static string ResolvePolicyAction(string effectiveDisposition)
{
return effectiveDisposition switch
{
"accepted_risk" => "allow_with_exception",
"mitigated" => "allow",
"action_required" => "block",
_ => "review"
};
}
private static string BuildPackageName(string componentName, string digest)
{
var normalized = componentName
.Trim()
.ToLowerInvariant()
.Replace(" ", "-", StringComparison.Ordinal)
.Replace("_", "-", StringComparison.Ordinal);
return $"{normalized}-pkg-{digest[..4]}";
}
private static string ExtractComponentVersion(string componentVersionId)
{
var normalized = NormalizeOptional(componentVersionId) ?? "component@0.0.0";
var markerIndex = normalized.LastIndexOf('@');
if (markerIndex < 0 || markerIndex == normalized.Length - 1)
{
return "0.0.0";
}
return normalized[(markerIndex + 1)..];
}
private static string BuildCveId(string digest)
{
var raw = int.Parse(digest[8..16], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
var suffix = (raw % 90000) + 10000;
return $"CVE-2025-{suffix:D5}";
}
private static string BuildSeverity(string digest)
{
return (ParseHexByte(digest, 0) % 5) switch
{
0 => "critical",
1 => "high",
2 => "medium",
3 => "low",
_ => "info"
};
}
private static byte ParseHexByte(string digest, int index)
{
var offset = index * 2;
if (offset + 2 > digest.Length)
{
return 0;
}
return byte.Parse(digest.AsSpan(offset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
private static string HashSeed(string seed)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
var builder = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes)
{
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static bool ContainsAny(string? value, IReadOnlyList<string> keywords)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim().ToLowerInvariant();
return keywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal));
}
private static int SeverityRank(string severity)
{
return severity switch
{
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 4
};
}
private static int ChangeTypeRank(string changeType)
{
return changeType switch
{
"added" => 0,
"removed" => 1,
"changed" => 2,
_ => 3
};
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
private sealed record SecuritySnapshot(
IReadOnlyList<SecurityFindingProjection> Findings,
IReadOnlyList<SecurityDispositionProjection> Disposition,
IReadOnlyDictionary<string, SecurityDispositionProjection> DispositionByFindingId,
IReadOnlyList<SecuritySbomComponentRow> SbomRows,
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges);
}
public sealed record SecurityPageResult<TItem>(
IReadOnlyList<TItem> Items,
int Total,
int Limit,
int Offset);
public sealed record SecurityFindingsPageResult(
IReadOnlyList<SecurityFindingProjection> Items,
int Total,
int Limit,
int Offset,
string Pivot,
IReadOnlyList<SecurityPivotBucket> PivotBuckets,
IReadOnlyList<SecurityFacetBucket> Facets);
public sealed record SecuritySbomExplorerResult(
string Mode,
IReadOnlyList<SecuritySbomComponentRow> Table,
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges,
IReadOnlyList<SecuritySbomDiffRow> Diff,
int TotalComponents,
int Limit,
int Offset);

View File

@@ -0,0 +1,842 @@
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
public sealed class TopologyReadModelService
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private const int BundleScanLimit = 500;
private const int RunScanLimit = 1000;
private static readonly DateTimeOffset ProjectionEpoch = DateTimeOffset.UnixEpoch;
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
public TopologyReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
}
public async Task<TopologyPageResult<TopologyRegionProjection>> ListRegionsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var allowedRegionsByEnvironment = environmentFilter.Count == 0
? null
: snapshot.Environments
.Where(item => environmentFilter.Contains(item.EnvironmentId))
.Select(item => item.RegionId)
.ToHashSet(StringComparer.Ordinal);
var filtered = snapshot.Regions
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.RegionId))
&& (allowedRegionsByEnvironment is null || allowedRegionsByEnvironment.Contains(item.RegionId)))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyEnvironmentProjection>> ListEnvironmentsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.Environments
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyTargetProjection>> ListTargetsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.Targets
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.Name, StringComparer.Ordinal)
.ThenBy(item => item.TargetId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyHostProjection>> ListHostsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.Hosts
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.HostName, StringComparer.Ordinal)
.ThenBy(item => item.HostId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyAgentProjection>> ListAgentsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.Agents
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.AgentName, StringComparer.Ordinal)
.ThenBy(item => item.AgentId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyPromotionPathProjection>> ListPromotionPathsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.PromotionPaths
.Where(item =>
(regionFilter.Count == 0 || regionFilter.Contains(item.RegionId))
&& (environmentFilter.Count == 0
|| environmentFilter.Contains(item.SourceEnvironmentId)
|| environmentFilter.Contains(item.TargetEnvironmentId)))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.SourceEnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.TargetEnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.PathId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyWorkflowProjection>> ListWorkflowsAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.Workflows
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.WorkflowName, StringComparer.Ordinal)
.ThenBy(item => item.WorkflowId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
public async Task<TopologyPageResult<TopologyGateProfileProjection>> ListGateProfilesAsync(
PlatformRequestContext context,
string? region,
string? environment,
int? limit,
int? offset,
CancellationToken cancellationToken = default)
{
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
var regionFilter = ParseFilterSet(region);
var environmentFilter = ParseFilterSet(environment);
var filtered = snapshot.GateProfiles
.Where(item => MatchesFilters(
item.RegionId,
item.EnvironmentId,
regionFilter,
environmentFilter))
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
.ThenBy(item => item.ProfileName, StringComparer.Ordinal)
.ThenBy(item => item.GateProfileId, StringComparer.Ordinal)
.ToArray();
return Page(filtered, limit, offset);
}
private async Task<TopologySnapshot> BuildSnapshotAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var regions = await contextService.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var bundles = await bundleStore.ListBundlesAsync(
context.TenantId,
BundleScanLimit,
0,
cancellationToken).ConfigureAwait(false);
var runs = await bundleStore.ListMaterializationRunsAsync(
context.TenantId,
RunScanLimit,
0,
cancellationToken).ConfigureAwait(false);
var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal);
var runsByEnvironment = runs
.Select(run => new
{
EnvironmentId = NormalizeOptional(run.TargetEnvironment),
Run = run
})
.Where(item => !string.IsNullOrWhiteSpace(item.EnvironmentId))
.GroupBy(item => item.EnvironmentId!, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<ReleaseControlBundleMaterializationRun>)group
.Select(item => item.Run)
.OrderByDescending(run => run.RequestedAt)
.ThenByDescending(run => run.RunId)
.ToArray(),
StringComparer.Ordinal);
var latestRunByBundle = runs
.GroupBy(run => run.BundleId)
.ToDictionary(
group => group.Key,
group => group
.OrderByDescending(run => run.RequestedAt)
.ThenByDescending(run => run.RunId)
.FirstOrDefault());
var targets = new List<TopologyTargetProjection>();
foreach (var bundle in bundles
.OrderBy(item => item.Name, StringComparer.Ordinal)
.ThenBy(item => item.Id))
{
cancellationToken.ThrowIfCancellationRequested();
if (bundle.LatestVersionId is null)
{
continue;
}
if (!latestRunByBundle.TryGetValue(bundle.Id, out var latestRun) || latestRun is null)
{
continue;
}
var environmentId = NormalizeOptional(latestRun.TargetEnvironment);
if (string.IsNullOrWhiteSpace(environmentId) || !environmentById.TryGetValue(environmentId, out var environment))
{
continue;
}
var version = await bundleStore.GetVersionAsync(
context.TenantId,
bundle.Id,
bundle.LatestVersionId.Value,
cancellationToken).ConfigureAwait(false);
if (version is null || version.Components.Count == 0)
{
continue;
}
var regionId = environment.RegionId;
var agentId = BuildAgentId(regionId, environment.EnvironmentId);
var healthStatus = MapRunStatusToHealth(latestRun.Status);
var orderedComponents = version.Components
.OrderBy(component => component.DeployOrder)
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
.ToArray();
for (var i = 0; i < orderedComponents.Length; i++)
{
var component = orderedComponents[i];
var normalizedComponentName = NormalizeToken(component.ComponentName);
var hostId = BuildHostId(regionId, environment.EnvironmentId, normalizedComponentName);
var targetType = InferTargetType(component.ComponentName, component.MetadataJson);
targets.Add(new TopologyTargetProjection(
TargetId: $"target-{bundle.Id:N}-{version.Id:N}-{i + 1:D2}",
Name: $"{component.ComponentName}-{environment.EnvironmentId}",
RegionId: regionId,
EnvironmentId: environment.EnvironmentId,
HostId: hostId,
AgentId: agentId,
TargetType: targetType,
HealthStatus: healthStatus,
ComponentVersionId: component.ComponentVersionId,
ImageDigest: component.ImageDigest,
ReleaseId: bundle.Id.ToString("D"),
ReleaseVersionId: version.Id.ToString("D"),
LastSyncAt: latestRun.RequestedAt));
}
}
var hosts = targets
.GroupBy(target => target.HostId, StringComparer.Ordinal)
.Select(group =>
{
var first = group
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
.First();
var hostStatus = ResolveHostStatus(group.Select(target => target.HealthStatus));
var lastSeen = MaxTimestamp(group.Select(target => target.LastSyncAt));
return new TopologyHostProjection(
HostId: group.Key,
HostName: $"{group.Key}.stella.local",
RegionId: first.RegionId,
EnvironmentId: first.EnvironmentId,
RuntimeType: first.TargetType,
Status: hostStatus,
AgentId: first.AgentId,
TargetCount: group.Count(),
LastSeenAt: lastSeen);
})
.OrderBy(host => host.RegionId, StringComparer.Ordinal)
.ThenBy(host => host.EnvironmentId, StringComparer.Ordinal)
.ThenBy(host => host.HostName, StringComparer.Ordinal)
.ThenBy(host => host.HostId, StringComparer.Ordinal)
.ToArray();
var agents = targets
.GroupBy(target => target.AgentId, StringComparer.Ordinal)
.Select(group =>
{
var ordered = group
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
.ToArray();
var first = ordered[0];
var capabilities = ordered
.Select(target => target.TargetType)
.Distinct(StringComparer.Ordinal)
.OrderBy(capability => capability, StringComparer.Ordinal)
.ToArray();
return new TopologyAgentProjection(
AgentId: first.AgentId,
AgentName: BuildAgentName(first.RegionId, first.EnvironmentId),
RegionId: first.RegionId,
EnvironmentId: first.EnvironmentId,
Status: ResolveAgentStatus(ordered.Select(target => target.HealthStatus)),
Capabilities: capabilities,
AssignedTargetCount: ordered.Length,
LastHeartbeatAt: MaxTimestamp(ordered.Select(target => target.LastSyncAt)));
})
.OrderBy(agent => agent.RegionId, StringComparer.Ordinal)
.ThenBy(agent => agent.EnvironmentId, StringComparer.Ordinal)
.ThenBy(agent => agent.AgentName, StringComparer.Ordinal)
.ThenBy(agent => agent.AgentId, StringComparer.Ordinal)
.ToArray();
var gateProfiles = environments
.OrderBy(environment => environment.SortOrder)
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
.Select(environment =>
{
var isProduction = IsProductionEnvironment(environment.EnvironmentType, environment.EnvironmentId);
var runUpdatedAt = runsByEnvironment.TryGetValue(environment.EnvironmentId, out var environmentRuns)
? environmentRuns.FirstOrDefault()?.UpdatedAt
: null;
var requiredApprovals = isProduction ? 2 : 1;
return new TopologyGateProfileProjection(
GateProfileId: BuildGateProfileId(environment.EnvironmentId),
ProfileName: $"{environment.DisplayName} Gate Profile",
RegionId: environment.RegionId,
EnvironmentId: environment.EnvironmentId,
PolicyProfile: isProduction ? "strict_prod" : "standard",
RequiredApprovals: requiredApprovals,
SeparationOfDuties: requiredApprovals > 1,
BlockingRules: isProduction
? ["approval_required", "critical_reachable_block", "evidence_freshness_required"]
: ["approval_required", "critical_reachable_block"],
UpdatedAt: runUpdatedAt ?? ProjectionEpoch.AddMinutes(environment.SortOrder));
})
.ToArray();
var workflows = environments
.OrderBy(environment => environment.SortOrder)
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
.Select(environment =>
{
var run = runsByEnvironment.TryGetValue(environment.EnvironmentId, out var environmentRuns)
? environmentRuns.FirstOrDefault()
: null;
return new TopologyWorkflowProjection(
WorkflowId: BuildWorkflowId(environment.EnvironmentId),
WorkflowName: $"{environment.DisplayName} Release Workflow",
RegionId: environment.RegionId,
EnvironmentId: environment.EnvironmentId,
TriggerType: "promotion",
Status: MapRunStatusToWorkflowStatus(run?.Status),
StepCount: IsProductionEnvironment(environment.EnvironmentType, environment.EnvironmentId) ? 6 : 4,
GateProfileId: BuildGateProfileId(environment.EnvironmentId),
UpdatedAt: run?.UpdatedAt ?? ProjectionEpoch.AddMinutes(environment.SortOrder));
})
.ToArray();
var promotionPaths = new List<TopologyPromotionPathProjection>();
foreach (var region in regions
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.RegionId, StringComparer.Ordinal))
{
var regionEnvironments = environments
.Where(environment => string.Equals(environment.RegionId, region.RegionId, StringComparison.Ordinal))
.OrderBy(environment => environment.SortOrder)
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
.ToArray();
for (var i = 0; i < regionEnvironments.Length - 1; i++)
{
var source = regionEnvironments[i];
var target = regionEnvironments[i + 1];
var targetRun = runsByEnvironment.TryGetValue(target.EnvironmentId, out var environmentRuns)
? environmentRuns.FirstOrDefault()
: null;
var requiredApprovals = IsProductionEnvironment(target.EnvironmentType, target.EnvironmentId) ? 2 : 1;
promotionPaths.Add(new TopologyPromotionPathProjection(
PathId: BuildPromotionPathId(region.RegionId, source.EnvironmentId, target.EnvironmentId),
RegionId: region.RegionId,
SourceEnvironmentId: source.EnvironmentId,
TargetEnvironmentId: target.EnvironmentId,
PathMode: requiredApprovals > 1 ? "manual" : "auto_on_success",
Status: MapRunStatusToPathStatus(targetRun?.Status),
RequiredApprovals: requiredApprovals,
WorkflowId: BuildWorkflowId(target.EnvironmentId),
GateProfileId: BuildGateProfileId(target.EnvironmentId),
LastPromotedAt: targetRun?.RequestedAt));
}
}
var environmentProjections = environments
.OrderBy(environment => environment.SortOrder)
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
.Select(environment =>
{
var targetSet = targets.Where(target =>
string.Equals(target.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
var hostSet = hosts.Where(host =>
string.Equals(host.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
var agentSet = agents.Where(agent =>
string.Equals(agent.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
var pathSet = promotionPaths.Where(path =>
string.Equals(path.SourceEnvironmentId, environment.EnvironmentId, StringComparison.Ordinal)
|| string.Equals(path.TargetEnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
var workflowSet = workflows.Where(workflow =>
string.Equals(workflow.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
return new TopologyEnvironmentProjection(
EnvironmentId: environment.EnvironmentId,
RegionId: environment.RegionId,
EnvironmentType: environment.EnvironmentType,
DisplayName: environment.DisplayName,
SortOrder: environment.SortOrder,
TargetCount: targetSet.Count(),
HostCount: hostSet.Count(),
AgentCount: agentSet.Count(),
PromotionPathCount: pathSet.Count(),
WorkflowCount: workflowSet.Count(),
LastSyncAt: MaxTimestamp(targetSet.Select(item => item.LastSyncAt)));
})
.ToArray();
var regionProjections = regions
.OrderBy(region => region.SortOrder)
.ThenBy(region => region.RegionId, StringComparer.Ordinal)
.Select(region =>
{
var regionEnvironments = environmentProjections
.Where(environment => string.Equals(environment.RegionId, region.RegionId, StringComparison.Ordinal))
.ToArray();
return new TopologyRegionProjection(
RegionId: region.RegionId,
DisplayName: region.DisplayName,
SortOrder: region.SortOrder,
EnvironmentCount: regionEnvironments.Length,
TargetCount: regionEnvironments.Sum(environment => environment.TargetCount),
HostCount: regionEnvironments.Sum(environment => environment.HostCount),
AgentCount: regionEnvironments.Sum(environment => environment.AgentCount),
LastSyncAt: MaxTimestamp(regionEnvironments.Select(environment => environment.LastSyncAt)));
})
.ToArray();
return new TopologySnapshot(
Regions: regionProjections,
Environments: environmentProjections,
Targets: targets
.OrderBy(target => target.RegionId, StringComparer.Ordinal)
.ThenBy(target => target.EnvironmentId, StringComparer.Ordinal)
.ThenBy(target => target.Name, StringComparer.Ordinal)
.ThenBy(target => target.TargetId, StringComparer.Ordinal)
.ToArray(),
Hosts: hosts,
Agents: agents,
PromotionPaths: promotionPaths
.OrderBy(path => path.RegionId, StringComparer.Ordinal)
.ThenBy(path => path.SourceEnvironmentId, StringComparer.Ordinal)
.ThenBy(path => path.TargetEnvironmentId, StringComparer.Ordinal)
.ThenBy(path => path.PathId, StringComparer.Ordinal)
.ToArray(),
Workflows: workflows
.OrderBy(workflow => workflow.RegionId, StringComparer.Ordinal)
.ThenBy(workflow => workflow.EnvironmentId, StringComparer.Ordinal)
.ThenBy(workflow => workflow.WorkflowName, StringComparer.Ordinal)
.ThenBy(workflow => workflow.WorkflowId, StringComparer.Ordinal)
.ToArray(),
GateProfiles: gateProfiles
.OrderBy(profile => profile.RegionId, StringComparer.Ordinal)
.ThenBy(profile => profile.EnvironmentId, StringComparer.Ordinal)
.ThenBy(profile => profile.ProfileName, StringComparer.Ordinal)
.ThenBy(profile => profile.GateProfileId, StringComparer.Ordinal)
.ToArray());
}
private static TopologyPageResult<TItem> Page<TItem>(
IReadOnlyList<TItem> items,
int? limit,
int? offset)
{
var normalizedLimit = NormalizeLimit(limit);
var normalizedOffset = NormalizeOffset(offset);
var paged = items
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArray();
return new TopologyPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
}
private static int NormalizeLimit(int? value)
{
return value switch
{
null => DefaultLimit,
< 1 => 1,
> MaxLimit => MaxLimit,
_ => value.Value
};
}
private static int NormalizeOffset(int? value)
{
return value is null or < 0 ? 0 : value.Value;
}
private static HashSet<string> ParseFilterSet(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static item => NormalizeOptional(item))
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Select(static item => item!)
.ToHashSet(StringComparer.Ordinal);
}
private static bool MatchesFilters(
string regionId,
string environmentId,
HashSet<string> regionFilter,
HashSet<string> environmentFilter)
{
var matchesRegion = regionFilter.Count == 0 || regionFilter.Contains(regionId);
var matchesEnvironment = environmentFilter.Count == 0 || environmentFilter.Contains(environmentId);
return matchesRegion && matchesEnvironment;
}
private static string MapRunStatusToHealth(string? status)
{
return status?.Trim().ToLowerInvariant() switch
{
"succeeded" => "healthy",
"completed" => "healthy",
"running" => "degraded",
"queued" => "degraded",
"failed" => "unhealthy",
"cancelled" => "offline",
_ => "unknown"
};
}
private static string ResolveHostStatus(IEnumerable<string> targetHealth)
{
var ordered = targetHealth
.Select(status => status.Trim().ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (ordered.Contains("unhealthy", StringComparer.Ordinal))
{
return "degraded";
}
if (ordered.Contains("offline", StringComparer.Ordinal))
{
return "offline";
}
if (ordered.Contains("degraded", StringComparer.Ordinal))
{
return "degraded";
}
if (ordered.Contains("healthy", StringComparer.Ordinal))
{
return "healthy";
}
return "unknown";
}
private static string ResolveAgentStatus(IEnumerable<string> targetHealth)
{
var status = ResolveHostStatus(targetHealth);
return status switch
{
"healthy" => "active",
"degraded" => "degraded",
"offline" => "offline",
_ => "pending"
};
}
private static string MapRunStatusToPathStatus(string? status)
{
return status?.Trim().ToLowerInvariant() switch
{
"running" => "running",
"queued" => "pending",
"succeeded" => "succeeded",
"completed" => "succeeded",
"failed" => "failed",
"cancelled" => "failed",
_ => "idle"
};
}
private static string MapRunStatusToWorkflowStatus(string? status)
{
return status?.Trim().ToLowerInvariant() switch
{
"running" => "running",
"queued" => "running",
"failed" => "failed",
"cancelled" => "failed",
_ => "active"
};
}
private static string InferTargetType(string componentName, string metadataJson)
{
var normalizedName = componentName.Trim().ToLowerInvariant();
var normalizedMetadata = metadataJson.Trim().ToLowerInvariant();
var combined = $"{normalizedName} {normalizedMetadata}";
if (combined.Contains("compose", StringComparison.Ordinal))
{
return "compose_host";
}
if (combined.Contains("ecs", StringComparison.Ordinal))
{
return "ecs_service";
}
if (combined.Contains("nomad", StringComparison.Ordinal))
{
return "nomad_job";
}
if (combined.Contains("ssh", StringComparison.Ordinal))
{
return "ssh_host";
}
return "docker_host";
}
private static bool IsProductionEnvironment(string environmentType, string environmentId)
{
return string.Equals(environmentType, "production", StringComparison.OrdinalIgnoreCase)
|| environmentId.Contains("prod", StringComparison.OrdinalIgnoreCase);
}
private static string BuildAgentId(string regionId, string environmentId)
{
return $"agent-{regionId}-{environmentId}";
}
private static string BuildAgentName(string regionId, string environmentId)
{
return $"agent-{regionId}-{environmentId}";
}
private static string BuildHostId(string regionId, string environmentId, string componentToken)
{
return $"host-{regionId}-{environmentId}-{componentToken}";
}
private static string BuildWorkflowId(string environmentId)
{
return $"workflow-{environmentId}";
}
private static string BuildGateProfileId(string environmentId)
{
return $"gate-{environmentId}";
}
private static string BuildPromotionPathId(string regionId, string sourceEnvironmentId, string targetEnvironmentId)
{
return $"path-{regionId}-{sourceEnvironmentId}-to-{targetEnvironmentId}";
}
private static string NormalizeToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var normalizedChars = value
.Trim()
.ToLowerInvariant()
.Select(static ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray();
var normalized = new string(normalizedChars);
while (normalized.Contains("--", StringComparison.Ordinal))
{
normalized = normalized.Replace("--", "-", StringComparison.Ordinal);
}
normalized = normalized.Trim('-');
return string.IsNullOrWhiteSpace(normalized) ? "component" : normalized;
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Trim().ToLowerInvariant();
}
private static DateTimeOffset? MaxTimestamp(IEnumerable<DateTimeOffset?> values)
{
var maxValue = values
.Where(value => value.HasValue)
.Select(value => value!.Value)
.DefaultIfEmpty()
.Max();
return maxValue == default ? null : maxValue;
}
private sealed record TopologySnapshot(
IReadOnlyList<TopologyRegionProjection> Regions,
IReadOnlyList<TopologyEnvironmentProjection> Environments,
IReadOnlyList<TopologyTargetProjection> Targets,
IReadOnlyList<TopologyHostProjection> Hosts,
IReadOnlyList<TopologyAgentProjection> Agents,
IReadOnlyList<TopologyPromotionPathProjection> PromotionPaths,
IReadOnlyList<TopologyWorkflowProjection> Workflows,
IReadOnlyList<TopologyGateProfileProjection> GateProfiles);
}
public sealed record TopologyPageResult<TItem>(
IReadOnlyList<TItem> Items,
int Total,
int Limit,
int Offset);

View File

@@ -7,6 +7,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| PACK-ADM-01 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented Pack-21 Administration A1-A7 adapter endpoints under `/api/v1/administration/*` with deterministic migration alias metadata. |
| PACK-ADM-02 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented trust owner mutation/read endpoints under `/api/v1/administration/trust-signing/*` with `trust:write`/`trust:admin` policy mapping and DB backing via migration `046_TrustSigningAdministration.sql`. |
| B22-01 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/context/*` endpoints, context scope/policy wiring, deterministic preference persistence baseline, and migration `047_GlobalContextAndFilters.sql`. |
| B22-02 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/releases`, `/api/v2/releases/{releaseId}`, `/api/v2/releases/activity`, and `/api/v2/releases/approvals` read-model endpoints with deterministic projection ordering and migration `048_ReleaseReadModels.sql`. |
| B22-03 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/topology/{regions,environments,targets,hosts,agents,promotion-paths,workflows,gate-profiles}` read-model endpoints, `platform.topology.read` policy mapping, and migration `049_TopologyInventory.sql`. |
| B22-04 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/security/{findings,disposition/{findingId},sbom-explorer}` read contracts, `platform.security.read` policy mapping, and migration `050_SecurityDispositionProjection.sql` integration. |
| B22-05 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/integrations/{feeds,vex-sources}` contracts with deterministic source type/status/freshness/last-sync metadata and migration `051_IntegrationSourceHealth.sql`. |
| B22-06 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v1/*` compatibility aliases for Pack 22 critical surfaces and deterministic deprecation telemetry for alias usage. |
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |

View File

@@ -0,0 +1,81 @@
-- SPRINT_20260220_018 / B22-01
-- Pack 22 global context baseline: regions, environments, and per-user selectors.
CREATE SCHEMA IF NOT EXISTS platform;
CREATE TABLE IF NOT EXISTS platform.context_regions (
region_id text PRIMARY KEY,
display_name text NOT NULL,
sort_order integer NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_context_regions_sort
ON platform.context_regions (sort_order, region_id);
CREATE TABLE IF NOT EXISTS platform.context_environments (
environment_id text PRIMARY KEY,
region_id text NOT NULL REFERENCES platform.context_regions(region_id) ON DELETE RESTRICT,
environment_type text NOT NULL,
display_name text NOT NULL,
sort_order integer NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS ix_platform_context_environments_region_sort
ON platform.context_environments (region_id, sort_order, environment_id);
CREATE INDEX IF NOT EXISTS ix_platform_context_environments_sort
ON platform.context_environments (sort_order, region_id, environment_id);
CREATE TABLE IF NOT EXISTS platform.ui_context_preferences (
tenant_id text NOT NULL,
actor_id text NOT NULL,
regions text[] NOT NULL DEFAULT ARRAY[]::text[],
environments text[] NOT NULL DEFAULT ARRAY[]::text[],
time_window text NOT NULL DEFAULT '24h',
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by text NOT NULL DEFAULT 'system',
PRIMARY KEY (tenant_id, actor_id)
);
CREATE INDEX IF NOT EXISTS ix_platform_ui_context_preferences_updated
ON platform.ui_context_preferences (updated_at DESC, tenant_id, actor_id);
ALTER TABLE platform.ui_context_preferences
DROP CONSTRAINT IF EXISTS ck_platform_ui_context_preferences_time_window;
ALTER TABLE platform.ui_context_preferences
ADD CONSTRAINT ck_platform_ui_context_preferences_time_window
CHECK (time_window IN ('1h', '24h', '7d', '30d', '90d'));
INSERT INTO platform.context_regions (region_id, display_name, sort_order, enabled)
VALUES
('us-east', 'US East', 10, true),
('eu-west', 'EU West', 20, true),
('apac', 'APAC', 30, true)
ON CONFLICT (region_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
sort_order = EXCLUDED.sort_order,
enabled = EXCLUDED.enabled;
INSERT INTO platform.context_environments (
environment_id,
region_id,
environment_type,
display_name,
sort_order,
enabled
)
VALUES
('us-prod', 'us-east', 'production', 'US Production', 10, true),
('us-uat', 'us-east', 'staging', 'US UAT', 11, true),
('eu-prod', 'eu-west', 'production', 'EU Production', 20, true),
('eu-stage', 'eu-west', 'staging', 'EU Staging', 21, true),
('apac-prod', 'apac', 'production', 'APAC Production', 30, true)
ON CONFLICT (environment_id) DO UPDATE SET
region_id = EXCLUDED.region_id,
environment_type = EXCLUDED.environment_type,
display_name = EXCLUDED.display_name,
sort_order = EXCLUDED.sort_order,
enabled = EXCLUDED.enabled;

View File

@@ -0,0 +1,86 @@
-- SPRINT_20260220_018 / B22-02
-- Pack 22 releases read-model projections (list/detail/activity/approvals queue).
CREATE TABLE IF NOT EXISTS release.release_read_model (
release_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
bundle_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
latest_version_id uuid NULL REFERENCES release.control_bundle_versions(id) ON DELETE SET NULL,
release_name text NOT NULL,
release_slug text NOT NULL,
release_type text NOT NULL DEFAULT 'standard',
release_status text NOT NULL DEFAULT 'draft',
gate_status text NOT NULL DEFAULT 'pending',
pending_approvals integer NOT NULL DEFAULT 0,
blocking_reasons text[] NOT NULL DEFAULT ARRAY[]::text[],
risk_delta jsonb NOT NULL DEFAULT '{}'::jsonb,
target_environment text NULL,
target_region text NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_release_read_model_tenant_bundle
ON release.release_read_model (tenant_id, bundle_id);
CREATE INDEX IF NOT EXISTS ix_release_release_read_model_tenant_status_region
ON release.release_read_model (tenant_id, release_status, target_region, updated_at DESC, release_id);
CREATE TABLE IF NOT EXISTS release.release_activity_projection (
activity_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
release_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
run_id uuid NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE SET NULL,
approval_id text NULL,
event_type text NOT NULL,
event_status text NOT NULL,
target_environment text NULL,
target_region text NULL,
actor_id text NOT NULL,
occurred_at timestamptz NOT NULL,
correlation_key text NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_release_activity_projection_tenant_occurred
ON release.release_activity_projection (tenant_id, occurred_at DESC, activity_id DESC);
CREATE INDEX IF NOT EXISTS ix_release_activity_projection_release
ON release.release_activity_projection (release_id, occurred_at DESC, activity_id DESC);
CREATE TABLE IF NOT EXISTS release.release_approvals_projection (
approval_id text PRIMARY KEY,
tenant_id uuid NOT NULL,
release_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
run_id uuid NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE SET NULL,
approval_status text NOT NULL,
required_approvals integer NOT NULL DEFAULT 1,
current_approvals integer NOT NULL DEFAULT 0,
blockers text[] NOT NULL DEFAULT ARRAY[]::text[],
requested_by text NOT NULL,
requested_at timestamptz NOT NULL,
source_environment text NULL,
target_environment text NULL,
target_region text NULL,
correlation_key text NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_release_approvals_projection_tenant_status
ON release.release_approvals_projection (tenant_id, approval_status, requested_at DESC, approval_id);
CREATE INDEX IF NOT EXISTS ix_release_approvals_projection_release
ON release.release_approvals_projection (release_id, requested_at DESC, approval_id);
ALTER TABLE release.release_read_model
DROP CONSTRAINT IF EXISTS ck_release_release_read_model_type;
ALTER TABLE release.release_read_model
ADD CONSTRAINT ck_release_release_read_model_type
CHECK (release_type IN ('standard', 'hotfix'));
ALTER TABLE release.release_approvals_projection
DROP CONSTRAINT IF EXISTS ck_release_approvals_projection_status;
ALTER TABLE release.release_approvals_projection
ADD CONSTRAINT ck_release_approvals_projection_status
CHECK (approval_status IN ('pending', 'approved', 'rejected'));

View File

@@ -0,0 +1,267 @@
-- SPRINT_20260220_018 / B22-03
-- Pack 22 topology inventory read-model projections (regions, environments, targets, hosts, agents, paths, workflows, gate profiles).
CREATE TABLE IF NOT EXISTS release.topology_region_inventory (
tenant_id uuid NOT NULL,
region_id text NOT NULL,
display_name text NOT NULL,
sort_order integer NOT NULL,
environment_count integer NOT NULL DEFAULT 0,
target_count integer NOT NULL DEFAULT 0,
host_count integer NOT NULL DEFAULT 0,
agent_count integer NOT NULL DEFAULT 0,
last_sync_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, region_id)
);
CREATE INDEX IF NOT EXISTS ix_topology_region_inventory_sort
ON release.topology_region_inventory (tenant_id, sort_order, region_id);
CREATE TABLE IF NOT EXISTS release.topology_environment_inventory (
tenant_id uuid NOT NULL,
environment_id text NOT NULL,
region_id text NOT NULL,
environment_type text NOT NULL,
display_name text NOT NULL,
sort_order integer NOT NULL,
target_count integer NOT NULL DEFAULT 0,
host_count integer NOT NULL DEFAULT 0,
agent_count integer NOT NULL DEFAULT 0,
promotion_path_count integer NOT NULL DEFAULT 0,
workflow_count integer NOT NULL DEFAULT 0,
last_sync_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, environment_id),
CONSTRAINT fk_topology_environment_inventory_region
FOREIGN KEY (tenant_id, region_id)
REFERENCES release.topology_region_inventory (tenant_id, region_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_environment_inventory_region_sort
ON release.topology_environment_inventory (tenant_id, region_id, sort_order, environment_id);
CREATE TABLE IF NOT EXISTS release.topology_target_inventory (
tenant_id uuid NOT NULL,
target_id text NOT NULL,
target_name text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
host_id text NOT NULL,
agent_id text NOT NULL,
target_type text NOT NULL,
health_status text NOT NULL,
component_version_id text NOT NULL,
image_digest text NOT NULL,
release_id uuid NULL,
release_version_id uuid NULL,
last_sync_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, target_id),
CONSTRAINT fk_topology_target_inventory_environment
FOREIGN KEY (tenant_id, environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_target_inventory_env_order
ON release.topology_target_inventory (tenant_id, region_id, environment_id, target_name, target_id);
CREATE TABLE IF NOT EXISTS release.topology_host_inventory (
tenant_id uuid NOT NULL,
host_id text NOT NULL,
host_name text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
runtime_type text NOT NULL,
host_status text NOT NULL,
agent_id text NOT NULL,
target_count integer NOT NULL DEFAULT 0,
last_seen_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, host_id),
CONSTRAINT fk_topology_host_inventory_environment
FOREIGN KEY (tenant_id, environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_host_inventory_env_order
ON release.topology_host_inventory (tenant_id, region_id, environment_id, host_name, host_id);
CREATE TABLE IF NOT EXISTS release.topology_agent_inventory (
tenant_id uuid NOT NULL,
agent_id text NOT NULL,
agent_name text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
agent_status text NOT NULL,
capabilities text[] NOT NULL DEFAULT ARRAY[]::text[],
assigned_target_count integer NOT NULL DEFAULT 0,
last_heartbeat_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, agent_id),
CONSTRAINT fk_topology_agent_inventory_environment
FOREIGN KEY (tenant_id, environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_agent_inventory_env_order
ON release.topology_agent_inventory (tenant_id, region_id, environment_id, agent_name, agent_id);
CREATE TABLE IF NOT EXISTS release.topology_promotion_path_inventory (
tenant_id uuid NOT NULL,
path_id text NOT NULL,
region_id text NOT NULL,
source_environment_id text NOT NULL,
target_environment_id text NOT NULL,
path_mode text NOT NULL,
path_status text NOT NULL,
required_approvals integer NOT NULL DEFAULT 1,
workflow_id text NOT NULL,
gate_profile_id text NOT NULL,
last_promoted_at timestamptz NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, path_id),
CONSTRAINT fk_topology_promotion_path_source_environment
FOREIGN KEY (tenant_id, source_environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE,
CONSTRAINT fk_topology_promotion_path_target_environment
FOREIGN KEY (tenant_id, target_environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_promotion_path_inventory_order
ON release.topology_promotion_path_inventory (tenant_id, region_id, source_environment_id, target_environment_id, path_id);
CREATE TABLE IF NOT EXISTS release.topology_workflow_inventory (
tenant_id uuid NOT NULL,
workflow_id text NOT NULL,
workflow_name text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
trigger_type text NOT NULL,
workflow_status text NOT NULL,
step_count integer NOT NULL DEFAULT 0,
gate_profile_id text NOT NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, workflow_id),
CONSTRAINT fk_topology_workflow_inventory_environment
FOREIGN KEY (tenant_id, environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_workflow_inventory_order
ON release.topology_workflow_inventory (tenant_id, region_id, environment_id, workflow_name, workflow_id);
CREATE TABLE IF NOT EXISTS release.topology_gate_profile_inventory (
tenant_id uuid NOT NULL,
gate_profile_id text NOT NULL,
profile_name text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
policy_profile text NOT NULL,
required_approvals integer NOT NULL DEFAULT 1,
separation_of_duties boolean NOT NULL DEFAULT false,
blocking_rules text[] NOT NULL DEFAULT ARRAY[]::text[],
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, gate_profile_id),
CONSTRAINT fk_topology_gate_profile_inventory_environment
FOREIGN KEY (tenant_id, environment_id)
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_topology_gate_profile_inventory_order
ON release.topology_gate_profile_inventory (tenant_id, region_id, environment_id, profile_name, gate_profile_id);
CREATE TABLE IF NOT EXISTS release.topology_sync_watermarks (
tenant_id uuid NOT NULL,
projection_name text NOT NULL,
last_synced_at timestamptz NOT NULL,
source_cursor text NULL,
status text NOT NULL DEFAULT 'idle',
error_message text NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, projection_name)
);
CREATE INDEX IF NOT EXISTS ix_topology_sync_watermarks_synced
ON release.topology_sync_watermarks (projection_name, last_synced_at DESC, tenant_id);
ALTER TABLE release.topology_environment_inventory
DROP CONSTRAINT IF EXISTS ck_topology_environment_inventory_type;
ALTER TABLE release.topology_environment_inventory
ADD CONSTRAINT ck_topology_environment_inventory_type
CHECK (environment_type IN ('development', 'staging', 'production'));
ALTER TABLE release.topology_target_inventory
DROP CONSTRAINT IF EXISTS ck_topology_target_inventory_type;
ALTER TABLE release.topology_target_inventory
ADD CONSTRAINT ck_topology_target_inventory_type
CHECK (target_type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job', 'ssh_host'));
ALTER TABLE release.topology_target_inventory
DROP CONSTRAINT IF EXISTS ck_topology_target_inventory_health;
ALTER TABLE release.topology_target_inventory
ADD CONSTRAINT ck_topology_target_inventory_health
CHECK (health_status IN ('healthy', 'degraded', 'unhealthy', 'offline', 'unknown'));
ALTER TABLE release.topology_host_inventory
DROP CONSTRAINT IF EXISTS ck_topology_host_inventory_status;
ALTER TABLE release.topology_host_inventory
ADD CONSTRAINT ck_topology_host_inventory_status
CHECK (host_status IN ('healthy', 'degraded', 'offline', 'unknown'));
ALTER TABLE release.topology_agent_inventory
DROP CONSTRAINT IF EXISTS ck_topology_agent_inventory_status;
ALTER TABLE release.topology_agent_inventory
ADD CONSTRAINT ck_topology_agent_inventory_status
CHECK (agent_status IN ('active', 'degraded', 'offline', 'pending'));
ALTER TABLE release.topology_promotion_path_inventory
DROP CONSTRAINT IF EXISTS ck_topology_promotion_path_inventory_mode;
ALTER TABLE release.topology_promotion_path_inventory
ADD CONSTRAINT ck_topology_promotion_path_inventory_mode
CHECK (path_mode IN ('manual', 'auto_on_success', 'emergency'));
ALTER TABLE release.topology_promotion_path_inventory
DROP CONSTRAINT IF EXISTS ck_topology_promotion_path_inventory_status;
ALTER TABLE release.topology_promotion_path_inventory
ADD CONSTRAINT ck_topology_promotion_path_inventory_status
CHECK (path_status IN ('idle', 'pending', 'running', 'failed', 'succeeded'));
ALTER TABLE release.topology_workflow_inventory
DROP CONSTRAINT IF EXISTS ck_topology_workflow_inventory_trigger;
ALTER TABLE release.topology_workflow_inventory
ADD CONSTRAINT ck_topology_workflow_inventory_trigger
CHECK (trigger_type IN ('manual', 'promotion', 'schedule', 'release_created'));
ALTER TABLE release.topology_workflow_inventory
DROP CONSTRAINT IF EXISTS ck_topology_workflow_inventory_status;
ALTER TABLE release.topology_workflow_inventory
ADD CONSTRAINT ck_topology_workflow_inventory_status
CHECK (workflow_status IN ('active', 'inactive', 'running', 'failed', 'idle'));
ALTER TABLE release.topology_sync_watermarks
DROP CONSTRAINT IF EXISTS ck_topology_sync_watermarks_status;
ALTER TABLE release.topology_sync_watermarks
ADD CONSTRAINT ck_topology_sync_watermarks_status
CHECK (status IN ('idle', 'running', 'failed', 'succeeded'));

View File

@@ -0,0 +1,171 @@
-- SPRINT_20260220_018 / B22-04
-- Pack 22 security consolidation read projections (findings/disposition/SBOM explorer).
CREATE TABLE IF NOT EXISTS release.security_finding_projection (
tenant_id uuid NOT NULL,
finding_id text NOT NULL,
cve_id text NOT NULL,
severity text NOT NULL,
package_name text NOT NULL,
component_name text NOT NULL,
release_id uuid NULL,
release_name text NOT NULL,
environment_id text NOT NULL,
region_id text NOT NULL,
reachable boolean NOT NULL DEFAULT false,
reachability_score integer NOT NULL DEFAULT 0,
effective_disposition text NOT NULL,
vex_status text NOT NULL,
exception_status text NOT NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, finding_id)
);
CREATE INDEX IF NOT EXISTS ix_security_finding_projection_filters
ON release.security_finding_projection (
tenant_id,
region_id,
environment_id,
severity,
effective_disposition,
updated_at DESC,
finding_id
);
CREATE INDEX IF NOT EXISTS ix_security_finding_projection_cve
ON release.security_finding_projection (tenant_id, cve_id, finding_id);
CREATE TABLE IF NOT EXISTS release.security_disposition_projection (
tenant_id uuid NOT NULL,
finding_id text NOT NULL,
cve_id text NOT NULL,
release_id uuid NULL,
release_name text NOT NULL,
package_name text NOT NULL,
component_name text NOT NULL,
environment_id text NOT NULL,
region_id text NOT NULL,
vex_status text NOT NULL,
vex_justification text NOT NULL,
vex_statement_id text NULL,
vex_updated_at timestamptz NULL,
exception_status text NOT NULL,
exception_reason text NOT NULL,
exception_approval_state text NOT NULL,
exception_id text NULL,
exception_expires_at timestamptz NULL,
exception_updated_at timestamptz NULL,
effective_disposition text NOT NULL,
policy_action text NOT NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, finding_id)
);
CREATE INDEX IF NOT EXISTS ix_security_disposition_projection_filters
ON release.security_disposition_projection (
tenant_id,
region_id,
environment_id,
effective_disposition,
updated_at DESC,
finding_id
);
CREATE INDEX IF NOT EXISTS ix_security_disposition_projection_release
ON release.security_disposition_projection (tenant_id, release_id, finding_id);
CREATE TABLE IF NOT EXISTS release.security_sbom_component_projection (
tenant_id uuid NOT NULL,
component_id text NOT NULL,
release_id uuid NULL,
release_name text NOT NULL,
environment_id text NOT NULL,
region_id text NOT NULL,
package_name text NOT NULL,
component_name text NOT NULL,
component_version text NOT NULL,
supplier text NOT NULL,
license text NOT NULL,
vulnerability_count integer NOT NULL DEFAULT 0,
critical_reachable_count integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, component_id)
);
CREATE INDEX IF NOT EXISTS ix_security_sbom_component_projection_filters
ON release.security_sbom_component_projection (
tenant_id,
region_id,
environment_id,
release_name,
component_name,
component_id
);
CREATE TABLE IF NOT EXISTS release.security_sbom_graph_projection (
tenant_id uuid NOT NULL,
edge_id text NOT NULL,
from_node_id text NOT NULL,
to_node_id text NOT NULL,
relation_type text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, edge_id)
);
CREATE INDEX IF NOT EXISTS ix_security_sbom_graph_projection_filters
ON release.security_sbom_graph_projection (
tenant_id,
region_id,
environment_id,
from_node_id,
to_node_id,
edge_id
);
ALTER TABLE release.security_finding_projection
DROP CONSTRAINT IF EXISTS ck_security_finding_projection_severity;
ALTER TABLE release.security_finding_projection
ADD CONSTRAINT ck_security_finding_projection_severity
CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'));
ALTER TABLE release.security_finding_projection
DROP CONSTRAINT IF EXISTS ck_security_finding_projection_disposition;
ALTER TABLE release.security_finding_projection
ADD CONSTRAINT ck_security_finding_projection_disposition
CHECK (effective_disposition IN ('accepted_risk', 'mitigated', 'action_required', 'review_required'));
ALTER TABLE release.security_disposition_projection
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_vex_status;
ALTER TABLE release.security_disposition_projection
ADD CONSTRAINT ck_security_disposition_projection_vex_status
CHECK (vex_status IN ('affected', 'not_affected', 'under_investigation'));
ALTER TABLE release.security_disposition_projection
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_exception_status;
ALTER TABLE release.security_disposition_projection
ADD CONSTRAINT ck_security_disposition_projection_exception_status
CHECK (exception_status IN ('none', 'pending', 'approved', 'rejected'));
ALTER TABLE release.security_disposition_projection
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_approval_state;
ALTER TABLE release.security_disposition_projection
ADD CONSTRAINT ck_security_disposition_projection_approval_state
CHECK (exception_approval_state IN ('not_requested', 'awaiting_review', 'approved', 'rejected'));
ALTER TABLE release.security_disposition_projection
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_policy_action;
ALTER TABLE release.security_disposition_projection
ADD CONSTRAINT ck_security_disposition_projection_policy_action
CHECK (policy_action IN ('allow_with_exception', 'allow', 'block', 'review'));

View File

@@ -0,0 +1,128 @@
-- SPRINT_20260220_018 / B22-05
-- Pack 22 integrations feed and VEX source health projections.
CREATE TABLE IF NOT EXISTS release.integration_feed_source_health (
tenant_id uuid NOT NULL,
source_id text NOT NULL,
source_name text NOT NULL,
source_type text NOT NULL,
provider text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
status text NOT NULL,
freshness text NOT NULL,
freshness_minutes integer NULL,
sla_minutes integer NOT NULL,
last_sync_at timestamptz NULL,
last_success_at timestamptz NULL,
last_error text NULL,
consumer_domains text[] NOT NULL DEFAULT ARRAY[]::text[],
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, source_id, region_id, environment_id)
);
CREATE INDEX IF NOT EXISTS ix_integration_feed_source_health_filters
ON release.integration_feed_source_health (
tenant_id,
region_id,
environment_id,
status,
freshness,
source_type,
source_name
);
CREATE TABLE IF NOT EXISTS release.integration_vex_source_health (
tenant_id uuid NOT NULL,
source_id text NOT NULL,
source_name text NOT NULL,
source_type text NOT NULL,
provider text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
status text NOT NULL,
freshness text NOT NULL,
freshness_minutes integer NULL,
sla_minutes integer NOT NULL,
statement_format text NOT NULL,
document_count_24h integer NOT NULL DEFAULT 0,
last_sync_at timestamptz NULL,
last_success_at timestamptz NULL,
last_error text NULL,
consumer_domains text[] NOT NULL DEFAULT ARRAY[]::text[],
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, source_id, region_id, environment_id)
);
CREATE INDEX IF NOT EXISTS ix_integration_vex_source_health_filters
ON release.integration_vex_source_health (
tenant_id,
region_id,
environment_id,
status,
freshness,
source_type,
source_name
);
CREATE TABLE IF NOT EXISTS release.integration_source_sync_watermarks (
tenant_id uuid NOT NULL,
source_family text NOT NULL,
region_id text NOT NULL,
environment_id text NOT NULL,
last_synced_at timestamptz NULL,
updated_at timestamptz NOT NULL,
correlation_key text NOT NULL,
PRIMARY KEY (tenant_id, source_family, region_id, environment_id)
);
ALTER TABLE release.integration_feed_source_health
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_source_type;
ALTER TABLE release.integration_feed_source_health
ADD CONSTRAINT ck_integration_feed_source_health_source_type
CHECK (source_type IN ('advisory_feed'));
ALTER TABLE release.integration_feed_source_health
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_status;
ALTER TABLE release.integration_feed_source_health
ADD CONSTRAINT ck_integration_feed_source_health_status
CHECK (status IN ('healthy', 'degraded', 'offline'));
ALTER TABLE release.integration_feed_source_health
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_freshness;
ALTER TABLE release.integration_feed_source_health
ADD CONSTRAINT ck_integration_feed_source_health_freshness
CHECK (freshness IN ('fresh', 'stale', 'unknown'));
ALTER TABLE release.integration_vex_source_health
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_source_type;
ALTER TABLE release.integration_vex_source_health
ADD CONSTRAINT ck_integration_vex_source_health_source_type
CHECK (source_type IN ('vex_source'));
ALTER TABLE release.integration_vex_source_health
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_status;
ALTER TABLE release.integration_vex_source_health
ADD CONSTRAINT ck_integration_vex_source_health_status
CHECK (status IN ('healthy', 'degraded', 'offline'));
ALTER TABLE release.integration_vex_source_health
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_freshness;
ALTER TABLE release.integration_vex_source_health
ADD CONSTRAINT ck_integration_vex_source_health_freshness
CHECK (freshness IN ('fresh', 'stale', 'unknown'));
ALTER TABLE release.integration_vex_source_health
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_statement_format;
ALTER TABLE release.integration_vex_source_health
ADD CONSTRAINT ck_integration_vex_source_health_statement_format
CHECK (statement_format IN ('openvex', 'csaf_vex'));

View File

@@ -0,0 +1,44 @@
-- SPRINT_20260220_023 / B23-RUN-02
-- Run input snapshot persistence for deterministic run-detail provenance.
CREATE TABLE IF NOT EXISTS release.run_input_snapshots (
snapshot_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
bundle_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
bundle_version_id uuid NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
policy_pack_version text NOT NULL,
feed_snapshot_id text NOT NULL,
feed_freshness_status text NOT NULL DEFAULT 'unknown',
feed_freshness_minutes integer NULL,
sbom_snapshot_id text NOT NULL,
sbom_job_id text NULL,
reachability_snapshot_id text NOT NULL,
reachability_job_id text NULL,
reachability_coverage_percent integer NOT NULL DEFAULT 0,
reachability_evidence_age_minutes integer NULL,
vex_snapshot_ref text NULL,
disposition_snapshot_ref text NULL,
captured_at timestamptz NOT NULL,
correlation_key text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_input_snapshots_tenant_run
ON release.run_input_snapshots (tenant_id, run_id, captured_at DESC, snapshot_id);
CREATE INDEX IF NOT EXISTS ix_release_run_input_snapshots_tenant_bundle_version
ON release.run_input_snapshots (tenant_id, bundle_id, bundle_version_id, captured_at DESC, snapshot_id);
ALTER TABLE release.run_input_snapshots
DROP CONSTRAINT IF EXISTS ck_release_run_input_snapshots_feed_freshness;
ALTER TABLE release.run_input_snapshots
ADD CONSTRAINT ck_release_run_input_snapshots_feed_freshness
CHECK (feed_freshness_status IN ('fresh', 'stale', 'unknown'));
ALTER TABLE release.run_input_snapshots
DROP CONSTRAINT IF EXISTS ck_release_run_input_snapshots_coverage_percent;
ALTER TABLE release.run_input_snapshots
ADD CONSTRAINT ck_release_run_input_snapshots_coverage_percent
CHECK (reachability_coverage_percent BETWEEN 0 AND 100);

View File

@@ -0,0 +1,44 @@
-- SPRINT_20260220_023 / B23-RUN-03
-- Run gate decision ledger for provenance and explainability.
CREATE TABLE IF NOT EXISTS release.run_gate_decision_ledger (
gate_decision_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
snapshot_id uuid NULL REFERENCES release.run_input_snapshots(snapshot_id) ON DELETE SET NULL,
verdict text NOT NULL,
machine_reason_codes text[] NOT NULL DEFAULT ARRAY[]::text[],
human_reason_codes text[] NOT NULL DEFAULT ARRAY[]::text[],
risk_budget_delta numeric(12, 4) NOT NULL DEFAULT 0,
risk_budget_contributors jsonb NOT NULL DEFAULT '[]'::jsonb,
staleness_verdict text NOT NULL DEFAULT 'unknown',
staleness_threshold_minutes integer NOT NULL DEFAULT 0,
blocked_items text[] NOT NULL DEFAULT ARRAY[]::text[],
evaluated_by text NOT NULL,
evaluated_at timestamptz NOT NULL,
correlation_key text NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_release_run_gate_decision_ledger_tenant_run
ON release.run_gate_decision_ledger (tenant_id, run_id, evaluated_at DESC, gate_decision_id DESC);
ALTER TABLE release.run_gate_decision_ledger
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_verdict;
ALTER TABLE release.run_gate_decision_ledger
ADD CONSTRAINT ck_release_run_gate_decision_ledger_verdict
CHECK (verdict IN ('allow', 'review', 'block'));
ALTER TABLE release.run_gate_decision_ledger
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_staleness;
ALTER TABLE release.run_gate_decision_ledger
ADD CONSTRAINT ck_release_run_gate_decision_ledger_staleness
CHECK (staleness_verdict IN ('fresh', 'stale', 'unknown'));
ALTER TABLE release.run_gate_decision_ledger
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_threshold;
ALTER TABLE release.run_gate_decision_ledger
ADD CONSTRAINT ck_release_run_gate_decision_ledger_threshold
CHECK (staleness_threshold_minutes >= 0);

View File

@@ -0,0 +1,41 @@
-- SPRINT_20260220_023 / B23-RUN-04
-- Ordered run approval checkpoints with signature and rationale trails.
CREATE TABLE IF NOT EXISTS release.run_approval_checkpoints (
checkpoint_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
checkpoint_order integer NOT NULL,
checkpoint_name text NOT NULL,
required_role text NOT NULL,
status text NOT NULL,
approver_id text NULL,
approved_at timestamptz NULL,
signature_algorithm text NULL,
signature_value text NULL,
rationale text NULL,
evidence_proof_ref text NULL,
correlation_key text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_approval_checkpoints_order
ON release.run_approval_checkpoints (tenant_id, run_id, checkpoint_order);
CREATE INDEX IF NOT EXISTS ix_release_run_approval_checkpoints_status
ON release.run_approval_checkpoints (tenant_id, run_id, status, updated_at DESC, checkpoint_order);
ALTER TABLE release.run_approval_checkpoints
DROP CONSTRAINT IF EXISTS ck_release_run_approval_checkpoints_order;
ALTER TABLE release.run_approval_checkpoints
ADD CONSTRAINT ck_release_run_approval_checkpoints_order
CHECK (checkpoint_order > 0);
ALTER TABLE release.run_approval_checkpoints
DROP CONSTRAINT IF EXISTS ck_release_run_approval_checkpoints_status;
ALTER TABLE release.run_approval_checkpoints
ADD CONSTRAINT ck_release_run_approval_checkpoints_status
CHECK (status IN ('pending', 'approved', 'rejected', 'skipped'));

View File

@@ -0,0 +1,55 @@
-- SPRINT_20260220_023 / B23-RUN-05
-- Run deployment phase timeline and rollback trigger/outcome lineage.
CREATE TABLE IF NOT EXISTS release.run_deployment_timeline (
deployment_event_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
target_id text NOT NULL,
target_name text NOT NULL,
target_environment text NOT NULL,
target_region text NOT NULL,
strategy text NOT NULL,
phase text NOT NULL,
status text NOT NULL,
artifact_digest text NULL,
log_ref text NULL,
rollback_trigger_id text NULL,
rollback_outcome text NULL,
occurred_at timestamptz NOT NULL,
correlation_key text NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_release_run_deployment_timeline_run
ON release.run_deployment_timeline (tenant_id, run_id, occurred_at DESC, deployment_event_id DESC);
CREATE INDEX IF NOT EXISTS ix_release_run_deployment_timeline_filters
ON release.run_deployment_timeline (tenant_id, target_environment, target_region, status, occurred_at DESC);
ALTER TABLE release.run_deployment_timeline
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_strategy;
ALTER TABLE release.run_deployment_timeline
ADD CONSTRAINT ck_release_run_deployment_timeline_strategy
CHECK (strategy IN ('canary', 'rolling', 'blue_green', 'recreate'));
ALTER TABLE release.run_deployment_timeline
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_phase;
ALTER TABLE release.run_deployment_timeline
ADD CONSTRAINT ck_release_run_deployment_timeline_phase
CHECK (phase IN ('queued', 'precheck', 'deploying', 'verifying', 'completed', 'rollback'));
ALTER TABLE release.run_deployment_timeline
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_status;
ALTER TABLE release.run_deployment_timeline
ADD CONSTRAINT ck_release_run_deployment_timeline_status
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'rolled_back'));
ALTER TABLE release.run_deployment_timeline
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_rollback_outcome;
ALTER TABLE release.run_deployment_timeline
ADD CONSTRAINT ck_release_run_deployment_timeline_rollback_outcome
CHECK (COALESCE(rollback_outcome, 'none') IN ('none', 'triggered', 'succeeded', 'failed'));

View File

@@ -0,0 +1,48 @@
-- SPRINT_20260220_023 / B23-RUN-06
-- Decision capsule and replay linkage per release run.
CREATE TABLE IF NOT EXISTS release.run_capsule_replay_linkage (
linkage_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
decision_capsule_id text NOT NULL,
capsule_hash text NOT NULL,
signature_status text NOT NULL,
transparency_receipt text NULL,
replay_verdict text NOT NULL,
replay_match boolean NOT NULL,
replay_mismatch_report_ref text NULL,
replay_checked_at timestamptz NULL,
audit_stream_ref text NULL,
export_profiles text[] NOT NULL DEFAULT ARRAY[]::text[],
correlation_key text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_capsule_replay_linkage_run
ON release.run_capsule_replay_linkage (tenant_id, run_id);
CREATE INDEX IF NOT EXISTS ix_release_run_capsule_replay_linkage_verdict
ON release.run_capsule_replay_linkage (tenant_id, replay_verdict, updated_at DESC, linkage_id DESC);
ALTER TABLE release.run_capsule_replay_linkage
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_signature_status;
ALTER TABLE release.run_capsule_replay_linkage
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_signature_status
CHECK (signature_status IN ('signed', 'unsigned', 'invalid'));
ALTER TABLE release.run_capsule_replay_linkage
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_replay_verdict;
ALTER TABLE release.run_capsule_replay_linkage
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_replay_verdict
CHECK (replay_verdict IN ('match', 'mismatch', 'not_available'));
ALTER TABLE release.run_capsule_replay_linkage
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_mismatch_report;
ALTER TABLE release.run_capsule_replay_linkage
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_mismatch_report
CHECK (replay_verdict <> 'mismatch' OR replay_mismatch_report_ref IS NOT NULL);

View File

@@ -4,5 +4,10 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| B22-01-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `047_GlobalContextAndFilters.sql` with `platform.context_regions`, `platform.context_environments`, and `platform.ui_context_preferences`. |
| B22-02-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `048_ReleaseReadModels.sql` with release list/activity/approvals projection tables, correlation keys, and deterministic ordering indexes. |
| B22-03-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `049_TopologyInventory.sql` with normalized topology inventory projection tables and sync-watermark indexes. |
| B22-04-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `050_SecurityDispositionProjection.sql` with consolidated findings/disposition/SBOM read-model projection tables, filters, and enum constraints. |
| B22-05-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `051_IntegrationSourceHealth.sql` for integrations feed and VEX source health/freshness read-model projection objects. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,154 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public ContextEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RegionsAndEnvironments_ReturnDeterministicOrdering()
{
using var client = CreateTenantClient("tenant-context-1");
var regionsFirst = await client.GetFromJsonAsync<PlatformContextRegion[]>(
"/api/v2/context/regions",
TestContext.Current.CancellationToken);
var regionsSecond = await client.GetFromJsonAsync<PlatformContextRegion[]>(
"/api/v2/context/regions",
TestContext.Current.CancellationToken);
Assert.NotNull(regionsFirst);
Assert.NotNull(regionsSecond);
Assert.Equal(
new[] { "us-east", "eu-west", "apac" },
regionsFirst!.Select(region => region.RegionId).ToArray());
Assert.Equal(
regionsFirst.Select(region => region.RegionId).ToArray(),
regionsSecond!.Select(region => region.RegionId).ToArray());
var environments = await client.GetFromJsonAsync<PlatformContextEnvironment[]>(
"/api/v2/context/environments?regions=eu-west,us-east,eu-west",
TestContext.Current.CancellationToken);
Assert.NotNull(environments);
Assert.Equal(
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
environments!.Select(environment => environment.EnvironmentId).ToArray());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Preferences_DefaultAndRoundTrip_AreDeterministic()
{
using var client = CreateTenantClient("tenant-context-2");
var defaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(defaults);
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, defaults!.Regions.ToArray());
Assert.Empty(defaults.Environments);
Assert.Equal("24h", defaults.TimeWindow);
var request = new PlatformContextPreferencesRequest(
Regions: new[] { "eu-west", "us-east", "unknown", "US-EAST" },
Environments: new[] { "eu-stage", "us-prod", "unknown", "apac-prod", "eu-prod", "US-PROD" },
TimeWindow: "7d");
var response = await client.PutAsJsonAsync(
"/api/v2/context/preferences",
request,
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var updated = await response.Content.ReadFromJsonAsync<PlatformContextPreferences>(
TestContext.Current.CancellationToken);
Assert.NotNull(updated);
Assert.Equal(new[] { "us-east", "eu-west" }, updated!.Regions.ToArray());
Assert.Equal(new[] { "us-prod", "eu-prod", "eu-stage" }, updated.Environments.ToArray());
Assert.Equal("7d", updated.TimeWindow);
var stored = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(updated.Regions.ToArray(), stored!.Regions.ToArray());
Assert.Equal(updated.Environments.ToArray(), stored.Environments.ToArray());
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(
"/api/v2/context/regions",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ContextEndpoints_RequireExpectedPolicies()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/context/regions", "GET", PlatformPolicies.ContextRead);
AssertPolicy(endpoints, "/api/v2/context/environments", "GET", PlatformPolicies.ContextRead);
AssertPolicy(endpoints, "/api/v2/context/preferences", "GET", PlatformPolicies.ContextRead);
AssertPolicy(endpoints, "/api/v2/context/preferences", "PUT", PlatformPolicies.ContextWrite);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
var endpoint = endpoints.Single(candidate =>
string.Equals(candidate.RoutePattern.RawText, routePattern, StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "context-tests");
return client;
}
}

View File

@@ -0,0 +1,70 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ContextMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration047_DefinesGlobalContextSchemaObjects()
{
var scriptPath = GetMigrationPath("047_GlobalContextAndFilters.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_regions", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_environments", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.ui_context_preferences", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (time_window IN ('1h', '24h', '7d', '30d', '90d'))", sql, StringComparison.Ordinal);
Assert.Contains("INSERT INTO platform.context_regions", sql, StringComparison.Ordinal);
Assert.Contains("INSERT INTO platform.context_environments", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration047_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index046 = Array.IndexOf(migrationNames, "046_TrustSigningAdministration.sql");
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
Assert.True(index046 >= 0, "Expected migration 046 to exist.");
Assert.True(index047 > index046, "Expected migration 047 to appear after migration 046.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,72 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class IntegrationSourceHealthMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration051_DefinesIntegrationSourceHealthProjectionObjects()
{
var scriptPath = GetMigrationPath("051_IntegrationSourceHealth.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_feed_source_health", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_vex_source_health", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_source_sync_watermarks", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (source_type IN ('advisory_feed'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (source_type IN ('vex_source'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (status IN ('healthy', 'degraded', 'offline'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (freshness IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (statement_format IN ('openvex', 'csaf_vex'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration051_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
Assert.True(index050 >= 0, "Expected migration 050 to exist.");
Assert.True(index051 > index050, "Expected migration 051 to appear after migration 050.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,233 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class IntegrationsReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public IntegrationsReadModelEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task IntegrationsEndpoints_ReturnDeterministicFeedAndVexSourceHealth_WithSecurityAndDashboardConsumerMetadata()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
await SeedReleaseAsync(client, "checkout-integrations", "Checkout Integrations", "us-prod", "feed-refresh");
await SeedReleaseAsync(client, "billing-integrations", "Billing Integrations", "eu-prod", "vex-sync");
var feedsFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v2/integrations/feeds?limit=200&offset=0",
TestContext.Current.CancellationToken);
var feedsSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v2/integrations/feeds?limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(feedsFirst);
Assert.NotNull(feedsSecond);
Assert.NotEmpty(feedsFirst!.Items);
Assert.Equal(
feedsFirst.Items.Select(item => item.SourceId).ToArray(),
feedsSecond!.Items.Select(item => item.SourceId).ToArray());
Assert.All(feedsFirst.Items, item =>
{
Assert.Equal("advisory_feed", item.SourceType);
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
Assert.Contains("security-findings", item.ConsumerDomains);
Assert.Contains("dashboard-posture", item.ConsumerDomains);
});
var usFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v2/integrations/feeds?region=us-east&sourceType=advisory_feed&limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usFeeds);
Assert.NotEmpty(usFeeds!.Items);
Assert.All(usFeeds.Items, item =>
{
Assert.Equal("us-east", item.Region);
Assert.Equal("advisory_feed", item.SourceType);
});
var usProdFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v2/integrations/feeds?environment=us-prod&limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usProdFeeds);
Assert.NotEmpty(usProdFeeds!.Items);
Assert.All(usProdFeeds.Items, item =>
{
Assert.Equal("us-prod", item.Environment);
Assert.NotNull(item.LastSyncAt);
Assert.NotNull(item.FreshnessMinutes);
});
var vexFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
"/api/v2/integrations/vex-sources?limit=200&offset=0",
TestContext.Current.CancellationToken);
var vexSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
"/api/v2/integrations/vex-sources?limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(vexFirst);
Assert.NotNull(vexSecond);
Assert.NotEmpty(vexFirst!.Items);
Assert.Equal(
vexFirst.Items.Select(item => item.SourceId).ToArray(),
vexSecond!.Items.Select(item => item.SourceId).ToArray());
Assert.All(vexFirst.Items, item =>
{
Assert.Equal("vex_source", item.SourceType);
Assert.Equal("openvex", item.StatementFormat);
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
Assert.Contains("security-disposition", item.ConsumerDomains);
Assert.Contains("dashboard-posture", item.ConsumerDomains);
Assert.True(item.DocumentCount24h >= 20);
});
var euVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
"/api/v2/integrations/vex-sources?environment=eu-prod&limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euVex);
Assert.NotEmpty(euVex!.Items);
Assert.All(euVex.Items, item => Assert.Equal("eu-prod", item.Environment));
var offlineVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
"/api/v2/integrations/vex-sources?environment=us-uat&status=offline&limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(offlineVex);
Assert.NotEmpty(offlineVex!.Items);
Assert.All(offlineVex.Items, item =>
{
Assert.Equal("us-uat", item.Environment);
Assert.Equal("offline", item.Status);
Assert.Equal("unknown", item.Freshness);
Assert.Null(item.LastSyncAt);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task IntegrationsEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/integrations/feeds", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void IntegrationsEndpoints_RequireExpectedPolicies()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/integrations/feeds", "GET", PlatformPolicies.IntegrationsRead);
AssertPolicy(endpoints, "/api/v2/integrations/vex-sources", "GET", PlatformPolicies.IntegrationsVexRead);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
static string NormalizePattern(string value) =>
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
var expectedPattern = NormalizePattern(routePattern);
var endpoint = endpoints.Single(candidate =>
string.Equals(
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
expectedPattern,
StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private static async Task SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: $"{slug}-api",
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"runtime\":\"docker\"}"),
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.1",
ComponentName: $"{slug}-worker",
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
DeployOrder: 20,
MetadataJson: "{\"runtime\":\"compose\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "integrations-v2-tests");
return client;
}
}

View File

@@ -0,0 +1,193 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class LegacyAliasCompatibilityTelemetryTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public LegacyAliasCompatibilityTelemetryTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CriticalPack22Surfaces_ExposeV1Aliases_AndEmitDeterministicDeprecationTelemetry()
{
var tenantId = Guid.NewGuid().ToString("D");
var telemetry = _factory.Services.GetRequiredService<LegacyAliasTelemetry>();
telemetry.Clear();
using var client = CreateTenantClient(tenantId);
await SeedReleaseAsync(client, "alias-release", "Alias Release", "us-prod", "alias-check");
var v1Context = await client.GetAsync("/api/v1/context/regions", TestContext.Current.CancellationToken);
var v2Context = await client.GetAsync("/api/v2/context/regions", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, v1Context.StatusCode);
Assert.Equal(HttpStatusCode.OK, v2Context.StatusCode);
var v1Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
"/api/v1/releases?limit=20&offset=0",
TestContext.Current.CancellationToken);
var v2Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
"/api/v2/releases?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(v1Releases);
Assert.NotNull(v2Releases);
Assert.NotEmpty(v1Releases!.Items);
Assert.NotEmpty(v2Releases!.Items);
var v1Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
"/api/v1/releases/runs?limit=20&offset=0",
TestContext.Current.CancellationToken);
var v2Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
"/api/v2/releases/runs?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(v1Runs);
Assert.NotNull(v2Runs);
Assert.NotEmpty(v1Runs!.Items);
Assert.NotEmpty(v2Runs!.Items);
var sampleRunId = v1Runs.Items[0].RunId;
var v1RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
$"/api/v1/releases/runs/{sampleRunId}/timeline",
TestContext.Current.CancellationToken);
var v2RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
$"/api/v2/releases/runs/{sampleRunId}/timeline",
TestContext.Current.CancellationToken);
Assert.NotNull(v1RunTimeline);
Assert.NotNull(v2RunTimeline);
Assert.Equal(sampleRunId, v1RunTimeline!.Item.RunId);
Assert.Equal(sampleRunId, v2RunTimeline!.Item.RunId);
var v1Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
"/api/v1/topology/regions?limit=20&offset=0",
TestContext.Current.CancellationToken);
var v2Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
"/api/v2/topology/regions?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(v1Topology);
Assert.NotNull(v2Topology);
Assert.NotEmpty(v1Topology!.Items);
Assert.NotEmpty(v2Topology!.Items);
var v1Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
"/api/v1/security/findings?limit=20&offset=0",
TestContext.Current.CancellationToken);
var v2Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
"/api/v2/security/findings?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(v1Security);
Assert.NotNull(v2Security);
Assert.NotEmpty(v1Security!.Items);
Assert.NotEmpty(v2Security!.Items);
var v1Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v1/integrations/feeds?limit=20&offset=0",
TestContext.Current.CancellationToken);
var v2Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
"/api/v2/integrations/feeds?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(v1Integrations);
Assert.NotNull(v2Integrations);
Assert.NotEmpty(v1Integrations!.Items);
Assert.NotEmpty(v2Integrations!.Items);
var usage = telemetry.Snapshot();
Assert.Contains(usage, item =>
string.Equals(item.AliasRoute, "/api/v1/context/regions", StringComparison.Ordinal)
&& string.Equals(item.CanonicalRoute, "/api/v2/context/regions", StringComparison.Ordinal)
&& string.Equals(item.EventKey, "alias_get_api_v1_context_regions", StringComparison.Ordinal));
Assert.Contains(usage, item =>
string.Equals(item.AliasRoute, "/api/v1/releases", StringComparison.Ordinal)
&& string.Equals(item.CanonicalRoute, "/api/v2/releases", StringComparison.Ordinal)
&& string.Equals(item.EventKey, "alias_get_api_v1_releases", StringComparison.Ordinal));
Assert.Contains(usage, item =>
item.AliasRoute.StartsWith("/api/v1/releases/runs", StringComparison.Ordinal)
&& item.CanonicalRoute.StartsWith("/api/v2/releases/runs", StringComparison.Ordinal)
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
Assert.Contains(usage, item =>
item.AliasRoute.Contains("/api/v1/releases/runs/", StringComparison.Ordinal)
&& item.AliasRoute.EndsWith("/timeline", StringComparison.Ordinal)
&& item.CanonicalRoute.Contains("/api/v2/releases/runs/", StringComparison.Ordinal)
&& item.CanonicalRoute.EndsWith("/timeline", StringComparison.Ordinal)
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
Assert.Contains(usage, item =>
string.Equals(item.AliasRoute, "/api/v1/topology/regions", StringComparison.Ordinal)
&& string.Equals(item.CanonicalRoute, "/api/v2/topology/regions", StringComparison.Ordinal)
&& string.Equals(item.EventKey, "alias_get_api_v1_topology_regions", StringComparison.Ordinal));
Assert.Contains(usage, item =>
string.Equals(item.AliasRoute, "/api/v1/security/findings", StringComparison.Ordinal)
&& string.Equals(item.CanonicalRoute, "/api/v2/security/findings", StringComparison.Ordinal)
&& string.Equals(item.EventKey, "alias_get_api_v1_security_findings", StringComparison.Ordinal));
Assert.Contains(usage, item =>
string.Equals(item.AliasRoute, "/api/v1/integrations/feeds", StringComparison.Ordinal)
&& string.Equals(item.CanonicalRoute, "/api/v2/integrations/feeds", StringComparison.Ordinal)
&& string.Equals(item.EventKey, "alias_get_api_v1_integrations_feeds", StringComparison.Ordinal));
Assert.DoesNotContain(usage, item =>
item.TenantHash is not null
&& item.TenantHash.Contains(tenantId, StringComparison.OrdinalIgnoreCase));
Assert.All(
usage.Where(item => item.AliasRoute.StartsWith("/api/v1/", StringComparison.Ordinal)),
item => Assert.StartsWith("alias_", item.EventKey));
}
private static async Task SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: $"{slug}-api",
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"runtime\":\"docker\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "legacy-alias-tests");
return client;
}
}

View File

@@ -0,0 +1,252 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ReleaseReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public ReleaseReadModelEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReleasesListDetailActivityAndApprovals_ReturnDeterministicProjections()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var checkout = await SeedReleaseAsync(
client,
"checkout-hotfix",
"Checkout Hotfix",
"us-prod",
"critical-fix");
var payments = await SeedReleaseAsync(
client,
"payments-release",
"Payments Release",
"eu-prod",
"policy-review");
var releasesFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
"/api/v2/releases?limit=20&offset=0",
TestContext.Current.CancellationToken);
var releasesSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
"/api/v2/releases?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(releasesFirst);
Assert.NotNull(releasesSecond);
Assert.True(releasesFirst!.Items.Count >= 2);
Assert.Equal(
releasesFirst.Items.Select(item => item.ReleaseId).ToArray(),
releasesSecond!.Items.Select(item => item.ReleaseId).ToArray());
var hotfix = releasesFirst.Items.Single(item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
Assert.Equal("hotfix", hotfix.ReleaseType);
Assert.Equal("pending_approval", hotfix.Status);
Assert.Equal("us-prod", hotfix.TargetEnvironment);
Assert.Equal("us-east", hotfix.TargetRegion);
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseDetailProjection>>(
$"/api/v2/releases/{checkout.Bundle.Id:D}",
TestContext.Current.CancellationToken);
Assert.NotNull(detail);
Assert.Equal(checkout.Bundle.Id.ToString("D"), detail!.Item.Summary.ReleaseId);
Assert.NotEmpty(detail.Item.Versions);
Assert.Contains(detail.Item.Approvals, approval =>
string.Equals(approval.TargetEnvironment, "us-prod", StringComparison.Ordinal));
var activityFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
"/api/v2/releases/activity?limit=50&offset=0",
TestContext.Current.CancellationToken);
var activitySecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
"/api/v2/releases/activity?limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(activityFirst);
Assert.NotNull(activitySecond);
Assert.Contains(activityFirst!.Items, item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
Assert.Contains(activityFirst.Items, item => item.ReleaseId == payments.Bundle.Id.ToString("D"));
Assert.Equal(
activityFirst.Items.Select(item => item.ActivityId).ToArray(),
activitySecond!.Items.Select(item => item.ActivityId).ToArray());
var approvalsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
"/api/v2/releases/approvals?limit=20&offset=0",
TestContext.Current.CancellationToken);
var approvalsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
"/api/v2/releases/approvals?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(approvalsFirst);
Assert.NotNull(approvalsSecond);
Assert.True(approvalsFirst!.Items.Count >= 2);
Assert.All(approvalsFirst.Items, item => Assert.Equal("pending", item.Status));
Assert.Equal(
approvalsFirst.Items.Select(item => item.ApprovalId).ToArray(),
approvalsSecond!.Items.Select(item => item.ApprovalId).ToArray());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReleasesEndpoints_ApplyRegionEnvironmentAndStatusFilters()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
await SeedReleaseAsync(client, "orders-hotfix", "Orders Hotfix", "us-prod", "critical-fix");
await SeedReleaseAsync(client, "inventory-release", "Inventory Release", "eu-prod", "policy-review");
var usReleases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
"/api/v2/releases?region=us-east&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usReleases);
Assert.NotEmpty(usReleases!.Items);
Assert.All(usReleases.Items, item => Assert.Equal("us-east", item.TargetRegion));
var euActivity = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
"/api/v2/releases/activity?environment=eu-prod&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euActivity);
Assert.NotEmpty(euActivity!.Items);
Assert.All(euActivity.Items, item => Assert.Equal("eu-prod", item.TargetEnvironment));
var euApprovals = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
"/api/v2/releases/approvals?status=pending&region=eu-west&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euApprovals);
Assert.NotEmpty(euApprovals!.Items);
Assert.All(euApprovals.Items, item => Assert.Equal("eu-west", item.TargetRegion));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReleasesEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/releases", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReleasesEndpoints_RequireExpectedPolicies()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/releases", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/{releaseId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/activity", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/approvals", "GET", PlatformPolicies.ReleaseControlRead);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
static string NormalizePattern(string value) =>
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
var expectedPattern = NormalizePattern(routePattern);
var endpoint = endpoints.Single(candidate =>
string.Equals(
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
expectedPattern,
StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private static async Task<SeededRelease> SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: slug,
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"track\":\"stable\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
TestContext.Current.CancellationToken);
Assert.NotNull(run);
return new SeededRelease(bundle, version, run!);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "releases-v2-tests");
return client;
}
private sealed record SeededRelease(
ReleaseControlBundleDetail Bundle,
ReleaseControlBundleVersionDetail Version,
ReleaseControlBundleMaterializationRun Run);
}

View File

@@ -0,0 +1,70 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ReleaseReadModelMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration048_DefinesReleaseReadModelProjectionObjects()
{
var scriptPath = GetMigrationPath("048_ReleaseReadModels.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_read_model", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_activity_projection", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_approvals_projection", sql, StringComparison.Ordinal);
Assert.Contains("correlation_key", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (release_type IN ('standard', 'hotfix'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (approval_status IN ('pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration048_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
Assert.True(index047 >= 0, "Expected migration 047 to exist.");
Assert.True(index048 > index047, "Expected migration 048 to appear after migration 047.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,244 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ReleaseRunEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public ReleaseRunEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunEndpoints_ReturnDeterministicRunCentricContractsAcrossTabs()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var hotfix = await SeedReleaseAsync(client, "checkout-hotfix", "Checkout Hotfix", "us-prod", "stale-integrity-window");
await SeedReleaseAsync(client, "payments-release", "Payments Release", "eu-prod", "routine-promotion");
var runsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
"/api/v2/releases/runs?limit=50&offset=0",
TestContext.Current.CancellationToken);
var runsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
"/api/v2/releases/runs?limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(runsFirst);
Assert.NotNull(runsSecond);
Assert.True(runsFirst!.Items.Count >= 2);
Assert.Equal(
runsFirst.Items.Select(item => item.RunId).ToArray(),
runsSecond!.Items.Select(item => item.RunId).ToArray());
var hotfixProjection = runsFirst.Items.Single(item => item.RunId == hotfix.Run.RunId.ToString("D"));
Assert.Equal("hotfix", hotfixProjection.Lane);
Assert.Equal("us-prod", hotfixProjection.TargetEnvironment);
Assert.Equal("us-east", hotfixProjection.TargetRegion);
Assert.True(hotfixProjection.BlockedByDataIntegrity);
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDetailProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}",
TestContext.Current.CancellationToken);
Assert.NotNull(detail);
Assert.Equal(hotfix.Run.RunId.ToString("D"), detail!.Item.RunId);
Assert.Equal("connect", detail.Item.Process[0].StepId);
Assert.Equal(5, detail.Item.Process.Count);
var timeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/timeline",
TestContext.Current.CancellationToken);
Assert.NotNull(timeline);
Assert.Equal(hotfix.Run.RunId.ToString("D"), timeline!.Item.RunId);
Assert.True(timeline.Item.Events.Count >= 6);
Assert.Contains(timeline.Item.Correlations, item => string.Equals(item.Type, "snapshot_id", StringComparison.Ordinal));
var gate = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunGateDecisionProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/gate-decision",
TestContext.Current.CancellationToken);
Assert.NotNull(gate);
Assert.Equal(hotfix.Run.RunId.ToString("D"), gate!.Item.RunId);
Assert.NotEmpty(gate.Item.PolicyPackVersion);
Assert.NotEmpty(gate.Item.MachineReasonCodes);
var approvals = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunApprovalsProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/approvals",
TestContext.Current.CancellationToken);
Assert.NotNull(approvals);
Assert.Equal(new[] { 1, 2 }, approvals!.Item.Checkpoints.Select(item => item.Order).ToArray());
var deployments = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDeploymentsProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/deployments",
TestContext.Current.CancellationToken);
Assert.NotNull(deployments);
Assert.NotEmpty(deployments!.Item.Targets);
Assert.NotEmpty(deployments.Item.RollbackTriggers);
var securityInputs = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunSecurityInputsProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/security-inputs",
TestContext.Current.CancellationToken);
Assert.NotNull(securityInputs);
Assert.InRange(securityInputs!.Item.ReachabilityCoveragePercent, 0, 100);
Assert.NotEmpty(securityInputs.Item.Drilldowns);
var evidence = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunEvidenceProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/evidence",
TestContext.Current.CancellationToken);
Assert.NotNull(evidence);
Assert.Contains("capsule-", evidence!.Item.DecisionCapsuleId, StringComparison.Ordinal);
var rollback = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunRollbackProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/rollback",
TestContext.Current.CancellationToken);
Assert.NotNull(rollback);
Assert.NotEmpty(rollback!.Item.KnownGoodReferences);
var replay = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunReplayProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/replay",
TestContext.Current.CancellationToken);
Assert.NotNull(replay);
Assert.Contains(replay!.Item.Verdict, new[] { "match", "mismatch", "not_available" });
var audit = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunAuditProjection>>(
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/audit",
TestContext.Current.CancellationToken);
Assert.NotNull(audit);
Assert.NotEmpty(audit!.Item.Entries);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v2/releases/runs", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RunEndpoints_RequireExpectedPolicies()
{
var endpoints = factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/releases/runs", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/timeline", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/gate-decision", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/approvals", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/deployments", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/security-inputs", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/evidence", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/rollback", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/replay", "GET", PlatformPolicies.ReleaseControlRead);
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/audit", "GET", PlatformPolicies.ReleaseControlRead);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
static string NormalizePattern(string value) =>
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
var expectedPattern = NormalizePattern(routePattern);
var endpoint = endpoints.Single(candidate =>
string.Equals(
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
expectedPattern,
StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private static async Task<SeededRelease> SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: slug,
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"track\":\"stable\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
TestContext.Current.CancellationToken);
Assert.NotNull(run);
return new SeededRelease(bundle, version, run!);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "release-runs-v2-tests");
return client;
}
private sealed record SeededRelease(
ReleaseControlBundleDetail Bundle,
ReleaseControlBundleVersionDetail Version,
ReleaseControlBundleMaterializationRun Run);
}

View File

@@ -0,0 +1,69 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class RunApprovalCheckpointsMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration054_DefinesRunApprovalCheckpointObjects()
{
var scriptPath = GetMigrationPath("054_RunApprovalCheckpoints.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_approval_checkpoints", sql, StringComparison.Ordinal);
Assert.Contains("ux_release_run_approval_checkpoints_order", sql, StringComparison.Ordinal);
Assert.Contains("signature_algorithm", sql, StringComparison.Ordinal);
Assert.Contains("signature_value", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (status IN ('pending', 'approved', 'rejected', 'skipped'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration054_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
Assert.True(index053 >= 0, "Expected migration 053 to exist.");
Assert.True(index054 > index053, "Expected migration 054 to appear after migration 053.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,70 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class RunCapsuleReplayLinkageMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration056_DefinesRunCapsuleReplayLinkageObjects()
{
var scriptPath = GetMigrationPath("056_RunCapsuleReplayLinkage.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_capsule_replay_linkage", sql, StringComparison.Ordinal);
Assert.Contains("ux_release_run_capsule_replay_linkage_run", sql, StringComparison.Ordinal);
Assert.Contains("ix_release_run_capsule_replay_linkage_verdict", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (signature_status IN ('signed', 'unsigned', 'invalid'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (replay_verdict IN ('match', 'mismatch', 'not_available'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (replay_verdict <> 'mismatch' OR replay_mismatch_report_ref IS NOT NULL)", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration056_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
var index056 = Array.IndexOf(migrationNames, "056_RunCapsuleReplayLinkage.sql");
Assert.True(index055 >= 0, "Expected migration 055 to exist.");
Assert.True(index056 > index055, "Expected migration 056 to appear after migration 055.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,70 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class RunDeploymentTimelineMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration055_DefinesRunDeploymentTimelineObjects()
{
var scriptPath = GetMigrationPath("055_RunDeploymentTimeline.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_deployment_timeline", sql, StringComparison.Ordinal);
Assert.Contains("ix_release_run_deployment_timeline_run", sql, StringComparison.Ordinal);
Assert.Contains("ix_release_run_deployment_timeline_filters", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (strategy IN ('canary', 'rolling', 'blue_green', 'recreate'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (phase IN ('queued', 'precheck', 'deploying', 'verifying', 'completed', 'rollback'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'rolled_back'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration055_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
Assert.True(index054 >= 0, "Expected migration 054 to exist.");
Assert.True(index055 > index054, "Expected migration 055 to appear after migration 054.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,69 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class RunGateDecisionLedgerMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration053_DefinesRunGateDecisionLedgerObjects()
{
var scriptPath = GetMigrationPath("053_RunGateDecisionLedger.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_gate_decision_ledger", sql, StringComparison.Ordinal);
Assert.Contains("risk_budget_delta", sql, StringComparison.Ordinal);
Assert.Contains("risk_budget_contributors", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (verdict IN ('allow', 'review', 'block'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (staleness_verdict IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration053_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
Assert.True(index052 >= 0, "Expected migration 052 to exist.");
Assert.True(index053 > index052, "Expected migration 053 to appear after migration 052.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,69 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class RunInputSnapshotsMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration052_DefinesRunInputSnapshotObjects()
{
var scriptPath = GetMigrationPath("052_RunInputSnapshots.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_input_snapshots", sql, StringComparison.Ordinal);
Assert.Contains("ux_release_run_input_snapshots_tenant_run", sql, StringComparison.Ordinal);
Assert.Contains("ix_release_run_input_snapshots_tenant_bundle_version", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (feed_freshness_status IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (reachability_coverage_percent BETWEEN 0 AND 100)", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration052_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
Assert.True(index051 >= 0, "Expected migration 051 to exist.");
Assert.True(index052 > index051, "Expected migration 052 to appear after migration 051.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,71 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class SecurityDispositionMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration050_DefinesSecurityDispositionProjectionObjects()
{
var scriptPath = GetMigrationPath("050_SecurityDispositionProjection.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_finding_projection", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_disposition_projection", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_component_projection", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_graph_projection", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (vex_status IN ('affected', 'not_affected', 'under_investigation'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (exception_status IN ('none', 'pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration050_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
Assert.True(index049 >= 0, "Expected migration 049 to exist.");
Assert.True(index050 > index049, "Expected migration 050 to appear after migration 049.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,224 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class SecurityReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public SecurityReadModelEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SecurityEndpoints_ReturnDeterministicFindingsDispositionAndSbomExplorer()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var checkout = await SeedReleaseAsync(client, "checkout-security-release", "Checkout Security Release", "us-prod", "security-exception");
var billing = await SeedReleaseAsync(client, "billing-security-release", "Billing Security Release", "eu-prod", "policy-review");
var findingsFirst = await client.GetFromJsonAsync<SecurityFindingsResponse>(
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
TestContext.Current.CancellationToken);
var findingsSecond = await client.GetFromJsonAsync<SecurityFindingsResponse>(
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(findingsFirst);
Assert.NotNull(findingsSecond);
Assert.NotEmpty(findingsFirst!.Items);
Assert.NotEmpty(findingsFirst.PivotBuckets);
Assert.NotEmpty(findingsFirst.Facets);
Assert.Equal(
findingsFirst.Items.Select(item => item.FindingId).ToArray(),
findingsSecond!.Items.Select(item => item.FindingId).ToArray());
var usFindings = await client.GetFromJsonAsync<SecurityFindingsResponse>(
"/api/v2/security/findings?region=us-east&severity=critical&limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usFindings);
Assert.All(usFindings!.Items, item => Assert.Equal("us-east", item.Region));
Assert.All(usFindings.Items, item => Assert.Equal("critical", item.Severity));
var dispositionList = await client.GetFromJsonAsync<PlatformListResponse<SecurityDispositionProjection>>(
"/api/v2/security/disposition?limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(dispositionList);
Assert.NotEmpty(dispositionList!.Items);
Assert.All(dispositionList.Items, item =>
{
Assert.Equal("vex", item.Vex.SourceModel);
Assert.Equal("exceptions", item.Exception.SourceModel);
});
var firstFindingId = dispositionList.Items[0].FindingId;
var dispositionDetail = await client.GetFromJsonAsync<PlatformItemResponse<SecurityDispositionProjection>>(
$"/api/v2/security/disposition/{firstFindingId}",
TestContext.Current.CancellationToken);
Assert.NotNull(dispositionDetail);
Assert.Equal(firstFindingId, dispositionDetail!.Item.FindingId);
var sbomTable = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
"/api/v2/security/sbom-explorer?mode=table&limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(sbomTable);
Assert.Equal("table", sbomTable!.Mode);
Assert.NotEmpty(sbomTable.Table);
Assert.Empty(sbomTable.GraphNodes);
Assert.Empty(sbomTable.GraphEdges);
var sbomGraph = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
"/api/v2/security/sbom-explorer?mode=graph&limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(sbomGraph);
Assert.Equal("graph", sbomGraph!.Mode);
Assert.NotEmpty(sbomGraph.GraphNodes);
Assert.NotEmpty(sbomGraph.GraphEdges);
var sbomDiff = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
$"/api/v2/security/sbom-explorer?mode=diff&leftReleaseId={checkout.Bundle.Id:D}&rightReleaseId={billing.Bundle.Id:D}&limit=50&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(sbomDiff);
Assert.Equal("diff", sbomDiff!.Mode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SecurityEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/security/findings", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SecurityEndpoints_RequireExpectedPolicies_AndDoNotExposeCombinedWriteRoute()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/security/findings", "GET", PlatformPolicies.SecurityRead);
AssertPolicy(endpoints, "/api/v2/security/disposition", "GET", PlatformPolicies.SecurityRead);
AssertPolicy(endpoints, "/api/v2/security/disposition/{findingId}", "GET", PlatformPolicies.SecurityRead);
AssertPolicy(endpoints, "/api/v2/security/sbom-explorer", "GET", PlatformPolicies.SecurityRead);
var hasCombinedWrite = endpoints.Any(candidate =>
string.Equals(candidate.RoutePattern.RawText, "/api/v2/security/disposition/exceptions", StringComparison.Ordinal)
&& candidate.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("POST", StringComparer.OrdinalIgnoreCase) == true);
Assert.False(hasCombinedWrite);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
static string NormalizePattern(string value) =>
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
var expectedPattern = NormalizePattern(routePattern);
var endpoint = endpoints.Single(candidate =>
string.Equals(
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
expectedPattern,
StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private static async Task<SeededRelease> SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: $"{slug}-api",
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"runtime\":\"docker\"}"),
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.1",
ComponentName: $"{slug}-worker",
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
DeployOrder: 20,
MetadataJson: "{\"runtime\":\"compose\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
TestContext.Current.CancellationToken);
Assert.NotNull(run);
return new SeededRelease(bundle, version, run!);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "security-v2-tests");
return client;
}
private sealed record SeededRelease(
ReleaseControlBundleDetail Bundle,
ReleaseControlBundleVersionDetail Version,
ReleaseControlBundleMaterializationRun Run);
}

View File

@@ -7,6 +7,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
| B22-01-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ContextEndpointsTests` + `ContextMigrationScriptTests` for `/api/v2/context/*` deterministic ordering, preference round-trip behavior, and migration `047` coverage. |
| B22-02-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ReleaseReadModelEndpointsTests` + `ReleaseReadModelMigrationScriptTests` for `/api/v2/releases{,/activity,/approvals,/{releaseId}}` deterministic projection behavior and migration `048` coverage. |
| B22-03-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `TopologyReadModelEndpointsTests` + `TopologyInventoryMigrationScriptTests` for `/api/v2/topology/*` deterministic ordering/filter behavior and migration `049` coverage. |
| B22-04-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `SecurityReadModelEndpointsTests` + `SecurityDispositionMigrationScriptTests` for `/api/v2/security/{findings,disposition,sbom-explorer}` deterministic behavior, policy metadata, write-boundary checks, and migration `050` coverage. |
| B22-05-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `IntegrationsReadModelEndpointsTests` + `IntegrationSourceHealthMigrationScriptTests` for `/api/v2/integrations/{feeds,vex-sources}` deterministic behavior, policy metadata, consumer compatibility, and migration `051` coverage. |
| B22-06-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added compatibility+telemetry contract tests covering both `/api/v1/*` aliases and `/api/v2/*` canonical routes for critical Pack 22 surfaces. |
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |

View File

@@ -0,0 +1,75 @@
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class TopologyInventoryMigrationScriptTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration049_DefinesTopologyInventoryProjectionObjects()
{
var scriptPath = GetMigrationPath("049_TopologyInventory.sql");
var sql = File.ReadAllText(scriptPath);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_region_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_environment_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_target_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_host_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_agent_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_promotion_path_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_workflow_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_gate_profile_inventory", sql, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_sync_watermarks", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (target_type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job', 'ssh_host'))", sql, StringComparison.Ordinal);
Assert.Contains("CHECK (path_status IN ('idle', 'pending', 'running', 'failed', 'succeeded'))", sql, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Migration049_IsPresentInReleaseMigrationSequence()
{
var migrationsDir = GetMigrationsDirectory();
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
.Select(Path.GetFileName)
.Where(static name => name is not null)
.Select(static name => name!)
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
Assert.True(index048 >= 0, "Expected migration 048 to exist.");
Assert.True(index049 > index048, "Expected migration 049 to appear after migration 048.");
}
private static string GetMigrationPath(string fileName)
{
return Path.Combine(GetMigrationsDirectory(), fileName);
}
private static string GetMigrationsDirectory()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Platform",
"__Libraries",
"StellaOps.Platform.Database",
"Migrations",
"Release");
if (Directory.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
}
}

View File

@@ -0,0 +1,257 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
namespace StellaOps.Platform.WebService.Tests;
public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public TopologyReadModelEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TopologyEndpoints_ReturnDeterministicInventoryAndSupportFilters()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
await SeedReleaseAsync(client, "orders-release", "Orders Release", "us-prod", "promotion");
await SeedReleaseAsync(client, "billing-release", "Billing Release", "eu-prod", "promotion");
var regions = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
"/api/v2/topology/regions?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(regions);
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, regions!.Items.Select(item => item.RegionId).ToArray());
var environments = await client.GetFromJsonAsync<PlatformListResponse<TopologyEnvironmentProjection>>(
"/api/v2/topology/environments?region=us-east,eu-west&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(environments);
Assert.Equal(
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
environments!.Items.Select(item => item.EnvironmentId).ToArray());
var targetsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=20&offset=0",
TestContext.Current.CancellationToken);
var targetsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(targetsFirst);
Assert.NotNull(targetsSecond);
Assert.NotEmpty(targetsFirst!.Items);
Assert.Equal(
targetsFirst.Items.Select(item => item.TargetId).ToArray(),
targetsSecond!.Items.Select(item => item.TargetId).ToArray());
var hostsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
"/api/v2/topology/hosts?limit=20&offset=0",
TestContext.Current.CancellationToken);
var hostsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
"/api/v2/topology/hosts?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(hostsFirst);
Assert.NotNull(hostsSecond);
Assert.NotEmpty(hostsFirst!.Items);
Assert.Equal(
hostsFirst.Items.Select(item => item.HostId).ToArray(),
hostsSecond!.Items.Select(item => item.HostId).ToArray());
var agentsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
"/api/v2/topology/agents?limit=20&offset=0",
TestContext.Current.CancellationToken);
var agentsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
"/api/v2/topology/agents?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(agentsFirst);
Assert.NotNull(agentsSecond);
Assert.NotEmpty(agentsFirst!.Items);
Assert.Equal(
agentsFirst.Items.Select(item => item.AgentId).ToArray(),
agentsSecond!.Items.Select(item => item.AgentId).ToArray());
var paths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
"/api/v2/topology/promotion-paths?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(paths);
Assert.NotEmpty(paths!.Items);
var workflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
"/api/v2/topology/workflows?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(workflows);
Assert.NotEmpty(workflows!.Items);
var profiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
"/api/v2/topology/gate-profiles?limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(profiles);
Assert.NotEmpty(profiles!.Items);
var usTargets = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?region=us-east&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usTargets);
Assert.NotEmpty(usTargets!.Items);
Assert.All(usTargets.Items, item => Assert.Equal("us-east", item.RegionId));
var euHosts = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
"/api/v2/topology/hosts?environment=eu-prod&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euHosts);
Assert.NotEmpty(euHosts!.Items);
Assert.All(euHosts.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
var usPaths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
"/api/v2/topology/promotion-paths?environment=us-prod&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(usPaths);
Assert.NotEmpty(usPaths!.Items);
Assert.All(usPaths.Items, item =>
Assert.True(
string.Equals(item.SourceEnvironmentId, "us-prod", StringComparison.Ordinal)
|| string.Equals(item.TargetEnvironmentId, "us-prod", StringComparison.Ordinal)));
var euWorkflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
"/api/v2/topology/workflows?environment=eu-prod&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euWorkflows);
Assert.NotEmpty(euWorkflows!.Items);
Assert.All(euWorkflows.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
var euProfiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
"/api/v2/topology/gate-profiles?region=eu-west&limit=20&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(euProfiles);
Assert.NotEmpty(euProfiles!.Items);
Assert.All(euProfiles.Items, item => Assert.Equal("eu-west", item.RegionId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TopologyEndpoints_WithoutTenantHeader_ReturnBadRequest()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v2/topology/regions", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TopologyEndpoints_RequireExpectedPolicy()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>()
.ToArray();
AssertPolicy(endpoints, "/api/v2/topology/regions", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/environments", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/targets", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/hosts", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/agents", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/promotion-paths", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/workflows", "GET", PlatformPolicies.TopologyRead);
AssertPolicy(endpoints, "/api/v2/topology/gate-profiles", "GET", PlatformPolicies.TopologyRead);
}
private static void AssertPolicy(
IReadOnlyList<RouteEndpoint> endpoints,
string routePattern,
string method,
string expectedPolicy)
{
static string NormalizePattern(string value) =>
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
var expectedPattern = NormalizePattern(routePattern);
var endpoint = endpoints.Single(candidate =>
string.Equals(
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
expectedPattern,
StringComparison.Ordinal)
&& candidate.Metadata
.GetMetadata<HttpMethodMetadata>()?
.HttpMethods
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
var policies = endpoint.Metadata
.GetOrderedMetadata<IAuthorizeData>()
.Select(metadata => metadata.Policy)
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
.ToArray();
Assert.Contains(expectedPolicy, policies);
}
private static async Task SeedReleaseAsync(
HttpClient client,
string slug,
string name,
string targetEnvironment,
string reason)
{
var createResponse = await client.PostAsJsonAsync(
"/api/v1/release-control/bundles",
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
var publishResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
new PublishReleaseControlBundleVersionRequest(
Changelog: "baseline",
Components:
[
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.0",
ComponentName: $"{slug}-api",
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
DeployOrder: 10,
MetadataJson: "{\"runtime\":\"docker\"}"),
new ReleaseControlBundleComponentInput(
ComponentVersionId: $"{slug}@1.0.1",
ComponentName: $"{slug}-worker",
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
DeployOrder: 20,
MetadataJson: "{\"runtime\":\"compose\"}")
]),
TestContext.Current.CancellationToken);
publishResponse.EnsureSuccessStatusCode();
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
TestContext.Current.CancellationToken);
Assert.NotNull(version);
var materializeResponse = await client.PostAsJsonAsync(
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
TestContext.Current.CancellationToken);
materializeResponse.EnsureSuccessStatusCode();
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "topology-v2-tests");
return client;
}
}

View File

@@ -40,6 +40,7 @@ import { AuthSessionStore } from './core/auth/auth-session.store';
import { TenantActivationService } from './core/auth/tenant-activation.service';
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor';
import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor';
import { seedAuthSession, type StubAuthSession } from './testing';
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
import { AUTH_SERVICE } from './core/auth';
@@ -124,43 +125,35 @@ import {
RELEASE_DASHBOARD_API,
RELEASE_DASHBOARD_API_BASE_URL,
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
} from './core/api/release-dashboard.client';
import {
RELEASE_ENVIRONMENT_API,
RELEASE_ENVIRONMENT_API_BASE_URL,
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
} from './core/api/release-environment.client';
import {
RELEASE_MANAGEMENT_API,
ReleaseManagementHttpClient,
MockReleaseManagementClient,
} from './core/api/release-management.client';
import {
WORKFLOW_API,
WorkflowHttpClient,
MockWorkflowClient,
} from './core/api/workflow.client';
import {
APPROVAL_API,
ApprovalHttpClient,
MockApprovalClient,
} from './core/api/approval.client';
import {
DEPLOYMENT_API,
DeploymentHttpClient,
MockDeploymentClient,
} from './core/api/deployment.client';
import {
RELEASE_EVIDENCE_API,
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
} from './core/api/release-evidence.client';
import {
DOCTOR_API,
HttpDoctorClient,
MockDoctorClient,
} from './features/doctor/services/doctor.client';
import {
WITNESS_API,
@@ -185,7 +178,6 @@ import {
import {
VULN_ANNOTATION_API,
HttpVulnAnnotationClient,
MockVulnAnnotationClient,
} from './core/api/vuln-annotation.client';
import {
AUTHORITY_ADMIN_API,
@@ -268,6 +260,11 @@ export const appConfig: ApplicationConfig = {
useClass: TenantHttpInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: GlobalContextHttpInterceptor,
multi: true,
},
{
provide: CONCELIER_EXPORTER_API_BASE_URL,
useValue: '/api/v1/concelier/exporters/trivy-db',
@@ -630,7 +627,7 @@ export const appConfig: ApplicationConfig = {
provide: NOTIFY_API,
useExisting: NotifyApiHttpClient,
},
// Release Dashboard API (using mock - no backend endpoint yet)
// Release Dashboard API (runtime HTTP client)
{
provide: RELEASE_DASHBOARD_API_BASE_URL,
deps: [AppConfigService],
@@ -645,7 +642,6 @@ export const appConfig: ApplicationConfig = {
},
},
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
{
provide: RELEASE_DASHBOARD_API,
useExisting: ReleaseDashboardHttpClient,
@@ -665,49 +661,42 @@ export const appConfig: ApplicationConfig = {
},
},
ReleaseEnvironmentHttpClient,
MockReleaseEnvironmentClient,
{
provide: RELEASE_ENVIRONMENT_API,
useExisting: ReleaseEnvironmentHttpClient,
},
// Release Management API (Sprint 111_003 - using mock until backend is available)
// Release Management API (runtime HTTP client)
ReleaseManagementHttpClient,
MockReleaseManagementClient,
{
provide: RELEASE_MANAGEMENT_API,
useExisting: ReleaseManagementHttpClient,
},
// Workflow API (Sprint 111_004 - using mock until backend is available)
// Workflow API (runtime HTTP client)
WorkflowHttpClient,
MockWorkflowClient,
{
provide: WORKFLOW_API,
useExisting: WorkflowHttpClient,
},
// Approval API (using mock - no backend endpoint yet)
// Approval API (runtime HTTP client)
ApprovalHttpClient,
MockApprovalClient,
{
provide: APPROVAL_API,
useExisting: ApprovalHttpClient,
},
// Deployment API (Sprint 111_006 - using mock until backend is available)
// Deployment API (runtime HTTP client)
DeploymentHttpClient,
MockDeploymentClient,
{
provide: DEPLOYMENT_API,
useExisting: DeploymentHttpClient,
},
// Release Evidence API (Sprint 111_007 - using mock until backend is available)
// Release Evidence API (runtime HTTP client)
ReleaseEvidenceHttpClient,
MockReleaseEvidenceClient,
{
provide: RELEASE_EVIDENCE_API,
useExisting: ReleaseEvidenceHttpClient,
},
// Doctor API (HTTP paths corrected; using mock until gateway auth chain is configured)
// Doctor API (runtime HTTP client)
HttpDoctorClient,
MockDoctorClient,
{
provide: DOCTOR_API,
useExisting: HttpDoctorClient,
@@ -752,9 +741,8 @@ export const appConfig: ApplicationConfig = {
provide: TRUST_API,
useExisting: TrustHttpService,
},
// Vuln Annotation API (using mock until backend is available)
// Vuln Annotation API (runtime HTTP client)
HttpVulnAnnotationClient,
MockVulnAnnotationClient,
{
provide: VULN_ANNOTATION_API,
useExisting: HttpVulnAnnotationClient,

View File

@@ -52,58 +52,89 @@ export const routes: Routes = [
redirectTo: '/',
},
// Domain 2: Release Control
// Domain 2: Releases
{
path: 'release-control',
title: 'Release Control',
path: 'releases',
title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Release Control' },
data: { breadcrumb: 'Releases' },
loadChildren: () =>
import('./routes/release-control.routes').then(
(m) => m.RELEASE_CONTROL_ROUTES
import('./routes/releases.routes').then(
(m) => m.RELEASES_ROUTES
),
},
// Domain 3: Security & Risk (formerly /security)
// Domain 3: Security
{
path: 'security-risk',
title: 'Security & Risk',
path: 'security',
title: 'Security',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Security & Risk' },
data: { breadcrumb: 'Security' },
loadChildren: () =>
import('./routes/security-risk.routes').then(
(m) => m.SECURITY_RISK_ROUTES
import('./routes/security.routes').then(
(m) => m.SECURITY_ROUTES
),
},
// Domain 4: Evidence and Audit (formerly /evidence)
// Domain 4: Evidence
{
path: 'evidence-audit',
title: 'Evidence & Audit',
path: 'evidence',
title: 'Evidence',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Evidence & Audit' },
data: { breadcrumb: 'Evidence' },
loadChildren: () =>
import('./routes/evidence-audit.routes').then(
(m) => m.EVIDENCE_AUDIT_ROUTES
import('./routes/evidence.routes').then(
(m) => m.EVIDENCE_ROUTES
),
},
// Domain 5: Integrations (already canonical — kept as-is)
// /integrations already loaded below; no path change for this domain.
// Domain 6: Platform Ops — canonical P0-P9 surface (SPRINT_20260218_008)
// Domain 6: Topology
{
path: 'platform-ops',
title: 'Platform Ops',
path: 'topology',
title: 'Topology',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
data: { breadcrumb: 'Topology' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
import('./routes/topology.routes').then(
(m) => m.TOPOLOGY_ROUTES
),
},
// Domain 7: Administration (canonical A0-A7 surface — SPRINT_20260218_007)
// Domain 7: Platform
{
path: 'platform',
title: 'Platform',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform' },
loadChildren: () =>
import('./routes/platform.routes').then(
(m) => m.PLATFORM_ROUTES
),
},
// Domain 8: Administration (legacy root retained as alias to Platform Setup)
{
path: 'administration',
pathMatch: 'full',
redirectTo: '/platform/setup',
},
// Domain 9: Operations (legacy alias root retained for migration window)
{
path: 'operations',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
// Domain 10: Administration deep-link compatibility surface
{
path: 'administration',
title: 'Administration',
@@ -123,36 +154,36 @@ export const routes: Routes = [
// Convert to redirects and remove at SPRINT_20260218_016 after confirming traffic.
// ========================================================================
// Release Control domain aliases
// Releases domain aliases
{
path: 'approvals',
pathMatch: 'full',
redirectTo: '/release-control/approvals',
redirectTo: '/releases/approvals',
},
{
path: 'environments',
pathMatch: 'full',
redirectTo: '/release-control/regions',
redirectTo: '/topology/environments',
},
{
path: 'releases',
path: 'release-control',
pathMatch: 'full',
redirectTo: '/release-control/releases',
redirectTo: '/releases',
},
{
path: 'deployments',
pathMatch: 'full',
redirectTo: '/release-control/deployments',
redirectTo: '/releases/activity',
},
// Security & Risk domain alias
// Legacy Security alias
{
path: 'security',
path: 'security-risk',
pathMatch: 'full',
redirectTo: '/security-risk',
redirectTo: '/security',
},
// Analytics alias (served under security-risk in v2)
// Analytics alias (served under Security in v3)
{
path: 'analytics',
title: 'Analytics',
@@ -161,22 +192,22 @@ export const routes: Routes = [
import('./features/analytics/analytics.routes').then((m) => m.ANALYTICS_ROUTES),
},
// Evidence and Audit domain alias
// Legacy Evidence alias
{
path: 'evidence',
path: 'evidence-audit',
pathMatch: 'full',
redirectTo: '/evidence-audit',
redirectTo: '/evidence',
},
// Platform Ops domain alias
// Legacy Operations aliases
{
path: 'operations',
title: 'Platform Ops',
path: 'platform-ops',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
import('./routes/operations.routes').then(
(m) => m.OPERATIONS_ROUTES
),
},
@@ -191,27 +222,27 @@ export const routes: Routes = [
{
path: 'settings/release-control',
pathMatch: 'full',
redirectTo: '/release-control/setup',
redirectTo: '/topology',
},
{
path: 'settings/release-control/environments',
pathMatch: 'full',
redirectTo: '/release-control/setup/environments-paths',
redirectTo: '/topology/environments',
},
{
path: 'settings/release-control/targets',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
redirectTo: '/topology/targets',
},
{
path: 'settings/release-control/agents',
pathMatch: 'full',
redirectTo: '/release-control/setup/targets-agents',
redirectTo: '/topology/agents',
},
{
path: 'settings/release-control/workflows',
pathMatch: 'full',
redirectTo: '/release-control/setup/workflows',
redirectTo: '/topology/workflows',
},
// Administration domain alias — settings

View File

@@ -4,7 +4,7 @@
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { Observable, catchError, delay, map, of } from 'rxjs';
import type {
ApprovalRequest,
ApprovalDetail,
@@ -32,18 +32,33 @@ export interface ApprovalApi {
@Injectable()
export class ApprovalHttpClient implements ApprovalApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/release-orchestrator/approvals';
private readonly queueBaseUrl = '/api/v2/releases/approvals';
private readonly detailBaseUrl = '/api/v1/approvals';
private readonly legacyBaseUrl = '/api/release-orchestrator/approvals';
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
if (filter?.urgencies?.length || (filter?.statuses?.length ?? 0) > 1) {
return this.listApprovalsLegacy(filter);
}
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.statuses?.length) params['status'] = filter.statuses[0];
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.baseUrl, { params });
return this.http.get<any>(this.queueBaseUrl, { params }).pipe(
map((rows) => {
const items = Array.isArray(rows) ? rows : (rows?.items ?? []);
return items.map((row: any) => this.mapV2ApprovalSummary(row));
}),
catchError(() => this.listApprovalsLegacy(filter))
);
}
getApproval(id: string): Observable<ApprovalDetail> {
return this.http.get<ApprovalDetail>(`${this.baseUrl}/${id}`);
return this.http.get<any>(`${this.detailBaseUrl}/${id}`).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.get<ApprovalDetail>(`${this.legacyBaseUrl}/${id}`))
);
}
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview> {
@@ -67,19 +82,97 @@ export class ApprovalHttpClient implements ApprovalApi {
}
approve(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/approve`, { comment });
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'approve',
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/approve`, { comment }))
);
}
reject(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/reject`, { comment });
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'reject',
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/reject`, { comment }))
);
}
batchApprove(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-approve`, { ids, comment });
return this.http.post<void>(`${this.legacyBaseUrl}/batch-approve`, { ids, comment });
}
batchReject(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/batch-reject`, { ids, comment });
return this.http.post<void>(`${this.legacyBaseUrl}/batch-reject`, { ids, comment });
}
private listApprovalsLegacy(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.legacyBaseUrl, { params });
}
private mapV2ApprovalSummary(row: any): ApprovalRequest {
return {
id: row.approvalId ?? row.id,
releaseId: row.releaseId,
releaseName: row.releaseName,
releaseVersion: row.releaseVersion ?? row.releaseName,
sourceEnvironment: row.sourceEnvironment,
targetEnvironment: row.targetEnvironment,
requestedBy: row.requestedBy,
requestedAt: row.requestedAt,
urgency: row.urgency ?? 'normal',
justification: row.justification ?? '',
status: row.status ?? 'pending',
currentApprovals: row.currentApprovals ?? 0,
requiredApprovals: row.requiredApprovals ?? 0,
gatesPassed: row.gatesPassed ?? ((row.blockers?.length ?? 0) === 0),
scheduledTime: row.scheduledTime ?? null,
expiresAt: row.expiresAt ?? row.requestedAt ?? '',
};
}
private mapV2ApprovalDetail(row: any): ApprovalDetail {
return {
...this.mapV2ApprovalSummary(row),
gateResults: (row.gateResults ?? []).map((gate: any) => ({
gateId: gate.gateId,
gateName: gate.gateName,
type: gate.type,
status: gate.status,
message: gate.message,
details: gate.details ?? {},
evaluatedAt: gate.evaluatedAt ?? '',
})),
actions: (row.actions ?? []).map((action: any) => ({
id: action.id,
approvalId: action.approvalId,
action: action.action,
actor: action.actor,
comment: action.comment,
timestamp: action.timestamp,
})),
approvers: (row.approvers ?? []).map((approver: any) => ({
id: approver.id,
name: approver.name,
email: approver.email,
hasApproved: approver.hasApproved,
approvedAt: approver.approvedAt ?? null,
})),
releaseComponents: (row.releaseComponents ?? []).map((component: any) => ({
name: component.name,
version: component.version,
digest: component.digest,
})),
};
}
}

View File

@@ -3,9 +3,28 @@
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
*/
export type ReleaseWorkflowStatus = 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
export type ReleaseWorkflowStatus =
| 'draft'
| 'ready'
| 'deploying'
| 'deployed'
| 'failed'
| 'rolled_back';
export type ReleaseType = 'standard' | 'hotfix';
export type ReleaseGateStatus = 'pass' | 'warn' | 'block' | 'pending' | 'unknown';
export type ReleaseRiskTier = 'critical' | 'high' | 'medium' | 'low' | 'none' | 'unknown';
export type ReleaseEvidencePosture = 'verified' | 'partial' | 'missing' | 'replay_mismatch' | 'unknown';
export type ComponentType = 'container' | 'helm' | 'script';
export type ReleaseEventType = 'created' | 'promoted' | 'approved' | 'rejected' | 'deployed' | 'failed' | 'rolled_back';
export type ReleaseEventType =
| 'created'
| 'promoted'
| 'approved'
| 'rejected'
| 'deployed'
| 'failed'
| 'rolled_back';
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate';
export interface ManagedRelease {
@@ -14,12 +33,31 @@ export interface ManagedRelease {
version: string;
description: string;
status: ReleaseWorkflowStatus;
releaseType: ReleaseType | string;
slug: string;
digest: string | null;
currentStage: string | null;
currentEnvironment: string | null;
targetEnvironment: string | null;
targetRegion: string | null;
componentCount: number;
gateStatus: ReleaseGateStatus;
gateBlockingCount: number;
gatePendingApprovals: number;
gateBlockingReasons: string[];
riskCriticalReachable: number;
riskHighReachable: number;
riskTrend: string;
riskTier: ReleaseRiskTier;
evidencePosture: ReleaseEvidencePosture;
needsApproval: boolean;
blocked: boolean;
hotfixLane: boolean;
replayMismatch: boolean;
createdAt: string;
createdBy: string;
updatedAt: string;
lastActor: string;
deployedAt: string | null;
deploymentStrategy: DeploymentStrategy;
}
@@ -84,7 +122,16 @@ export interface AddComponentRequest {
export interface ReleaseFilter {
search?: string;
statuses?: ReleaseWorkflowStatus[];
stages?: string[];
types?: string[];
gateStatuses?: ReleaseGateStatus[];
riskTiers?: ReleaseRiskTier[];
blocked?: boolean;
needsApproval?: boolean;
hotfixLane?: boolean;
replayMismatch?: boolean;
environment?: string;
region?: string;
sortField?: string;
sortOrder?: 'asc' | 'desc';
page?: number;
@@ -123,6 +170,43 @@ export function getStatusColor(status: ReleaseWorkflowStatus): string {
return colors[status] || 'var(--color-text-secondary)';
}
export function getGateStatusLabel(status: ReleaseGateStatus): string {
const labels: Record<ReleaseGateStatus, string> = {
pass: 'Pass',
warn: 'Warn',
block: 'Block',
pending: 'Pending',
unknown: 'Unknown',
};
return labels[status] ?? 'Unknown';
}
export function getRiskTierLabel(tier: ReleaseRiskTier): string {
const labels: Record<ReleaseRiskTier, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
none: 'None',
unknown: 'Unknown',
};
return labels[tier] ?? 'Unknown';
}
export function getEvidencePostureLabel(posture: ReleaseEvidencePosture): string {
const labels: Record<ReleaseEvidencePosture, string> = {
verified: 'Verified',
partial: 'Partial',
missing: 'Missing',
replay_mismatch: 'Replay Mismatch',
unknown: 'Unknown',
};
return labels[posture] ?? 'Unknown';
}
export function getEventIcon(type: ReleaseEventType): string {
const icons: Record<ReleaseEventType, string> = {
created: '+',

View File

@@ -5,8 +5,9 @@
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { catchError, delay, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { PlatformContextStore } from '../context/platform-context.store';
// ============================================================================
// Models
@@ -59,12 +60,34 @@ export const SECURITY_FINDINGS_API_BASE_URL = new InjectionToken<string>('SECURI
// HTTP Implementation
// ============================================================================
interface SecurityFindingProjectionDto {
findingId: string;
cveId: string;
severity: string;
packageName: string;
componentName: string;
releaseId: string;
releaseName: string;
environment: string;
region: string;
reachable: boolean;
reachabilityScore: number;
effectiveDisposition: string;
vexStatus: string;
updatedAt: string;
}
interface SecurityFindingsResponseDto {
items: SecurityFindingProjectionDto[];
}
@Injectable()
export class SecurityFindingsHttpClient implements SecurityFindingsApi {
constructor(
private readonly http: HttpClient,
@Inject(SECURITY_FINDINGS_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore,
private readonly context: PlatformContextStore,
) {}
listFindings(filter?: FindingsFilter): Observable<FindingDto[]> {
@@ -74,18 +97,48 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
if (filter?.environment) params = params.set('environment', filter.environment);
if (filter?.limit) params = params.set('limit', filter.limit.toString());
if (filter?.sort) params = params.set('sort', filter.sort);
return this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
params,
headers: this.buildHeaders(),
}).pipe(
map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])),
);
const selectedRegion = this.context.selectedRegions()[0];
if (selectedRegion) {
params = params.set('region', selectedRegion);
}
if (!filter?.environment) {
const selectedEnvironment = this.context.selectedEnvironments()[0];
if (selectedEnvironment) {
params = params.set('environment', selectedEnvironment);
}
}
return this.http
.get<SecurityFindingsResponseDto>(`${this.baseUrl}/api/v2/security/findings`, {
params,
headers: this.buildHeaders(),
})
.pipe(
map((res) => (res?.items ?? []).map((row) => this.mapV2Finding(row))),
catchError(() =>
this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
params,
headers: this.buildHeaders(),
}).pipe(
map((res: any) => (Array.isArray(res) ? res : (res?.items ?? []))),
),
),
);
}
getFinding(findingId: string): Observable<FindingDetailDto> {
return this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
headers: this.buildHeaders(),
});
return this.http
.get<any>(`${this.baseUrl}/api/v2/security/disposition/${findingId}`, {
headers: this.buildHeaders(),
})
.pipe(
map((res) => this.mapDispositionToDetail(res?.item ?? res, findingId)),
catchError(() =>
this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
headers: this.buildHeaders(),
}),
),
);
}
private buildHeaders(): HttpHeaders {
@@ -96,6 +149,59 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
}
return new HttpHeaders(headers);
}
private mapV2Finding(row: SecurityFindingProjectionDto): FindingDto {
return {
id: row.findingId,
package: row.packageName,
version: row.componentName || 'n/a',
severity: this.mapSeverity(row.severity),
cvss: Math.round((Math.max(0, row.reachabilityScore ?? 0) / 10) * 10) / 10,
reachable: row.reachable,
reachabilityConfidence: row.reachabilityScore,
vexStatus: row.vexStatus || row.effectiveDisposition || 'none',
releaseId: row.releaseId,
releaseVersion: row.releaseName,
delta: 'carried',
environments: row.environment ? [row.environment] : [],
firstSeen: row.updatedAt,
};
}
private mapDispositionToDetail(row: any, fallbackId: string): FindingDetailDto {
const base = this.mapV2Finding({
findingId: row?.findingId ?? fallbackId,
cveId: row?.cveId ?? fallbackId,
severity: 'medium',
packageName: row?.packageName ?? 'unknown',
componentName: row?.componentName ?? 'unknown',
releaseId: row?.releaseId ?? '',
releaseName: row?.releaseName ?? '',
environment: row?.environment ?? '',
region: row?.region ?? '',
reachable: true,
reachabilityScore: 0,
effectiveDisposition: row?.effectiveDisposition ?? 'unknown',
vexStatus: row?.vex?.status ?? row?.effectiveDisposition ?? 'none',
updatedAt: row?.updatedAt ?? new Date().toISOString(),
});
return {
...base,
description: `Disposition: ${row?.effectiveDisposition ?? 'unknown'}`,
references: [],
affectedVersions: [],
fixedVersions: [],
};
}
private mapSeverity(value: string): FindingDto['severity'] {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'CRITICAL' || normalized === 'HIGH' || normalized === 'MEDIUM' || normalized === 'LOW') {
return normalized;
}
return 'MEDIUM';
}
}
// ============================================================================

View File

@@ -0,0 +1,43 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { PlatformContextStore } from './platform-context.store';
@Injectable()
export class GlobalContextHttpInterceptor implements HttpInterceptor {
private readonly context = inject(PlatformContextStore);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (!this.isPack22ContextAwareRoute(request.url)) {
return next.handle(request);
}
let params = request.params;
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
const timeWindow = this.context.timeWindow();
if (region && !params.has('region')) {
params = params.set('region', region);
}
if (environment && !params.has('environment')) {
params = params.set('environment', environment);
}
if (timeWindow && !params.has('timeWindow')) {
params = params.set('timeWindow', timeWindow);
}
return next.handle(request.clone({ params }));
}
private isPack22ContextAwareRoute(url: string): boolean {
return (
url.includes('/api/v2/releases') ||
url.includes('/api/v2/security') ||
url.includes('/api/v2/evidence') ||
url.includes('/api/v2/topology') ||
url.includes('/api/v2/integrations')
);
}
}

View File

@@ -0,0 +1,317 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs';
export interface PlatformContextRegion {
regionId: string;
displayName: string;
sortOrder: number;
enabled: boolean;
}
export interface PlatformContextEnvironment {
environmentId: string;
regionId: string;
environmentType: string;
displayName: string;
sortOrder: number;
enabled: boolean;
}
export interface PlatformContextPreferences {
tenantId: string;
actorId: string;
regions: string[];
environments: string[];
timeWindow: string;
updatedAt: string;
updatedBy: string;
}
const DEFAULT_TIME_WINDOW = '24h';
@Injectable({ providedIn: 'root' })
export class PlatformContextStore {
private readonly http = inject(HttpClient);
private persistPaused = false;
private readonly apiDisabled = this.shouldDisableApiCalls();
readonly regions = signal<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]);
readonly selectedRegions = signal<string[]>([]);
readonly selectedEnvironments = signal<string[]>([]);
readonly timeWindow = signal(DEFAULT_TIME_WINDOW);
readonly loading = signal(false);
readonly initialized = signal(false);
readonly error = signal<string | null>(null);
// Incremented on context updates so route-level stores can trigger refetch.
readonly contextVersion = signal(0);
readonly regionSummary = computed(() => {
const selected = this.selectedRegions();
if (selected.length === 0) {
return 'All regions';
}
if (selected.length === 1) {
const region = this.regions().find((item) => item.regionId === selected[0]);
return region?.displayName ?? selected[0];
}
return `${selected.length} regions`;
});
readonly environmentSummary = computed(() => {
const selected = this.selectedEnvironments();
if (selected.length === 0) {
return 'All environments';
}
if (selected.length === 1) {
const env = this.environments().find((item) => item.environmentId === selected[0]);
return env?.displayName ?? selected[0];
}
return `${selected.length} environments`;
});
initialize(): void {
if (this.initialized() || this.loading()) {
return;
}
if (this.apiDisabled) {
this.loading.set(false);
this.error.set(null);
this.initialized.set(true);
this.persistPaused = false;
return;
}
this.loading.set(true);
this.error.set(null);
this.persistPaused = true;
this.http
.get<PlatformContextRegion[]>('/api/v2/context/regions')
.pipe(take(1))
.subscribe({
next: (regions) => {
const sortedRegions = [...(regions ?? [])].sort((a, b) => {
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
});
this.regions.set(sortedRegions);
this.loadPreferences();
},
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to load global regions.'));
this.loading.set(false);
this.persistPaused = false;
},
});
}
setRegions(regionIds: string[]): void {
const next = this.normalizeIds(regionIds, this.regions().map((item) => item.regionId));
if (this.arraysEqual(next, this.selectedRegions())) {
return;
}
this.selectedRegions.set(next);
this.loadEnvironments(next, this.selectedEnvironments(), true);
}
setEnvironments(environmentIds: string[]): void {
const next = this.normalizeIds(
environmentIds,
this.environments().map((item) => item.environmentId),
);
if (this.arraysEqual(next, this.selectedEnvironments())) {
return;
}
this.selectedEnvironments.set(next);
this.persistPreferences();
this.bumpContextVersion();
}
setTimeWindow(timeWindow: string): void {
const normalized = (timeWindow || DEFAULT_TIME_WINDOW).trim();
if (normalized === this.timeWindow()) {
return;
}
this.timeWindow.set(normalized);
this.persistPreferences();
this.bumpContextVersion();
}
private loadPreferences(): void {
this.http
.get<PlatformContextPreferences>('/api/v2/context/preferences')
.pipe(take(1))
.subscribe({
next: (prefs) => {
const preferredRegions = this.normalizeIds(
prefs?.regions ?? [],
this.regions().map((item) => item.regionId),
);
this.selectedRegions.set(preferredRegions);
this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW);
this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false);
},
error: () => {
// Preferences are optional; continue with default empty context.
this.selectedRegions.set([]);
this.selectedEnvironments.set([]);
this.timeWindow.set(DEFAULT_TIME_WINDOW);
this.loadEnvironments([], [], false);
},
});
}
private loadEnvironments(
regionIds: string[],
preferredEnvironmentIds: string[],
persistAfterLoad: boolean,
): void {
let params = new HttpParams();
if (regionIds.length > 0) {
params = params.set('regions', regionIds.join(','));
}
this.http
.get<PlatformContextEnvironment[]>('/api/v2/context/environments', { params })
.pipe(take(1))
.subscribe({
next: (environments) => {
const sortedEnvironments = [...(environments ?? [])].sort((a, b) => {
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
if (a.regionId !== b.regionId) {
return a.regionId.localeCompare(b.regionId, 'en', { sensitivity: 'base' });
}
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
});
this.environments.set(sortedEnvironments);
const nextEnvironments = this.normalizeIds(
preferredEnvironmentIds,
sortedEnvironments.map((item) => item.environmentId),
);
this.selectedEnvironments.set(nextEnvironments);
if (persistAfterLoad) {
this.persistPreferences();
}
this.finishInitialization();
this.bumpContextVersion();
},
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to load global environments.'));
this.environments.set([]);
this.selectedEnvironments.set([]);
if (persistAfterLoad) {
this.persistPreferences();
}
this.finishInitialization();
this.bumpContextVersion();
},
});
}
private persistPreferences(): void {
if (this.persistPaused || this.apiDisabled) {
return;
}
const payload = {
regions: this.selectedRegions(),
environments: this.selectedEnvironments(),
timeWindow: this.timeWindow(),
};
this.http
.put<PlatformContextPreferences>('/api/v2/context/preferences', payload)
.pipe(take(1))
.subscribe({
error: (err: unknown) => {
this.error.set(this.normalizeError(err, 'Failed to persist global context preferences.'));
},
});
}
private finishInitialization(): void {
this.loading.set(false);
this.initialized.set(true);
this.persistPaused = false;
}
private normalizeIds(values: string[], allowedValues: string[]): string[] {
const allowed = new Set(allowedValues.map((value) => value.toLowerCase()));
const deduped = new Map<string, string>();
for (const raw of values ?? []) {
const trimmed = (raw ?? '').trim();
if (!trimmed) {
continue;
}
const normalized = trimmed.toLowerCase();
if (!allowed.has(normalized)) {
continue;
}
if (!deduped.has(normalized)) {
deduped.set(normalized, normalized);
}
}
return [...deduped.values()];
}
private arraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) {
return false;
}
}
return true;
}
private normalizeError(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
private bumpContextVersion(): void {
this.contextVersion.update((value) => value + 1);
}
private shouldDisableApiCalls(): boolean {
const userAgent = (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent ?? '';
if (userAgent.toLowerCase().includes('jsdom')) {
return true;
}
const protocol = (globalThis as { location?: { protocol?: string } }).location?.protocol ?? '';
return protocol === 'about:';
}
}

View File

@@ -19,6 +19,44 @@ import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
* Used to detect when a route was accessed via legacy URL.
*/
const LEGACY_ROUTE_MAP: Record<string, string> = {
// Pack 22 root migration aliases
'release-control': '/releases',
'release-control/releases': '/releases',
'release-control/approvals': '/releases/approvals',
'release-control/runs': '/releases/activity',
'release-control/deployments': '/releases/activity',
'release-control/promotions': '/releases/activity',
'release-control/hotfixes': '/releases',
'release-control/regions': '/topology/regions',
'release-control/setup': '/topology',
'security-risk': '/security',
'security-risk/findings': '/security/findings',
'security-risk/vulnerabilities': '/security/vulnerabilities',
'security-risk/disposition': '/security/disposition',
'security-risk/sbom': '/security/sbom-explorer/graph',
'security-risk/sbom-lake': '/security/sbom-explorer/table',
'security-risk/vex': '/security/disposition',
'security-risk/exceptions': '/security/disposition',
'security-risk/advisory-sources': '/integrations/feeds',
'evidence-audit': '/evidence',
'evidence-audit/packs': '/evidence/packs',
'evidence-audit/bundles': '/evidence/bundles',
'evidence-audit/evidence': '/evidence/evidence',
'evidence-audit/proofs': '/evidence/proofs',
'evidence-audit/audit-log': '/evidence/audit-log',
'evidence-audit/replay': '/evidence/replay',
'platform-ops': '/operations',
'platform-ops/data-integrity': '/operations/data-integrity',
'platform-ops/orchestrator': '/operations/orchestrator',
'platform-ops/health': '/operations/health',
'platform-ops/quotas': '/operations/quotas',
'platform-ops/feeds': '/operations/feeds',
'platform-ops/offline-kit': '/operations/offline-kit',
'platform-ops/agents': '/topology/agents',
// Home & Dashboard
'dashboard/sources': '/operations/feeds',
'home': '/',
@@ -104,6 +142,12 @@ const LEGACY_ROUTE_MAP: Record<string, string> = {
* These use regex to match dynamic segments.
*/
const LEGACY_ROUTE_PATTERNS: Array<{ pattern: RegExp; oldPrefix: string; newPrefix: string }> = [
{ pattern: /^release-control\/releases\/([^/]+)$/, oldPrefix: 'release-control/releases/', newPrefix: '/releases/' },
{ pattern: /^release-control\/approvals\/([^/]+)$/, oldPrefix: 'release-control/approvals/', newPrefix: '/releases/approvals/' },
{ pattern: /^security-risk\/findings\/([^/]+)$/, oldPrefix: 'security-risk/findings/', newPrefix: '/security/findings/' },
{ pattern: /^security-risk\/vulnerabilities\/([^/]+)$/, oldPrefix: 'security-risk/vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
{ pattern: /^evidence-audit\/packs\/([^/]+)$/, oldPrefix: 'evidence-audit/packs/', newPrefix: '/evidence/packs/' },
// Scan/finding details
{ pattern: /^findings\/([^/]+)$/, oldPrefix: 'findings/', newPrefix: '/security/scans/' },
{ pattern: /^scans\/([^/]+)$/, oldPrefix: 'scans/', newPrefix: '/security/scans/' },

Some files were not shown because too many files have changed in this diff Show More