diff --git a/src/ApiAdvancedPlugin.php b/src/ApiAdvancedPlugin.php
index f3bc7c6..d5cce9d 100644
--- a/src/ApiAdvancedPlugin.php
+++ b/src/ApiAdvancedPlugin.php
@@ -15,10 +15,13 @@
* features that don't belong in the lean core. The first is a **security audit
* log** of API access failures.
*
- * It listens to the core best-effort `api.token_rejected` and `api.access_denied`
- * events and records each into a table it owns, surfaced on an admin page. Those
- * events fire only *after* the per-IP flood guard, so a flood is already `429`'d
- * before it reaches here — the recording is bounded by the core rate limits.
+ * It listens to the core best-effort access events — `api.token_rejected`,
+ * `api.access_denied`, `api.entry_written` and `api.management_written` — and
+ * records each into a table it owns, surfaced on an admin page: a who-did-what
+ * trail of both failures and the content/structural writes an agent makes over
+ * MCP. The failure events fire only *after* the per-IP flood guard, so a flood is
+ * already `429`'d before it reaches here — recording is bounded by the core rate
+ * limits.
*
* It is the **second unrelated consumer** of the plugin event + storage
* capabilities (after Analytics), the independent proof both were waiting for.
@@ -56,6 +59,15 @@ static function (mixed $payload) use ($recorder): void {
$recorder->record('entry_written', $payload);
},
);
+ // Management actions through the API/MCP — schema, media, users, tokens,
+ // settings (ADR 0009). The structural counterpart of entry_written, so an
+ // agent reshaping the CMS leaves a full who-did-what trail.
+ $context->events()->listen(
+ CoreEvents::API_MANAGEMENT_WRITTEN,
+ static function (mixed $payload) use ($recorder): void {
+ $recorder->record('management', $payload);
+ },
+ );
$log = new AuditLog($storage);
$context->adminPages()->register(
diff --git a/src/AuditRecorder.php b/src/AuditRecorder.php
index 2f48ba9..d6c0542 100644
--- a/src/AuditRecorder.php
+++ b/src/AuditRecorder.php
@@ -43,8 +43,8 @@ public function record(string $kind, mixed $payload): void
/**
* Map an event payload to a stored row, or null to skip a malformed one.
- * Handles both failure payloads (which carry `resource`) and write payloads
- * (which carry `collection` + `slug`).
+ * Handles failure payloads (which carry `resource`), content-write payloads
+ * (`collection` + `slug`), and management payloads (`capability` + `target`).
*
* @return array|null
*/
@@ -62,8 +62,8 @@ public function entry(string $kind, mixed $payload): ?array
'reason' => $str('reason'),
'token_id' => $int('token_id'),
'token_name' => $str('token_name'),
- 'resource' => $str('resource') ?? $str('collection'),
- 'target' => $str('slug'),
+ 'resource' => $str('resource') ?? $str('collection') ?? $str('capability'),
+ 'target' => $str('target') ?? $str('slug'),
'action' => $str('action'),
'ip' => $str('ip'),
'path' => $str('path'),
diff --git a/src/AuditView.php b/src/AuditView.php
index ff7650e..5cde411 100644
--- a/src/AuditView.php
+++ b/src/AuditView.php
@@ -15,6 +15,7 @@ final class AuditView
'token_rejected' => 'Token rejected',
'access_denied' => 'Access denied',
'entry_written' => 'Entry written',
+ 'management' => 'Management',
];
/**
@@ -30,10 +31,12 @@ public function html(array $recent, array $summary): string
$rejected = $summary['token_rejected'] ?? 0;
$denied = $summary['access_denied'] ?? 0;
$writes = $summary['entry_written'] ?? 0;
+ $managed = $summary['management'] ?? 0;
$html .= 'Last 24 hours: '
. '' . $rejected . ' rejected token' . ($rejected === 1 ? '' : 's') . ', '
. '' . $denied . ' scope denial' . ($denied === 1 ? '' : 's') . ', '
- . '' . $writes . ' write' . ($writes === 1 ? '' : 's') . '.
';
+ . '' . $writes . ' write' . ($writes === 1 ? '' : 's') . ', '
+ . '' . $managed . ' management action' . ($managed === 1 ? '' : 's') . '.
';
if ($recent === []) {
$html .= '🛡️'
@@ -50,8 +53,8 @@ public function html(array $recent, array $summary): string
foreach ($recent as $row) {
$kind = (string) ($row['kind'] ?? '');
$detail = match ($kind) {
- 'access_denied', 'entry_written' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''),
- default => $e($row['reason'] ?? ''),
+ 'access_denied', 'entry_written', 'management' => $e($row['resource'] ?? '') . ':' . $e($row['action'] ?? ''),
+ default => $e($row['reason'] ?? ''),
};
$target = ($row['target'] ?? '') !== '' ? $e($row['target']) : '—';
$token = ($row['token_name'] ?? '') !== '' ? $e($row['token_name']) : '—';
diff --git a/tests/AuditRecorderTest.php b/tests/AuditRecorderTest.php
index 81b856e..fddfd9e 100644
--- a/tests/AuditRecorderTest.php
+++ b/tests/AuditRecorderTest.php
@@ -66,6 +66,21 @@ public function test_it_maps_a_write_from_a_collection_and_slug(): void
self::assertNull($row['reason']);
}
+ public function test_it_maps_a_management_action_from_capability_and_target(): void
+ {
+ $row = $this->recorder->entry('management', [
+ 'token_id' => 7, 'token_name' => 'agent', 'capability' => 'schema', 'action' => 'create_collection',
+ 'target' => 'events', 'ip' => 'stdio', 'path' => 'mcp',
+ ]);
+
+ self::assertIsArray($row);
+ self::assertSame('management', $row['kind']);
+ self::assertSame('schema', $row['resource'], 'capability maps to resource');
+ self::assertSame('events', $row['target'], 'target maps through');
+ self::assertSame('create_collection', $row['action']);
+ self::assertSame(7, $row['token_id']);
+ }
+
public function test_it_defaults_the_timestamp_when_absent(): void
{
$row = $this->recorder->entry('token_rejected', ['reason' => 'missing']);
diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php
index 438923f..21aed81 100644
--- a/tests/PackageIntegrationTest.php
+++ b/tests/PackageIntegrationTest.php
@@ -96,6 +96,7 @@ public function test_discovery_registers_the_migration_listeners_admin_page_and_
self::assertTrue($events->hasListeners(CoreEvents::API_TOKEN_REJECTED), 'the rejection listener');
self::assertTrue($events->hasListeners(CoreEvents::API_ACCESS_DENIED), 'the scope-denial listener');
self::assertTrue($events->hasListeners(CoreEvents::API_ENTRY_WRITTEN), 'the write listener');
+ self::assertTrue($events->hasListeners(CoreEvents::API_MANAGEMENT_WRITTEN), 'the management listener');
self::assertSame(['api-audit'], array_column($adminPages->all(), 'slug'), 'its admin page');
self::assertSame(['nimbuscms.api-advanced:prune-audit'], array_column($maintenance->all(), 'name'), 'its retention task');
}