diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/OverrideCsvParser.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/OverrideCsvParser.cs
new file mode 100644
index 00000000..a847ced3
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/OverrideCsvParser.cs
@@ -0,0 +1,197 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
+
+///
+/// One parsed instance-attribute-override CSV row: an attribute name plus its
+/// (canonical-string) value and optional list element type. A null
+/// means "clear the override". is the
+/// 1-based source line (the header is line 1) so downstream errors can point back
+/// at the operator's file.
+///
+public sealed record OverrideCsvRow(string AttributeName, string? Value, string? ElementType, int LineNumber);
+
+///
+/// Outcome of parsing an override CSV: the successfully-parsed
+/// plus per-line . Parsing never throws — malformed rows are
+/// reported and excluded, valid rows still flow through. Downstream callers
+/// validate names/types against the instance schema; this parser is purely
+/// syntactic.
+///
+public sealed record OverrideCsvParseResult(IReadOnlyList Rows, IReadOnlyList Errors);
+
+///
+/// Pure, dependency-free, quote-aware parser turning instance-attribute-override
+/// CSV text into structured rows plus per-line errors. Callers supply the text
+/// (no file I/O). The header row is required and case-insensitive
+/// (AttributeName,Value,ElementType); the ElementType column is
+/// optional. Fields follow RFC-4180 quoting: a double-quoted field may embed
+/// commas and doubled quotes ("" → "); only unquoted fields are
+/// whitespace-trimmed.
+///
+public static class OverrideCsvParser
+{
+ private const string HeaderError =
+ "Missing or invalid header row. Expected 'AttributeName,Value,ElementType' " +
+ "(ElementType column optional).";
+
+ ///
+ /// Parses override CSV . Returns parsed rows and any
+ /// per-line errors; never throws. On a missing/unrecognized header returns zero
+ /// rows and a single header error.
+ ///
+ public static OverrideCsvParseResult Parse(string csvText)
+ {
+ var rows = new List();
+ var errors = new List();
+
+ // Split into physical lines; \r\n and \r are normalized to \n boundaries.
+ var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
+
+ var headerSeen = false;
+ var hasElementTypeColumn = false;
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var lineNumber = i + 1;
+ var rawLine = lines[i];
+
+ // Skip fully-blank lines (whitespace-only included) without error.
+ if (string.IsNullOrWhiteSpace(rawLine))
+ continue;
+
+ var fields = SplitFields(rawLine);
+
+ if (!headerSeen)
+ {
+ if (!TryMatchHeader(fields, out hasElementTypeColumn))
+ {
+ errors.Add(HeaderError);
+ return new OverrideCsvParseResult(rows, errors);
+ }
+
+ headerSeen = true;
+ continue;
+ }
+
+ var expectedColumns = hasElementTypeColumn ? 3 : 2;
+ if (fields.Count != expectedColumns)
+ {
+ errors.Add(
+ $"Line {lineNumber}: expected {expectedColumns} columns but found {fields.Count}.");
+ continue;
+ }
+
+ var attributeName = fields[0];
+ if (string.IsNullOrWhiteSpace(attributeName))
+ {
+ errors.Add($"Line {lineNumber}: AttributeName must not be blank.");
+ continue;
+ }
+
+ var value = NullIfEmpty(fields[1]);
+ var elementType = hasElementTypeColumn ? NullIfEmpty(fields[2]) : null;
+
+ rows.Add(new OverrideCsvRow(attributeName, value, elementType, lineNumber));
+ }
+
+ if (!headerSeen)
+ errors.Add(HeaderError);
+
+ return new OverrideCsvParseResult(rows, errors);
+ }
+
+ /// Matches the required header (case-insensitive); reports whether the optional ElementType column is present.
+ private static bool TryMatchHeader(IReadOnlyList fields, out bool hasElementTypeColumn)
+ {
+ hasElementTypeColumn = false;
+
+ var matchesTwoColumn =
+ fields.Count == 2 &&
+ HeaderEquals(fields[0], "AttributeName") &&
+ HeaderEquals(fields[1], "Value");
+
+ var matchesThreeColumn =
+ fields.Count == 3 &&
+ HeaderEquals(fields[0], "AttributeName") &&
+ HeaderEquals(fields[1], "Value") &&
+ HeaderEquals(fields[2], "ElementType");
+
+ if (matchesThreeColumn)
+ {
+ hasElementTypeColumn = true;
+ return true;
+ }
+
+ return matchesTwoColumn;
+ }
+
+ private static bool HeaderEquals(string field, string expected) =>
+ string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
+
+ private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
+
+ ///
+ /// RFC-4180-ish field splitter for a single physical line: quoted fields may
+ /// embed commas and doubled quotes ("" → "); unquoted fields are
+ /// whitespace-trimmed.
+ ///
+ private static List SplitFields(string line)
+ {
+ var fields = new List();
+ var field = new System.Text.StringBuilder();
+ var inQuotes = false;
+ var quoted = false; // this field was (at least partly) quoted → preserve whitespace
+
+ for (var i = 0; i < line.Length; i++)
+ {
+ var c = line[i];
+
+ if (inQuotes)
+ {
+ if (c == '"')
+ {
+ // Doubled quote inside a quoted field → a single literal quote.
+ if (i + 1 < line.Length && line[i + 1] == '"')
+ {
+ field.Append('"');
+ i++;
+ }
+ else
+ {
+ inQuotes = false;
+ }
+ }
+ else
+ {
+ field.Append(c);
+ }
+
+ continue;
+ }
+
+ switch (c)
+ {
+ case '"':
+ inQuotes = true;
+ quoted = true;
+ break;
+ case ',':
+ fields.Add(Finalize(field, quoted));
+ field.Clear();
+ quoted = false;
+ break;
+ default:
+ field.Append(c);
+ break;
+ }
+ }
+
+ fields.Add(Finalize(field, quoted));
+ return fields;
+ }
+
+ private static string Finalize(System.Text.StringBuilder field, bool quoted)
+ {
+ var text = field.ToString();
+ return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs
new file mode 100644
index 00000000..51e3c7dc
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/OverrideCsvParserTests.cs
@@ -0,0 +1,172 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
+
+public class OverrideCsvParserTests
+{
+ [Fact]
+ public void Parse_SimpleThreeColumnFile_ReturnsTwoRowsNoErrors()
+ {
+ const string csv = "AttributeName,Value,ElementType\nSetpoint,42,Int32\nName,Pump A,\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ Assert.Equal(2, result.Rows.Count);
+
+ Assert.Equal("Setpoint", result.Rows[0].AttributeName);
+ Assert.Equal("42", result.Rows[0].Value);
+ Assert.Equal("Int32", result.Rows[0].ElementType);
+ Assert.Equal(2, result.Rows[0].LineNumber);
+
+ Assert.Equal("Name", result.Rows[1].AttributeName);
+ Assert.Equal("Pump A", result.Rows[1].Value);
+ Assert.Null(result.Rows[1].ElementType);
+ Assert.Equal(3, result.Rows[1].LineNumber);
+ }
+
+ [Fact]
+ public void Parse_TwoColumnFileWithoutElementType_RowsHaveNullElementType()
+ {
+ const string csv = "AttributeName,Value\nSetpoint,42\nName,Pump A\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ Assert.Equal(2, result.Rows.Count);
+ Assert.All(result.Rows, r => Assert.Null(r.ElementType));
+ Assert.Equal("42", result.Rows[0].Value);
+ Assert.Equal("Pump A", result.Rows[1].Value);
+ }
+
+ [Fact]
+ public void Parse_QuotedValueWithComma_PreservesEmbeddedComma()
+ {
+ const string csv = "AttributeName,Value,ElementType\nName,\"a,b,c\",\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("Name", row.AttributeName);
+ Assert.Equal("a,b,c", row.Value);
+ Assert.Null(row.ElementType);
+ }
+
+ [Fact]
+ public void Parse_DoubledQuoteEscape_UnescapesToSingleQuote()
+ {
+ const string csv = "AttributeName,Value,ElementType\nName,\"he said \"\"hi\"\"\",\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("he said \"hi\"", row.Value);
+ }
+
+ [Fact]
+ public void Parse_EmptyValueField_YieldsNullValue()
+ {
+ const string csv = "AttributeName,Value,ElementType\nSetpoint,,\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("Setpoint", row.AttributeName);
+ Assert.Null(row.Value);
+ Assert.Null(row.ElementType);
+ }
+
+ [Fact]
+ public void Parse_RowWithTooFewColumns_ProducesLineNumberedErrorAndExcludesRow()
+ {
+ const string csv = "AttributeName,Value,ElementType\nSetpoint\nName,Pump A,\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ // Bad row on line 2 excluded; good row on line 3 retained.
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("Name", row.AttributeName);
+ Assert.Equal(3, row.LineNumber);
+
+ var error = Assert.Single(result.Errors);
+ Assert.Contains("2", error);
+ }
+
+ [Fact]
+ public void Parse_BlankAttributeName_ProducesLineNumberedError()
+ {
+ const string csv = "AttributeName,Value,ElementType\n,42,Int32\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Rows);
+ var error = Assert.Single(result.Errors);
+ Assert.Contains("2", error);
+ }
+
+ [Fact]
+ public void Parse_BlankLines_AreSkippedWithoutError()
+ {
+ const string csv = "AttributeName,Value,ElementType\n\nSetpoint,42,Int32\n \nName,Pump A,\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ Assert.Equal(2, result.Rows.Count);
+ // LineNumber reflects the true source line (blank line 2 skipped).
+ Assert.Equal(3, result.Rows[0].LineNumber);
+ Assert.Equal(5, result.Rows[1].LineNumber);
+ }
+
+ [Fact]
+ public void Parse_MissingHeader_ReturnsZeroRowsAndHeaderError()
+ {
+ const string csv = "Setpoint,42,Int32\nName,Pump A,\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Rows);
+ var error = Assert.Single(result.Errors);
+ Assert.Contains("header", error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Parse_HeaderIsCaseInsensitiveAndTrimsWhitespace()
+ {
+ const string csv = "attributename, value , elementtype\nSetpoint,42,Int32\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ var row = Assert.Single(result.Rows);
+ Assert.Equal("Setpoint", row.AttributeName);
+ Assert.Equal("42", row.Value);
+ Assert.Equal("Int32", row.ElementType);
+ }
+
+ [Fact]
+ public void Parse_EmptyInput_ReturnsHeaderError()
+ {
+ var result = OverrideCsvParser.Parse(string.Empty);
+
+ Assert.Empty(result.Rows);
+ Assert.Single(result.Errors);
+ }
+
+ [Fact]
+ public void Parse_UnquotedWhitespace_IsTrimmedButQuotedWhitespacePreserved()
+ {
+ const string csv = "AttributeName,Value,ElementType\n Setpoint , 42 ,Int32\nName,\" spaced \",\n";
+
+ var result = OverrideCsvParser.Parse(csv);
+
+ Assert.Empty(result.Errors);
+ Assert.Equal(2, result.Rows.Count);
+ Assert.Equal("Setpoint", result.Rows[0].AttributeName);
+ Assert.Equal("42", result.Rows[0].Value);
+ Assert.Equal(" spaced ", result.Rows[1].Value);
+ }
+}