feat(events): enrich PLAN_APPLIED event with changeset statistics from PlanApplyService
CI / typecheck (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / security (push) Has been cancelled
CI / build (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / helm (push) Has been cancelled

Enriches the PLAN_APPLIED domain event with SEC7 audit-logging statistics per docs/specification.md §Audit Logging (issue #716).

Changes:
- PlanLifecycleService.complete_apply() now accepts optional keyword arguments: files_changed, lines_added, lines_removed, resources_modified, apply_duration_seconds (all default to 0). These are included in the PLAN_APPLIED event details dict alongside the existing action_name, phase, and project_names fields.
- PlanApplyService.apply_with_validation_gate() computes changeset statistics from SpecChangeSet.summary() and measures apply duration, then passes all statistics to complete_apply().
- New BDD feature features/plan_applied_event_enrichment.feature with 13 scenarios.

Closes #716

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
This commit was merged in pull request #1300.
This commit is contained in:
2026-04-02 17:08:38 +00:00
committed by Forgejo
parent 4a18a15ca5
commit c2097ee2ed
4 changed files with 628 additions and 4 deletions
@@ -0,0 +1,87 @@
Feature: PLAN_APPLIED event enrichment with changeset statistics
As an audit log consumer
I want the PLAN_APPLIED domain event to include changeset statistics
So that audit entries contain rich context per SEC7 requirements
Background:
Given a plan applied event enrichment test environment
# -- complete_apply passes statistics to event -------------------------
Scenario: complete_apply emits PLAN_APPLIED with files_changed statistic
Given a plan in Apply/PROCESSING state
When complete_apply is called with files_changed=5
Then the emitted PLAN_APPLIED event details should include files_changed=5
Scenario: complete_apply emits PLAN_APPLIED with lines_added statistic
Given a plan in Apply/PROCESSING state
When complete_apply is called with lines_added=42
Then the emitted PLAN_APPLIED event details should include lines_added=42
Scenario: complete_apply emits PLAN_APPLIED with lines_removed statistic
Given a plan in Apply/PROCESSING state
When complete_apply is called with lines_removed=7
Then the emitted PLAN_APPLIED event details should include lines_removed=7
Scenario: complete_apply emits PLAN_APPLIED with resources_modified statistic
Given a plan in Apply/PROCESSING state
When complete_apply is called with resources_modified=3
Then the emitted PLAN_APPLIED event details should include resources_modified=3
Scenario: complete_apply emits PLAN_APPLIED with apply_duration_seconds statistic
Given a plan in Apply/PROCESSING state
When complete_apply is called with apply_duration_seconds=1.5
Then the emitted PLAN_APPLIED event details should include apply_duration_seconds=1.5
Scenario: complete_apply emits PLAN_APPLIED with all statistics combined
Given a plan in Apply/PROCESSING state
When complete_apply is called with all changeset statistics
Then the emitted PLAN_APPLIED event details should include all changeset statistics
Scenario: complete_apply emits PLAN_APPLIED with zero statistics by default
Given a plan in Apply/PROCESSING state
When complete_apply is called with no changeset statistics
Then the emitted PLAN_APPLIED event details should include files_changed=0
And the emitted PLAN_APPLIED event details should include lines_added=0
And the emitted PLAN_APPLIED event details should include lines_removed=0
And the emitted PLAN_APPLIED event details should include resources_modified=0
And the emitted PLAN_APPLIED event details should include apply_duration_seconds=0.0
Scenario: complete_apply preserves existing event details alongside statistics
Given a plan applied event enrichment test environment
Given a plan in Apply/PROCESSING state with action_name "deploy-service"
When complete_apply is called with files_changed=2
Then the emitted PLAN_APPLIED event details should include action_name "deploy-service"
And the emitted PLAN_APPLIED event details should include files_changed=2
# -- apply_with_validation_gate passes statistics through ---------------
Scenario: apply_with_validation_gate passes files_changed to complete_apply
Given a plan with changeset containing 4 entries
And the plan has no validation failures
When apply_with_validation_gate is called
Then complete_apply should be called with files_changed=4
Scenario: apply_with_validation_gate passes resources_modified to complete_apply
Given a plan with changeset containing entries for 2 distinct resources
And the plan has no validation failures
When apply_with_validation_gate is called
Then complete_apply should be called with resources_modified=2
Scenario: apply_with_validation_gate passes apply_duration_seconds to complete_apply
Given a plan with changeset containing 1 entries
And the plan has no validation failures
When apply_with_validation_gate is called
Then complete_apply should be called with a non-negative apply_duration_seconds
Scenario: apply_with_validation_gate passes lines_added from creates to complete_apply
Given a plan with changeset containing 3 CREATE entries
And the plan has no validation failures
When apply_with_validation_gate is called
Then complete_apply should be called with lines_added=3
Scenario: apply_with_validation_gate passes lines_removed from deletes to complete_apply
Given a plan with changeset containing 2 DELETE entries
And the plan has no validation failures
When apply_with_validation_gate is called
Then complete_apply should be called with lines_removed=2
@@ -0,0 +1,489 @@
"""Step definitions for PLAN_APPLIED event enrichment with changeset statistics.
Tests that:
1. ``PlanLifecycleService.complete_apply`` accepts changeset statistics kwargs
and includes them in the emitted ``PLAN_APPLIED`` domain event.
2. ``PlanApplyService.apply_with_validation_gate`` computes and passes
changeset statistics to ``complete_apply``.
Based on issue #716 (SEC7 audit logging enrichment).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import structlog
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.application.services.plan_apply_service import (
PlanApplyService,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
SpecChangeSet,
)
from cleveragents.domain.models.core.plan import (
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PLAN_ID = "01ENRICHTEST00000000001"
_CHANGESET_ID = "01CSENRICH000000000001"
# ---------------------------------------------------------------------------
# Mock helpers
# ---------------------------------------------------------------------------
def _make_mock_plan(
*,
plan_id: str = _PLAN_ID,
phase: PlanPhase = PlanPhase.APPLY,
state: ProcessingState = ProcessingState.PROCESSING,
changeset_id: str | None = _CHANGESET_ID,
action_name: str = "test-action",
project_links: list[Any] | None = None,
created_by: str = "test-actor",
validation_summary: dict[str, Any] | None = None,
is_terminal: bool = False,
error_details: dict[str, str] | None = None,
) -> MagicMock:
"""Create a mock Plan object for enrichment tests."""
plan = MagicMock()
plan.identity.plan_id = plan_id
plan.phase = phase
plan.processing_state = state
plan.state = state
plan.changeset_id = changeset_id
plan.action_name = action_name
plan.project_links = project_links or []
plan.created_by = created_by
plan.validation_summary = validation_summary
plan.is_terminal = is_terminal
plan.error_details = error_details
plan.timestamps = PlanTimestamps()
return plan
def _make_changeset(
plan_id: str = _PLAN_ID,
n_modify: int = 0,
n_create: int = 0,
n_delete: int = 0,
resource_ids: list[str] | None = None,
) -> SpecChangeSet:
"""Create a SpecChangeSet with specified entry counts."""
cs = SpecChangeSet(plan_id=plan_id, changeset_id=_CHANGESET_ID)
idx = 0
def _res(i: int) -> str:
if resource_ids:
return resource_ids[i % len(resource_ids)]
return f"RES{i:03d}"
for i in range(n_create):
cs.add_change(
ChangeEntry(
plan_id=plan_id,
resource_id=_res(idx),
tool_name="builtin/test",
operation=ChangeOperation.CREATE,
path=f"src/new_file_{i}.py",
after_hash=f"after-{i}",
)
)
idx += 1
for i in range(n_modify):
cs.add_change(
ChangeEntry(
plan_id=plan_id,
resource_id=_res(idx),
tool_name="builtin/test",
operation=ChangeOperation.MODIFY,
path=f"src/file_{i}.py",
before_hash=f"before-{i}",
after_hash=f"after-{i}",
)
)
idx += 1
for i in range(n_delete):
cs.add_change(
ChangeEntry(
plan_id=plan_id,
resource_id=_res(idx),
tool_name="builtin/test",
operation=ChangeOperation.DELETE,
path=f"src/old_file_{i}.py",
before_hash=f"before-del-{i}",
)
)
idx += 1
return cs
def _build_lifecycle_with_capturing_bus(
plan: MagicMock,
) -> tuple[MagicMock, list[DomainEvent]]:
"""Build a lifecycle mock that calls the real complete_apply and captures events.
Returns:
Tuple of (lifecycle_mock, emitted_events_list).
"""
emitted_events: list[DomainEvent] = []
event_bus = MagicMock()
event_bus.emit.side_effect = lambda e: emitted_events.append(e)
# We need a lifecycle object that has all the attributes complete_apply
# accesses, but calls the real implementation.
lifecycle = MagicMock(spec=PlanLifecycleService)
lifecycle.event_bus = event_bus
lifecycle.get_plan.return_value = plan
lifecycle._commit_plan = MagicMock()
lifecycle._cleanup_devcontainers = MagicMock()
lifecycle._logger = structlog.get_logger("test.lifecycle")
# Bind the real complete_apply to this mock instance
def _real_complete_apply(plan_id: str, **kwargs: Any) -> Any:
return PlanLifecycleService.complete_apply(lifecycle, plan_id, **kwargs)
lifecycle.complete_apply = _real_complete_apply
return lifecycle, emitted_events
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a plan applied event enrichment test environment")
def step_enrichment_env(context: Context) -> None:
"""Initialise shared context for enrichment tests."""
context.emitted_events = []
context.complete_apply_calls = []
# ---------------------------------------------------------------------------
# Given steps — lifecycle service scenarios
# ---------------------------------------------------------------------------
@given("a plan in Apply/PROCESSING state")
def step_plan_apply_processing(context: Context) -> None:
"""Set up a plan in Apply/PROCESSING state with a capturing event bus."""
context.plan = _make_mock_plan()
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
context.lifecycle = lifecycle
context.emitted_events = emitted_events
@given('a plan in Apply/PROCESSING state with action_name "{action_name}"')
def step_plan_apply_processing_with_action(context: Context, action_name: str) -> None:
"""Set up a plan in Apply/PROCESSING state with a specific action_name."""
context.plan = _make_mock_plan(action_name=action_name)
lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
context.lifecycle = lifecycle
context.emitted_events = emitted_events
# ---------------------------------------------------------------------------
# Given steps — apply_with_validation_gate scenarios
# ---------------------------------------------------------------------------
@given("a plan with changeset containing {n:d} entries")
def step_plan_with_n_entries(context: Context, n: int) -> None:
"""Set up a plan with a changeset of n MODIFY entries."""
context.plan = _make_mock_plan()
context.changeset = _make_changeset(n_modify=n)
context.complete_apply_kwargs = {}
context.apply_result = None
@given("a plan with changeset containing entries for {n:d} distinct resources")
def step_plan_with_n_resources(context: Context, n: int) -> None:
"""Set up a plan with a changeset spanning n distinct resources."""
resource_ids = [f"RES{i:03d}" for i in range(n)]
context.plan = _make_mock_plan()
context.changeset = _make_changeset(
n_modify=n,
resource_ids=resource_ids,
)
context.complete_apply_kwargs = {}
context.apply_result = None
@given("a plan with changeset containing {n:d} CREATE entries")
def step_plan_with_n_creates(context: Context, n: int) -> None:
"""Set up a plan with a changeset of n CREATE entries."""
context.plan = _make_mock_plan()
context.changeset = _make_changeset(n_create=n)
context.complete_apply_kwargs = {}
context.apply_result = None
@given("a plan with changeset containing {n:d} DELETE entries")
def step_plan_with_n_deletes(context: Context, n: int) -> None:
"""Set up a plan with a changeset of n DELETE entries."""
context.plan = _make_mock_plan()
context.changeset = _make_changeset(n_delete=n)
context.complete_apply_kwargs = {}
context.apply_result = None
@given("the plan has no validation failures")
def step_no_validation_failures(context: Context) -> None:
"""Ensure the plan has no validation failures."""
context.plan.validation_summary = {
"required_passed": 0,
"required_failed": 0,
"total": 0,
}
context.plan.is_terminal = False
# ---------------------------------------------------------------------------
# When steps — lifecycle service scenarios
# ---------------------------------------------------------------------------
@when("complete_apply is called with files_changed={n:d}")
def step_complete_apply_files_changed(context: Context, n: int) -> None:
"""Call complete_apply with files_changed kwarg."""
context.lifecycle.complete_apply(_PLAN_ID, files_changed=n)
@when("complete_apply is called with lines_added={n:d}")
def step_complete_apply_lines_added(context: Context, n: int) -> None:
"""Call complete_apply with lines_added kwarg."""
context.lifecycle.complete_apply(_PLAN_ID, lines_added=n)
@when("complete_apply is called with lines_removed={n:d}")
def step_complete_apply_lines_removed(context: Context, n: int) -> None:
"""Call complete_apply with lines_removed kwarg."""
context.lifecycle.complete_apply(_PLAN_ID, lines_removed=n)
@when("complete_apply is called with resources_modified={n:d}")
def step_complete_apply_resources_modified(context: Context, n: int) -> None:
"""Call complete_apply with resources_modified kwarg."""
context.lifecycle.complete_apply(_PLAN_ID, resources_modified=n)
@when("complete_apply is called with apply_duration_seconds={v:f}")
def step_complete_apply_duration(context: Context, v: float) -> None:
"""Call complete_apply with apply_duration_seconds kwarg."""
context.lifecycle.complete_apply(_PLAN_ID, apply_duration_seconds=v)
@when("complete_apply is called with all changeset statistics")
def step_complete_apply_all_stats(context: Context) -> None:
"""Call complete_apply with all changeset statistics kwargs."""
context.lifecycle.complete_apply(
_PLAN_ID,
files_changed=10,
lines_added=200,
lines_removed=50,
resources_modified=4,
apply_duration_seconds=2.75,
)
context.expected_stats = {
"files_changed": 10,
"lines_added": 200,
"lines_removed": 50,
"resources_modified": 4,
"apply_duration_seconds": 2.75,
}
@when("complete_apply is called with no changeset statistics")
def step_complete_apply_no_stats(context: Context) -> None:
"""Call complete_apply with no changeset statistics (defaults)."""
context.lifecycle.complete_apply(_PLAN_ID)
# ---------------------------------------------------------------------------
# When steps — apply_with_validation_gate scenarios
# ---------------------------------------------------------------------------
@when("apply_with_validation_gate is called")
def step_apply_with_validation_gate(context: Context) -> None:
"""Call apply_with_validation_gate and capture complete_apply kwargs."""
plan = context.plan
changeset = context.changeset
lifecycle = MagicMock()
lifecycle.get_plan.return_value = plan
lifecycle._commit_plan = MagicMock()
captured_kwargs: dict[str, Any] = {}
def _capture_complete_apply(plan_id: str, **kwargs: Any) -> MagicMock:
captured_kwargs.update(kwargs)
return plan
lifecycle.complete_apply.side_effect = _capture_complete_apply
store = MagicMock()
store.get.return_value = changeset
service = PlanApplyService(
lifecycle_service=lifecycle,
changeset_store=store,
)
context.apply_result = service.apply_with_validation_gate(plan.identity.plan_id)
context.complete_apply_kwargs = captured_kwargs
# ---------------------------------------------------------------------------
# Then steps — event details assertions
# ---------------------------------------------------------------------------
def _get_plan_applied_event(context: Context) -> DomainEvent:
"""Return the first PLAN_APPLIED event from the captured list."""
events = [
e for e in context.emitted_events if e.event_type == EventType.PLAN_APPLIED
]
assert events, "No PLAN_APPLIED event was emitted"
return events[0]
@then("the emitted PLAN_APPLIED event details should include files_changed={n:d}")
def step_assert_files_changed(context: Context, n: int) -> None:
"""Assert files_changed in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
assert event.details.get("files_changed") == n, (
f"Expected files_changed={n}, got {event.details.get('files_changed')!r}"
)
@then("the emitted PLAN_APPLIED event details should include lines_added={n:d}")
def step_assert_lines_added(context: Context, n: int) -> None:
"""Assert lines_added in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
assert event.details.get("lines_added") == n, (
f"Expected lines_added={n}, got {event.details.get('lines_added')!r}"
)
@then("the emitted PLAN_APPLIED event details should include lines_removed={n:d}")
def step_assert_lines_removed(context: Context, n: int) -> None:
"""Assert lines_removed in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
assert event.details.get("lines_removed") == n, (
f"Expected lines_removed={n}, got {event.details.get('lines_removed')!r}"
)
@then("the emitted PLAN_APPLIED event details should include resources_modified={n:d}")
def step_assert_resources_modified(context: Context, n: int) -> None:
"""Assert resources_modified in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
assert event.details.get("resources_modified") == n, (
f"Expected resources_modified={n}, got {event.details.get('resources_modified')!r}"
)
@then(
"the emitted PLAN_APPLIED event details should include apply_duration_seconds={v:f}"
)
def step_assert_apply_duration(context: Context, v: float) -> None:
"""Assert apply_duration_seconds in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
actual = event.details.get("apply_duration_seconds")
assert actual == v, f"Expected apply_duration_seconds={v}, got {actual!r}"
@then("the emitted PLAN_APPLIED event details should include all changeset statistics")
def step_assert_all_stats(context: Context) -> None:
"""Assert all changeset statistics are present in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
expected = context.expected_stats
for key, value in expected.items():
actual = event.details.get(key)
assert actual == value, f"Expected {key}={value}, got {actual!r}"
@then(
'the emitted PLAN_APPLIED event details should include action_name "{action_name}"'
)
def step_assert_action_name(context: Context, action_name: str) -> None:
"""Assert action_name is preserved in PLAN_APPLIED event details."""
event = _get_plan_applied_event(context)
assert event.details.get("action_name") == action_name, (
f"Expected action_name={action_name!r}, got {event.details.get('action_name')!r}"
)
# ---------------------------------------------------------------------------
# Then steps — complete_apply call assertions
# ---------------------------------------------------------------------------
@then("complete_apply should be called with files_changed={n:d}")
def step_assert_complete_apply_files_changed(context: Context, n: int) -> None:
"""Assert complete_apply was called with files_changed=n."""
actual = context.complete_apply_kwargs.get("files_changed")
assert actual == n, (
f"Expected complete_apply called with files_changed={n}, got {actual!r}"
)
@then("complete_apply should be called with resources_modified={n:d}")
def step_assert_complete_apply_resources_modified(context: Context, n: int) -> None:
"""Assert complete_apply was called with resources_modified=n."""
actual = context.complete_apply_kwargs.get("resources_modified")
assert actual == n, (
f"Expected complete_apply called with resources_modified={n}, got {actual!r}"
)
@then("complete_apply should be called with a non-negative apply_duration_seconds")
def step_assert_complete_apply_duration_nonneg(context: Context) -> None:
"""Assert complete_apply was called with a non-negative apply_duration_seconds."""
actual = context.complete_apply_kwargs.get("apply_duration_seconds")
assert actual is not None, "apply_duration_seconds was not passed to complete_apply"
assert actual >= 0.0, f"Expected apply_duration_seconds >= 0.0, got {actual!r}"
@then("complete_apply should be called with lines_added={n:d}")
def step_assert_complete_apply_lines_added(context: Context, n: int) -> None:
"""Assert complete_apply was called with lines_added=n."""
actual = context.complete_apply_kwargs.get("lines_added")
assert actual == n, (
f"Expected complete_apply called with lines_added={n}, got {actual!r}"
)
@then("complete_apply should be called with lines_removed={n:d}")
def step_assert_complete_apply_lines_removed(context: Context, n: int) -> None:
"""Assert complete_apply was called with lines_removed=n."""
actual = context.complete_apply_kwargs.get("lines_removed")
assert actual == n, (
f"Expected complete_apply called with lines_removed={n}, got {actual!r}"
)
@@ -13,6 +13,10 @@ Provides the service layer for D0b.apply features:
unless ``allow_empty`` is explicitly set.
- **Validation-gated apply**: Block apply when required validations have not
passed, setting plan to ``constrained`` state with actionable error.
- **Changeset statistics enrichment**: Passes SEC7 audit statistics
(files changed, resources modified, apply duration) to
``PlanLifecycleService.complete_apply`` so the ``PLAN_APPLIED`` domain
event carries rich context for audit logging (issue #716).
All public methods accept ``plan_id`` and delegate plan lookups to
``PlanLifecycleService``.
@@ -596,16 +600,24 @@ class PlanApplyService:
# All required validations passed — proceed with apply
files_changed = len(changeset.entries)
# Compute changeset statistics for SEC7 audit enrichment (issue #716)
changeset_summary = changeset.summary()
resources_modified = changeset_summary.get("resources_involved", 0)
self._logger.info(
"Validation gate passed, applying",
plan_id=plan_id,
files_changed=files_changed,
resources_modified=resources_modified,
required_passed=req_passed,
)
# Checkpoint: pre_apply snapshot
self._try_checkpoint(plan_id, "pre_apply")
# Record apply start time for duration measurement
apply_start = datetime.now(tz=UTC)
# Persist apply summary
self.persist_apply_summary(
plan_id=plan_id,
@@ -613,9 +625,20 @@ class PlanApplyService:
validations_run=total,
)
# Transition to applied via lifecycle service
# Compute apply duration
apply_duration_seconds = (datetime.now(tz=UTC) - apply_start).total_seconds()
# Transition to applied via lifecycle service, passing changeset
# statistics so the PLAN_APPLIED event carries rich SEC7 audit context.
try:
self._lifecycle.complete_apply(plan_id)
self._lifecycle.complete_apply(
plan_id,
files_changed=files_changed,
lines_added=changeset_summary.get("creates", 0),
lines_removed=changeset_summary.get("deletes", 0),
resources_modified=resources_modified,
apply_duration_seconds=apply_duration_seconds,
)
except (PlanError, ValidationError):
# If lifecycle transition fails, attempt rollback
self._try_rollback(plan_id)
@@ -1526,14 +1526,33 @@ class PlanLifecycleService:
return plan
def complete_apply(self, plan_id: str) -> Plan:
def complete_apply(
self,
plan_id: str,
*,
files_changed: int = 0,
lines_added: int = 0,
lines_removed: int = 0,
resources_modified: int = 0,
apply_duration_seconds: float = 0.0,
) -> Plan:
"""Complete the Apply phase — terminal success.
Sets ``processing_state`` to ``APPLIED`` (the terminal success
state within the Apply phase). The phase remains ``APPLY``.
The optional changeset statistics parameters enrich the emitted
``PLAN_APPLIED`` domain event with SEC7 audit-logging context
(see docs/specification.md §Audit Logging).
Args:
plan_id: The plan ULID
plan_id: The plan ULID.
files_changed: Number of files changed during apply.
lines_added: Total lines added across all changed files.
lines_removed: Total lines removed across all changed files.
resources_modified: Number of distinct resources modified.
apply_duration_seconds: Wall-clock duration of the apply
operation in seconds.
Returns:
The updated Plan with processing_state=APPLIED
@@ -1578,6 +1597,12 @@ class PlanLifecycleService:
"action_name": plan.action_name,
"phase": plan.phase.value,
"project_names": project_names,
# SEC7 changeset statistics (issue #716)
"files_changed": files_changed,
"lines_added": lines_added,
"lines_removed": lines_removed,
"resources_modified": resources_modified,
"apply_duration_seconds": apply_duration_seconds,
},
)
)