diff --git a/src/ScadaLink.Commons/Types/TrackedOperationId.cs b/src/ScadaLink.Commons/Types/TrackedOperationId.cs
new file mode 100644
index 0000000..3a6bb22
--- /dev/null
+++ b/src/ScadaLink.Commons/Types/TrackedOperationId.cs
@@ -0,0 +1,53 @@
+namespace ScadaLink.Commons.Types;
+
+///
+/// Strongly-typed identifier for a cached outbound operation
+/// (ExternalSystem.CachedCall / Database.CachedWrite) — the unified
+/// tracking handle introduced by Audit Log #23 (M3). The same id is the
+/// idempotency key end-to-end: it is stamped on every AuditLog row
+/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached /
+/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central
+/// SiteCalls row that mirrors the operation's operational state.
+///
+///
+///
+/// The struct wraps a so it serialises identically to a
+/// 36-character "D"-format string anywhere the existing GUID conventions are
+/// used (gRPC strings, JSON, SQL TEXT columns). returns
+/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens-
+/// wrapped variants — central ingest parses with , which
+/// is format-tolerant but the wire shape is fixed for log readability.
+///
+///
+public readonly record struct TrackedOperationId(Guid Value)
+{
+ /// Mint a fresh id at the call site (script-thread safe).
+ public static TrackedOperationId New() => new(Guid.NewGuid());
+
+ ///
+ /// Parse a serialised id back into the strong type. Throws when the input
+ /// is not a valid GUID — callers crossing untrusted boundaries should use
+ /// instead.
+ ///
+ public static TrackedOperationId Parse(string s) => new(Guid.Parse(s));
+
+ ///
+ /// Attempt to parse a serialised id. Returns false for null, empty
+ /// or non-GUID input; is default on
+ /// failure.
+ ///
+ public static bool TryParse(string? s, out TrackedOperationId result)
+ {
+ if (Guid.TryParse(s, out var g))
+ {
+ result = new TrackedOperationId(g);
+ return true;
+ }
+
+ result = default;
+ return false;
+ }
+
+ ///
+ public override string ToString() => Value.ToString("D");
+}
diff --git a/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs b/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs
new file mode 100644
index 0000000..3e8822d
--- /dev/null
+++ b/tests/ScadaLink.Commons.Tests/Types/TrackedOperationIdTests.cs
@@ -0,0 +1,80 @@
+using ScadaLink.Commons.Types;
+
+namespace ScadaLink.Commons.Tests.Types;
+
+///
+/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
+/// produced by ExternalSystem.CachedCall / Database.CachedWrite and
+/// surfaced to scripts via Tracking.Status(id).
+///
+public class TrackedOperationIdTests
+{
+ [Fact]
+ public void New_ProducesUniqueIds()
+ {
+ var a = TrackedOperationId.New();
+ var b = TrackedOperationId.New();
+
+ Assert.NotEqual(a, b);
+ Assert.NotEqual(Guid.Empty, a.Value);
+ Assert.NotEqual(Guid.Empty, b.Value);
+ }
+
+ [Fact]
+ public void Parse_RoundTrip_PreservesValue()
+ {
+ var original = TrackedOperationId.New();
+ var serialized = original.ToString();
+
+ var parsed = TrackedOperationId.Parse(serialized);
+
+ Assert.Equal(original, parsed);
+ Assert.Equal(original.Value, parsed.Value);
+ }
+
+ [Fact]
+ public void TryParse_InvalidInput_ReturnsFalse()
+ {
+ Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result));
+ Assert.Equal(default, result);
+
+ Assert.False(TrackedOperationId.TryParse(null, out var nullResult));
+ Assert.Equal(default, nullResult);
+
+ Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult));
+ Assert.Equal(default, emptyResult);
+ }
+
+ [Fact]
+ public void TryParse_ValidInput_ReturnsTrueAndId()
+ {
+ var original = TrackedOperationId.New();
+ var serialized = original.ToString();
+
+ Assert.True(TrackedOperationId.TryParse(serialized, out var parsed));
+ Assert.Equal(original, parsed);
+ }
+
+ [Fact]
+ public void Equality_BasedOnValue()
+ {
+ var guid = Guid.NewGuid();
+ var a = new TrackedOperationId(guid);
+ var b = new TrackedOperationId(guid);
+
+ Assert.Equal(a, b);
+ Assert.True(a == b);
+ Assert.False(a != b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void ToString_StandardGuidFormat()
+ {
+ var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab");
+ var id = new TrackedOperationId(guid);
+
+ // "D" format: 32 hex digits separated by hyphens (8-4-4-4-12).
+ Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString());
+ }
+}