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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user