D2 (revision-write): probe SysTimeSec — same gate, narrower scope

Extended the harness with --write-revision-target-tag <name> (overrides
the value's TagKey via SQL lookup) and --write-revision-skip-validate
(passes false to AddNonStreamedValue's `validate` boolean). Added
--write-revision-commit gate so the harness validates without actually
calling SendValues by default — important when targeting system tags.

Probed SysTimeSec (wwTagKey=12, server-cache-resident system tag):
- AddNonStreamedValue: ErrorCode=TagNotFoundInCache (129) — same failure
- With validate=false: same failure (the cache check is intrinsic, not
  gated by the boolean)

Conclusion: the gate is per-(client-session, tag), not per-server-cache.
Even tags the SERVER cache knows about are rejected because the LIBRARY
maintains a separate per-connection tag list that AddNonStreamedValue
checks. That list isn't populated by knowing the wwTagKey alone — it
needs whatever mechanism (RegisterTags2 / read flow side effect / IO
server registration) that we haven't reverse-engineered.

The revision-write path remains architecturally blocked for managed
clients. Plan doc updated with the SysTimeSec finding.

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 02:27:58 -04:00
parent 2feb56d52c
commit 3af8a13059
2 changed files with 93 additions and 11 deletions
@@ -460,9 +460,34 @@ internal static class Program
ParameterCount = beginArgs.Length,
});
// Optional override: target a different tag (e.g. SysTimeSec) by name.
// Used to investigate whether the cache gate is per-tag (i.e. tags
// already in the runtime cache pass validation while client-created
// sandbox tags don't).
string? targetTagOverride = GetArg(args, "--write-revision-target-tag");
uint revTagKey = tagKey;
if (!string.IsNullOrWhiteSpace(targetTagOverride))
{
using System.Data.SqlClient.SqlConnection sqlT = new("Server=.;Database=Runtime;Integrated Security=SSPI;");
sqlT.Open();
using System.Data.SqlClient.SqlCommand cmdT = sqlT.CreateCommand();
cmdT.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmdT.Parameters.AddWithValue("@t", targetTagOverride);
object? overrideKey = cmdT.ExecuteScalar();
if (overrideKey is int k)
{
revTagKey = (uint)k;
rows.Add(new { Kind = "RevisionTargetTagOverride", Tag = targetTagOverride, TagKey = revTagKey });
}
else
{
rows.Add(new { Kind = "RevisionTargetTagOverrideNotFound", Tag = targetTagOverride });
}
}
// Build a single HistorianDataValue for the revision sample.
object revValue = Activator.CreateInstance(dataValueType)!;
SetProperty(revValue, "TagKey", tagKey);
SetProperty(revValue, "TagKey", revTagKey);
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(revValue, "OpcQuality", (ushort)192);
SetProperty(revValue, "Value", writeValue);
@@ -483,13 +508,16 @@ internal static class Program
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
// --write-revision-skip-validate flips the bool to false to see if the
// cache gate is enforced inside this function or elsewhere downstream.
bool validateFlag = !HasFlag(args, "--write-revision-skip-validate");
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[i] = pt == typeof(bool) ? validateFlag : pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addArgs[addArgs.Length - 1] = addError0;
object addResult = addMethod.Invoke(listInstance, addArgs)!;
@@ -513,6 +541,17 @@ internal static class Program
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
// Safety: require explicit --write-revision-commit to actually fire
// SendValues. Without it, the harness validates the path (cache gate,
// value validation) but does NOT push anything to the wire. Important
// when targeting system tags via --write-revision-target-tag.
bool commitRevision = HasFlag(args, "--write-revision-commit");
if (!commitRevision)
{
rows.Add(new { Kind = "RevisionSendValuesSkipped", Reason = "Pass --write-revision-commit to actually call SendValues." });
goto skipSendValues;
}
// SendValues drives the on-the-wire revision flow:
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
// → SendNonStreamedValues (the actual WCF push).
@@ -550,6 +589,8 @@ internal static class Program
ErrorType = GetPropertyText(sendError, "ErrorType"),
});
snapshots["SendValuesError"] = SnapshotObject(sendError);
skipSendValues:;
}
catch (Exception ex)
{