diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/DatasetUpdater/RecordsUpdaterContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/DatasetUpdater/RecordsUpdaterContractTests.cs
new file mode 100644
index 00000000..1b641bd3
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/DatasetUpdater/RecordsUpdaterContractTests.cs
@@ -0,0 +1,212 @@
+using System.Collections.Generic;
+using Argumentum.AssetConverter;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.DatasetUpdater
+{
+ ///
+ /// Contract pin for — the LLM-facing function-tool that the
+ /// DatasetUpdater translation loop exposes (wired in DatasetUpdaterConfig as the
+ /// UpdateRecord tool the model calls for EVERY target field of EVERY record).
+ ///
+ /// Had ZERO coverage (no reference to RecordsUpdater/UpdateRecord/DecodeValue anywhere in the
+ /// test project; DatasetUpdater had no dedicated test folder). Two surfaces pinned here:
+ ///
+ /// (1) — the observable contract: PK/field lookup,
+ /// the LLM-facing error strings ("target record not found...", "field '...' not found...
+ /// Available: ..."), the FilledOverwriteCount safety counter (tracks how many updates
+ /// OVERWROTE a non-empty cell — a corruption-risk signal), and the "old==>new" confirmation
+ /// format the model reads back. A refactor that changed any of these would silently change
+ /// the model's feedback loop or the overwrite metric, with no exception.
+ ///
+ /// (2) DecodeValue (private, exercised via UpdateRecord) — a 3-pass decode applied to
+ /// every value before storage: Regex.Unescape → HtmlDecode → UrlDecode,
+ /// IN THAT ORDER. The order is a real silent-wrong-output hazard: e.g. %26amp%3B
+ /// yields &apm;-ish results that differ depending on whether UrlDecode runs before
+ /// or after HtmlDecode. These tests pin the order additively so a reorder fails loud.
+ ///
+ /// All tests drive the real public UpdateRecord entry (no production code changed;
+ /// DecodeValue stays private). Pure & deterministic given controlled Records.
+ ///
+ public class RecordsUpdaterContractTests
+ {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Helpers — build a RecordsUpdater with a controlled, deterministic record set.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private static RecordsUpdater MakeUpdater()
+ {
+ // Insertion order (pk, name) is preserved by Dictionary iteration,
+ // so the "Available: ..." error string is deterministic.
+ return new RecordsUpdater
+ {
+ PrimaryKeyField = "pk",
+ Records = new List>
+ {
+ new() { ["pk"] = "1", ["name"] = "" }, // empty target cell
+ new() { ["pk"] = "2", ["name"] = "existing" } // pre-filled target cell
+ }
+ };
+ }
+
+ private static string Stored(RecordsUpdater u, string pk, string field)
+ => u.Records.Find(r => r["pk"].ToString() == pk)![field].ToString()!;
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (1) DecodeValue — the 3-pass decode contract, exercised via UpdateRecord.
+ // DecodeValue(s) = UrlDecode(HtmlDecode(Regex.Unescape(s))).
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Decode_PlainValue_PassesThroughUnchanged()
+ {
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "hello world");
+ Stored(u, "1", "name").Should().Be("hello world");
+ }
+
+ [Fact]
+ public void Decode_HtmlEntity_IsHtmlDecoded()
+ {
+ // < → "<" (HtmlDecode pass).
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "<");
+ Stored(u, "1", "name").Should().Be("<");
+ }
+
+ [Fact]
+ public void Decode_PercentEncoding_IsUrlDecoded()
+ {
+ // %3C → "<" (UrlDecode pass).
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "%3C");
+ Stored(u, "1", "name").Should().Be("<");
+ }
+
+ [Fact]
+ public void Decode_RegexBackslashEscape_IsRegexUnescaped()
+ {
+ // The 2-char literal backslash-n (what the LLM sends as JSON "\\n") → real newline
+ // (Regex.Unescape pass).
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "\\n");
+ Stored(u, "1", "name").Should().Be("\n");
+ }
+
+ [Fact]
+ public void Decode_OrderDependence_UrlDecodeAfterHtmlDecode_Pinned()
+ {
+ // THE fragile case: %26amp%3B.
+ // Current order (Unescape → HtmlDecode → UrlDecode):
+ // Unescape("%26amp%3B")="%26amp%3B" HtmlDecode("%26amp%3B")="%26amp%3B"
+ // UrlDecode("%26amp%3B")="&" → final "&"
+ // Reversed (UrlDecode first):
+ // UrlDecode("%26amp%3B")="&" HtmlDecode("&")="&" → final "&"
+ // So the stored value DIFFERS by order. We pin the CURRENT order = "&". A refactor
+ // that swapped the two decode passes would land "&" instead → silent wrong translation.
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "%26amp%3B");
+ Stored(u, "1", "name").Should().Be("&",
+ "DecodeValue runs HtmlDecode BEFORE UrlDecode; reversing yields '&' — order is load-bearing");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (2) UpdateRecord — the observable contract (lookup, errors, counters, return format).
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void UpdateRecord_EmptyTarget_SetsValue_NoOverwriteCount()
+ {
+ var u = MakeUpdater();
+ var ret = u.UpdateRecord("1", "name", "newval");
+
+ Stored(u, "1", "name").Should().Be("newval");
+ u.FilledOverwriteCount.Should().Be(0, "the target cell was empty — this is a fill, not an overwrite");
+ u.CallCount.Should().Be(1);
+ // Return format: "{existing}\n==>\n{newValue}" — existing was "".
+ ret.Should().Be("\n==>\nnewval");
+ }
+
+ [Fact]
+ public void UpdateRecord_FilledTarget_SetsValue_IncrementsOverwriteCount()
+ {
+ var u = MakeUpdater();
+ var ret = u.UpdateRecord("2", "name", "new2");
+
+ Stored(u, "2", "name").Should().Be("new2");
+ u.FilledOverwriteCount.Should().Be(1, "the target cell was non-empty — this overwrites, a corruption-risk signal");
+ u.CallCount.Should().Be(1);
+ ret.Should().Be("existing\n==>\nnew2");
+ }
+
+ [Fact]
+ public void UpdateRecord_RecordNotFound_ReturnsErrorMessage_RecordsUnchanged()
+ {
+ var u = MakeUpdater();
+ var ret = u.UpdateRecord("99", "name", "x");
+
+ ret.Should().Be("target record not found for pk=99");
+ // No record mutated, no field set on any record.
+ Stored(u, "1", "name").Should().Be("");
+ Stored(u, "2", "name").Should().Be("existing");
+ u.FilledOverwriteCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void UpdateRecord_FieldNotFound_ReturnsErrorMessage_ListingAvailableFields()
+ {
+ var u = MakeUpdater();
+ var ret = u.UpdateRecord("1", "nonexistent", "x");
+
+ // The LLM-facing feedback lists the record's actual keys so the model can self-correct.
+ ret.Should().Be("field 'nonexistent' not found in record 1. Available: pk, name");
+ u.FilledOverwriteCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void UpdateRecord_DecodeAppliedToStoredValue()
+ {
+ // Cross-check: the value is decoded before storage (not stored raw).
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "%3C");
+ Stored(u, "1", "name").Should().Be("<", "newValue is UrlDecoded before being stored");
+ }
+
+ [Fact]
+ public void UpdateRecord_CallCount_IncrementsPerCall()
+ {
+ var u = MakeUpdater();
+ u.UpdateRecord("1", "name", "a");
+ u.UpdateRecord("2", "name", "b");
+ u.UpdateRecord("1", "name", "c");
+
+ u.CallCount.Should().Be(3);
+ }
+
+ [Fact]
+ public void UpdateRecord_OverwriteCount_AccumulatesAcrossOverwrites()
+ {
+ var u = MakeUpdater();
+ u.UpdateRecord("2", "name", "first"); // overwrite (existing="existing") → +1
+ u.UpdateRecord("2", "name", "second"); // overwrite (now "first") → +1
+ u.UpdateRecord("1", "name", "fill"); // fill (empty) → +0
+
+ u.FilledOverwriteCount.Should().Be(2);
+ Stored(u, "2", "name").Should().Be("second");
+ }
+
+ [Fact]
+ public void UpdateRecord_Determinism_SameInputsSameResult()
+ {
+ var u1 = MakeUpdater();
+ var u2 = MakeUpdater();
+
+ u1.UpdateRecord("1", "name", "%26amp%3B");
+ u2.UpdateRecord("1", "name", "%26amp%3B");
+
+ Stored(u1, "1", "name").Should().Be(Stored(u2, "1", "name"));
+ u1.FilledOverwriteCount.Should().Be(u2.FilledOverwriteCount);
+ }
+ }
+}