feat(audit): ScriptTrace channel + RouteCall/ScriptRun/AttributeWrite/AttributeWait kinds

Flow Debugger (design 2026-09-05 §5.1): trace steps are audit rows. New
AuditChannel.ScriptTrace appended after Cluster and four kinds appended after
ManualFailover so stored ordinals never move; lock-in tests + alog.md §4 updated.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-05 23:41:50 -04:00
parent 61d1785fcb
commit 352e0bfa37
5 changed files with 62 additions and 6 deletions
+5 -1
View File
@@ -135,7 +135,7 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X." - `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm). - Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
**`Kind` values (flat — 17 discriminators across all channels; pinned by `AuditEnumTests`):** **`Kind` values (flat — 21 discriminators across all channels; pinned by `AuditEnumTests`):**
| Kind | Fires when | | Kind | Fires when |
|---|---| |---|---|
@@ -156,6 +156,10 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
| `SecuredWriteExpire` | A `Pending` secured write aged past its server-side TTL and was transitioned to `Expired` without executing — emitted by the system (no verifier). | | `SecuredWriteExpire` | A `Pending` secured write aged past its server-side TTL and was transitioned to `Expired` without executing — emitted by the system (no verifier). |
| `ReconciliationAbandoned` | A reconciliation pull row failed to insert up to the permanent-abandon threshold and central advanced its cursor past it; one synthetic row so the loss is queryable in the Audit Log itself. | | `ReconciliationAbandoned` | A reconciliation pull row failed to insert up to the permanent-abandon threshold and central advanced its cursor past it; one synthetic row so the loss is queryable in the Audit Log itself. |
| `ManualFailover` | An administrator triggered a manual failover of the central pair from the Health page; one row per invocation, written BEFORE the graceful `Cluster.Leave` is issued. `Target` = the leaving node's address. | | `ManualFailover` | An administrator triggered a manual failover of the central pair from the Health page; one row per invocation, written BEFORE the graceful `Cluster.Leave` is issued. `Target` = the leaving node's address. |
| `RouteCall` | Central `Route.To(...).Call(...)` round-trip completed (success or failure). **Always-on** — one row per routed call regardless of tracing; `Extra.childExecutionId` = the site run's `ExecutionId`. Channel `ScriptTrace`. (Flow Debugger, 2026-09-05) |
| `ScriptRun` | A traced site script run completed/failed/timed out; `Extra.trigger*` carries the trigger for value-change runs. Channel `ScriptTrace`, traced executions only. |
| `AttributeWrite` | A traced script wrote an attribute (or a batch); `Extra.writes[]` lists each write's outcome. Channel `ScriptTrace`, traced executions only. |
| `AttributeWait` | A traced script's `WaitAsync`/`WaitForAsync` resolved (satisfied / timeout / cancelled / error) with the observed value. Channel `ScriptTrace`, traced executions only. |
### Site: `AuditLog` (SQLite) ### Site: `AuditLog` (SQLite)
@@ -21,5 +21,13 @@ public enum AuditChannel
/// restarted the active node, which is exactly the kind of act an audit log exists to /// restarted the active node, which is exactly the kind of act an audit log exists to
/// attribute. (decision 2026-07-22) /// attribute. (decision 2026-07-22)
/// </summary> /// </summary>
Cluster Cluster,
/// <summary>
/// Flow Debugger trace rows (design 2026-09-05): a script run, an attribute write, an
/// attribute wait or a central route call, emitted only for traced executions
/// (plus the always-on <see cref="AuditKind.RouteCall"/>). Appended last so stored
/// ordinals of existing channels never move.
/// </summary>
ScriptTrace
} }
@@ -49,5 +49,17 @@ public enum AuditKind
/// is issued (the acting node is usually not the one that goes away, but the audit must /// is issued (the acting node is usually not the one that goes away, but the audit must
/// survive either outcome). (decision 2026-07-22) /// survive either outcome). (decision 2026-07-22)
/// </summary> /// </summary>
ManualFailover ManualFailover,
/// <summary>Central <c>Route.To().Call</c> round-trip (always-on; <see cref="AuditChannel.ScriptTrace"/> channel).</summary>
RouteCall,
/// <summary>One site script run completed/failed/timed out (traced executions only; <see cref="AuditChannel.ScriptTrace"/>).</summary>
ScriptRun,
/// <summary>One attribute write or batch write made by a traced script (traced executions only; <see cref="AuditChannel.ScriptTrace"/>).</summary>
AttributeWrite,
/// <summary>One <c>WaitAsync</c>/<c>WaitForAsync</c> outcome in a traced script (traced executions only; <see cref="AuditChannel.ScriptTrace"/>).</summary>
AttributeWait
} }
@@ -0,0 +1,31 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Audit;
/// <summary>
/// Flow Debugger (design 2026-09-05): the <c>ScriptTrace</c> channel and its four kinds must
/// fit the central <c>AuditLog.Category</c>/<c>Kind</c> columns and never move existing ordinals.
/// </summary>
public class ScriptTraceEnumTests
{
[Theory]
[InlineData(AuditKind.RouteCall)]
[InlineData(AuditKind.ScriptRun)]
[InlineData(AuditKind.AttributeWrite)]
[InlineData(AuditKind.AttributeWait)]
public void ScriptTraceKinds_FitTheCentralColumns(AuditKind kind)
{
// AuditLog.Category / Kind are nvarchar(32) (AuditLogEntityTypeConfiguration).
Assert.True(kind.ToString().Length <= 32);
Assert.True(AuditChannel.ScriptTrace.ToString().Length <= 32);
Assert.Equal($"ScriptTrace.{kind}", AuditFieldBuilders.BuildAction(AuditChannel.ScriptTrace, kind));
}
[Fact]
public void ScriptTrace_IsAppendedAfterCluster_SoExistingOrdinalsAreStable()
{
Assert.True((int)AuditChannel.ScriptTrace > (int)AuditChannel.Cluster);
Assert.True((int)AuditKind.RouteCall > (int)AuditKind.ManualFailover);
}
}
@@ -12,7 +12,7 @@ public class AuditEnumTests
[Fact] [Fact]
public void AuditChannel_HasExactlyExpectedMembers() public void AuditChannel_HasExactlyExpectedMembers()
{ {
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster" }; var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster", "ScriptTrace" };
var actual = Enum.GetValues(typeof(AuditChannel)) var actual = Enum.GetValues(typeof(AuditChannel))
.Cast<AuditChannel>() .Cast<AuditChannel>()
.Select(x => x.ToString()) .Select(x => x.ToString())
@@ -23,7 +23,7 @@ public class AuditEnumTests
} }
[Fact] [Fact]
public void AuditKind_HasExactlySeventeenExpectedMembers() public void AuditKind_HasExactlyTwentyOneExpectedMembers()
{ {
var expected = new[] var expected = new[]
{ {
@@ -34,13 +34,14 @@ public class AuditEnumTests
"SecuredWriteExpire", "SecuredWriteExpire",
"ReconciliationAbandoned", "ReconciliationAbandoned",
"ManualFailover", "ManualFailover",
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait",
}; };
var actual = Enum.GetValues(typeof(AuditKind)) var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>() .Cast<AuditKind>()
.Select(x => x.ToString()) .Select(x => x.ToString())
.ToArray(); .ToArray();
Assert.Equal(17, actual.Length); Assert.Equal(21, actual.Length);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }