D2 (revision-write): empirically blocked by same gate as AddS2

Drove the revision-write flow via reflection in the native trace harness
(--write-revision-values) to see whether it bypasses the AddS2
architectural blocker. It doesn't.

Findings:
- HistorianAccess.CreateHistorianDataValueList(NonStreamedOriginal) succeeds
- HistorianDataValueList.NonStreamedValuesBegin() succeeds (batchID 0->1)
- HistorianDataValueList.AddNonStreamedValue(value, validate=true, out err)
  FAILS with ErrorCode=TagNotFoundInCache (129) — same client-side
  validation gate that blocks AddS2
- AddNonStreamedValuesEnd() returns void; SendValues() returns true
  with Success because the list is empty (no value was ever added)
- No AddNonStreamValues* WCF calls reach the wire

Conclusion: the revision-write path requires the tag to be in the
library's runtime tag cache, which is only populated by configured
IO server / Application Server pipelines, not by HistorianAccess.AddTag.
This matches the architectural blocker documented for AddS2 and means
no public WriteRevisionsAsync / BeginRevisionAsync should be added to
the SDK — the path is unreachable for client-created sandbox tags.

The Wcf/Contracts/ITransactionServiceContract methods (AddNonStream-
ValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd) remain declared
for completeness; no orchestrator or public surface is added.

The harness extension is preserved as a deterministic reproducer for
the blocker: re-run --write-revision-values to verify the gate any
time. docs/plans/revision-write-path.md updated with the empirical
finding plus the original plan retained as historical context.

177/177 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 01:45:48 -04:00
parent f4709ff143
commit 2feb56d52c
2 changed files with 253 additions and 2 deletions
@@ -393,6 +393,170 @@ internal static class Program
}
// --write-revision-values triggers the revision (non-streamed) write path.
// Calls SendValues with a HistorianDataValueList populated via
// NonStreamedValuesBegin / AddNonStreamedValue / AddNonStreamedValuesEnd.
// Captures whatever happens — success, server-side rejection, or client
// gate — for protocol decoding purposes.
if (HasFlag(args, "--write-revision-values"))
{
try
{
Type dataValueListType = GetType(assembly, "ArchestrA.HistorianDataValueList");
Type dataCategoryEnum = GetType(assembly, "ArchestrA.HistorianDataCategory");
// Use HistorianAccess.CreateHistorianDataValueList — the public factory that
// binds the list to the live HistorianClient* via GetClient(ConnectionIndex).
MethodInfo createListMethod = accessType.GetMethods()
.Where(m => m.Name == "CreateHistorianDataValueList")
.OrderBy(m => m.GetParameters().Length)
.First();
rows.Add(new
{
Kind = "CreateHistorianDataValueListSig",
Params = createListMethod.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}").ToArray(),
});
rows.Add(new
{
Kind = "EnumValues",
DataCategory = Enum.GetValues(dataCategoryEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
});
// Pick non-zero values where appropriate. Common AVEVA convention:
// HistorianDataCategory.Real or Process is typically the first non-zero entry
// ConnectionIndex.Process is the first non-zero entry
object?[] createListArgs = createListMethod.GetParameters().Select(p =>
{
if (p.ParameterType == dataCategoryEnum)
{
// Prefer first declared (likely Process or Real)
return Enum.GetValues(dataCategoryEnum).Cast<object>().FirstOrDefault();
}
if (p.ParameterType == connectionIndexEnum)
{
return Enum.Parse(connectionIndexEnum, "Process", ignoreCase: true);
}
return p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null;
}).ToArray();
object listInstance = createListMethod.Invoke(access, createListArgs)!;
snapshots["DataValueListBeforeBegin"] = SnapshotObject(listInstance);
System.Reflection.BindingFlags allInstance =
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance;
MethodInfo beginMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "NonStreamedValuesBegin");
object?[] beginArgs = beginMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object beginResult = beginMethod.Invoke(listInstance, beginArgs)!;
rows.Add(new
{
Kind = "NonStreamedValuesBegin",
Result = beginResult?.ToString(),
ParameterCount = beginArgs.Length,
});
// Build a single HistorianDataValue for the revision sample.
object revValue = Activator.CreateInstance(dataValueType)!;
SetProperty(revValue, "TagKey", tagKey);
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(revValue, "OpcQuality", (ushort)192);
SetProperty(revValue, "Value", writeValue);
SetProperty(revValue, "StartDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "EndDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "ApplyScaling", false);
MethodInfo[] addMethodCandidates = dataValueListType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "AddNonStreamedValueCandidates",
Count = addMethodCandidates.Length,
Sigs = addMethodCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo addMethod = addMethodCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
object addError0 = Activator.CreateInstance(errorType)!;
object?[] addArgs = new object?[addMethod.GetParameters().Length];
addArgs[0] = revValue;
for (int i = 1; i < addArgs.Length - 1; i++)
{
Type pt = addMethod.GetParameters()[i].ParameterType;
addArgs[i] = pt == typeof(bool) ? true : pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addArgs[addArgs.Length - 1] = addError0;
object addResult = addMethod.Invoke(listInstance, addArgs)!;
object addErrorAfter = addArgs[addArgs.Length - 1]!;
rows.Add(new
{
Kind = "AddNonStreamedValue",
Result = addResult?.ToString(),
ErrorDescription = GetPropertyText(addErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(addErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(addErrorAfter, "ErrorType"),
});
MethodInfo endListMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "AddNonStreamedValuesEnd");
object?[] endListArgs = endListMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object endListResult = endListMethod.Invoke(listInstance, endListArgs)!;
rows.Add(new { Kind = "AddNonStreamedValuesEnd", Result = endListResult?.ToString() });
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
// SendValues drives the on-the-wire revision flow:
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
// → SendNonStreamedValues (the actual WCF push).
object sendError = Activator.CreateInstance(errorType)!;
MethodInfo[] sendValuesCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "SendValues")
.ToArray();
rows.Add(new
{
Kind = "SendValuesCandidates",
Count = sendValuesCandidates.Length,
Sigs = sendValuesCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo sendValuesMethod = sendValuesCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValueList list, HistorianAccessError& error)
object?[] sendArgs = new object?[sendValuesMethod.GetParameters().Length];
sendArgs[0] = listInstance;
for (int i = 1; i < sendArgs.Length - 1; i++)
{
Type pt = sendValuesMethod.GetParameters()[i].ParameterType;
sendArgs[i] = pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
sendArgs[sendArgs.Length - 1] = sendError;
bool sendSuccess = (bool)sendValuesMethod.Invoke(access, sendArgs)!;
sendError = sendArgs[sendArgs.Length - 1]!;
rows.Add(new
{
Kind = "SendValues",
Success = sendSuccess,
SignatureParamCount = sendArgs.Length,
ErrorDescription = GetPropertyText(sendError, "ErrorDescription"),
ErrorCode = GetPropertyText(sendError, "ErrorCode"),
ErrorType = GetPropertyText(sendError, "ErrorType"),
});
snapshots["SendValuesError"] = SnapshotObject(sendError);
}
catch (Exception ex)
{
rows.Add(new { Kind = "RevisionFlowException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// DeleteTags runs independently of AddStreamedValue success (write-RE
// sandbox cleanup); guarded by --write-delete-after to keep the default
// run non-destructive.