#152 left a hook for structured logging when an auto-prohibition first
fires; this commit completes the wiring.
Changes:
- ModbusDriver constructor takes an optional ILogger<ModbusDriver> (defaults
to NullLogger). Existing standalone callers stay compile-clean.
- RecordAutoProhibition logs LogWarning on first-fire only (re-fires of the
same range stay quiet via the existing isNew de-dupe). Format includes
DriverInstanceId, UnitId, Region, Start, End, Span — log aggregators can
filter / count by any field.
- New LogProhibitionCleared helper called by both StraightReprobeAsync (when
the re-probe succeeds on a single-register range) and BisectAndReprobeAsync
(per-half clearing + a single combined line when both halves succeed).
- ModbusDriverFactoryExtensions.Register accepts an optional ILoggerFactory.
Captured at registration time and used in the factory closure to construct
a per-driver logger. Server bootstrap code that already has an ILoggerFactory
in DI threads it through with a single argument addition; old call sites
(Register(registry)) keep working with a null logger.
Tests (2 new ModbusLoggerInjectionTests):
- First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet — pins
the de-dupe behaviour. First scan logs one warning with the expected
structured fields; second scan with the same prohibition stays silent.
- Reprobe_Clearing_Prohibition_Emits_Information_Log — protected register
unlocked between record and re-probe; re-probe success emits an info log
containing "cleared".
CapturingLogger test harness is purpose-built (xUnit doesn't ship a logger
mock by default and adding Moq is overkill for two tests).
240 + 2 = 242 unit tests green.
Auto-prohibited ranges (#148) were previously visible only through an
internal AutoProhibitedRangeCount accessor used by tests. Production
operators had no way to see what the planner had learned without pulling
logs or inspecting driver state.
Changes:
- New public record `ModbusAutoProhibition(UnitId, Region, StartAddress,
EndAddress, LastProbedUtc, BisectionPending)` — operator-facing snapshot
shape. Lives in the addressing assembly's logical namespace alongside
the other public types.
- `ModbusDriver.GetAutoProhibitedRanges()` returns
`IReadOnlyList<ModbusAutoProhibition>` — a copy of the live prohibition
map. Lock-protected snapshot so consumers don't race with the re-probe
loop.
- RecordAutoProhibition tracks first-fire vs re-fire via the dictionary
insert path, leaving a hook to add structured logging once an ILogger
is plumbed through (currently elided to keep the constructor minimal
for testability — a future change can wire ILogger and emit a single
warning per first-fire).
Tests (1 new, additive to the 6 in ModbusCoalescingAutoRecoveryTests):
- GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot — confirms
the snapshot shape: empty before any failure, populated with correct
UnitId/Region/Start/End/BisectionPending after a failed coalesced read,
LastProbedUtc within the recent past.
Docs:
- docs/v2/modbus-addressing.md — new "Coalescing auto-recovery" subsection
consolidates the #148/#150/#151/#152 surface in one place. Documents
the diagnostic accessor + flags the in-process consumption pattern
(Server health endpoints today; Admin UI when an RPC channel exists).
239 + 1 = 240 unit tests green.
Caveat: the Admin UI surfacing (table render, "clear all prohibitions"
button) is intentionally NOT shipped here. Admin can't reach a live
ModbusDriver instance without a driver-diagnostics RPC channel that
doesn't exist yet — that's a larger architectural piece. For now the
data is queryable in-process by the Server's health endpoints; once an
RPC channel lands, Admin can wire the existing GetAutoProhibitedRanges
into a Blazor table without further driver changes.
Pre-#150 a coalesced read failure recorded the FULL failed range as
permanently prohibited. Healthy registers around the actual protected
register stayed in per-tag mode forever (until ReinitializeAsync). The
re-probe loop shipped in #151 retried the whole range as a single block,
which would either succeed (clearing everything) or fail (changing
nothing).
Post-#150 the re-probe loop bisects multi-register prohibitions:
- _autoProhibited refactored from Dictionary<key, DateTime> to
Dictionary<key, ProhibitionState> where ProhibitionState carries
LastProbedUtc + SplitPending. Multi-register prohibitions enter with
SplitPending=true; single-register prohibitions enter with
SplitPending=false (already minimal).
- ReprobeLoopAsync delegates the per-pass work to
RunReprobeOnceForTestAsync (also exposed for synchronous test driving).
Each entry routes to BisectAndReprobeAsync (split-pending + multi-reg)
or StraightReprobeAsync (single-reg / non-split-pending).
- Bisection: split (start, end) at mid = (start+end)/2. Try (start, mid)
and (mid+1, end) as separate coalesced reads. Each FAILED half re-enters
the prohibition map with SplitPending = (its end > its start). SUCCEEDED
halves vanish, freeing the planner to coalesce across them on the next
scan.
- Convergence: log2(span) re-probe ticks pin the prohibition to the
actual single offending register(s). For a 100-register block with one
protected address that's ~7 ticks.
Tests (3 new ModbusCoalescingBisectionTests):
- Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe — 11 tags
100..110 with protected address 105. After 4 re-probe passes the
prohibition collapses from (100..110) → (100..105) → (103..105) →
(105..105).
- Bisection_Clears_When_Both_Halves_Are_Healthy — transient failure
scenario; protection lifted before re-probe; both bisection halves
succeed and the parent vanishes entirely.
- Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail — TwoHoleTransport
with protected addresses 102 + 108 in the same coalesced range. After
bisection both halves still fail (each contains one of the protected
addresses); the prohibition map grows to 2 entries.
236 + 3 = 239 unit tests green. Solution build clean.
#148 introduced auto-prohibited coalesced ranges that persist for the
driver lifetime. Long-running deployments with transient PLC permission
changes (firmware update unlocking a previously-protected register,
operator reconfiguring the device) had no recovery short of operator
restart.
Adds an opt-in background loop that re-probes each prohibition periodically:
- ModbusDriverOptions.AutoProhibitReprobeInterval (TimeSpan?, default null
= disabled). Set to e.g. TimeSpan.FromHours(1) to opt in.
- _autoProhibited refactored from HashSet<key> to Dictionary<key, DateTime>
so each entry tracks its last failure / last re-probe timestamp.
- ReprobeLoopAsync runs on the same Task.Run pattern as ProbeLoopAsync;
cancelled by ShutdownAsync. Each tick snapshots the prohibition set
and issues a one-shot coalesced read per range. Successful re-probes
drop the prohibition; failed ones bump the timestamp + leave the
prohibition in place.
- Communication failures during re-probe (transport-level) are treated
the same as PLC-exception failures — the prohibition stays, but isn't
upgraded to "permanent" since transports recover. The driver-instance
health surface picks up the failure separately.
- ShutdownAsync explicitly clears the prohibition set so a manual restart
via ReinitializeAsync starts with a clean slate (matches the old
"restart to clear" semantics).
- Factory DTO + JSON binding extended with AutoProhibitReprobeMs field.
Tests (2 new, additive to the 3 in ModbusCoalescingAutoRecoveryTests):
- Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy — protected
register at 102 records prohibition; clearing the simulated protection
+ invoking the re-probe drops the prohibition.
- Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad — re-probe on a
still-failing range keeps the prohibition in place.
Tests use a new internal RunReprobeOnceForTestAsync helper to fire one
re-probe pass synchronously, so the suite doesn't have to wait on the
background timer (the loop's timer behaviour is exercised implicitly via
the InitializeAsync wire-up + the synchronous helper sharing the actual
re-probe code path).
234 + 2 = 236 unit tests green.
Pre-#148 behaviour: a coalesced FC03/FC04 read that crossed a write-only or
PLC-fault register marked every member tag Bad until the operator manually
flagged the offending tag with CoalesceProhibited. Healthy tags around the
hole stayed broken indefinitely.
Post-#148: two-stage recovery, no operator intervention needed.
1. Same-scan fallback: when a coalesced read fails with a Modbus exception
(IllegalDataAddress, SlaveDeviceFailure, etc.), the planner does NOT
mark members handled. The per-tag fallback in the same scan reads each
member individually — non-protected members surface Good values
immediately, and only the actual protected register stays Bad.
2. Cross-scan prohibition: the failed range (Unit, Region, Start, End) is
recorded in a per-driver `_autoProhibited` set. On subsequent scans the
planner checks each candidate merge against the set and refuses to
re-form any block that overlaps a known-bad range. Net effect: after one
scan with a failure, the protected range goes "per-tag mode" indefinitely
while ranges around it keep coalescing normally.
Communication failures (timeouts, socket drops) are NOT auto-prohibited —
they're transport-level, not structural. The same coalesced read can succeed
once the transport recovers; recording it as "permanently bad" would defeat
coalescing for the whole driver instance.
Auto-prohibition state lives for the driver lifetime and clears on
ReinitializeAsync (operator restart). A periodic re-probe is a follow-up if
deployments need it without a restart.
Implementation:
- Added `_autoProhibited` HashSet<(byte, ModbusRegion, ushort, ushort)> +
`_autoProhibitedLock` on ModbusDriver.
- `RangeIsAutoProhibited(unit, region, start, end)` overlap check called
from the planner when forming blocks.
- `RecordAutoProhibition(...)` called from the catch (ModbusException)
branch.
- The catch (Exception) branch (non-Modbus failures) keeps the pre-#148
"mark all Bad in this scan, don't auto-prohibit" behaviour.
- Internal `AutoProhibitedRangeCount` accessor for tests.
Tests (3 new ModbusCoalescingAutoRecoveryTests):
- First_Failure_Falls_Back_To_PerTag_Same_Scan — three tags around a
protected register at 102: T100 + T104 surface Good values via the
per-tag fallback in the SAME scan; T102 surfaces the exception.
- Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range — confirms scan 2
doesn't re-attempt the failed merge (no FC03 with quantity > 1 at the
prohibited start).
- Tags_Outside_Prohibited_Range_Still_Coalesce — separate cluster at HR
200..202 keeps coalescing normally even after the 100..104 cluster is
prohibited.
234/234 unit tests green.
Follow-ups intentionally NOT shipped (smaller, independent changes):
- Bisection-style range narrowing — currently the prohibition range is the
full failed block; the planner doesn't try to find the exact protected
register. Operator-visible diagnostic + prohibition stays correct.
- Periodic re-probe to clear stale prohibitions.
- Surface auto-prohibited ranges through GetHostStatuses or a new
diagnostic so the Admin UI can show what's been auto-isolated.
Adds a coalescing read planner that merges nearby tags into single FC03/FC04
PDUs, opt-in via ModbusDriverOptions.MaxReadGap. Default 0 = no coalescing
(every tag gets its own PDU — preserves pre-#143 wire output).
Worked example with MaxReadGap=10:
T1 @ HR 100 (Int16, 1 reg)
T2 @ HR 102 (Int16, 1 reg, gap 1 → joins block)
T3 @ HR 110 (Float32, 2 regs, gap 7 → joins block)
T4 @ HR 200 (Int16, 1 reg, gap 89 → splits, separate read)
→ 2 PDUs total: FC03 start=100 quantity=12 + FC03 start=200 quantity=1.
Planner:
- Eligible tags: known + register region (HR/IR) + scalar + not String /
BitInRegister / array + not CoalesceProhibited.
- Groups by (UnitId, Region) — never coalesces across slaves or regions.
- Sorts by start address; merges when (next.start - last.end - 1) ≤ MaxReadGap
AND the resulting span ≤ MaxRegistersPerRead. Otherwise opens a new block.
- Single-tag blocks are deferred to the per-tag path so WriteOnChange cache
semantics stay correct without duplication.
- Per-block failure marks every member tag Bad and degrades health — same
semantics the per-tag path has, but at the block granularity.
Per-tag escape hatch ModbusTagDefinition.CoalesceProhibited (bool, default
false) — when true, the tag is read in isolation regardless of MaxReadGap.
For PLCs with protected register holes between adjacent tags.
Tests (7 new ModbusCoalescingTests):
- MaxReadGap=0 keeps the per-tag behavior (2 reads for 2 tags).
- MaxReadGap=2 merges 3 tags within 5 registers into 1 read of qty=5.
- MaxReadGap=10 splits T1+T2 from T3 when the gap exceeds the threshold.
- CoalesceProhibited tag reads alone even when neighbours are eligible.
- Coalescing never crosses UnitId boundaries (multi-slave gateway safety).
- MaxRegistersPerRead caps a would-be block; planner falls back to separate
reads when the merged span would exceed the cap.
- Per-tag values surface independently after coalescing (slice-math sanity).
Existing 220 unit tests still green; total 224 pass with the new file (tests
are additive, no regressions).
Follow-up: auto-split-on-protected-hole isn't shipped — a coalesced read
that hits an Illegal Data Address right now marks every member Bad until
the operator sets CoalesceProhibited on the offending tag. Tracked
implicitly by #138's e2e drill against a pymodbus profile with a protected
hole mid-block.
Lifts the previous "one driver = one slave" assumption so a single Modbus
driver instance can front N RTU slaves behind one Ethernet gateway (Anybus,
ProSoft, Lantronix style). Each tag carries an optional UnitId that drives
the MBAP unit-id byte per-PDU, and the IPerCallHostResolver contract surfaces
per-slave host strings so per-PLC circuit breakers fire per-slave (matches
the AB CIP template documented in docs/v2/multi-host-dispatch.md).
Changes:
- ModbusTagDefinition gains optional UnitId (byte?). Null = use driver-level
ModbusDriverOptions.UnitId (preserves single-slave deployments verbatim).
- ResolveUnitId(tag) helper computed once per ReadOneAsync / WriteOneAsync
call; passed through ReadRegisterBlockAsync / ReadBitBlockAsync /
ReadRegisterBlockChunkedAsync / ReadBitBlockChunkedAsync explicitly. The
probe loop continues using driver-level UnitId (the probe is a
connection-health check, not slave-specific).
- ModbusDriver implements IPerCallHostResolver. ResolveHost(fullReference)
returns "host:port/unitN" — distinct strings per slave so the resilience
pipeline keys breakers on the right granularity. Unknown references fall
back to the bare HostName (single-slave behaviour).
- BitInRegister RMW path also threads the per-tag UnitId through both the
read and write halves so a multi-slave deployment stays correct under bit-
level writes.
- Factory DTO + JSON binding extended with the per-tag UnitId field.
Tests (4 new ModbusMultiUnitTests):
- Per-tag UnitId routes to the correct slave in the MBAP header (driver-level
UnitId=99 must NOT appear when both tags override).
- Tag without override falls back to driver-level UnitId.
- IPerCallHostResolver returns distinct "host:port/unitN" strings per slave.
- Unknown reference returns the bare HostName fallback.
Existing 220 unit tests + 107 addressing tests still green. Per-PLC breaker
isolation under simulated dead slaves is verifiable via the existing AB CIP
test infra; live coverage lands as an integration test in the #138 docs/e2e
refresh.
Two driver-side filters that ≥5 of 6 surveyed vendors expose:
1. Per-tag Deadband (double?, on ModbusTagDefinition) — when set, the
PollGroupEngine onChange callback suppresses publishes whose distance
from the last-published value is below the threshold. Reduces wire
traffic to OPC UA clients on noisy analog signals (flow meters,
temperatures). Numeric scalar types only — Bool / BitInRegister / String
/ array tags publish unconditionally.
2. WriteOnChangeOnly (bool, on ModbusDriverOptions) — when true, the driver
short-circuits writes whose value matches the most recent successful
write to that tag. Saves PLC bandwidth on clients that re-publish the
same setpoint every scan. Cache invalidates on any read that returns a
different value, so HMI-side changes don't get masked.
Both default off so existing deployments see no behaviour change.
Implementation:
- ShouldPublish guard wraps the existing OnDataChange invocation. First sample
always passes through (no baseline); subsequent samples compare via
Convert.ToDouble for the cross-numeric-type math.
- IsRedundantWrite check at the top of WriteAsync; on success the cache is
populated. Object.Equals handles boxed-numeric equality; arrays are
excluded (reference-equality would never match anyway).
- ReadAsync invalidates the WriteOnChangeOnly cache when the new value
differs from the cached last-written value.
Tests (5 new ModbusSubscribeOptionsTests):
- Deadband suppresses sub-threshold changes (100 → 102 → 106 → 107 with
deadband=5 publishes 100 and 106 only).
- Deadband=null still publishes every change.
- WriteOnChangeOnly suppresses 3 identical 42 writes (only first hits wire).
- WriteOnChangeOnly default false hits the wire every time.
- Read-divergence cache invalidation: external panel write to 99, our
client's re-write of 42 must NOT be suppressed.
220/220 unit tests green; existing ProtocolOptions tests hardened against
probe-loop noise by disabling the probe in their fixtures.
Adds ModbusDriverOptions knobs that ≥4 of 6 surveyed vendors expose:
1. MaxCoilsPerRead (ushort, default 2000) — separate from MaxRegistersPerRead
because coil packing (1 bit per coil) and register packing (16 bits each)
have different spec ceilings. Coil-array reads above the cap auto-chunk
the same way register reads have always done. New ReadBitBlockChunkedAsync
re-assembles per-chunk LSB-first bitmaps into one logical bitmap.
2. UseFC15ForSingleCoilWrites (default false) — forces FC15 (Write Multiple
Coils with quantity=1) for single-coil writes instead of the default FC05
(Write Single Coil). Safety / audit PLCs that only accept the multi-write
codes need this.
3. UseFC16ForSingleRegisterWrites (default false) — same idea for FC16 vs
FC06 on single holding-register writes.
4. DisableFC23 (default false) — placeholder no-op for the future block-read
coalescing (#143) work that may opt into FC23 (Read/Write Multiple
Registers). Lets deployments pre-disable FC23 for PLCs that won't accept
it, before we ship the optimisation that emits it.
Defaults preserve the historical wire output bit-for-bit (FC05/FC06 for
singles, no chunking under 2000 coils, no FC23). Factory DTO + JSON-binding
extended with parallel fields.
6 new ModbusProtocolOptionsTests covering: defaults, FC05→FC15 forcing,
FC06→FC16 forcing, MaxCoilsPerRead chunking math (2500 coils / 2000 cap →
2 reads of 2000 + 500). Existing 209 unit tests still green.
Promotes the previously hardcoded transport-layer settings to ModbusDriverOptions
so users can tune them through DriverConfig JSON without recompiling.
Three new option groups:
1. KeepAlive (ModbusKeepAliveOptions): Enabled / Time / Interval / RetryCount.
Defaults preserve the historical PR 53 wire output exactly (Enabled=true,
Time=30s, Interval=10s, RetryCount=3). Set Enabled=false for PLCs that
reject SO_KEEPALIVE.
2. IdleDisconnectTimeout (TimeSpan?): when set, the transport tracks last-PDU-
success and proactively closes + reconnects on the next request after the
threshold. Defends against silent NAT / firewall socket reaping. Default
null = disabled (no behaviour change).
3. Reconnect (ModbusReconnectOptions): InitialDelay / MaxDelay /
BackoffMultiplier for the post-drop reconnect loop. Defaults
(InitialDelay=0, MaxDelay=30s, Multiplier=2.0) preserve the historical
immediate-retry behaviour for the first attempt and add geometric backoff
only if the reconnect itself fails. Capped at 10 attempts before propagating.
ModbusTcpTransport ctor extended with optional keepAlive / idleDisconnect /
reconnect parameters; existing 4-arg call sites continue to compile. Factory
DTO gains parallel KeepAlive / IdleDisconnectMs / Reconnect fields with
default-aware binding.
5 new ModbusConnectionOptionsTests covering the default-preservation contract
(every default field matches pre-#139) and the JSON-binding round-trip for
each knob group. Existing 204 unit tests still green.
Adds the full Wonderware/Kepware/Ignition-style address suffix grammar so
users paste tag spreadsheets without per-tag manual translation:
<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
Examples that now parse end-to-end:
40001 HoldingRegisters[0], Int16
400001 same, 6-digit form
40001.5 bit 5 of HR[0]
40001:F Float32 (HR[0..1])
40001:F:CDAB word-swapped Float32
40001:STR20 20-char ASCII string
HR1:DI Int32 via mnemonic region
C100 Coils[99] (mnemonic)
40001:F:5 Float32[5] array (3-field shorthand)
40001:I:CDAB:10 Int16[10] word-swapped (4-field strict)
Driver-side plumbing:
- ModbusAddressParser + ParsedModbusAddress in the shared Addressing
assembly. 91 parser tests (every grammar variant + malformed shapes).
- ModbusDataType / ModbusByteOrder moved to shared (with the same namespace
so callers compile unchanged). ModbusByteOrder gains ByteSwap (BADC) and
FullReverse (DCBA) alongside the existing BigEndian (ABCD) and WordSwap
(CDAB).
- NormalizeWordOrder extended to honor all four orders for both 4-byte and
8-byte values. Old WordSwap behavior preserved bit-for-bit.
- ModbusTagDefinition gains optional ArrayCount.
- ReadOneAsync / WriteOneAsync handle array fan-out: one FC03/04 read covers
N consecutive register-typed elements, decoded into a typed array (short[],
float[], etc.). Coil arrays use FC01 reads + FC15 writes (FakeTransport
in tests gains FC15 support to match).
- DriverAttributeInfo IsArray / ArrayDim flow from ArrayCount so the OPC UA
address space surfaces ValueRank=1 + ArrayDimensions to clients.
- ModbusDriverFactoryExtensions gains AddressString DTO field. When
present, the parser drives Region/Address/DataType/ByteOrder/Bit/
StringLength/ArrayCount; structured fields (Writable, WriteIdempotent,
StringByteOrder) still come from the DTO. Existing structured tag rows
keep working unchanged.
Tests: 91 parser unit tests (Driver.Modbus.Addressing.Tests, all green) +
204 driver tests including new ModbusByteOrderTests (BADC/DCBA roundtrips
across Int32/Float32/Float64) and ModbusArrayTests (Int16[5], Float32[3]
CDAB, Coil[10], length-mismatch error, IsArray/ArrayDim discovery).
Solution-wide build clean.
Caveat: grammar names (type codes, byte-order mnemonics, the :count
shorthand) were synthesized from training-era vendor docs. Verify against
current Kepware Modbus Ethernet Driver Help and Ignition Modbus Addressing
manuals before freezing for production deployments — naming may need a
back-compat layer if vendor wording has shifted.
Per-tag opt-in for write-retry per docs/v2/plan.md decisions #44, #45, #143.
Default is false — writes never auto-retry unless the driver author has marked
the tag as safe to replay.
Core.Abstractions:
- DriverAttributeInfo gains `bool WriteIdempotent = false` at the end of the
positional record (back-compatible; every existing call site uses the default).
Driver.Modbus:
- ModbusTagDefinition gains `bool WriteIdempotent = false`. Safe candidates
documented in the param XML: holding-register set-points, configuration
registers. Unsafe: edge-triggered coils, counter-increment addresses.
- ModbusDriver.DiscoverAsync propagates t.WriteIdempotent into
DriverAttributeInfo.WriteIdempotent.
Driver.S7:
- S7TagDefinition gains `bool WriteIdempotent = false`. Safe candidates:
DB word/dword set-points, configuration DBs. Unsafe: M/Q bits that drive
edge-triggered program routines.
- S7Driver.DiscoverAsync propagates the flag.
Stream A.5 integration tests (FlakeyDriverIntegrationTests, 4 new) exercise
the invoker + flaky-driver contract the plan enumerates:
- Read with 5 transient failures succeeds on the 6th attempt (RetryCount=10).
- Non-idempotent write with RetryCount=5 configured still fails on the first
failure — no replay (decision #44 guard at the ExecuteWriteAsync surface).
- Idempotent write with 2 transient failures succeeds on the 3rd attempt.
- Two hosts on the same driver have independent breakers — dead-host trips
its breaker but live-host's first call still succeeds.
Propagation tests:
- ModbusDriverTests: SetPoint WriteIdempotent=true flows into
DriverAttributeInfo; PulseCoil default=false.
- S7DiscoveryAndSubscribeTests: same pattern for DBx SetPoint vs M-bit.
Full solution dotnet test: 947 passing (baseline 906, +41 net across Stream A
so far). Pre-existing Client.CLI Subscribe flake unchanged.
Stream A's remaining work (wiring CapabilityInvoker into DriverNodeManager's
OnReadValue / OnWriteValue / History / Subscribe dispatch paths) is the
server-side integration piece + needs DI wiring for the pipeline builder —
lands in the next PR on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>