Files
FT546-WCS/docs/superpowers/plans/2026-08-12-ft546-realtime-readonly-shadow.md

51 KiB

FT546 Real-Time Read-Only Shadow Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an independently deployable FT546 real-time shadow program that reads approved production facts, runs the same dispatch and FT546 PLC-intent code intended for STANDARD, and persists comparison evidence without starting legacy threads, connecting to PLC/WMS, or writing any production table.

Architecture: Keep the deployed stable WCS as the only production scheduler and PLC/WMS writer. Add a separate ruoyi-wcs-shadow Spring Boot process with two explicit data sources: a production account restricted to approved read-only views and a physically separate evidence database. Extract FT546 register encoding into a pure protocol component shared by the real adapter and shadow recorder; run the shared dispatch kernel through a shadow-specific cycle that persists virtual commands and comparison evidence instead of dispatching Outbox entries.

Tech Stack: Java 8, Maven multi-module, Spring Boot 2.5.15, Spring JDBC transactions, SQL Server JDBC, H2 file database for local evidence tests, JUnit 5, ArchUnit, PowerShell deployment scripts.

Global Constraints

  • Work only in the optimize/wcs worktree; do not merge, push, or modify the deployed stable branch.
  • The deployed stable artifact remains the only scheduler and only PLC/WMS writer throughout shadow collection.
  • LEGACY remains the default ft546.wcs.runtime-mode; no task changes that default.
  • SHADOW must have zero PLC connections, zero PLC writes, zero WMS callbacks, zero writes to production business/core tables, and zero legacy business or PLC-reader threads.
  • The real-time shadow is a separate process, service port, log directory, deployment directory, configuration file, and preferably a separate host.
  • The shadow production account may read only shadow_ro.* views; it must not receive INSERT, UPDATE, DELETE, EXECUTE, ALTER, CONTROL, or ownership permissions in the production database.
  • Shadow evidence writes use a physically separate database or server. A different schema in the production database is insufficient for the first site run.
  • Do not use DataSourceType, DynamicDataSourceContextHolder, or the shared RuoYi SqlSessionFactory for the two shadow data sources. Use explicit bean qualifiers and explicit transaction managers so a production connection can never be reused for evidence writes.
  • Production reads use a maximum pool size of 2, zero minimum idle connections, a 2-second connect timeout, a 3-second query timeout, fixed-delay polling, exponential backoff, and a one-cycle in-flight guard.
  • Missing, stale, partial, or contradictory facts produce INSUFFICIENT_FACTS; they do not generate a comparable virtual command.
  • The FT546 protocol contract remains exactly: main 4004 -> 4000 -> 4002 -> 4021; cross-floor adds 4020=1 -> 4022=<target>; two-stage one 4404 -> 4422=1 -> 4402=<taskType> -> 4400; two-stage two 4404 -> 4422=2 -> 4423=99 -> 4402=9 -> 4400.
  • A shadow protocol difference and an algorithm decision difference are separate categories. Never call a different selected vehicle/path a protocol failure when both encodings are internally correct.
  • No history field (agv_id, path_info, completion status, or finish_date) may force the new kernel to repeat the legacy choice.
  • This plan delivers the real-time read-only shadow and its deployment/rollback evidence. It does not authorize STANDARD PLC control or a production cutover.
  • All feature changes follow red-green-refactor. Each task runs its named test while red and again while green.
  • Preserve the four pre-existing untracked documentation HTML files and the generated javaTest/outputs/wcs-history-business-shadow-20260812-f66585f/ evidence directory.

File Structure

New shared protocol module:

  • ruoyi-ft546-protocol/: pure FT546 profile, capability, command-intent, register-write, and encoder types. It has no Spring, JDBC, PLC, RuoYi Admin, or network dependency.
  • Ft546SrgvCommandIntent: strong input containing command identity, phase, route version, task number/type, route points, optional cross-floor target, and optional two-stage number.
  • Ft546RegisterWrite: immutable address/dataType/values/writeSequence/trigger value.
  • Ft546EncodedCommand: immutable ordered write list and command evidence.
  • Ft546SrgvCommandEncoder: the only FT546 shuttle register encoder used by both real and shadow paths.

New independent shadow process:

  • ruoyi-wcs-shadow/: Spring Boot command-line service with no dependency on ruoyi-admin or ruoyi-framework.
  • Ft546ShadowApplication: imports only explicit shadow configuration and pure core/protocol beans; it does not component-scan com.ruoyi.wcs or com.ruoyi.project, so it cannot discover WcsCoreConfiguration, ScheduleThread, ThreadOperationController, PLC readers, WMS controllers, or production adapters.
  • ShadowSourceConfiguration: explicit read-only SQL Server DataSource, JdbcTemplate, and transaction manager.
  • ShadowEvidenceConfiguration: explicit evidence DataSource, JdbcTemplate, and transaction manager.
  • ShadowStartupGate: fail-closed verification of process mode, JDBC users/databases, source permissions, evidence location, dependency graph, and local output writability.
  • SqlServerShadowFactReader: bounded reads from shadow_ro.v_* views and assembly of one immutable ShadowFactBatch.
  • Ft546ShadowCycle: source fact collection -> fact validation -> same-kernel dispatch -> virtual command intent -> protocol encoding -> comparison -> evidence commit. It never invokes CommandOutboxDispatcher.
  • RecordingFt546CommandGateway: records encoded commands to the evidence repository; it has no send or PLC runtime reference.
  • ShadowEvidenceRepository: stores run, input batch, legacy result, new decision, encoded writes, differences, and cycle health in the evidence database.

Existing production files remain owners of production behavior:

  • ScheduleThread continues to own legacy readers/business threads in LEGACY and standard PLC fact readers in STANDARD; SHADOW constructs neither.
  • Ft546LegacySrgvAdapter remains the real PLC gateway in STANDARD, but delegates encoding to the shared pure protocol module before iterating the encoded writes.
  • Ft546AdapterConfiguration remains STANDARD-only. The shadow process does not import it.

Task 1: Make Runtime Modes Fail-Closed Before Any Legacy or PLC Object Exists

Files:

  • Modify: ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java
  • Modify: ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java
  • Modify: ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java
  • Modify: ruoyi-admin/src/main/resources/META-INF/spring.factories
  • Modify: ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java
  • Create: ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java

Interfaces:

  • Produces: Ft546WcsProperties.RuntimeMode.isLegacy/isShadow/isStandard.

  • Produces: ScheduleThread.RuntimeCapabilities with plcFactReadersAllowed, legacyBusinessThreadsAllowed, and automaticFactReaderStartAllowed.

  • Preserves: LEGACY initialization/start/stop behavior and STANDARD application-ready PLC fact-reader start.

  • Enforces: every thread operation method rejects SHADOW before dereferencing a thread field.

  • Enforces: the ruoyi-admin artifact refuses to start in SHADOW during environment preparation, before Spring constructs ScheduleThread; operators must use the dedicated ruoyi-wcs-shadow artifact.

  • Step 1: Write failing mode-isolation tests

@Test
void shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread() {
    ScheduleThread runtime = runtime(SHADOW);
    ReflectionTestUtils.setField(runtime, "deviceSrgvService",
            failOnAnyInvocation(IDeviceSrgvService.class));

    runtime.init();

    assertTrue(runtime.plcSrgvReadCustomThreads.isEmpty());
    assertNull(runtime.plcSsjReadCustomThread);
    assertNull(runtime.plcTsjReadCustomThread);
    assertNull(runtime.plcCdzReadCustomThread);
    assertNull(runtime.sRgvThreadCustomThread);
}

@Test
void everyManualThreadOperationFailsClosedInShadow() {
    ScheduleThread runtime = runtime(SHADOW);

    assertThrows(IllegalStateException.class, runtime::startAllThread);
    assertThrows(IllegalStateException.class, runtime::startAllThread2);
    assertThrows(IllegalStateException.class, runtime::stopAllThread);
    assertThrows(IllegalStateException.class, runtime::stopAllThread2);
}

Add a MockMvc test proving all four mutation endpoints return an error in SHADOW. Preserve the deployed stable authorization surface exactly: the stable controller currently leaves its @PreAuthorize lines commented, so this task must not enable them and risk blocking existing operator accounts. Add a SpringApplication test proving Ft546AdminRuntimeModeEnvironmentPostProcessor throws ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW during environment preparation, after Config Data is loaded but before any test bean's constructor or @PostConstruct, when the admin artifact is configured with runtime-mode=SHADOW.

  • Step 2: Run the isolation test and verify current @PostConstruct behavior fails

Run:

mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest test

Expected: shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread fails because current init() unconditionally calls plcReadInit() and scheduleInit(); manual PLC-reader start also does not reject SHADOW.

  • Step 3: Centralize mode capabilities and guard before construction
public void init() {
    RuntimeCapabilities capabilities = capabilities();
    if (capabilities.plcFactReadersAllowed()) {
        plcReadInit();
    }
    if (capabilities.legacyBusinessThreadsAllowed()) {
        scheduleInit();
    }
}

private RuntimeCapabilities capabilities() {
    RuntimeMode mode = ft546WcsProperties.getRuntimeMode();
    return new RuntimeCapabilities(
            mode == RuntimeMode.LEGACY || mode == RuntimeMode.STANDARD,
            mode == RuntimeMode.LEGACY,
            mode == RuntimeMode.STANDARD);
}

Call requirePlcFactReadersAllowed() at the first line of startAllThread/stopAllThread, and requireLegacyBusinessThreadsAllowed() at the first line of startAllThread2/stopAllThread2. Keep BaseSRgvThread.run() fail-closed for every non-LEGACY mode. Preserve the four endpoints' stable authorization behavior. Preserve the existing ScheduleThread.getScheduleStatus() Java signature and add a detailed status view for the controller so operators can see the current mode and allowed operations without breaking existing callers. Register Ft546AdminRuntimeModeEnvironmentPostProcessor in META-INF/spring.factories; it reads the raw environment property, accepts missing/LEGACY/STANDARD, and throws after Config Data loading but before bean creation for SHADOW or an unknown value. The exception names the dedicated shadow artifact and does not silently fall back to LEGACY. Lower-level thread guards remain as defense in depth and are unit-tested directly.

  • Step 4: Run focused and regression tests

Run:

mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest,Ft546DeviceRuntimeRegistryTest test

Expected: SHADOW constructs no thread/runtime object; LEGACY existing initialization remains intact; STANDARD starts readers only after ApplicationReadyEvent; endpoint authorization and mode rejection pass.

  • Step 5: Commit the hard runtime boundary
git add ruoyi-admin/src/main/java/com/ruoyi/schedule \
        ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java \
        ruoyi-admin/src/main/resources/META-INF/spring.factories \
        ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime
git commit -m "fix(ft546): isolate shadow from legacy and PLC threads"

Task 2: Extract One Pure FT546 Protocol Encoder For Real And Shadow Paths

Files:

  • Create: ruoyi-ft546-protocol/pom.xml
  • Modify: pom.xml
  • Create: ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java
  • Create: ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java
  • Create: ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java
  • Create: ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java
  • Create: ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java
  • Delete after import migration: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java
  • Modify: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java
  • Modify: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java
  • Modify: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java
  • Create: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java
  • Delete after callers migrate: ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java
  • Create: ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java
  • Modify: ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java
  • Move/copy fixtures: ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json and srgv-two-stage-gate.json

Interfaces:

  • Produces: Ft546SrgvCommandEncoder.encode(Ft546SrgvCommandIntent, Ft546DeviceProfile, Set<String>) -> Ft546EncodedCommand.

  • Produces: Ft546CommandIntentMapper.from(DeviceCommandEnvelope) -> Ft546SrgvCommandIntent in ruoyi-admin; the pure module never imports WCS envelope types.

  • Produces: ordered Ft546RegisterWrite values with public getters for address, dataType, values, writeSequence, and trigger.

  • Preserves: current SendReceipt and UNKNOWN behavior in the real adapter.

  • Step 1: Write protocol golden-master tests independent of PLC classes

@Test
void crossFloorIntentEncodesTheExactLegacySequence() {
    Ft546EncodedCommand encoded = encoder.encode(
            intent("PARENT-1", "LOADED_TO_PLACE", 30001, (short) 8,
                    points(70501, 70502), (short) 8, null),
            fullProfile(), set("POINT_TO_POINT_COMMAND"));

    assertWrites(encoded,
            write(1, "4004", INT32, 70501, 70502, 0, 0, 0, 0, 0, 0),
            write(2, "4000", INT32, 30001),
            write(3, "4002", INT16, 8),
            trigger(4, "4021", INT16, 1),
            write(5, "4020", INT16, 1),
            write(6, "4022", INT16, 8));
}

Add exact tests for ordinary main task, two-stage stage one, two-stage stage two, missing registers, route length 2/8 boundaries, route length 1/9 rejection, six-digit point overflow, capability gates, and immutable profile/write lists.

  • Step 2: Run the new module test and verify it fails to compile

Run:

mvn -pl ruoyi-ft546-protocol -am -Dtest=Ft546SrgvCommandEncoderTest test

Expected: Maven initially cannot find the module/types.

  • Step 3: Implement the pure encoder and make the real adapter consume it
Ft546EncodedCommand encoded = encoder.encode(
        intentMapper.from(envelope), profile,
        capabilities.getEnabledCapabilities());
boolean wroteAny = false;
for (Ft546RegisterWrite write : encoded.getWrites()) {
    if (!plcRegisterWriter.write(runtime, write)) {
        return markUnknown(envelope.getDeviceId(), receiptId,
                wroteAny
                        ? "PLC rejected a partial register sequence"
                        : "first register write result is ambiguous");
    }
    wroteAny = true;
}

Keep PlcRegisterWriter inside ruoyi-admin; the pure module never imports PlcSrgvRead. The encoder includes evidence fields deviceId, parentTaskId, phase, commandKey, routeVersion, taskNo, taskType, pathPoints, writeSequence, and triggerValue.

  • Step 4: Run protocol and adapter regression tests

Run:

mvn -pl ruoyi-ft546-protocol,ruoyi-admin -am \
  -Dtest=Ft546SrgvCommandEncoderTest,Ft546LegacySrgvAdapterTest,Ft546StandardWcsEndToEndTest test

Expected: golden protocol fixtures and all current real-adapter behaviors pass; partial writes still become ACCEPTED_UNKNOWN; no address/type/value/order changes occur.

  • Step 5: Commit the shared protocol seam
git add pom.xml ruoyi-ft546-protocol ruoyi-admin/pom.xml \
        ruoyi-admin/src/main/java/com/ruoyi/project/ft546 \
        ruoyi-admin/src/test/java/com/ruoyi/project/ft546
git commit -m "refactor(ft546): share pure PLC command encoding"

Task 3: Add An Independent Shadow Launcher With A Dependency Deny-List

Files:

  • Create: ruoyi-wcs-shadow/pom.xml
  • Modify: pom.xml
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java
  • Create: ruoyi-wcs-shadow/src/main/resources/application-shadow.yml
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java
  • Create: ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml

Interfaces:

  • Launch: java -jar ruoyi-wcs-shadow.jar --spring.profiles.active=shadow --shadow.enabled=true.

  • Status: GET /shadow/status returns STARTING, READY, DEGRADED, or STOPPED, last completed cycle, source lag, evidence backlog, and startup checks.

  • Produces: ShadowStartupGate.verify(ApplicationContext, ShadowProperties).

  • Enforces: server.address=127.0.0.1 by default and server.port=18091; no actuator mutation endpoint.

  • Step 1: Write application and architecture tests

@Test
void shadowContextContainsNoProductionSideEffectType() {
    contextRunner.run(context -> {
        assertThat(context).doesNotHaveBean(DeviceCommandGateway.class);
        assertThat(context).doesNotHaveBean(WmsNotificationGateway.class);
        assertFalse(ClassUtils.isPresent(
                "com.ruoyi.schedule.ScheduleThread", context.getClassLoader()));
        assertThat(context.getBeanDefinitionNames())
                .noneMatch(name -> name.toLowerCase().contains("plc"));
    });
}

@ArchTest
static final ArchRule shadowMustNotDependOnAdminOrPlc = noClasses()
        .that().resideInAPackage("com.ruoyi.shadow..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("com.ruoyi.schedule..",
                "com.ruoyi.api..", "HslCommunication..");

Also assert startup fails unless shadow.enabled=true, spring.profiles.active=shadow, and both source/evidence URLs are explicitly supplied outside tests.

  • Step 2: Run the new module test and verify the launcher is missing

Run:

mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test

Expected: module/application classes are missing.

  • Step 3: Implement a narrow component graph
@SpringBootConfiguration
@EnableAutoConfiguration
@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class})
@EnableScheduling
public class Ft546ShadowApplication {
    public static void main(String[] args) {
        SpringApplication.run(Ft546ShadowApplication.class, args);
    }
}

Exclude RuoYi security, Redis, Quartz, Swagger, TCP servers, MyBatis auto-scans, WcsCoreConfiguration, and all ruoyi-admin dependencies. ShadowKernelConfiguration constructs DefaultVehicleAssignmentPolicy, AStarRoutePlanner, DeadlockGuard, and DispatchCoordinator explicitly. The module depends only on ruoyi-wcs, ruoyi-ft546-protocol, Spring Web/JDBC, SQL Server driver, Jackson, and tests. ShadowStartupGate inspects bean types and package names at runtime and stops scheduling if a forbidden side-effect type appears.

  • Step 4: Run context and package-boundary tests

Run:

mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test

Expected: the context starts with in-memory test adapters only, status binds to loopback, and no forbidden type is reachable.

  • Step 5: Commit the isolated process
git add pom.xml ruoyi-wcs-shadow
git commit -m "feat(ft546): add isolated shadow process"

Task 4: Enforce Two Physical Data Sources And Read-Only Production Permissions

Files:

  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java
  • Modify: ruoyi-wcs-shadow/src/main/resources/application-shadow.yml
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java
  • Create: sql/shadow/V001__ft546_shadow_read_views.sql
  • Create: sql/shadow/V002__ft546_shadow_read_login.sql
  • Create: sql/shadow/V003__ft546_shadow_evidence.sql
  • Create: sql/shadow/verify_ft546_shadow_permissions.sql

Interfaces:

  • Beans: @Qualifier("shadowSourceDataSource"), shadowSourceJdbc, shadowSourceTx; @Qualifier("shadowEvidenceDataSource"), shadowEvidenceJdbc, shadowEvidenceTx.

  • Produces: SqlServerPermissionProbe.verifySource(Connection) and verifyEvidence(Connection).

  • Production views: shadow_ro.v_wcs_srgv_queue, v_wcs_srgv_queue_finish, v_device_srgv, v_wcs_srgv_map, v_bas_cargo_space, v_tsj_execution, and v_current_database_clock.

  • Evidence tables: shadow_run, shadow_cycle, shadow_input_batch, shadow_legacy_queue, shadow_decision, shadow_encoded_write, shadow_difference, shadow_traffic_state, shadow_hold_history, and shadow_startup_check.

  • Step 1: Write dual-source and permission-probe tests

@Test
void sourceAndEvidenceConnectionsCannotResolveToTheSameDatabase() {
    StartupFailure failure = assertThrows(StartupFailure.class,
            () -> gate.verify(sourceMeta("prod-host", "FT546-wcs", "shadow_ro"),
                    evidenceMeta("prod-host", "FT546-wcs", "shadow_evidence")));
    assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER",
            failure.getReasonCode());
}

@Test
void sourceProbeRejectsAnyProductionWritePermission() {
    PermissionCheckResult result = probe.verifySource(connectionWith(
            "SELECT=1;INSERT=0;UPDATE=1;DELETE=0;CONTROL=0"));

    assertFalse(result.isAccepted());
    assertEquals("SOURCE_HAS_UPDATE_PERMISSION", result.getReasonCode());
}

The source connection test executes SET TRANSACTION ISOLATION LEVEL READ COMMITTED, calls Connection.setReadOnly(true), and verifies permissions only through HAS_PERMS_BY_NAME, fn_my_permissions, database role membership, and JDBC metadata. The startup probe performs no DML of any kind, including temporary or probe-table writes.

  • Step 2: Run tests and verify current dynamic data-source path cannot satisfy them

Run:

mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test

Expected: missing explicit data-source beans/probe classes.

  • Step 3: Implement explicit pools and fail-closed startup checks
@Bean("shadowSourceDataSource")
DataSource source(ShadowProperties properties) {
    HikariConfig config = base(properties.getSource());
    config.setReadOnly(true);
    config.setMaximumPoolSize(2);
    config.setMinimumIdle(0);
    config.setConnectionTimeout(2000L);
    return new HikariDataSource(config);
}

@Bean("shadowSourceTx")
DataSourceTransactionManager sourceTx(
        @Qualifier("shadowSourceDataSource") DataSource source) {
    DataSourceTransactionManager manager = new DataSourceTransactionManager(source);
    manager.setEnforceReadOnly(true);
    return manager;
}

Do not modify DataSourceType, DruidConfig, or DynamicDataSourceContextHolder; the shadow process does not load them. ShadowStartupGate compares JDBC URL host/database and login names, persists every check to the evidence database, and keeps the scheduler disabled unless all checks pass.

  • Step 4: Add additive SQL objects and least-privilege verification

V001 creates only shadow_ro views selecting the exact existing columns needed from wcs_srgv_queue, wcs_srgv_queue_finish, device_srgv, wcs_srgv_map, bas_cargo_space, and tsj_device_execution_queue. V002 creates a login/user template, denies write/control permissions, and grants SELECT only on shadow_ro; site credentials remain deployment secrets and are not committed. V003 creates evidence tables in a separate database. The verify script returns one row per expected grant/deny and fails if the source user can write any approved base table.

  • Step 5: Run automated tests; run SQL verification only against an approved isolated SQL Server

Run locally:

mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test

Run in the approved SQL Server integration environment:

sqlcmd -S $env:SHADOW_SQLSERVER -d FT546-wcs -E -b -i sql/shadow/verify_ft546_shadow_permissions.sql

Expected: local tests pass. SQL verification reports source SELECT on views, zero source business-table write permissions, and evidence write permission only in the distinct evidence database. If no isolated SQL Server is available, record this gate as NOT_RUN; do not claim site readiness.

  • Step 6: Commit data isolation and additive SQL
git add ruoyi-wcs-shadow sql/shadow
git commit -m "feat(ft546): enforce shadow database isolation"

Task 5: Collect One Immutable, Bounded Production Fact Batch

Files:

  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowVehicleFact.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowMapFact.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactValidation.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java

Interfaces:

  • Produces: ShadowFactBatch SqlServerShadowFactReader.read(ShadowReadCursor cursor, Instant deadline).

  • Produces: ShadowFactValidation validate(ShadowFactBatch, Instant now).

  • Produces: LegacyQueueProtocolProjector.project(ShadowQueueFact) -> ShadowLegacyQueueResult using captured legacy queue fields only; it does not call the shared new encoder.

  • Cursor: (lastCreateDate, lastGuid) with a 5-second overlap; duplicate rows are retained as source evidence but deduplicated by (tableName, guid, contentHash) for kernel input.

  • Hard limits: 500 queue rows, 100 vehicle rows, 10,000 map rows on startup refresh, 3-second JDBC query timeout, and 4-second whole-batch deadline.

  • Step 1: Write immutable batch, ordering, and stale-fact tests

@Test
void readsQueueVehicleLegacyResultAndDatabaseClockInOneReadOnlyTransaction() {
    ShadowFactBatch batch = reader.read(cursor, deadline);

    assertEquals(Arrays.asList("Q-1", "Q-2"), guids(batch.getQueues()));
    assertEquals("070502", batch.vehicle("1").getCurrentSite());
    assertEquals("4004,4000,4002,4021",
            projector.project(batch.queue("Q-1")).getWriteAddresses());
    assertTrue(batch.getDatabaseCapturedAt().isBefore(deadline));
}

@Test
void staleOrContradictoryFactsAreNotComparable() {
    ShadowFactValidation validation = validator.validate(
            batchWithVehicleAtTwoSites(), NOW);

    assertEquals(INSUFFICIENT_FACTS, validation.getStatus());
    assertEquals("CONTRADICTORY_VEHICLE_POSITION:1",
            validation.getReasonCode());
}

Add tests for query timeout, row-limit overflow, non-monotonic database clock, missing map site, missing vehicle for legacy assignment, queue mutation during the overlap window, source outage backoff, ordinary/cross-floor/two-stage legacy projection, and LEGACY_RESULT_UNAVAILABLE when the captured queue lacks the fields needed to reconstruct old register values.

  • Step 2: Run fact tests and verify the reader/validator are missing

Run:

mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test

Expected: missing fact types/readers.

  • Step 3: Implement bounded read-only SQL with deterministic ordering
SELECT TOP (?) guid, task_no, task_type, priority, cargo_pick_site,
       cargo_place_site, start_site, end_site, create_date, agv_id,
       path_info, default02, default03, default04, default05, default06,
       default07, default08
FROM shadow_ro.v_wcs_srgv_queue
WHERE create_date > ? OR (create_date = ? AND guid > ?)
ORDER BY create_date, guid;

Read all component queries inside shadowSourceTx with readOnly=true. Capture the database clock before and after; accept a batch only when the span is within the configured freshness window and every required object is present. LegacyQueueProtocolProjector independently reconstructs the old expected queue/PLC result from captured agv_id/path_info/task_type/default* fields and the frozen legacy contract; it must not invoke Ft546SrgvCommandEncoder. Keep all legacy result fields out of new-kernel candidate selection inputs. When legacy values are not reconstructable, persist LEGACY_RESULT_UNAVAILABLE and skip protocol equality rather than declaring a match.

  • Step 4: Add load-shedding and outage behavior

One AtomicBoolean prevents overlapping reads. On timeout/row overflow, persist a failed cycle, discard the partial batch, and back off 1s -> 2s -> 4s -> 8s -> 30s. A successful complete batch resets the backoff. The reader never retries inside the same cycle.

  • Step 5: Run focused tests

Run:

mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test

Expected: deterministic ordering, immutable data, stale/contradictory rejection, query limits, and backoff pass.

  • Step 6: Commit the fact boundary
git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts \
        ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts
git commit -m "feat(ft546): collect bounded read-only shadow facts"

Task 6: Run The Shared Dispatch Kernel Without Command Outbox Or Side Effects

Files:

  • Create: ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java
  • Create: ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java
  • Create: ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java
  • Modify: ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java

Interfaces:

  • Produces: ReadOnlyDispatchCycle.run(DispatchSnapshot) -> ShadowDispatchResult.

  • Produces: ShadowDispatchSnapshotAssembler.assemble(ShadowFactBatch) -> DispatchSnapshot.

  • Produces: ShadowCommandIntentFactory.create(GrantedDispatch) -> Ft546SrgvCommandIntent.

  • Produces: RecordingFt546CommandGateway.record(Ft546SrgvCommandIntent) -> Ft546EncodedCommand.

  • Produces: JdbcShadowTrafficStateRepository.commit(DispatchCommit) in the evidence database with optimistic snapshotVersion; reservations, wait-for edges, and HOLD history survive process restart.

  • Preserves: WcsRuntimeCoordinator.tick() production order and Outbox behavior unchanged.

  • Step 1: Write a same-kernel/no-Outbox test

@Test
void readOnlyCycleUsesDispatchCoordinatorButNeverCreatesOrSendsACommand() {
    List<String> calls = new ArrayList<String>();
    ReadOnlyDispatchCycle cycle = new ReadOnlyDispatchCycle(
            snapshot -> {
                calls.add("dispatch");
                return committedGrant();
            },
            grant -> calls.add("intent:" + grant.getVehicleId()));

    ShadowDispatchResult result = cycle.run(snapshot());

    assertEquals(Arrays.asList("dispatch", "intent:1"), calls);
    assertEquals(1, result.getIntents().size());
}

The integration test injects throwing fakes for every DeviceCommandRepository, CommandOutboxRepository, DeviceCommandGateway, and WmsNotificationGateway; a successful shadow cycle proves none is called.

  • Step 2: Run the tests and verify the read-only cycle does not exist

Run:

mvn -pl ruoyi-wcs,ruoyi-wcs-shadow -am \
  -Dtest=ReadOnlyDispatchCycleTest,Ft546ShadowCycleTest,WcsRuntimeCoordinatorTest test

Expected: missing cycle/assembler/recorder types.

  • Step 3: Extract the pure dispatch use without changing production tick
public ShadowDispatchResult run(DispatchSnapshot snapshot) {
    DispatchCycleResult decisions = dispatcher.apply(snapshot);
    List<Ft546SrgvCommandIntent> intents = new ArrayList<>();
    for (GrantedDispatch grant : decisions.getGranted()) {
        intents.add(intentFactory.apply(grant));
    }
    return new ShadowDispatchResult(decisions, intents);
}

Ft546ShadowCycle validates facts first. For valid facts it assembles the same DispatchSnapshot/DispatchTask/VehicleSnapshot/EffectiveTopology values used by DispatchCoordinator, invokes ReadOnlyDispatchCycle, encodes every intent through Ft546SrgvCommandEncoder, and sends all evidence to one evidence-database transaction. It does not instantiate DeviceExecutionService, CommandOutboxDispatcher, WmsNotificationOutboxDispatcher, or a production lease/fencing writer. JdbcShadowTrafficStateRepository stores the resulting ledger, wait-for graph, consecutive HOLD counters, and snapshot version only in the evidence database; restart reload tests must prove avoidance/HOLD state is not reset.

  • Step 4: Prove phase parameters include ordinary, cross-floor, and two-stage semantics

Add fixture cases whose GrantedDispatch.commandParameters include:

parentTaskId=PARENT-1
phase=LOADED_TO_PLACE
routeVersion=3
taskNo=30001
taskType=1|8
crossFloorTarget=8 (only when taskType=8)
stage=1|2 (only for the matching two-stage phase)

Assert the encoder output equals the legacy sequence fixture and the vehicle/path may differ without changing protocol classification.

  • Step 5: Run kernel, protocol, and shadow cycle tests

Run:

mvn -pl ruoyi-wcs,ruoyi-ft546-protocol,ruoyi-wcs-shadow -am \
  -Dtest=ReadOnlyDispatchCycleTest,WcsRuntimeCoordinatorTest,Ft546SrgvCommandEncoderTest,Ft546ShadowCycleTest test

Expected: shared dispatch policy and encoder run; all side-effect fakes remain uncalled; invalid facts produce no intent.

  • Step 6: Commit the same-kernel shadow cycle
git add ruoyi-wcs ruoyi-wcs-shadow
git commit -m "feat(ft546): run shared dispatch kernel in shadow"

Task 7: Persist Auditable Differences And Prove Idempotent Collection

Files:

  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java
  • Create: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java
  • Modify: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java
  • Modify: ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java

Interfaces:

  • Categories: MATCH, PROTOCOL_ADDRESS, PROTOCOL_DATA_TYPE, PROTOCOL_VALUE, PROTOCOL_ORDER, QUEUE_FIELD, ALGORITHM_VEHICLE, ALGORITHM_ROUTE, ALGORITHM_HOLD, INSUFFICIENT_FACTS, and SOURCE_GAP.

  • Idempotency: unique (run_id, input_batch_hash, parent_task_id, phase, route_version).

  • Produces: ShadowComparisonService.compare(ShadowLegacyQueueResult, ShadowDecision, Ft546EncodedCommand).

  • Produces: daily digest counts and reproducible evidence hash.

  • Step 1: Write classification and idempotency tests

@Test
void differentVehicleWithMatchingWritesIsAlgorithmOnly() {
    List<ShadowDifference> differences = comparison.compare(
            legacy("1", path(70501, 70502), mainWrites()),
            decision("2", path(70501, 70502)), encoded(mainWrites()));

    assertEquals(singletonList(ALGORITHM_VEHICLE), categories(differences));
}

@Test
void changedWriteOrderIsProtocolOrderEvenWhenValuesMatch() {
    assertEquals(PROTOCOL_ORDER, singleDifference(
            legacy(mainWrites()), encoded(swappedTaskNoAndType())).getCategory());
}

@Test
void replayingTheSameBatchDoesNotDuplicateEvidence() {
    repository.save(cycle);
    repository.save(cycle);

    assertEquals(1, repository.countCycles());
    assertEquals(cycle.getEvidenceHash(), repository.findCycle(
            cycle.getRunId(), cycle.getInputBatchHash(),
            cycle.getParentTaskId(), cycle.getPhase(),
            cycle.getRouteVersion()).getEvidenceHash());
}
  • Step 2: Run evidence tests and verify repository/comparison types are missing

Run:

mvn -pl ruoyi-wcs-shadow -am \
  -Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest test

Expected: missing evidence types.

  • Step 3: Implement one evidence transaction per complete cycle

Persist immutable JSON plus indexed columns. Hash canonical UTF-8 JSON for the input batch, legacy result, decision, encoded writes, and differences. If the unique key already exists, verify the stored hash matches and return the existing cycle; a hash mismatch is EVIDENCE_KEY_COLLISION and stops collection. Never partially commit a cycle.

  • Step 4: Add storage-pressure fail-safe

Before every cycle check configured minimum free bytes and evidence database health. When the threshold is crossed, change status to DEGRADED:EVIDENCE_STORAGE_UNAVAILABLE, stop new reads/calculations, retain the last complete evidence, and keep the stable WCS unaffected. Do not discard differences and continue reporting healthy.

  • Step 5: Run comparison, repository, and cycle tests

Run:

mvn -pl ruoyi-wcs-shadow -am \
  -Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest,Ft546ShadowCycleTest test

Expected: exact classification, idempotent replay, hash verification, atomic commit, and disk/db fail-safe pass.

  • Step 6: Commit the evidence chain
git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence \
        ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence \
        ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel \
        ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web
git commit -m "feat(ft546): persist shadow comparison evidence"

Task 8: Verify End-To-End Shadow Operation And Prepare Immediate Stable Rollback

Files:

  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java
  • Create: ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java
  • Create: ruoyi-wcs-shadow/src/test/resources/fixtures/ordinary-cycle.json
  • Create: ruoyi-wcs-shadow/src/test/resources/fixtures/cross-floor-cycle.json
  • Create: ruoyi-wcs-shadow/src/test/resources/fixtures/two-stage-cycle.json
  • Create: ruoyi-wcs-shadow/src/test/resources/fixtures/hold-avoidance-cycle.json
  • Create: ruoyi-wcs-shadow/src/test/resources/application-shadow-local.yml
  • Create: tools/shadow/Start-Ft546Shadow.ps1
  • Create: tools/shadow/Stop-Ft546Shadow.ps1
  • Create: tools/shadow/Test-Ft546ShadowPreflight.ps1
  • Create: tools/shadow/Export-Ft546ShadowEvidence.ps1
  • Create: tools/shadow/Capture-StableWcsManifest.ps1
  • Create: docs/runbooks/ft546-realtime-shadow-runbook.md
  • Modify: README.md

Interfaces:

  • Preflight output: stable PID/command line/artifact SHA-256/config SHA-256/listening ports; shadow artifact/config SHA-256; source/evidence JDBC identities; permission checks; PLC/WMS network-deny checks; output free space.

  • Start command refuses to run without -ConfirmReadOnlyShadow, a successful preflight JSON, and a distinct shadow deployment directory.

  • Stop command stops only the recorded shadow PID and never finds/kills a process by broad Java name.

  • Evidence export emits manifest.json, manifest.sha256, cycle CSV/JSON, protocol-difference CSV, algorithm-difference CSV, insufficient-facts CSV, and startup checks.

  • Step 1: Write end-to-end and fault-isolation tests first

@Test
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence() {
    ShadowRunResult result = harness.run(fixtures());

    assertEquals(3, result.getComparableCycles());
    assertWrites(result.forTask("ORDINARY"), "4004", "4000",
            "4002", "4021");
    assertWrites(result.forTask("CROSS_FLOOR"), "4004", "4000",
            "4002", "4021", "4020", "4022");
    assertWrites(result.forTask("STAGE_2"), "4404", "4422",
            "4423", "4402", "4400");
    assertEquals("HOLD", result.forTask("AVOIDANCE").getDecision());
    assertTrue(result.forTask("AVOIDANCE").getEncodedWrites().isEmpty());
    assertEquals(0, result.getSideEffectCalls());
}

@Test
void sourceOutageEvidenceFailureAndShadowCrashNeverAffectStableProbe() {
    faultHarness.inject(SOURCE_TIMEOUT, EVIDENCE_DISK_FULL, SHADOW_CRASH);

    assertTrue(stableProbe.isContinuouslyHealthy());
    assertEquals(0, sideEffectProbe.totalCalls());
}

Add a package/socket test proving the shadow process has no configured PLC IP/port or WMS URL, a process test proving stop/restart changes only the shadow PID, and a restart test proving persisted wait-for edges/consecutive HOLD counters are reloaded so the third identical HOLD follows the configured reroute rule instead of resetting to the first HOLD.

  • Step 2: Run end-to-end tests and verify scripts/runbook are missing

Run:

mvn -pl ruoyi-ft546-protocol,ruoyi-wcs,ruoyi-wcs-shadow,ruoyi-admin -am \
  -Dtest=Ft546ShadowEndToEndTest,Ft546ShadowFaultIsolationTest,Ft546StandardRuntimeTest,Ft546LegacySrgvAdapterTest test

Expected: missing fixtures/harness or end-to-end program.

  • Step 3: Implement safe operational scripts

Capture-StableWcsManifest.ps1 records the actual deployed stable artifact path, SHA-256, command line, configuration paths/hashes, service name/PID, and listening ports; it does not assume origin/main is the deployed version. Test-Ft546ShadowPreflight.ps1 verifies the shadow source login, separate evidence database, permissions, distinct PID/ports/directories, loopback status endpoint, PLC/WMS network denial, disk threshold, and stable health before start. Start-Ft546Shadow.ps1 starts only the shadow jar with a PID file in its own directory. Stop-Ft546Shadow.ps1 resolves and validates that exact PID and executable path before stopping it.

  • Step 4: Write the site runbook with explicit stop conditions

The runbook sequence is:

capture stable manifest and verify stable health
  -> create/read-only source login and separate evidence database
  -> deny shadow host PLC/WMS egress at network layer
  -> run preflight and archive JSON
  -> start shadow only
  -> verify stable PID/health/PLC writer unchanged
  -> collect 15 minutes at low polling rate
  -> inspect source query latency and evidence growth
  -> extend collection window only when stable metrics are unchanged
  -> stop shadow immediately on any stable degradation
  -> export evidence and verify manifest hashes

Immediate stop conditions include production DB latency increase above the agreed threshold, permission-probe change, evidence disk threshold, source lag beyond freshness, any PLC/WMS connection attempt, any production-table write audit, shadow CPU/memory limit breach, or stable WCS health change. Stopping/removing the shadow service is the rollback for this phase; the stable WCS is never stopped or switched.

  • Step 5: Run all automated verification from a clean build

Run:

mvn clean test

Expected: all modules pass with zero failures/errors; record exact test count and duration.

  • Step 6: Run local process smoke without production endpoints

Run:

.\tools\shadow\Test-Ft546ShadowPreflight.ps1 `
  -Config .\ruoyi-wcs-shadow\src\test\resources\application-shadow-local.yml
.\tools\shadow\Start-Ft546Shadow.ps1 `
  -ConfirmReadOnlyShadow `
  -PreflightJson .\outputs\shadow-preflight-local.json
Invoke-RestMethod http://127.0.0.1:18091/shadow/status
.\tools\shadow\Stop-Ft546Shadow.ps1

Expected: local H2/test-view mode reaches READY, generates evidence, makes zero external calls, and stops only its PID.

  • Step 7: Run SQL Server/HIL/site gates only in approved environments

Required evidence before site collection:

  • SQL Server integration: exact transaction/permission/query-timeout behavior passes against an isolated SQL Server.
  • Network: shadow host cannot reach PLC write endpoints or production WMS callback endpoints.
  • Site preflight: stable manifest captured, stable process healthy, source login read-only, evidence database distinct, and shadow directories/ports distinct.
  • Site observation: stable process metrics and PLC ownership stay unchanged while shadow is started/stopped repeatedly.

HIL remains a later gate for real PLC timing and partial-write behavior; this shadow program does not connect to HIL or production PLC. If any gate is missing, the conclusion is limited to local or SQL integration readiness.

  • Step 8: Run repository checks and request review

Run:

git diff --check
git status --short
git log --oneline -15

Reviewers receive the confirmed design, this plan, base 48b0b154b6319de05df3b71b4457d12110bdb095, stable tracking reference 652607ce15653f12a682248ad3f335861ebd9e3f, final HEAD, Maven evidence, preflight output, SQL permission output, process start/stop evidence, and exported manifest. Resolve all Critical/Important findings and rerun focused plus full tests.

  • Step 9: Commit the runbook and acceptance harness
git add ruoyi-wcs-shadow/src/test tools/shadow \
        docs/runbooks/ft546-realtime-shadow-runbook.md README.md
git commit -m "test(ft546): verify read-only shadow operation"

End-To-End Acceptance Matrix

Layer Required proof Permitted conclusion
Unit/domain Runtime guards, fact validation, same-kernel decision, pure PLC encoding, difference classification Local implementation behavior only
Spring context No side-effect beans/types, separate data sources, fail-closed startup Process wiring is isolated
Local E2E Ordinary/cross-floor/two-stage reach virtual PLC evidence; zero side-effect calls Local shadow flow works
SQL Server integration Read-only permissions, transactions, timeouts, separate evidence writes, restart/idempotency Database integration works
Site preflight Stable artifact/PID/config captured; network deny; accounts/ports/directories distinct Safe to start collection window
Live shadow Stable health unchanged, source query load bounded, evidence continuous, zero production side effects Shadow observations are valid
HIL Real isolated PLC register sequence, disconnect, partial write, restart Protocol adapter hardware gate only
Cutover drill Single-writer lease/fencing, recovery, reconciliation, rollback Future STANDARD cutover gate; outside this plan

Plan Self-Review Checklist

  • Design coverage: mode isolation (Task 1), pure PLC intent (Task 2), independent launcher (Task 3), two-data-source permission gate (Task 4), read-only facts (Task 5), same-kernel cycle (Task 6), differences/evidence (Task 7), E2E/site protection (Task 8).
  • Stable WCS protection is independent at code, process, database, network, operations, and test layers; one configuration mistake is not the sole barrier.
  • LEGACY remains default and unchanged; no task authorizes production STANDARD.
  • SHADOW never uses current PLC readers as its source because those readers can connect/write and mutate production device tables.
  • The real adapter and shadow recorder share one pure encoder, so address/type/value/order comparisons do not rely on duplicated protocol code.
  • Production source and evidence data sources are explicit and physically distinct; no ThreadLocal routing or transaction-time switching is used.
  • Every partial/invalid fact path closes as INSUFFICIENT_FACTS; no fabricated comparison is emitted.
  • Protocol and algorithm differences have distinct enums and tests.
  • Ordinary, cross-floor, two-stage, HOLD/avoidance inputs, source outage, evidence failure, restart, and stable-process isolation all have named automated tests.
  • SQL Server, site, network, and HIL evidence boundaries are explicit; local tests cannot be reported as production acceptance.
  • Every task contains concrete files, interfaces, red/green commands, expected results, and explicit failure behavior; no deferred implementation placeholder remains.