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_BYrelationships on identity/workload entities, with optionalownership_levelproperty (primary,secondary,inherited).CREATED_BYas a fallback when noOWNED_BYedges exist (treated as implicit primary ownership byorphaned-ownership.ts).ownership_statusonAuthorityPathCurrentState-- a computed string (owned,orphaned,degraded,ambiguous,unknown) displayed in the UI but not directly editable.
Five evaluator rules assess ownership quality:
| Rule | Finding Type | Severity | Trigger |
|---|---|---|---|
orphaned-ownership | orphaned_ownership | critical | No owners at all, or all owners non-active |
orphaned-ownership | ownership_degraded | high | Primary owners non-active but secondary/inherited still active |
ownership-drift | ownership_drift | high (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-ambiguous | ownership_ambiguous | medium | Only group/team owners, no individual |
ownership-unknown | ownership_unknown | medium | No 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_nameproperty - Departed owner text derived from orphaned_ownership finding's
evidence_refs.owner_descriptions
Known codebase bug: The
orphaned-ownership.tsevaluator storesrelationshipsandexecution_path_countinevidence_refs, but the remediation service readsnon_active_countandowner_descriptions-- fields the evaluator never sets. The UI's departed-owner text relies onowner_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:
- Ephemeral: Actions are computed fresh on every
GET /api/v1/authority-paths/:id/remediationcall. Nothing is stored. - Finding-driven: Each active finding type maps to 1-3
ActionTemplateobjects via a large switch statement infindingActions(). - 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.
- Capped at 3: After choke-point deduplication, only
MAX_ACTIONS = 3are returned. - 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,signalsapplies_to(resolved entity scope),impact_score(category rank)finding_type,evidence(links to finding_id, path_id, entity_id)- Optional
business_impactandchoke_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:
remediatedandfalse_positiveare terminal states -- no transitions out.remediatedrequiresresolved_by(free-text string, typically email).false_positiverequiresreason(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
activestatus), butremediated/false_positivefindings are not re-evaluated. acknowledged_byis 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:
| From | To | Required Fields |
|---|---|---|
proposed | assigned | assigned_to, assigned_at |
proposed | rejected | rejection_reason |
proposed | deferred | deferral_reason, deferral_until |
assigned | in_progress | started_at |
assigned | rejected | rejection_reason |
assigned | deferred | deferral_reason, deferral_until |
in_progress | completed | completed_at, completion_notes |
in_progress | assigned | (reassignment) assigned_to |
in_progress | deferred | deferral_reason, deferral_until |
completed | verified | verified_by, verified_at |
completed | in_progress | (verification failure, no extra fields) |
rejected | proposed | (reopen, clears rejection_reason) |
deferred | proposed | (un-defer, clears deferral fields) |
deferred | assigned | assigned_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:
- Connector data is ground truth for what exists. The OWNED_BY, CREATED_BY relationships from connectors describe what the source system says.
- Platform assignments are governance overrides. When an operator assigns ownership via the platform, it creates an
OwnershipAssignmentDocthat takes precedence for governance workflows (action routing, review assignment, attestation responsibility). - 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. - 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_ownershipfinding, 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
- Creation: An operator reviews a scope (finding, path, entity, cluster) and creates an attestation with an outcome.
- Validity window: Attestations have a
valid_untildate derived from the review cadence. A quarterly cadence means the attestation is valid for 90 days. - Staleness detection: After each evaluator run or sync, compare the current
context_snapshothash against stored attestations. If the posture changed materially (new findings, severity increase, ownership change, execution pattern change), mark the attestation stale. - Stale attestation handling: Stale attestations remain on record but no longer satisfy review requirements. A new attestation is required.
- Attestation does not suppress findings. An
acceptedoraccepted_with_riskattestation 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_authoritytoobserved_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.querythrough 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
/revokesub-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
OwnershipAssignmentDocor "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,unknownbased 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:
- Click "Assign owner"
- Inline form appears: email input, name input, optional team, primary/secondary toggle
- Submit creates
OwnershipAssignmentDocvia API - Section updates immediately to show platform assignment
- 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
MitigationActionDocrecords 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
RemediationGuidancesection) creates aMitigationActionDoc
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):
- Opens a modal showing the current state summary: finding count, severity, ownership status, execution stats
- Four outcome buttons: Accept, Accept with Risk, Require Remediation, Escalate
- 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)
- Creates
AttestationDocvia API - 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
StatusWorkflowcomponent - 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:
- Proposal flow:
GET /api/v1/authority-paths/:id/remediationreturns ephemeral actions as before. The UI adds a "Track" button on each action. - Persistence flow:
POST /api/v1/mitigation-actionswithfrom_remediationre-generates and captures the specific action. - 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.
- 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:
- One-way push from SecurityV0 to ticket systems (ServiceNow, Jira, etc.).
- 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.
- Bidirectional status sync (optional, Phase 4+): Poll ticket system for status updates to keep
MitigationActionDoc.statusin sync. This is a read from the ticket system, which is acceptable. - Implementation pattern: On
MitigationActionDoccreation or status change, optionally push to configured ticket system. Storeexternal_ticket_idandexternal_ticket_urlon the doc. - 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:
MitigationActionDoctype definition insrc/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
RemediationGuidancecomponent - "Tracked Actions" section on
AuthorityPathDetailPage - Basic
/mitigation-actionslist 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:
OwnershipAssignmentDoctype definition insrc/domain/ownership-assignments/types.ts- MongoDB collection, indexes, adapter
- REST API routes
- Replace
OwnershipSectioncomponent 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:
AttestationDocandReviewCadenceConfigtype 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:
- Store
source_state_hashat creation time (hash of finding.evidence_refs + path.current_state). - On each evaluator run, recompute the hash for findings that have linked mitigation actions.
- If the hash differs, set
stale = trueon the mitigation action. - The UI shows a "Posture changed" indicator. The assignee can review and either confirm the action is still relevant or close it.
- 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
-
Auto-propose threshold: Should critical-severity findings auto-create
proposedmitigation 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. -
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]."
-
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.
-
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.
-
Deferral governance: Should there be a maximum deferral period? Should deferred actions auto-reopen when
deferral_untilpasses? Recommendation: yes to auto-reopen, no maximum in Phase 1. -
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 acluster_keyfield onMitigationActionDoc(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_actionmaps toMitigationActionDoc.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_rolemaps toOwnershipAssignmentDoc-- 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
Related Documents
- 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."