code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97

Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.

regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
This commit is contained in:
Joseph Doherty
2026-05-28 02:55:47 -04:00
parent 1eb6e972b0
commit f93b7b99bb
25 changed files with 8793 additions and 115 deletions
+466 -3
View File
@@ -5,10 +5,10 @@
| Module | `src/ScadaLink.Commons` |
| Design doc | `docs/requirements/Component-Commons.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-17 |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 0 |
| Commit reviewed | `1eb6e97` |
| Open findings | 9 |
## Summary
@@ -46,6 +46,42 @@ indexer that rejects `long` indices (Commons-013) and an `OpcUaEndpointConfigSer
legacy-fallback path that can mislabel a corrupt new-shape row as `Legacy` (Commons-014).
No Critical, High, or Medium issues were found.
#### Re-review 2026-05-28 (commit `1eb6e97`)
Commons has grown substantially since `39d737e` — 132 changed files (≈ +4 600 lines), driven
by the Audit Log (#23), Site Call Audit (#22), and Transport (#24) work. The new surface
area covers six new entity domain folders (Audit, Transport types under `Types/Transport`),
seven new service interfaces (`IPartitionMaintenance`, `INodeIdentityProvider`,
`ISiteAuditQueue`, `ICachedCallLifecycleObserver`, `ICachedCallTelemetryForwarder`,
`IOperationTrackingStore`, `IBundleExporter` / `IBundleImporter` / `IBundleSessionStore` /
`IAuditCorrelationContext`), a new `IAuditLogRepository`, and three new message folders
(`Messages/Audit/`, `Messages/Integration/` extensions, `Messages/Management/TransportCommands`).
The `SourceNode` thread-through and `ExecutionId` / `ParentExecutionId` additive-evolution
fields are uniformly applied across `AuditEvent`, `SiteCall`, `Notification`,
`NotificationSubmit`, `RouteToCallRequest`, `ScriptCallRequest`, and `SiteHealthReport`
all as trailing optional parameters, consistent with REQ-COM-5a.
All fourteen prior findings (Commons-001 through Commons-014) remain `Resolved`. Nine new
findings were recorded this pass: one Medium on the lack of UTC-kind enforcement for the
new `DateTime`-typed `*Utc` columns (Commons-019), one Medium on unconstrained
`EncryptionMetadata` (Commons-015), one Medium on the now-substantially-stale design doc
(Commons-017), and six Low findings covering minor convention drift, missing unit tests
for the Transport types, an unresolvable `<see cref>` in `IAuditCorrelationContext`, a
benign lazy-parse race in `ExternalCallResult.Response`, undocumented JSON-blob shapes,
two interfaces parked in the wrong folder, and a magic-number threshold in `BundleSession`.
No Critical or High issues were found.
The architectural-constraint tests still enforce the no-Akka/no-EF/no-ASP.NET rule, the
POCO-entity and message-as-record conventions, and the `ToLocalTime` ban; they do not yet
cover the new `*Utc`-suffixed `DateTime` properties on `AuditEvent` / `SiteCall`. Test
coverage for the new types is uneven — `TrackedOperationId`, `SiteCallOperational`,
`CachedCallTelemetry`, `SiteCallQueries`, `AuditQueryParamParsers`, `ApiKeyHasher`,
`Notification`, and `SiteCall` are all directly tested; the Transport types
(`BundleManifest`, `EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`,
`ImportPreview`, `ImportResolution`, `ImportResult`, `ManifestContentEntry`) have only
integration-level coverage in `tests/ScadaLink.Transport.IntegrationTests/`, with no
shape/serialization tests in `ScadaLink.Commons.Tests`.
## Checklist coverage
| # | Category | Examined | Notes |
@@ -61,6 +97,21 @@ No Critical, High, or Medium issues were found.
| 9 | Testing coverage | ✓ | `ValueFormatter`, `DynamicJsonElement`, `ScriptArgs`, `ManagementCommandRegistry`, `Result<T>`, `ConfigurationDiff`, `AlarmContext`, and the OPC UA serializer round-trip have no tests (Commons-010). |
| 10 | Documentation & comments | ✓ | `OpcUaEndpointConfigSerializer.Deserialize` XML doc does not mention the silent data-loss path (Commons-005). `Component-Commons.md` is stale relative to the actual file set (Commons-009). `ValueFormatter` uses current-culture formatting without documenting it (Commons-012). |
## Checklist coverage — Re-review 2026-05-28 (commit `1eb6e97`)
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ✓ | `EncryptionMetadata` accepts any algorithm string + any iteration count with no validation (Commons-015). New `*Utc`-suffixed `DateTime` columns on `AuditEvent`/`SiteCall` have no `DateTimeKind.Utc` enforcement and are inconsistent with `Notification`'s `DateTimeOffset` (Commons-019). |
| 2 | Akka.NET conventions | ✓ | Commons has no actors. All new message contracts (`Messages/Audit`, `Messages/Integration` extensions, `RouteToCallRequest`, `ScriptCallRequest`) are records with trailing optional members per REQ-COM-5a. Correlation IDs present on request/response messages. |
| 3 | Concurrency & thread safety | ✓ | `IAuditCorrelationContext` documents its scoped/sequential thread-safety contract explicitly (good). `ExternalCallResult.Response` has a benign lazy-parse race — two concurrent reads can both parse and produce distinct wrappers (Commons-021). |
| 4 | Error handling & resilience | ✓ | The new ingest/upsert command + reply pairs (`UpsertSiteCallReply`, `IngestAuditEventsReply`, `IngestCachedTelemetryReply`) carry idempotency-friendly accepted-id lists and an `Accepted` flag that explicitly does NOT propagate audit-write failure to the user-facing action (alog.md §13). |
| 5 | Security | ✓ | `ApiKeyHasher` correctly fails-fast on missing / weak pepper (≥16 chars), uses HMAC-SHA256, never accepts a null plaintext, and provides a clearly-labelled `Default` for tests only. `ApiKey.FromHash` is the production constructor; the plaintext constructor only ever uses the unpeppered `Default` and is documented as such. No script-trust violations in any new file. |
| 6 | Performance & resource management | ✓ | `IBundleSessionStore.EvictExpired` exists for sessions — good. `BundleSession` carries `DecryptedContent` plus `Manifest` per session; the size is bounded by the configured bundle cap but no explicit per-session size accounting. `ExternalCallResult.Response` lazy parse not thread-safe (Commons-021). |
| 7 | Design-document adherence | ✓ | `Component-Commons.md` is now significantly stale relative to the actual file set: stale enum values for `AuditKind`/`AuditStatus`, missing `AuditEvent`/`SiteCall` entities, missing `IAuditLogRepository`, missing six service interfaces and `Interfaces/Transport/`, missing four `Types/*` folders and `Messages/Audit/` (Commons-017). |
| 8 | Code organization & conventions | ✓ | `IOperationTrackingStore` and `IPartitionMaintenance` live at the root of `Interfaces/` rather than under `Interfaces/Services/` (Commons-018). `BundleSession.Locked` uses a magic `3` rather than a named constant (Commons-016). Message contracts and entities otherwise follow the additive-evolution / POCO / `record` conventions. |
| 9 | Testing coverage | ✓ | Transport types (`BundleManifest`, `EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `ManifestContentEntry`) have no unit tests in `tests/ScadaLink.Commons.Tests/`; only `tests/ScadaLink.Transport.IntegrationTests/` exercises them (Commons-020). `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` / `UpsertSiteCallCommand` / `PullAuditEventsRequest` / `PullAuditEventsResponse` / `AuditTelemetryEnvelope` shape tests also absent. |
| 10 | Documentation & comments | ✓ | `IAuditCorrelationContext` references `BundleImporter.ApplyAsync` — an implementation type Commons does not see, so the `<see cref>` is unresolvable (Commons-022b, folded into Commons-022). `ImportPreviewItem.FieldDiffJson` and `Notification.ResolvedTargets` are JSON-string columns with no documented shape contract (Commons-022). |
## Findings
### Commons-001 — `StaleTagMonitor` stale-fire race between timer and `OnValueReceived`
@@ -674,3 +725,415 @@ describe the corrupt-typed-row branch. Regression tests added in
`OpcUaEndpointConfigSerializerTests` (`Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy`,
`Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy`,
`Deserialize_ValidTypedRow_StillReportsTyped`).
### Commons-015 — `EncryptionMetadata` accepts any algorithm string and any iteration count
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs:3-8` |
**Description**
`EncryptionMetadata` is a positional record that carries the bundle's encryption parameters
over the wire and into the persistence/audit layer:
```csharp
public sealed record EncryptionMetadata(
string Algorithm, // "AES-256-GCM"
string Kdf, // "PBKDF2-SHA256"
int Iterations,
string SaltB64,
string IvB64);
```
The expected values are documented as inline comments only — there is no validation, no
enum, and no constructor invariant. The consequences:
- A bundle manifest that says `Algorithm = "AES-128-CBC"` (or any garbage string) will
deserialize successfully. The mismatch surfaces only when `BundleImporter` tries to
decrypt, where it most likely manifests as a misleading exception (or a silent wrong-key
result, depending on the implementation).
- `Iterations` is unconstrained — `0`, negative, or absurdly large values round-trip. A
zero/negative iteration count weakens the KDF and a billion-iteration count is a DoS
vector against a passphrase-unlock attempt.
- `SaltB64` / `IvB64` are just `string` — there is no length, format, or non-null check.
A null or empty salt/IV silently rides through serialization and surfaces inside the
cipher init.
`EncryptionMetadata` is the integrity contract for the bundle's encryption envelope and
crosses both the file boundary (the on-disk bundle manifest) and the central audit log.
The defense-in-depth principle says malformed values should be rejected at the type
boundary, not at the cipher.
**Recommendation**
Validate in a static factory or constructor: reject unsupported `Algorithm`/`Kdf` (an
enum or a small whitelist of strings), require `Iterations >= 100_000` (or whatever the
documented PBKDF2 minimum is) and `<= 10_000_000`, require non-blank `SaltB64`/`IvB64`,
and Base64-decode them at construction so a malformed encoding fails fast. Document the
accepted values on the record.
### Commons-016 — `BundleSession.Locked` uses a magic `3` rather than a named constant
| | |
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Location | `src/ScadaLink.Commons/Types/Transport/BundleSession.cs:13-16` |
**Description**
`BundleSession` exposes:
```csharp
public int FailedUnlockAttempts { get; set; }
public bool Locked => FailedUnlockAttempts >= 3;
```
The `3` is a magic number with no constant, no XML doc reference, and no symbol to
search for if a future operator wants to change the threshold (or write a test that
deliberately exercises the lockout). The XML comment on `Locked` repeats the literal
("three or more unlock attempts have failed") rather than citing a constant, so a
change to the threshold would have to be made in three places (the comparison, the XML
text, and any caller-side `attempts < 3` checks). The lockout count is also a
security-relevant policy parameter — it deserves a named symbol so a security review
can find it.
**Recommendation**
Promote the threshold to a `public const int MaxUnlockAttempts = 3;` on `BundleSession`
(or to the `IBundleSessionStore`/`BundleImporter` if that is the better home), and rewrite
the `Locked` expression and the XML comment in terms of it. If the threshold is actually
owned by a Transport-component option, document the link.
### Commons-017 — `Component-Commons.md` is significantly stale (audit enums, new entities, new repositories, new service interfaces, new folders)
| | |
|--|--|
| Severity | Medium |
| Category | Design-document adherence |
| Status | Open |
| Location | `docs/requirements/Component-Commons.md:41-44`, `:75-79`, `:88-95`, `:107-117`, `:152-232` |
**Description**
The Commons design doc has fallen materially behind the code:
- **REQ-COM-1 audit enums** — the doc's `AuditKind` enum lists
`SyncCall, CachedEnqueued, CachedAttempt, CachedTerminal, SyncWrite, SyncRead, Enqueued,
Attempt, Terminal, Completed`; the actual enum in `Types/Enums/AuditKind.cs` has
*completely different* values: `ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend,
NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve`.
Likewise `AuditStatus` — doc says `Success, TransientFailure, PermanentFailure, Enqueued,
Retrying, Delivered, Parked, Discarded`; actual values are `Submitted, Forwarded,
Attempted, Delivered, Failed, Parked, Discarded, Skipped`. The doc's enum names cannot
be matched to the code at all.
- **REQ-COM-3 entities** — the Audit bullet still lists only `AuditLogEntry`; the
actual `Entities/Audit/` folder now contains `AuditEvent` and `SiteCall` as well, and
both carry significant additional columns (`SourceNode`, `ExecutionId`,
`ParentExecutionId`) that are core to the M3-M7 work and entirely absent from the doc.
- **REQ-COM-4 repositories** — `IAuditLogRepository` is in the code (with its
`InsertIfNotExistsAsync`, `QueryAsync`, `SwitchOutPartitionAsync`,
`GetPartitionBoundariesOlderThanAsync`, `GetKpiSnapshotAsync`, `GetExecutionTreeAsync`,
`GetDistinctSourceNodesAsync` surface) but missing from the REQ-COM-4 list.
- **REQ-COM-4a services** — the doc lists seven service interfaces. The code adds
`ICachedCallLifecycleObserver`, `ICachedCallTelemetryForwarder`, `INodeIdentityProvider`,
`ISiteAuditQueue`, plus the misplaced `IOperationTrackingStore` and `IPartitionMaintenance`
(see Commons-018), and the `Interfaces/Transport/` folder with four more interfaces
(`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`)
— none of which appear in REQ-COM-4a.
- **REQ-COM-5b folder tree** — missing: `Types/Audit/` (`AuditLogPaging`,
`AuditLogQueryFilter`, `AuditQueryParamParsers`, `ExecutionTreeNode`,
`SiteCallKpiSnapshot`, `SiteCallPaging`, `SiteCallQueryFilter`,
`SiteCallSiteKpiSnapshot`), `Types/Notifications/` (`NotificationKpiSnapshot`,
`NotificationOutboxFilter`, `SiteNotificationKpiSnapshot`), `Types/InboundApi/`
(`ApiKeyHasher`, `ParameterDefinition`), `Types/Transport/` (nine records),
`Messages/Audit/` (seven new message files), `Interfaces/Transport/` (four
interfaces), plus the new `AuditLogKpiSnapshot`, `SiteAuditBacklogSnapshot`,
`SiteCallOperational`, `TrackingStatusSnapshot` directly under `Types/`.
CLAUDE.md's editing rules state design docs and code must travel together. The doc is now
much less useful as a map of the actual file set than after the previous (Commons-009)
refresh.
**Recommendation**
Refresh `Component-Commons.md` against the current file set: rewrite the `AuditKind` /
`AuditStatus` enum value lists to match the code, add `AuditEvent` and `SiteCall` to
REQ-COM-3, add `IAuditLogRepository` to REQ-COM-4, expand REQ-COM-4a with the new service
interfaces (and add a sentence on the Transport interfaces in `Interfaces/Transport/`),
and rewrite the REQ-COM-5b folder tree to include the new `Types/*`, `Messages/Audit`,
and `Interfaces/Transport` folders. The same kind of refresh that resolved Commons-009 is
needed again now.
### Commons-018 — `IOperationTrackingStore` and `IPartitionMaintenance` are at the root of `Interfaces/` instead of `Interfaces/Services/`
| | |
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Location | `src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs`, `src/ScadaLink.Commons/Interfaces/IPartitionMaintenance.cs` |
**Description**
REQ-COM-5b documents the `Interfaces/` folder as having exactly three sub-folders:
`Protocol/` (REQ-COM-2), `Repositories/` (REQ-COM-4), and `Services/` (REQ-COM-4a). Two
new interfaces — `IOperationTrackingStore` and `IPartitionMaintenance` — are filed at
the root of `Interfaces/` (namespace `ScadaLink.Commons.Interfaces`) rather than under
`Interfaces/Services/` (namespace `ScadaLink.Commons.Interfaces.Services`). They are
straightforward cross-cutting service interfaces consumed by the Audit Log component (a
site-local SQLite tracking store; a central partition-maintenance hosted-service helper)
and conceptually belong alongside `ISiteAuditQueue`, `ICachedCallLifecycleObserver`, etc.
The inconsistency is small but it breaks the "every interface lives under a sub-folder"
rule REQ-COM-5b establishes, and it makes the namespace surface inconsistent — every
other recently-added service interface uses `Interfaces.Services`.
**Recommendation**
Move both files into `Interfaces/Services/` and adjust the namespace to
`ScadaLink.Commons.Interfaces.Services`. Update consumers in `ScadaLink.AuditLog`,
`ScadaLink.SiteRuntime`, and `ScadaLink.ConfigurationDatabase`. Add them to the
REQ-COM-4a list (see Commons-017).
### Commons-019 — New `*Utc`-suffixed `DateTime` columns on `AuditEvent` / `SiteCall` are not enforced as UTC; inconsistent with `Notification`'s `DateTimeOffset`
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Location | `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs:15-18`, `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs:59-68`, `tests/ScadaLink.Commons.Tests/Entities/EntityConventionTests.cs:49-69` |
**Description**
CLAUDE.md mandates UTC throughout the system, "DateTime with DateTimeKind.Utc *or*
DateTimeOffset". The pre-existing convention in Commons entities is `DateTimeOffset`,
and the architectural test `AllTimestampProperties_ShouldBeDateTimeOffset` enforces it
on a name-allowlist (`Timestamp`, `DeployedAt`, `CompletedAt`, `GeneratedAt`,
`ReportTimestamp`, `SnapshotTimestamp`). The new audit entities deviate:
- `AuditEvent.OccurredAtUtc` and `IngestedAtUtc``DateTime` (nullable on the second).
- `SiteCall.CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`, `IngestedAtUtc``DateTime`.
The `Notification` entity in the same domain uses `DateTimeOffset` for every timestamp
(`SiteEnqueuedAt`, `CreatedAt`, `LastAttemptAt`, `NextAttemptAt`, `DeliveredAt`). The
architectural test does not catch the `*Utc` columns because those property names are not
on the allowlist. Concretely:
- Nothing prevents a producer from assigning `DateTime.Now` (kind = `Local`) or
`new DateTime(2026,1,1)` (kind = `Unspecified`) to an `OccurredAtUtc` column. The
value will round-trip through `System.Text.Json` losing the `Kind` (it defaults to
`Unspecified` on read). The `Utc` suffix is convention-only.
- Comparison across the boundary is now ambiguous — the central `AuditLog.OccurredAtUtc`
and the central `Notifications.CreatedAt` are different CLR types, with `DateTimeOffset`
carrying an explicit offset and `DateTime` not.
- The repository query filters (`AuditLogQueryFilter.FromUtc`/`ToUtc`,
`SiteCallQueryFilter.FromUtc`/`ToUtc`) also use bare `DateTime`. A caller building one
from `DateTime.UtcNow.AddHours(-1)` is fine; a caller using `DateTimeOffset.UtcNow.DateTime`
is fine; a caller using `DateTime.Now` is silently wrong.
This is the same defect the architectural test was designed to catch on the
`DateTimeOffset` side — the test just doesn't cover the new column-naming convention.
**Recommendation**
Pick a single rule:
1. Convert the audit entities to `DateTimeOffset` to match every other Commons entity
and the architectural-test allowlist (largest blast radius — touches gRPC proto
types, EF mappings, SQL schemas, query filters).
2. Keep `DateTime` for audit but extend `EntityConventionTests` to recognise the `*Utc`
property-name pattern and assert (a) it is `DateTime` (not `DateTimeOffset`) and
(b) any constant-default has `DateTimeKind.Utc`. Add a runtime assertion at the
write boundary (`SqliteAuditWriter.WriteAsync`, the central upsert) that the
incoming `Kind == DateTimeKind.Utc` and reject otherwise.
Option 2 is the smaller change and is consistent with how `AuditLog` rows are stored in
SQL Server (`datetime2`, no offset). Either way the inconsistency with `Notification`
should be documented in REQ-COM-1 as a deliberate choice.
### Commons-020 — Transport types and new Audit-message types have no unit tests in `ScadaLink.Commons.Tests`
| | |
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Open |
| Location | `tests/ScadaLink.Commons.Tests/` |
**Description**
The Transport (#24) work adds nine records under `Types/Transport/` (`BundleManifest`,
`EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`,
`ImportPreview` + `ImportPreviewItem`, `ImportResolution`, `ImportResult`,
`ManifestContentEntry`) and four interfaces under `Interfaces/Transport/`. None of them
have a focused test file in `tests/ScadaLink.Commons.Tests/` — coverage is entirely
inside `tests/ScadaLink.Transport.IntegrationTests/`, which exercises the
end-to-end exporter/importer flow but does not pin the Commons-level wire contracts.
Similarly, the new `Messages/Audit/` folder (`IngestAuditEventsCommand`/`Reply`,
`IngestCachedTelemetryCommand`/`Reply`, `UpsertSiteCallCommand`/`Reply`,
`SiteCallRelayMessages`) and the `Messages/Integration/` additions
(`AuditTelemetryEnvelope`, `PullAuditEventsRequest`/`Response`) have no
serialization-shape tests in Commons. The existing `MessageConventionTests`,
`CompatibilityTests`, `ConnectionBindingSerializationTests`, and
`SiteCallQueriesTests` cover some but not all of the new traffic — `PullAuditEvents`
and `AuditTelemetryEnvelope` in particular cross the site→central version-skew
boundary that REQ-COM-5a is designed to enforce, so a JSON round-trip + named-property
assertion is the minimum protection against a future positional/tuple slip.
This is the same pattern as Commons-010 — behavior-bearing types with no Commons-level
test coverage, where the integration suite cannot catch a Commons-only contract
regression.
**Recommendation**
Add focused tests in `tests/ScadaLink.Commons.Tests/Types/Transport/` (round-trip
serialization for each Transport record, named JSON property assertions for
`EncryptionMetadata` / `BundleManifest`, the `BundleSession.Locked` threshold —
see Commons-016, the `ConflictKind`/`ResolutionAction` enum coverage), and in
`tests/ScadaLink.Commons.Tests/Messages/Audit/` (round-trip + named-property assertions
for the seven new message files). Prioritise the contracts that cross the site→central
boundary (`AuditTelemetryEnvelope`, `PullAuditEventsRequest`/`Response`,
`IngestCachedTelemetryCommand`).
### Commons-021 — `ExternalCallResult.Response` has a benign lazy-parse race
| | |
|--|--|
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Open |
| Location | `src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs:91-104` |
**Description**
`ExternalCallResult` is a `record` returned to scripts after an outbound HTTP call. The
`Response` property lazily parses `ResponseJson` into a `DynamicJsonElement`:
```csharp
public dynamic? Response
{
get
{
if (!_responseParsed)
{
_response = string.IsNullOrEmpty(ResponseJson)
? null
: new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement);
_responseParsed = true;
}
return _response;
}
}
```
`_response` and `_responseParsed` are plain mutable fields on a `record` that the
language otherwise treats as immutable. Two threads reading `Response` simultaneously
can both see `_responseParsed == false`, both call `JsonDocument.Parse`, and produce
two distinct `DynamicJsonElement` wrappers — the second write wins, and any reference
the loser thread already held becomes inconsistent with the winner. The race is benign
in the current usage (scripts get the result on one thread and use it on that thread),
and `DynamicJsonElement` after Commons-002 clones the underlying `JsonElement`, so the
duplicate parses do not even leak document handles. But the pattern is fragile — a
future caller that hands the result to a background continuation or `Task.WhenAll` would
introduce a real correctness gap, and the laziness is implicit in `record` semantics
that otherwise suggest immutability.
**Recommendation**
Use `Lazy<dynamic?>` initialised in the property (with `LazyThreadSafetyMode.ExecutionAndPublication`,
the default) and drop the mutable backing fields, or replace the property with a method
named `ParseResponse()` so the laziness is explicit and the caller knows to call it once
and cache. Either way, the change is local and preserves the existing `record`-equality
behavior.
### Commons-022 — `IAuditCorrelationContext` references an unresolvable `BundleImporter.ApplyAsync` cref; JSON-blob columns have no documented shape
| | |
|--|--|
| Severity | Low |
| Category | Documentation & comments |
| Status | Open |
| Location | `src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs:11`, `src/ScadaLink.Commons/Types/Transport/ImportPreview.cs:11`, `src/ScadaLink.Commons/Entities/Notifications/Notification.cs:33` |
**Description**
Two related XML-doc weaknesses, both around the new Transport / Audit surface:
1. `IAuditCorrelationContext`'s remarks say
`<see cref="BundleImporter.ApplyAsync"/>`. `BundleImporter` is the concrete
implementation in `ScadaLink.Transport.Import`, which Commons does not (and must
not) reference. The cref is unresolvable from Commons and will surface as a
build-time XML doc warning. The correct reference is the interface method
`IBundleImporter.ApplyAsync`.
2. Two JSON-string columns flow across components without a documented shape:
- `ImportPreviewItem.FieldDiffJson` — described only as "string?" with no remarks on
who produces it, who reads it, or what shape it carries. The Central UI renders it,
so a drift between producer and renderer is a silent UI regression.
- `Notification.ResolvedTargets` — described as "Resolved delivery targets snapshotted
at delivery time, for audit" but the shape (newline-separated emails? a JSON array?
comma-separated?) is undocumented. Audit consumers and the Central UI both read
this field.
Both are wire/persistence-format strings; an undocumented schema invites the same
kind of producer/consumer drift the `ValueTuple` finding in Commons-008 surfaced for
the typed messages.
**Recommendation**
- Fix the `<see cref>` in `IAuditCorrelationContext` to point at `IBundleImporter.ApplyAsync`.
- Add a remarks block to `ImportPreviewItem.FieldDiffJson` describing the JSON shape
(e.g. "a JSON object keyed by field name with `{ existing, incoming }` values") or, if
the shape is meant to be opaque to the wire, document that explicitly.
- Add a remarks block to `Notification.ResolvedTargets` documenting the format.
- Consider replacing both with strong-typed Commons records — `ResolvedTargets` could be
`IReadOnlyList<string>` serialised via EF value converter, and `FieldDiffJson` could
be a `FieldDiff` record. That is a larger change and is left as a follow-up.
### Commons-023 — Trailing-optional `SourceNode` on positional records mixes additive evolution patterns
| | |
|--|--|
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Open |
| Location | `src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs:53-66`, `:110-123`, `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs:26-39`, `:104-123`, `src/ScadaLink.Commons/Types/SiteCallOperational.cs:42-54`, `src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs:33-46` |
**Description**
The `SourceNode` rollout adds an optional trailing parameter to a long list of positional
records. Two minor patterns emerge that are worth flagging:
- `SiteCallSummary` (twelve required positional members plus an optional 13th
`SourceNode = null`) — and the parallel `NotificationSummary` (ten required + optional
`SourceNode = null`) — both push the optional past a `bool IsStuck` flag. A consumer
reading the positional signature is now mixing required and optional members. The
record otherwise works correctly because every consumer constructs it via named
arguments, but a positional constructor call (which the language allows) would silently
miss the new field.
- `TrackingStatusSnapshot` has been made non-optional `SourceNode` (`string? SourceNode`
without `= null`), inconsistent with `SiteCallOperational`'s `string? SourceNode` (also
without default — but `SiteCallOperational` is purely positional). The mix of "optional
with default" and "optional without default" across the same domain is fine technically
but is the kind of inconsistency that bites a future additive field.
Neither pattern is a defect today — every consumer is updated, and JSON serialization
treats nullable-without-default the same as nullable-with-default. But the conventions
across the Audit / Notifications message surface have drifted enough that REQ-COM-5a's
"additive-only" rule deserves a one-paragraph clarification: do new optional fields take
a `= null` default, or not? The current code is mixed.
**Recommendation**
Add a one-paragraph "How to add a field" sub-section to REQ-COM-5a stating: new optional
fields on positional records MUST be added at the end of the parameter list AND MUST
carry a `= null` (or other safe) default value, so existing positional construction
sites keep compiling. Apply that rule retroactively to `TrackingStatusSnapshot` and any
other recent record that did not adopt it. No behavioral change required.