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:
@@ -42,16 +42,57 @@ flow.
|
|||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
The revision-write path **does not bypass the AddS2 blocker** — it
|
The revision-write path **does not bypass the AddS2 blocker** — it
|
||||||
shares the same `TagNotFoundInCache` precondition. There is no path
|
shares the same `TagNotFoundInCache` precondition.
|
||||||
from a managed client to a successful AddNonStreamValues call against
|
|
||||||
a client-created sandbox tag.
|
|
||||||
|
|
||||||
To validate this conclusion further (not done in this pass — too risky
|
### Follow-up probe (2026-05-05): SysTimeSec
|
||||||
for the production Historian) one could try AddNonStreamedValue against
|
|
||||||
a system tag like `SysTimeSec` whose key IS in the cache from upstream
|
To narrow the gate's scope, the harness was extended with
|
||||||
registration. If that succeeds, the path is implementable in principle
|
`--write-revision-target-tag <name>` (overrides the value's TagKey via
|
||||||
for IO-registered tags; if it also fails, the prerequisite is even
|
SQL lookup). Probed `SysTimeSec` (an auto-populated system tag whose
|
||||||
stricter.
|
wwTagKey=12 is well-known in the runtime cache):
|
||||||
|
|
||||||
|
```
|
||||||
|
AddNonStreamedValue (TagKey=12 SysTimeSec):
|
||||||
|
Result=False
|
||||||
|
ErrorCode=TagNotFoundInCache
|
||||||
|
ErrorDescription="error = 129 (Tag not found in cache)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Same failure. Then probed with `--write-revision-skip-validate` to set
|
||||||
|
the `validate` boolean to false on `AddNonStreamedValue` — same
|
||||||
|
`TagNotFoundInCache` failure. The cache check is intrinsic to the
|
||||||
|
function, not gated by the `validate` parameter.
|
||||||
|
|
||||||
|
So the gate is **per-(client-session, tag)**, not per-(server-cache, tag):
|
||||||
|
|
||||||
|
- Server-side, `SysTimeSec` IS in the runtime cache (it's auto-populated).
|
||||||
|
- Client-side, the managed library has its own per-connection tag list
|
||||||
|
that AddNonStreamedValue checks. That list is NOT populated by simply
|
||||||
|
knowing the wwTagKey — something else (likely a `RegisterTags2` call
|
||||||
|
during connection open, or the read flow as a side effect, or
|
||||||
|
IO-server-driven registration) populates it.
|
||||||
|
|
||||||
|
The harness opens with `ReadOnly=false` for the write scenario, which
|
||||||
|
may suppress the read-flow side effect that would otherwise populate
|
||||||
|
the local cache. Without further RE on what populates the local cache,
|
||||||
|
no path is reachable for a managed client to write either streaming or
|
||||||
|
revision values.
|
||||||
|
|
||||||
|
### Decisive blocker
|
||||||
|
|
||||||
|
Both `AddStreamedValue` (AddS2) and `AddNonStreamedValue` (revision
|
||||||
|
write) hit the same client-side cache gate. That gate isn't bypassed by:
|
||||||
|
|
||||||
|
1. Using a real wwTagKey from SQL
|
||||||
|
2. Targeting a server-cache-resident tag (SysTimeSec)
|
||||||
|
3. Setting `validate=false` on AddNonStreamedValue
|
||||||
|
|
||||||
|
There is no managed-client path to a successful write against this
|
||||||
|
server architecture without first reverse-engineering and exercising
|
||||||
|
whatever populates the per-connection local cache. That's a much larger
|
||||||
|
investigation — likely involving the WCF `RegisterTags2` op,
|
||||||
|
HistorianClient C++ internals, and/or IO-server-driven cache
|
||||||
|
registration that managed clients can't trigger directly.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
|
|||||||
@@ -460,9 +460,34 @@ internal static class Program
|
|||||||
ParameterCount = beginArgs.Length,
|
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.
|
// Build a single HistorianDataValue for the revision sample.
|
||||||
object revValue = Activator.CreateInstance(dataValueType)!;
|
object revValue = Activator.CreateInstance(dataValueType)!;
|
||||||
SetProperty(revValue, "TagKey", tagKey);
|
SetProperty(revValue, "TagKey", revTagKey);
|
||||||
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
|
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
|
||||||
SetProperty(revValue, "OpcQuality", (ushort)192);
|
SetProperty(revValue, "OpcQuality", (ushort)192);
|
||||||
SetProperty(revValue, "Value", writeValue);
|
SetProperty(revValue, "Value", writeValue);
|
||||||
@@ -483,13 +508,16 @@ internal static class Program
|
|||||||
.OrderBy(m => m.GetParameters().Length)
|
.OrderBy(m => m.GetParameters().Length)
|
||||||
.First();
|
.First();
|
||||||
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
|
// (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 addError0 = Activator.CreateInstance(errorType)!;
|
||||||
object?[] addArgs = new object?[addMethod.GetParameters().Length];
|
object?[] addArgs = new object?[addMethod.GetParameters().Length];
|
||||||
addArgs[0] = revValue;
|
addArgs[0] = revValue;
|
||||||
for (int i = 1; i < addArgs.Length - 1; i++)
|
for (int i = 1; i < addArgs.Length - 1; i++)
|
||||||
{
|
{
|
||||||
Type pt = addMethod.GetParameters()[i].ParameterType;
|
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;
|
addArgs[addArgs.Length - 1] = addError0;
|
||||||
object addResult = addMethod.Invoke(listInstance, addArgs)!;
|
object addResult = addMethod.Invoke(listInstance, addArgs)!;
|
||||||
@@ -513,6 +541,17 @@ internal static class Program
|
|||||||
|
|
||||||
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
|
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:
|
// SendValues drives the on-the-wire revision flow:
|
||||||
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
|
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
|
||||||
// → SendNonStreamedValues (the actual WCF push).
|
// → SendNonStreamedValues (the actual WCF push).
|
||||||
@@ -550,6 +589,8 @@ internal static class Program
|
|||||||
ErrorType = GetPropertyText(sendError, "ErrorType"),
|
ErrorType = GetPropertyText(sendError, "ErrorType"),
|
||||||
});
|
});
|
||||||
snapshots["SendValuesError"] = SnapshotObject(sendError);
|
snapshots["SendValuesError"] = SnapshotObject(sendError);
|
||||||
|
|
||||||
|
skipSendValues:;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user