Skip to main content

Ownership Workflow & Mitigation Tracking -- Research Brief

Executive Summary

SecurityV0 currently generates remediation actions ephemerally on every API call. The actions are never stored, never tracked, and never assigned. Ownership data comes exclusively from connector-ingested relationships (OWNED_BY, CREATED_BY), and the platform has no mechanism for a human operator to assign, override, or attest to ownership. The finding status workflow supports four states (active -> acknowledged -> remediated, or active -> false_positive) but has no connection to remediation actions -- marking a finding as remediated requires only a resolved_by string, with no proof that any specific action was completed.

This document proposes four new data models and their corresponding APIs, storage, and UI surfaces: MitigationActionDoc (persistent, trackable remediation actions), OwnershipAssignmentDoc (platform-level ownership assignments as a live workflow), AttestationDoc (review attestation records), and ReviewCadenceConfig (per-scope recurring review schedules). Together these form the operating loop that Sergey identified as the product moat: inventory + ownership + action, with recurring review and attestation closing the accountability gap.

Critically, ownership assignment as proposed here is an operating workflow -- not a static label. It includes routing (who owns what), follow-through (are mitigation actions progressing), accountability (attestation and review cadence), and lifecycle management (assignment, expiry, revocation). The evidence classification document's DEFAULT_OWNER_ROLES provides static text descriptions; the system proposed here turns those roles into live, trackable assignments with status, history, and governance hooks.

The key architectural decision is that ownership assignment is a platform write -- distinct from the read-only connector model. Connectors read from source systems and never write back. But ownership assignments, mitigation tracking, and attestations are SecurityV0-native data that exists only within the platform. This is not a violation of the read-only model; it is the platform's own governance layer on top of connector-sourced truth.

Current State Analysis

Ownership Model

Ownership is currently derived from entity relationships ingested by connectors:

  • OWNED_BY relationships on identity/workload entities, with optional ownership_level property (primary, secondary, inherited).
  • CREATED_BY as a fallback when no OWNED_BY edges exist (treated as implicit primary ownership by orphaned-ownership.ts).
  • ownership_status on AuthorityPathCurrentState -- a computed string (owned, orphaned, degraded, ambiguous, unknown) displayed in the UI but not directly editable.

Five evaluator rules assess ownership quality:

RuleFinding TypeSeverityTrigger
orphaned-ownershiporphaned_ownershipcriticalNo owners at all, or all owners non-active
orphaned-ownershipownership_degradedhighPrimary owners non-active but secondary/inherited still active
ownership-driftownership_drifthigh (all owners lost) / medium (partial loss)Owners removed or disabled since baseline (see ownership-drift.ts line 73; FindingSeverity allows low | medium | high | critical only)
ownership-ambiguousownership_ambiguousmediumOnly group/team owners, no individual
ownership-unknownownership_unknownmediumNo ownership metadata from any source

The UI's OwnershipSection component (line 601 of AuthorityPathDetailPage.tsx) displays:

  • Automation owner: from workloadEntity.properties.owner_name
  • Runtime identity: from identity entity's owner_name property
  • Departed owner text derived from orphaned_ownership finding's evidence_refs.owner_descriptions

Known codebase bug: The orphaned-ownership.ts evaluator stores relationships and execution_path_count in evidence_refs, but the remediation service reads non_active_count and owner_descriptions -- fields the evaluator never sets. The UI's departed-owner text relies on owner_descriptions, which is always undefined at runtime. This mismatch should be fixed when implementing ownership workflows.

There is no mechanism to assign or override ownership from the platform. The "Create ticket" button in the path header is disabled (Coming soon).

Remediation System

Remediation actions are generated on-the-fly by generatePathRemediation() in remediation-service.ts (789 lines). Key characteristics:

  1. Ephemeral: Actions are computed fresh on every GET /api/v1/authority-paths/:id/remediation call. Nothing is stored.
  2. Finding-driven: Each active finding type maps to 1-3 ActionTemplate objects via a large switch statement in findingActions().
  3. Category-ranked: Actions are sorted by exposure reduction impact: egress(0) > sensitive_access(1) > privilege_scope(2) > governance_ownership(3) > other(4), with resolving actions ranked above mitigating actions.
  4. Capped at 3: After choke-point deduplication, only MAX_ACTIONS = 3 are returned.
  5. Context-enriched: Entity display names, source systems, roles, and sensitivity are interpolated into action text.

The PathRemediationAction interface contains:

  • action (human-readable instruction), rationale, reduction_effect, signals
  • applies_to (resolved entity scope), impact_score (category rank)
  • finding_type, evidence (links to finding_id, path_id, entity_id)
  • Optional business_impact and choke_point_count

The TicketModal component generates copy-pasteable text from these actions -- essentially a clipboard-based ticket creation workflow. There is no integration with any ticket system.

Finding Status Workflow

Current status machine:

active -> acknowledged -> remediated
active -> false_positive
acknowledged -> active (reopen)
acknowledged -> false_positive
remediated -> (terminal)
false_positive -> (terminal)

Key observations:

  • remediated and false_positive are terminal states -- no transitions out.
  • remediated requires resolved_by (free-text string, typically email).
  • false_positive requires reason (free-text string).
  • There is no connection between finding remediation and specific actions taken.
  • The evaluator can reopen findings by re-detecting them (upsert with active status), but remediated/false_positive findings are not re-evaluated.
  • acknowledged_by is optional on acknowledge transition.

Baseline & Reports

Baselines (BaselineMetadataDoc) support types: initial, scheduled, manual, pre_retention_expiry. They have a schedule_id field, suggesting scheduled baseline creation was planned. This provides a precedent for scheduled/recurring operations.

Reports (ReportDoc) have types scan_digest and assessment, with a lifecycle: pending -> generating -> completed | failed. Reports are generated by system or a user identifier.

Posture Snapshots (PostureSnapshotDoc) track ownership_invalid_count per sync, providing a historical record of ownership health.

Proposed Data Models

MitigationActionDoc

Persistent remediation action tracking. Each document represents a single remediation action that was either auto-proposed by the remediation engine or manually created by an operator.

export const MITIGATION_ACTION_STATUSES = [
"proposed", // Generated by remediation engine or created by operator
"assigned", // Assigned to a specific person/team
"in_progress", // Assignee has started work
"completed", // Assignee reports completion
"verified", // Completion independently confirmed (action validated as performed)
"rejected", // Action deemed unnecessary or inappropriate
"deferred", // Intentionally postponed with justification
] as const;

export type MitigationActionStatus = (typeof MITIGATION_ACTION_STATUSES)[number];

export const VALID_MITIGATION_TRANSITIONS: Record<MitigationActionStatus, MitigationActionStatus[]> = {
proposed: ["assigned", "rejected", "deferred"],
assigned: ["in_progress", "rejected", "deferred"],
in_progress: ["completed", "assigned", "deferred"],
completed: ["verified", "in_progress"], // Can revert to in_progress if verification fails
verified: [], // Terminal
rejected: ["proposed"], // Can reopen
deferred: ["proposed", "assigned"], // Can un-defer
};

export interface MitigationActionDoc {
_id: string;
tenant_id: string;

// --- Origin ---
/** How this action was created */
origin: "auto_proposed" | "manual";
/** Remediation engine snapshot that generated this action (null for manual) */
source_generation_id?: string;

// --- Action content (copied from ephemeral generation at creation time) ---
action: string;
rationale: string;
reduction_effect: string;
signals: string[];
applies_to: string;
impact_score: number;
finding_type: FindingType;
business_impact?: string;

// --- Linkage ---
/** Finding that triggered this action */
finding_id: string;
/** Authority path this action relates to */
path_id: string;
/** Entity (workload/identity) this action targets */
entity_id: string;
/** External ticket system reference (e.g. ServiceNow incident ID) */
external_ticket_id?: string;
external_ticket_url?: string;

// --- Lifecycle ---
status: MitigationActionStatus;
assigned_to?: string; // Email or user identifier
assigned_at?: Date;
due_date?: Date;
started_at?: Date;
completed_at?: Date;
verified_at?: Date;
verified_by?: string;

// --- Notes ---
rejection_reason?: string;
deferral_reason?: string;
deferral_until?: Date; // When deferred, when to re-propose
completion_notes?: string;
verification_notes?: string;

// --- Staleness tracking ---
/** Hash of the finding + path state at creation time. Used to detect when
* the underlying posture has changed and the action may need re-evaluation. */
source_state_hash: string;
/** Set to true when evaluator re-runs and the source state hash no longer matches */
stale: boolean;
stale_since?: Date;
stale_reason?: string;

// --- Audit trail ---
/** Full status history for governance compliance. Single timestamps (e.g. completed_at)
* lose history on re-transitions (completed -> in_progress -> completed). */
status_history: Array<{
status: MitigationActionStatus;
changed_at: Date;
changed_by: string;
}>;

// --- Concurrency control ---
/** Optimistic locking version. Incremented on every write. Status transition
* requests must include the current version; a mismatch returns 409 Conflict. */
version: number;

// --- Timestamps ---
created_at: Date;
updated_at: Date;
}

OwnershipAssignmentDoc

Platform-level ownership overrides. These are SecurityV0-native records, not connector data. They take precedence over connector-sourced ownership for governance purposes.

export const OWNERSHIP_ASSIGNMENT_SCOPES = [
"workload", // Ownership of a specific workload
"identity", // Ownership of a specific identity
"path", // Ownership of a specific authority path
"cluster", // Ownership of a finding cluster
] as const;

export type OwnershipAssignmentScope = (typeof OWNERSHIP_ASSIGNMENT_SCOPES)[number];

export interface OwnershipAssignmentDoc {
_id: string;
tenant_id: string;

// --- What is being owned ---
scope: OwnershipAssignmentScope;
/** The entity, path, or cluster being assigned ownership */
target_id: string;
/** Human-readable label for the target */
target_label: string;

// --- Who owns it ---
owner_email: string;
owner_name: string;
/** Optional team/group for routing */
owner_team?: string;
ownership_level: "primary" | "secondary";

// --- Lifecycle ---
/** Whether this overrides connector-sourced ownership */
is_override: boolean;
/** If override, what connector-sourced owner is being replaced */
overrides_source_owner_id?: string;
overrides_source_owner_name?: string;

assigned_by: string; // Who made the assignment
assigned_at: Date;
effective_from: Date;
effective_until?: Date; // Null = indefinite
revoked_at?: Date;
revoked_by?: string;
revocation_reason?: string;

/** Computed status, updated on write. Avoids a three-condition check
* (revoked_at == null && effective_from <= now && (effective_until == null || effective_until > now))
* on every "active" query. Updated by the write path and a periodic TTL job for expiry. */
status: "active" | "revoked" | "expired";

// --- Metadata ---
notes?: string;

created_at: Date;
updated_at: Date;
}

AttestationDoc

Review attestation records. An attestation is a human statement that a specific scope (finding, path, entity, cluster) has been reviewed and its current state is accepted, with optional conditions.

export const ATTESTATION_OUTCOMES = [
"accepted", // Reviewed and accepted as-is
"accepted_with_risk", // Reviewed, risk acknowledged, accepted with conditions
"remediation_required", // Reviewed, remediation action required before next review
"escalated", // Reviewed, escalated to higher authority
] as const;

export type AttestationOutcome = (typeof ATTESTATION_OUTCOMES)[number];

export const ATTESTATION_SCOPES = [
"finding",
"path",
"entity",
"cluster",
] as const;

export type AttestationScope = (typeof ATTESTATION_SCOPES)[number];

export interface AttestationDoc {
_id: string;
tenant_id: string;

// --- What was reviewed ---
scope: AttestationScope;
target_id: string;
target_label: string;

// --- Review result ---
outcome: AttestationOutcome;
attested_by: string; // Email/user identifier
attested_at: Date;

// --- Context at time of attestation ---
/** Snapshot of key metrics at attestation time for staleness detection */
context_snapshot: {
finding_count: number;
active_finding_count: number;
max_severity: string | null;
ownership_status: string;
execution_30d: number;
/** Distribution of evidence classifications (e.g. observed_execution vs structural_authority).
* A shift in classification mix is material for staleness detection -- if findings move from
* structural_authority to observed_execution, the risk profile has changed. */
classification_distribution: Record<string, number>;
};
/** Hash of the context_snapshot, for quick staleness comparison */
context_hash: string;

// --- Conditions and notes ---
conditions?: string; // Free text conditions for accepted_with_risk
review_notes?: string;
/** Linked mitigation actions that must be completed (for remediation_required) */
required_mitigation_ids?: string[];

// --- Validity ---
/** Attestation is valid until this date or until context changes */
valid_until: Date;
/** Set to true when posture changes invalidate this attestation */
stale: boolean;
stale_since?: Date;
stale_reason?: string;
/** The review cadence that triggered this attestation (if any) */
review_cadence_id?: string;

created_at: Date;
updated_at: Date;
}

ReviewCadenceConfig

Per-scope recurring review schedule configuration.

export const REVIEW_CADENCE_INTERVALS = [
"weekly",
"biweekly",
"monthly",
"quarterly",
] as const;

export type ReviewCadenceInterval = (typeof REVIEW_CADENCE_INTERVALS)[number];

export const REVIEW_CADENCE_SCOPES = [
"severity", // All findings of a given severity
"finding_type", // All findings of a given type
"cluster", // A specific cluster
"path", // A specific path
"entity", // A specific entity
"global", // Entire tenant
] as const;

export type ReviewCadenceScope = (typeof REVIEW_CADENCE_SCOPES)[number];

export interface ReviewCadenceConfig {
_id: string;
tenant_id: string;

// --- Scope ---
scope: ReviewCadenceScope;
/** Scope qualifier: severity value, finding_type value, cluster/path/entity ID */
scope_value: string;
scope_label: string;

// --- Schedule ---
interval: ReviewCadenceInterval;
/** Next review due date */
next_review_at: Date;
/** Last completed review date */
last_reviewed_at?: Date;
last_attestation_id?: string;

// --- Ownership ---
reviewer_email: string;
reviewer_name: string;
backup_reviewer_email?: string;

// --- Configuration ---
enabled: boolean;
/** Auto-create mitigation actions on overdue review */
auto_escalate: boolean;
escalation_target?: string;

created_by: string;
created_at: Date;
updated_at: Date;
}

Status Workflows

Mitigation Action Lifecycle

                                    +-----------+
+--->| rejected |---+
| +-----------+ |
| ^ | (reopen)
| | v
+----------+ +----------+ | +-----------+ +----------+
| proposed |--->| assigned |---+--->|in_progress|--->| completed|--->| verified |
+----------+ +----------+ | +-----------+ +----------+ +----------+
| | | | |
| | | v |
| | | +-----------+ |
| +--------|+-->| deferred | |
+-----------------------+| +-----------+ |
| | |
+---------+ (un-defer) |
|
(verification fails) |
+---------------------------+

Transition rules:

FromToRequired Fields
proposedassignedassigned_to, assigned_at
proposedrejectedrejection_reason
proposeddeferreddeferral_reason, deferral_until
assignedin_progressstarted_at
assignedrejectedrejection_reason
assigneddeferreddeferral_reason, deferral_until
in_progresscompletedcompleted_at, completion_notes
in_progressassigned(reassignment) assigned_to
in_progressdeferreddeferral_reason, deferral_until
completedverifiedverified_by, verified_at
completedin_progress(verification failure, no extra fields)
rejectedproposed(reopen, clears rejection_reason)
deferredproposed(un-defer, clears deferral fields)
deferredassignedassigned_to

No auto-verification from finding status. When a finding transitions to remediated, linked mitigation actions should not auto-transition to verified. Verification must be an independent act — someone confirms the action was actually performed and the posture improved. If verification were derived from a manual "Remediate" click on a finding, verified would only mean "someone clicked remediate," not "the action was independently validated." The evaluator can also reopen findings on the next run, which would make auto-verified actions misleading.

Instead, the flow is: mitigation actions reach completed → an operator (or future automation) independently verified them → the UI suggests "Ready for remediation" on the finding → the operator remediates the finding. Verification supports remediation, not the other way around.

Staleness detection: On each evaluator run, compute source_state_hash from finding.evidence_refs + path.current_state. If it differs from the stored hash on any non-terminal mitigation action, set stale = true with reason. Stale actions should be flagged in the UI but not auto-closed -- the assignee decides whether the action is still relevant.

Ownership Assignment Lifecycle

Platform ownership assignments are a live operating workflow, not static metadata labels. Each assignment has a status (active, revoked, expired), a full lifecycle (creation, expiry, revocation), and governance hooks (linked mitigation actions, attestation responsibility, review cadence). The system enables routing (who should act), follow-through (are they acting), and accountability (when did they last attest).

Platform ownership assignments coexist with connector-sourced ownership:

  1. Connector data is ground truth for what exists. The OWNED_BY, CREATED_BY relationships from connectors describe what the source system says.
  2. Platform assignments are governance overrides. When an operator assigns ownership via the platform, it creates an OwnershipAssignmentDoc that takes precedence for governance workflows (action routing, review assignment, attestation responsibility).
  3. Findings remain active — governance state runs in parallel. Platform assignments do NOT suppress source-state findings (orphaned_ownership, ownership_unknown, ownership_ambiguous). These findings represent what the source system says, and suppressing them would rewrite the posture the product is supposed to be reviewing. Instead, the platform adds a parallel governance annotation: "platform owner assigned" is surfaced alongside the active finding. The UI shows both: the finding (source system says orphaned) and the governance state (platform has assigned owner X). This preserves deterministic truth while showing that governance action has been taken.
  4. Refresh handling: When connectors re-sync, if they bring new OWNED_BY data that resolves the orphaned state, the finding is naturally resolved by the evaluator (no platform intervention needed). If connector data contradicts the platform assignment, flag the assignment for review but do not auto-revoke it.

Precedence model:

Effective owner = platform_assignment ?? connector_owned_by ?? connector_created_by ?? unowned

Platform Ownership Does NOT Change Posture Metrics

Platform ownership assignments do not suppress findings and therefore do not change AuthorityPathCurrentState.active_finding_count, max_finding_severity, posture snapshots, or reports. The source-state posture remains exactly as the evaluator computed it from connector data.

What platform assignments DO provide:

  • Governance metadata alongside findings: The UI shows "Platform owner: X assigned on DATE" next to the active orphaned_ownership finding, proving that governance action has been taken.
  • Action routing: Mitigation actions and review assignments can be routed to the platform-assigned owner.
  • Attestation responsibility: The assigned owner is responsible for reviewing and attesting to the current state.
  • Resolution path: When the source system is updated (connector brings new OWNED_BY data), the evaluator naturally resolves the finding. The platform assignment helped route the work that led to the source fix.

This model preserves deterministic truth: the posture reflects what the source system says, while the governance layer tracks what the organization is doing about it.

Attestation Lifecycle

  1. Creation: An operator reviews a scope (finding, path, entity, cluster) and creates an attestation with an outcome.
  2. Validity window: Attestations have a valid_until date derived from the review cadence. A quarterly cadence means the attestation is valid for 90 days.
  3. Staleness detection: After each evaluator run or sync, compare the current context_snapshot hash against stored attestations. If the posture changed materially (new findings, severity increase, ownership change, execution pattern change), mark the attestation stale.
  4. Stale attestation handling: Stale attestations remain on record but no longer satisfy review requirements. A new attestation is required.
  5. Attestation does not suppress findings. An accepted or accepted_with_risk attestation means the operator has reviewed and accepted the current state. The finding remains active but the attestation proves governance review occurred.

Staleness triggers:

  • New active finding detected on the same scope
  • Finding severity increased
  • Ownership status changed
  • Execution count changed by more than 50% (configurable threshold)
  • New authority path appeared in scope
  • Evidence classification distribution shifted (e.g. findings moved from structural_authority to observed_execution)

API Contract

Validation: All proposed endpoints require Zod request validation schemas, following the existing codebase pattern where every route handler validates req.body / req.query through a Zod schema before processing. The schemas should be co-located with the route files (e.g. src/api/routes/mitigation-actions.schema.ts).

Mitigation Actions

List mitigation actions:

GET /api/v1/mitigation-actions
?status=assigned,in_progress
&assigned_to=alice@example.com
&finding_id=f-123
&path_id=ap-456
&entity_id=e-789
&overdue=true
&stale=true
&sort=-due_date
&limit=50
&cursor=...

Response 200:
{
data: MitigationActionDoc[],
cursor: { next: string | null, has_more: boolean },
meta: {
total_count: number,
by_status: Record<MitigationActionStatus, number>,
overdue_count: number,
stale_count: number
}
}

Get single action:

GET /api/v1/mitigation-actions/:id

Response 200:
{ data: MitigationActionDoc }

Create action (manual or from ephemeral generation):

POST /api/v1/mitigation-actions
Body: {
// When creating from remediation guidance:
from_remediation?: {
path_id: string;
finding_id: string;
/** Content hash of the action text, used to match the intended action from the
* ephemeral array. Using an array index would be brittle because re-generation
* between the user viewing and clicking "Track" can reorder or change actions. */
action_content_hash: string;
};
// When creating manually:
action?: string;
rationale?: string;
finding_id?: string;
path_id?: string;
entity_id?: string;
// Optional immediate assignment:
assigned_to?: string;
due_date?: string; // ISO 8601
}

Response 201:
{ data: MitigationActionDoc }

When from_remediation is provided, the server calls generatePathRemediation() internally, matches the action by action_content_hash (a hash of the action text and finding_id), and persists it. This ensures the stored action matches what the user saw in the UI even if the ephemeral array has been reordered or regenerated between view and click.

Update action status:

PATCH /api/v1/mitigation-actions/:id/status
Body: {
status: MitigationActionStatus;
/** Required for optimistic locking. Must match current doc version. */
version: number;
assigned_to?: string;
rejection_reason?: string;
deferral_reason?: string;
deferral_until?: string;
completion_notes?: string;
verified_by?: string;
verification_notes?: string;
}

Response 200:
{ data: MitigationActionDoc }

Response 409:
{ error: { code: "INVALID_STATUS_TRANSITION" | "VERSION_CONFLICT", message: "..." } }

Update action metadata:

PATCH /api/v1/mitigation-actions/:id
Body: {
due_date?: string;
assigned_to?: string;
external_ticket_id?: string;
external_ticket_url?: string;
}

Response 200:
{ data: MitigationActionDoc }

Summary/aggregate endpoint:

GET /api/v1/mitigation-actions/summary
?entity_id=e-789
&path_id=ap-456

Response 200:
{
data: {
total: number,
by_status: Record<MitigationActionStatus, number>,
overdue_count: number,
stale_count: number,
avg_time_to_complete_days: number | null,
oldest_open_action_date: string | null,
}
}

Ownership Assignment

Assign ownership:

POST /api/v1/ownership-assignments
Body: {
scope: OwnershipAssignmentScope;
target_id: string;
owner_email: string;
owner_name: string;
owner_team?: string;
ownership_level?: "primary" | "secondary"; // Default: "primary"
notes?: string;
effective_until?: string;
}

Response 201:
{ data: OwnershipAssignmentDoc }

List assignments:

GET /api/v1/ownership-assignments
?scope=workload
&target_id=e-123
&owner_email=alice@example.com
&active=true // Only non-revoked, currently effective
&limit=50
&cursor=...

Response 200:
{
data: OwnershipAssignmentDoc[],
cursor: { next: string | null, has_more: boolean },
meta: { total_count: number }
}

Revoke assignment:

PATCH /api/v1/ownership-assignments/:id/revoke
Body: {
revocation_reason?: string;
}

Response 200:
{ data: OwnershipAssignmentDoc } // With revoked_at set

HTTP DELETE with a request body is unconventional and no existing DELETE endpoints in the codebase use one. PATCH to a /revoke sub-resource is consistent with the status-transition pattern used elsewhere.

Bulk assignment (for cluster-level ownership):

POST /api/v1/ownership-assignments/bulk
Body: {
/** Maximum 100 items per request, consistent with existing batch endpoint patterns. */
assignments: Array<{
scope: OwnershipAssignmentScope;
target_id: string;
owner_email: string;
owner_name: string;
}>; // max length: 100
}

Response 201:
{ data: { created: number, skipped: number } }

Attestation

Create attestation:

POST /api/v1/attestations
Body: {
scope: AttestationScope;
target_id: string;
outcome: AttestationOutcome;
attested_by: string;
conditions?: string;
review_notes?: string;
required_mitigation_ids?: string[];
valid_until?: string; // If omitted, derived from review cadence
}

Response 201:
{ data: AttestationDoc }

List attestations:

GET /api/v1/attestations
?scope=finding
&target_id=f-123
&stale=false
&attested_by=alice@example.com
&sort=-attested_at
&limit=50
&cursor=...

Response 200:
{
data: AttestationDoc[],
cursor: { next: string | null, has_more: boolean },
meta: { total_count: number, stale_count: number }
}

Get review status for a scope:

GET /api/v1/attestations/status
?scope=path
&target_id=ap-456

Response 200:
{
data: {
last_attestation: AttestationDoc | null,
is_current: boolean, // Not stale and not expired
next_review_due: string | null,
days_until_due: number | null,
overdue: boolean,
}
}

Review Cadence

Create/update cadence:

POST /api/v1/review-cadences
Body: {
scope: ReviewCadenceScope;
scope_value: string;
scope_label: string;
interval: ReviewCadenceInterval;
reviewer_email: string;
reviewer_name: string;
backup_reviewer_email?: string;
auto_escalate?: boolean;
escalation_target?: string;
}

Response 201:
{ data: ReviewCadenceConfig }

List cadences:

GET /api/v1/review-cadences
?scope=severity
&overdue=true
&enabled=true

Response 200:
{
data: ReviewCadenceConfig[],
meta: { total_count: number, overdue_count: number }
}

Update cadence:

PATCH /api/v1/review-cadences/:id
Body: {
interval?: ReviewCadenceInterval;
reviewer_email?: string;
reviewer_name?: string;
enabled?: boolean;
auto_escalate?: boolean;
}

Response 200:
{ data: ReviewCadenceConfig }

Delete cadence:

DELETE /api/v1/review-cadences/:id

Response 204

Storage Adapter Changes

New Collections

Add to COLLECTION_NAMES in src/storage/mongo/collections.ts:

export const COLLECTION_NAMES = {
// ... existing ...
mitigationActions: "mitigation_actions",
ownershipAssignments: "ownership_assignments",
attestations: "attestations",
reviewCadences: "review_cadences",
} as const;

Indexes

mitigation_actions:

// Primary query: list by tenant, status, sorted by due_date
{ tenant_id: 1, status: 1, due_date: 1 } // name: "tenant_status_due"

// Query by assignee
{ tenant_id: 1, assigned_to: 1, status: 1 } // name: "tenant_assignee_status"

// Query by finding (includes status so staleness queries don't scan terminal actions)
{ tenant_id: 1, finding_id: 1, status: 1 } // name: "tenant_finding_status"

// Query by path
{ tenant_id: 1, path_id: 1, status: 1 } // name: "tenant_path_status"

// Query by entity
{ tenant_id: 1, entity_id: 1, status: 1 } // name: "tenant_entity_status"

// Overdue detection (non-terminal statuses with past due_date)
{ tenant_id: 1, status: 1, due_date: 1, stale: 1 } // name: "tenant_status_due_stale"

ownership_assignments:

// Primary query: active assignments for a target
{ tenant_id: 1, target_id: 1, revoked_at: 1 } // name: "tenant_target_active"

// Query by scope
{ tenant_id: 1, scope: 1, revoked_at: 1 } // name: "tenant_scope_active"

// Query by owner
{ tenant_id: 1, owner_email: 1, revoked_at: 1 } // name: "tenant_owner_active"

attestations:

// Primary query: attestations for a target
{ tenant_id: 1, target_id: 1, attested_at: -1 } // name: "tenant_target_latest"

// Query by scope and staleness
{ tenant_id: 1, scope: 1, stale: 1, valid_until: 1 } // name: "tenant_scope_stale_expiry"

// Query by attester
{ tenant_id: 1, attested_by: 1, attested_at: -1 } // name: "tenant_attester_latest"

review_cadences:

// Primary query: overdue reviews
{ tenant_id: 1, enabled: 1, next_review_at: 1 } // name: "tenant_enabled_next_review"

// Query by scope
{ tenant_id: 1, scope: 1, scope_value: 1 } // name: "tenant_scope_value"
// unique: true -- one cadence per scope+value

Query and Summary Types

The StorageAdapter methods reference several query/summary types. Their concrete definitions:

export interface MitigationActionQuery {
status?: MitigationActionStatus | MitigationActionStatus[];
assigned_to?: string;
finding_id?: string;
path_id?: string;
entity_id?: string;
overdue?: boolean;
stale?: boolean;
sort?: string; // e.g. "-due_date"
limit?: number;
cursor?: string;
}

export interface MitigationSummary {
total: number;
by_status: Record<MitigationActionStatus, number>;
overdue_count: number;
stale_count: number;
avg_time_to_complete_days: number | null;
oldest_open_action_date: string | null;
}

export interface OwnershipAssignmentQuery {
scope?: OwnershipAssignmentScope;
target_id?: string;
owner_email?: string;
status?: "active" | "revoked" | "expired";
limit?: number;
cursor?: string;
}

export interface AttestationQuery {
scope?: AttestationScope;
target_id?: string;
stale?: boolean;
attested_by?: string;
sort?: string;
limit?: number;
cursor?: string;
}

export interface ReviewCadenceQuery {
scope?: ReviewCadenceScope;
overdue?: boolean;
enabled?: boolean;
}

StorageAdapter Interface Extensions

export interface StorageAdapter {
// ... existing methods ...

// === Mitigation Actions ===
insertMitigationAction(action: MitigationActionDoc): Promise<void>;
getMitigationAction(tenantId: string, actionId: string): Promise<MitigationActionDoc | null>;
updateMitigationAction(
tenantId: string,
actionId: string,
update: Partial<MitigationActionDoc>
): Promise<MitigationActionDoc | null>;
queryMitigationActions(
tenantId: string,
query: MitigationActionQuery
): Promise<MitigationActionDoc[]>;
countMitigationActions(
tenantId: string,
query?: MitigationActionQuery
): Promise<number>;
aggregateMitigationSummary(
tenantId: string,
query?: MitigationActionQuery
): Promise<MitigationSummary>;
markMitigationActionsStale(
tenantId: string,
findingId: string,
reason: string
): Promise<number>;

// === Ownership Assignments ===
insertOwnershipAssignment(assignment: OwnershipAssignmentDoc): Promise<void>;
getOwnershipAssignment(tenantId: string, id: string): Promise<OwnershipAssignmentDoc | null>;
getActiveOwnershipAssignments(
tenantId: string,
targetId: string
): Promise<OwnershipAssignmentDoc[]>;
revokeOwnershipAssignment(
tenantId: string,
id: string,
revokedBy: string,
reason?: string
): Promise<OwnershipAssignmentDoc | null>;
queryOwnershipAssignments(
tenantId: string,
query: OwnershipAssignmentQuery
): Promise<OwnershipAssignmentDoc[]>;

// === Attestations ===
insertAttestation(attestation: AttestationDoc): Promise<void>;
getAttestation(tenantId: string, id: string): Promise<AttestationDoc | null>;
getLatestAttestation(
tenantId: string,
scope: string,
targetId: string
): Promise<AttestationDoc | null>;
queryAttestations(tenantId: string, query: AttestationQuery): Promise<AttestationDoc[]>;
markAttestationsStale(
tenantId: string,
targetId: string,
reason: string
): Promise<number>;

// === Review Cadences ===
upsertReviewCadence(cadence: ReviewCadenceConfig): Promise<{ upserted: boolean }>;
getReviewCadence(tenantId: string, id: string): Promise<ReviewCadenceConfig | null>;
queryReviewCadences(tenantId: string, query: ReviewCadenceQuery): Promise<ReviewCadenceConfig[]>;
deleteReviewCadence(tenantId: string, id: string): Promise<boolean>;
getOverdueReviewCadences(tenantId: string): Promise<ReviewCadenceConfig[]>;
}

Migration

No backfill of existing ephemeral actions. The ephemeral remediation system continues to work as-is. When users click "Track this action" (new button alongside existing "Create Ticket"), a MitigationActionDoc is created from the ephemeral data. Over time, the ephemeral system becomes the proposal engine and the persistent system becomes the tracking layer.

Ownership backfill consideration: On first deployment, we could run a one-time migration that creates OwnershipAssignmentDoc records from existing OWNED_BY relationships where the owner is active. This seeds the platform ownership layer. However, this is optional -- the system works without it, using connector data as the default.

UI Design

Ownership Assignment Panel

Replace the current OwnershipSection component (lines 601-665 of AuthorityPathDetailPage.tsx) with an interactive panel:

Layout:

  • Header: "Ownership" with Users icon (keep existing)
  • Row 1: Platform owner -- shows assigned owner from OwnershipAssignmentDoc or "Not assigned" with an "Assign" button
  • Row 2: Source system owner -- shows connector-sourced OWNED_BY data (read-only, with source system badge)
  • Row 3: Runtime identity owner -- shows identity entity's owner (read-only)
  • Status badge: Shows owned, orphaned, degraded, ambiguous, unknown based on source system data (unchanged by platform assignments)
  • If platform owner is assigned and source system shows orphaned: display "Platform owner assigned — source still orphaned" badge (governance state alongside source truth)

Assign flow:

  1. Click "Assign owner"
  2. Inline form appears: email input, name input, optional team, primary/secondary toggle
  3. Submit creates OwnershipAssignmentDoc via API
  4. Section updates immediately to show platform assignment
  5. Source-state finding remains active. The governance layer now shows action has been taken. When the source system is updated (e.g., operator fixes ownership in IAM), the evaluator naturally resolves the finding on next run.

Mitigation Tracking Dashboard

New section in AuthorityPathDetailPage, below the existing RemediationGuidance component:

"Tracked Actions" section:

  • Lists all MitigationActionDoc records linked to this path
  • Each action row shows: action text, status badge, assignee avatar/name, due date (with overdue highlighting), stale indicator
  • Status can be updated inline via dropdown
  • "Track" button on each ephemeral remediation action (in the existing RemediationGuidance section) creates a MitigationActionDoc

New top-level page: /mitigation-actions:

  • Table view of all mitigation actions across the tenant
  • Filterable by: status, assignee, overdue, stale, finding type, entity
  • Summary cards at top: total open, overdue count, completion rate, average time to complete
  • Bulk actions: assign, defer, reject

Attestation Workflow

"Review & Attest" button on the AuthorityPathDetailPage header (replacing the disabled "Create ticket" button):

  1. Opens a modal showing the current state summary: finding count, severity, ownership status, execution stats
  2. Four outcome buttons: Accept, Accept with Risk, Require Remediation, Escalate
  3. Required fields based on outcome:
    • Accept: review_notes (optional)
    • Accept with Risk: conditions (required), review_notes (optional)
    • Require Remediation: select mitigation actions (required), review_notes (optional)
    • Escalate: escalation_target (required), review_notes (optional)
  4. Creates AttestationDoc via API
  5. Shows attestation badge on path: "Reviewed 3 days ago by Alice" or "Review overdue (14 days)"

Finding detail page integration:

  • Show latest attestation status below the StatusWorkflow component
  • If finding is covered by a non-stale attestation: "Reviewed and accepted by Alice on 2026-03-20"
  • If attestation is stale: "Previous review stale -- posture changed since last attestation"

Finding Status Integration

The existing finding status workflow (StatusWorkflow.tsx) should be extended:

  • When a finding has linked mitigation actions: show "N actions tracked" badge
  • When all linked actions reach verified: show "Ready for remediation" prompt
  • Auto-suggest "Remediate" transition when all linked actions are verified
  • Show "N actions overdue" warning when linked actions are past due_date

Finding remediation and action verification are independent acts. Verification means "the action was independently confirmed as performed." Remediation means "the finding is resolved." Neither auto-triggers the other.

Integration Points

Remediation Service

The existing generatePathRemediation() function continues to generate ephemeral actions. The new persistent layer sits on top:

  1. Proposal flow: GET /api/v1/authority-paths/:id/remediation returns ephemeral actions as before. The UI adds a "Track" button on each action.
  2. Persistence flow: POST /api/v1/mitigation-actions with from_remediation re-generates and captures the specific action.
  3. No auto-creation: Actions are not automatically persisted on evaluator runs. The operator decides which actions to track. This prevents database bloat and keeps the operator in control.
  4. Future consideration: A tenant-level setting could enable auto-proposing critical-severity actions (auto-create with status proposed), but this should not be in Phase 1.

Evaluator

Ownership rules remain unchanged. The evaluator does NOT check platform assignments. Ownership findings (orphaned_ownership, ownership_unknown, ownership_ambiguous) continue to fire based solely on connector-sourced data. This preserves the deterministic evaluation model — the evaluator reports what the source system says, without platform governance state influencing posture assessment.

Platform assignments are surfaced in the UI and API as a parallel governance layer, not as evaluator input. The API endpoint GET /api/v1/ownership-assignments?target_id=X provides the governance state that the UI renders alongside findings.

Attestation influence: Attestations should NOT influence finding evaluation. Findings represent ground truth about posture. Attestations represent governance review status. Suppressing findings based on attestation would undermine the deterministic trust model. Instead, attestation status is surfaced alongside findings in the UI.

Staleness marking: After each evaluator run that upserts findings, compute the state hash for each affected finding+path and compare against stored mitigation actions and attestations. Mark stale any that no longer match.

Attestation staleness performance: A full-scan comparison of all attestation context hashes on every sync would be expensive at scale. Instead, use incremental checking: only re-hash and compare attestations for entities and paths that the current evaluator run actually touched (i.e. where findings were upserted, severity changed, or ownership status changed). The evaluator already tracks which paths it evaluated; pass that set to the staleness checker. Attestations for untouched scopes are guaranteed unchanged and can be skipped.

External Ticket Systems

Phase 4 scope. The integration strategy:

  1. One-way push from SecurityV0 to ticket systems (ServiceNow, Jira, etc.).
  2. Not a violation of read-only model: Ticket systems are the customer's action-tracking systems, not source systems we read from. We push work items to them; we don't read identity/permission data from them.
  3. Bidirectional status sync (optional, Phase 4+): Poll ticket system for status updates to keep MitigationActionDoc.status in sync. This is a read from the ticket system, which is acceptable.
  4. Implementation pattern: On MitigationActionDoc creation or status change, optionally push to configured ticket system. Store external_ticket_id and external_ticket_url on the doc.
  5. Read-only model boundary: We never write OWNED_BY relationships back to Entra ID, GCP IAM, etc. We do write tickets to ServiceNow/Jira because those are action-routing systems, not identity source systems.

Implementation Sequence

Phase 1: Persistent Mitigation Actions (2-3 sprints)

Scope:

  • MitigationActionDoc type definition in src/domain/mitigation-actions/types.ts
  • MongoDB collection, indexes, adapter in src/storage/mongo/adapters/mitigation-action-adapter.ts
  • StorageAdapter interface extension
  • REST API routes in src/api/routes/mitigation-actions.ts
  • "Track" button on existing RemediationGuidance component
  • "Tracked Actions" section on AuthorityPathDetailPage
  • Basic /mitigation-actions list page in the UI
  • Staleness detection in evaluator post-run hook

Why first: This is the highest-value addition. It turns ephemeral guidance into trackable, assignable work. It directly addresses "pending mitigations are first-class product value."

Phase 2: Ownership Assignment (1-2 sprints)

Scope:

  • OwnershipAssignmentDoc type definition in src/domain/ownership-assignments/types.ts
  • MongoDB collection, indexes, adapter
  • REST API routes
  • Replace OwnershipSection component with interactive panel
  • Keep evaluator rules unchanged; surface platform assignments as parallel governance state in API/UI
  • Bulk assignment endpoint for cluster-level ownership

Why second: Ownership assignment is the most visible gap -- the "Coming soon" button. It immediately improves routing, accountability, and review workflows, even though source-state ownership findings remain active until the underlying system is fixed.

Phase 3: Attestation & Review Cadence (2 sprints)

Scope:

  • AttestationDoc and ReviewCadenceConfig type definitions
  • MongoDB collections, indexes, adapters
  • REST API routes
  • "Review & Attest" modal on path detail page
  • Review cadence configuration UI (settings page)
  • Staleness detection for attestations
  • Overdue review indicators

Why third: Attestation and review cadence complete the operating loop. They require Phase 1 (mitigation actions to link to) and Phase 2 (ownership to know who reviews).

Phase 4: External Ticket Integration (1-2 sprints)

Scope:

  • Ticket system integration service (ServiceNow first)
  • Push-on-create for mitigation actions
  • Optional bidirectional status sync
  • Configuration UI for ticket system connection

Why last: External integration is valuable but not required for the core operating loop. The clipboard-based TicketModal remains functional as a stopgap.

Risk Analysis

Read-Only Model Tension

Resolution: There is no tension. The read-only connector model means connectors never write back to source systems (Entra ID, GCP IAM, AWS IAM, etc.). Ownership assignments, mitigation actions, and attestations are SecurityV0-native data -- they exist only within the platform's own MongoDB. This is the same pattern as findings, evidence packs, baselines, and reports, all of which are platform writes.

The key distinction:

  • Source system data (entities, relationships, execution evidence): read-only, ingested by connectors.
  • Platform governance data (findings, ownership assignments, mitigation actions, attestations): platform-owned, written by the platform.
  • External action systems (ServiceNow, Jira): we push work items to them. These are not source systems.

Action Staleness

Problem: The evaluator re-runs periodically and may generate different ephemeral actions (because findings changed, entity names changed, new evidence appeared). Stored MitigationActionDoc records reference a point-in-time snapshot.

Solution:

  1. Store source_state_hash at creation time (hash of finding.evidence_refs + path.current_state).
  2. On each evaluator run, recompute the hash for findings that have linked mitigation actions.
  3. If the hash differs, set stale = true on the mitigation action.
  4. The UI shows a "Posture changed" indicator. The assignee can review and either confirm the action is still relevant or close it.
  5. Terminal actions (verified, rejected) are never marked stale -- they are historical records.

Scope Creep

Where to draw the line:

This is a mitigation tracking and governance review system, not a project management system.

In scope:

  • Tracking specific remediation actions linked to specific findings
  • Assigning ownership for governance purposes
  • Recording review attestations
  • Scheduling recurring reviews
  • Basic status lifecycle (proposed -> verified)
  • External ticket system integration (push-only)

Out of scope:

  • Sprint planning, story points, velocity tracking
  • Approval chains, multi-level sign-off workflows
  • SLA management and contractual compliance tracking
  • Notification/alerting system (separate concern)
  • Custom workflow builder (too complex, use ticket systems)
  • Comments/discussion threads on actions (use ticket systems)

Open Questions

  1. Auto-propose threshold: Should critical-severity findings auto-create proposed mitigation actions? Or should all tracking be user-initiated? Recommendation: user-initiated in Phase 1, with an opt-in tenant setting for auto-proposal in a later phase.

  2. Ownership assignment and source-system resolution: Platform assignments do not suppress findings (see "Platform Ownership Does NOT Change Posture Metrics" above). The finding resolves only when the source system is updated and the evaluator confirms the change on next run. The open question is: should the UI prompt the operator to update the source system when a platform assignment is created? E.g., "You've assigned ownership in SecurityV0. To resolve this finding, update ownership in [source system]."

  3. Attestation validity period defaults: What should the default validity period be when no review cadence is configured? 90 days (quarterly) seems reasonable for most cases. Critical findings might warrant 30 days.

  4. Multi-tenant attestation authority: Who is allowed to attest? Any authenticated user? Only assigned owners? This is an authorization question that depends on the tenant's RBAC model, which does not yet exist. For Phase 3, any authenticated user can attest; RBAC constraints come later.

  5. Deferral governance: Should there be a maximum deferral period? Should deferred actions auto-reopen when deferral_until passes? Recommendation: yes to auto-reopen, no maximum in Phase 1.

  6. Cluster-level vs path-level mitigation: The current remediation service also generates ClusterRemediationGuidance. Should mitigation tracking support cluster-level actions that span multiple paths? Recommendation: yes, via a cluster_key field on MitigationActionDoc (optional), but defer cluster-specific UI to Phase 2.

Cross-Document Alignment

Evidence Classification Document

The evidence classification research document (2026-03-26-evidence-classification-and-claim-structure.md) defines EvidenceClaim types that map directly to structures proposed here:

  • EvidenceClaim.recommended_action maps to MitigationActionDoc.action -- these should share vocabulary and phrasing conventions to avoid confusion when a claim's recommended action is promoted to a tracked mitigation.
  • EvidenceClaim.owner_role maps to OwnershipAssignmentDoc -- the roles and ownership levels should use consistent terminology across both documents.

When implementing, ensure the evidence classification and ownership/mitigation systems share type definitions or at minimum a common vocabulary file to prevent drift.

References

Source Files

  • Entity types: src/domain/entities/types.ts
  • Authority path types: src/domain/authority-paths/types.ts
  • Finding types: src/domain/findings/types.ts
  • Evidence pack types: src/domain/evidence-packs/types.ts
  • Remediation service: src/services/remediation-service.ts
  • Orphaned ownership rule: src/evaluator/rules/orphaned-ownership.ts
  • Ownership drift rule: src/evaluator/rules/ownership-drift.ts
  • Ownership ambiguous rule: src/evaluator/rules/ownership-ambiguous.ts
  • Ownership unknown rule: src/evaluator/rules/ownership-unknown.ts
  • Authority path routes: src/api/routes/authority-paths.ts
  • Finding routes: src/api/routes/findings.ts
  • StatusWorkflow component: ui/src/components/findings/StatusWorkflow.tsx
  • AuthorityPathDetailPage: ui/src/pages/AuthorityPathDetailPage.tsx
  • Storage adapter interface: src/storage/storage-adapter.ts
  • MongoDB collections: src/storage/mongo/collections.ts
  • Schema/indexes: src/storage/mongo/schema.ts
  • Baseline types: src/domain/baselines/types.ts
  • Report types: src/domain/reports/types.ts
  • Posture snapshot types: src/domain/posture/types.ts
  • Sergey feedback: 2026-03-26-sergey-feedback-on-sprint-review-and-direction.md
  • GitHub issue: #215

Key Quotes from Founder Direction

"Ownership workflows and pending mitigations should be treated as core operating surfaces, because that is where the product becomes sticky."

"Pending mitigations, recurring review, and attestation are first-class product value, not reporting polish."

"The moat is inventory + ownership + action, with recurring review and attestation as the operating loop."