ApplyScaling (HistorianTagDefinition.ApplyScaling): The EnsT2 trailer's second byte controls server-side scaling — `FE 00` mirrors MinRaw to MinEU and sets AnalogTag.Scaling=0; `FE 01` persists distinct MinRaw/MaxRaw and sets Scaling=1. Decoded by toggling set_ApplyScaling on the native harness and capturing the wire bytes for both values with identical inputs. The earlier docs claimed EnsureTagAsync needed a follow-up "UpdateTags" call; the WCF surface has no such operation — toggling that one byte is the whole fix. StorageRate (HistorianTagDefinition.StorageRateMs): Serializer accepts a non-default rate, validated empirically against the live server which only accepts quantized values (1000/5000/10000/60000/300000 ms). EnsureTagAsync upsert semantics: Second call on the same tag name with different fields succeeds and updates Description, MinEU, MaxEU, MinRaw, MaxRaw, Scaling in place (verified by direct SQL inspection in a live test). Plan + doc closeout: write-commands-reverse-engineering.md rewritten as a current-state plan with three workstreams (A doc closeout / B idempotency / C1 StorageRate) and a parallelism table; prior phase notes preserved as appendix. handoff.md, implementation-status.md, wcf-contract-evidence.md, README.md updated to remove "writes are out of scope" / non-existent UpdateTags references and document the actual EnsT2 wire format including the `FE xx` trailer. Reverse-engineering harness gains --write-apply-scaling and a SQL post-check that prints the persisted AnalogTag bounds so future RE sessions can verify wire→DB causality without leaving the harness. 169/169 tests pass (was 165; +4 new tests covering ApplyScaling, StorageRate golden bytes, StorageRate live persistence, and EnsureTagAsync upsert semantics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Plan: Reverse-Engineering Write Commands
Status (2026-05-04, post-ApplyScaling landing)
Phase 2 is complete. The write surface that the server actually supports for managed clients is implemented and live-verified:
EnsureTagAsyncfor analog tags (Float, Double, Int2, Int4, UInt4), with optionalApplyScaling=truefor distinct MinRaw/MaxRaw persistence (AnalogTag.Scaling=1).DeleteTagAsyncfor any tag created via the SDK.
Architecturally blocked / out of scope:
AddS2(write samples) — server's runtime cache only ingests from configured IOServers / Application Server pipelines, not from client-onlyAddTagflows. The native wrapper hits the same wall; this is a server architecture decision, not a protocol gap.- Discrete / String / Int1 / Int8 / UInt8 tag types — fail at
native
HistorianAccess.AddTagbefore any wire bytes leave the client. Likely require a different code path (AddTagExtendedPropertiesor pre-population via SMC); not investigated.
This plan covers the residual workstreams.
Workstreams
A. Documentation closeout
Status docs across the repo still describe write commands as
"in progress" or speculate about a non-existent UpdateTags
operation. Closing those out so future agents don't re-walk the
same dead ends.
| Step | File | Action |
|---|---|---|
| A1 | docs/reverse-engineering/handoff.md |
Add a "Write-flow status" note marking EnsT2/DelT live; remove stale write-blocker callouts |
| A2 | docs/reverse-engineering/implementation-status.md |
Flip EnsT2 / DelT rows from "out of scope" to "implemented"; add ApplyScaling row |
| A3 | docs/reverse-engineering/wcf-contract-evidence.md |
Add evidence rows for EnsT2(analog) and DelT pointing at the captured fixtures |
| A4 | README.md |
Operation-status table reflects the two write ops |
B. EnsT2 idempotency / update behavior
We don't currently know what happens when EnsureTagAsync is called
against a tag name that already exists with different fields. Three
plausible outcomes: server errors, server silently updates, server
no-ops. This affects how callers should think about the API
(create-only vs upsert).
| Step | Action |
|---|---|
| B1 | Add a live integration test that calls EnsureTagAsync twice on the same tag name with different MinEU/MaxEU/Description; query SQL after each call to capture observed behavior |
| B2 | Document the observed contract in HistorianTagDefinition doc-comment and (if surprising) in CLAUDE.md |
C. Expose currently-hardcoded CTagMetadata fields
The serializer hardcodes StorageRate=1000ms, StorageType=Cyclic,
IntegralDivisor=1.0, and a few flag-block bytes. The server accepts
those defaults so existing tests pass, but customers building tags
with non-default rates can't currently express that.
| Step | Field | Effort | Notes |
|---|---|---|---|
| C1 | StorageRate (uint32 ms) |
small — wire field is already at a known offset, just plumb a parameter through | Default stays 1000ms |
| C2 | StorageType (Cyclic / Delta) |
medium — need a comparison capture to find which byte in the flag block encodes it | Deferred unless customer asks |
| C3 | IntegralDivisor (double) |
small — wire field already known | Deferred unless customer asks |
C1 is the only one I'm executing in this round. C2/C3 are listed for completeness; pick them up when there's a concrete request.
D. Deferred — no current evidence or customer ask
| ID | Item | Why deferred |
|---|---|---|
| D1 | AddTagExtendedProperties / DeleteTagExtendedProperties |
No wire captures yet; no customer ask |
| D2 | AddRevisionValuesBegin/Value/End (revision-write path) |
Multi-step capture needed against an existing historized tag; complex; no customer ask |
| D3 | Discrete/String/Int1/Int8/UInt8 EnsT2 root cause | Native AddTag fails for these — likely requires an entirely different code path; would need a fresh capture and IL walkthrough |
Parallelism
| Track | Files touched | Conflicts with |
|---|---|---|
| A1 | docs/reverse-engineering/handoff.md |
none |
| A2 | docs/reverse-engineering/implementation-status.md |
none |
| A3 | docs/reverse-engineering/wcf-contract-evidence.md |
none |
| A4 | README.md |
none |
| B | tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs, src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs |
C1 (same HistorianTagDefinition file) |
| C1 | src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs, src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs, src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs, tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs, tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs |
B (same HistorianTagDefinition and integration-test file) |
Concurrency-safe groupings:
- A1–A4 are pairwise independent and pairwise independent of B and C1 → can be assigned to four agents in parallel.
- B and C1 both touch
HistorianTagDefinition.cs. Sequence them: B first (it just adds a doc-comment) then C1 (which adds a field).
In a single-agent execution (this session), the order is: A* batched edits → B → C1 → build/test → commit.
Success Criteria
- A1–A4: documentation reflects the actual current write surface and
removes references to non-existent operations (
UpdateTags). - B: a passing live test that asserts the observed double-EnsT2
behavior, plus a doc-comment update on
HistorianTagDefinition. - C1:
HistorianTagDefinition.StorageRateMsfield exposed, default preserves existing wire output, golden test for a non-default rate, live test that creates a tag with a non-default rate and asserts via SQL thatTag.CurrentEditorUserKey(or the storage-rate column, TBD) reflects the value. - All 165+ tests still pass; no regression in existing live tests.
Appendix: Prior Phase Notes
The historical phase logs that drove the implementation are preserved below for context. They describe the path from "write surface unknown" to "write surface implemented and live-verified" as it unfolded through 2026-05-04. Anything in this appendix that contradicts the current Status section above is superseded.
Phase 1 findings (recorded, not implementing)
§3.4 ModifyData/DeleteData — ELIMINATED FROM SCOPE
methods aahClientManaged.dll returns no managed wrapper for any of:
EditValue, ModifyValue, EditData, DeleteData, ModifyData,
OverwriteData. Per the plan's §3.4 disposition rule, this op is
REST-only / SMC-only and remains out of scope for the SDK.
§4.a Native serializers identified
The wrapper does have managed-public write API:
| Public method | Token | Used for |
|---|---|---|
ArchestrA.HistorianAccess.AddTag |
0x0600619A |
Creates a new tag (drives EnsT2) |
ArchestrA.HistorianAccess.AddStreamedValue |
0x0600618C/D/E (3 overloads) |
Pushes one timestamped value (drives AddS2) |
ArchestrA.HistorianAccess.AddNonStreamedValue |
0x0600618F/90 (2 overloads) |
Pushes one timestamped value, non-stream mode |
ArchestrA.HistorianAccess.DeleteTags |
0x060061A4 |
Removes tags (drives DelT) |
ArchestrA.HistorianAccess.AddVersionedStreamedValue |
0x0600616F |
Pushes versioned value (rev edit) |
ArchestrA.HistorianAccess.AddRevisionValuesBegin/Value/End/AddRevisionValues |
0x06006175-77, 0x0600617F |
Multi-row revision write |
Native serializer for EnsT2:
<Module>.CTagUtil.ConvertTagMetadataToHistorianTag at token
0x060055CE. WCF wrapper for AddS2:
<Module>.CHistoryConnectionWCF.AddStreamValuesToHistorian at token
0x0600404C.
Phase 2 follow-on findings (2026-05-04, second pass)
The AddS2 prereq is architectural, not protocol-level. Three follow-up attempts to trigger AddS2 from the sandbox harness all hit a client-side gate before any AddS2 byte reaches the wire:
- TagKey synthetic→real override: even with the real
wwTagKeythe server returns error 129 "Tag not found in cache". - Server-cache settle wait of 8s: error 129 persists.
- Fresh process / fresh connection (skip AddTag): error 129; no AddS2 bytes sent on wire.
The Historian engine's runtime tag cache only ingests tags from
configured IOServers / Application Server pipelines, not from
HistorianAccess.AddTag-only flows. WriteValueAsync cannot be
implemented as a generic client API against this server architecture.
Phase 2 results (write captures + EnsureTagAsync/DeleteTagAsync)
EnsT2 + DelT priming chain captured (no RTag2 between Open2 and
EnsT2):
Hist.GetV → Hist.GetI ×2 → Hist.ValCl ×2 → Hist.Open2 →
Stat.GetV ×2 → Stat.GETHI ×2 → Hist.UpdC3 →
Stat.GetSystemParameter ×7 → Trx.GetV → Stat.GetV → Retr.GetV →
Hist.EnsT2 → Hist.Close2
Open2 with NativeIntegratedWriteEnabledConnectionMode = 0x401
(Process | Write | IntegratedSecurity) is required — read-mode
0x402 makes the server return err 132 OperationNotEnabled
silently. The analog Float CTagMetadata payload is 144 bytes with a
leading 0x4E marker byte and a 2-byte trailer FE xx where the
second byte is the ApplyScaling flag (00 = false, 01 = true).
ApplyScaling resolution (2026-05-04)
Earlier docs claimed "MinRaw is mirrored to MinEU — server quirk,
not SDK bug". That conclusion was based on tests that always set
ApplyScaling=false on the native side. Re-running with
set_ApplyScaling(true) on the harness and capturing wire bytes for
both values revealed:
- ApplyScaling=false → trailer =
FE 00→ server mirrors MinRaw→MinEU, setsAnalogTag.Scaling=0 - ApplyScaling=true → trailer =
FE 01→ server persists distinct MinRaw/MaxRaw, setsAnalogTag.Scaling=1
The IHistoryServiceContract2 surface has no UpdateTags
operation. Distinct MinRaw/MaxRaw persistence is achieved entirely
by toggling that one byte in the EnsT2 payload. The SDK now exposes
this via HistorianTagDefinition.ApplyScaling.
Capture artifacts:
artifacts/reverse-engineering/apply-scaling-experiment/enst2-applyscaling-{false,true}.ndjson.
Original goal section (preserved for historical reference)
"Write commands work" was originally defined as the four ops:
EnsT2, AddS2, DelT, and ModifyData/DeleteData. The realized
scope is EnsT2 + DelT only. AddS2 is permanently blocked by
server architecture; ModifyData/DeleteData were eliminated by
static analysis (no managed wrapper exists). The
AddRevisionValuesBegin/Value/End chain remains a stretch goal
(item D2 in the current plan) — it was never investigated because
no SDK consumer has asked for revision writes.
Original safety rules (still applicable)
- Single dedicated sandbox tag per RE session, name must start with
RetestSdkWrite. - Never write to any tag named in
HISTORIAN_TEST_TAG,HISTORIAN_TAG_FILTER, the docs, or the captured RE ndjson. - Time bounds on writes: every test uses
DateTime.UtcNowso writes land inside the liveRealTimeWindow/FutureTimeThreshold. localhostonly; no customer / corporate hosts.- Sanitization scan after every session.
- Write captures live in
artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/(gitignored).