From d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b Mon Sep 17 00:00:00 2001 From: RobinLee Date: Tue, 6 Oct 2026 10:39:58 +0800 Subject: [PATCH 1/3] Establish DL-06 transition observer specification --- README.md | 24 +- .../actual_automation_integration_plan.md | 27 +- .../dual_lab_vrrp_ai_query_mvp.md | 470 ++++++++++++++++++ 3 files changed, 515 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5a649ae..0b80a09 100644 --- a/README.md +++ b/README.md @@ -545,9 +545,27 @@ Owner authorization. P3 remains OPEN_NON_BLOCKING, and seven pre-existing Next.js tracing warnings remain maintenance items. `LIVE_AUTHORITY_GRANTED = NO` and `LIVE_READINESS_GRANTED = NO`; configuration mutation, provider/model integration, -and production readiness remain unauthorized. `DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED`; future DL-06 work requires separate readiness/specification and -exact Owner authorization. Historical Stage-2 proof and closure semantics remain -unchanged. +and production readiness remain unauthorized. `DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED`. +The [DL-06-00 readiness specification](docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md#dl-06-00-transition-observer-readiness-and-specification) +is established for an offline transition contract; no implementation or live +authority is granted. Day35 remains the proven historical manual live failover +predecessor. DL-06 composes accepted Stage-2, DL-02 and DL-03 using a +PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE with no authorization reuse. A valid +sample with a positively classified observation-level target failure may lead +to the next distinct preauthorized sample; authorization/session-integrity or +ambiguous failures terminate. The current coarse failure mapping does not yet +prove that distinction. No retry, fallback, parallel execution, automatic +failover or configuration mutation is permitted. + +The 30-second limit requires hard whole-observer completion, including in-flight +work and cleanup. Shared deadline propagation/enforcement is NOT_ESTABLISHED; +DL-06-03 repeated live observation remains BLOCKED until separately authorized +enforcement is implemented and proven, with trusted failure classification also +required. DL-06-01 offline transition-contract implementation is the next Owner +decision. The deadline blocker does not prevent that offline work or a separately +authorized DL-06-02 single live Dual-Lab snapshot proof. That current-baseline +single proof is required before repeated observation and cannot claim DL-06-03 +readiness. Historical Stage-2 proof and closure semantics remain unchanged. `DEFERRED_SECURITY_RESEARCH_BLOCKED` is not a Security PASS, completion, activation, or integration claim. Detailed experimental research evidence is diff --git a/docs/automation_readiness/actual_automation_integration_plan.md b/docs/automation_readiness/actual_automation_integration_plan.md index 5827aee..aaaf4b9 100644 --- a/docs/automation_readiness/actual_automation_integration_plan.md +++ b/docs/automation_readiness/actual_automation_integration_plan.md @@ -33,7 +33,7 @@ remain non-blocking maintenance items. | DL-03 | ACCEPTED | | DL-04 | ACCEPTED | | DL-05 | ACCEPTED; bounded offline MVP integration and acceptance complete | -| DL-06 | NOT ESTABLISHED; requires separate future readiness/specification and exact Owner authorization | +| DL-06 | DL-06-00 readiness specification established; offline contract not implemented; execution contract NOT_ESTABLISHED and live authority absent | `DL_05_OWNER_ACCEPTANCE = ACCEPTED`, `DL_05_ACCEPTANCE_RESULT = PASS`, `DL_05_ACCEPTED = YES`, `COMPLETE_DUAL_LAB_MVP = YES`, and @@ -57,6 +57,26 @@ provider/model integration, production readiness, and standing Lab1/Lab2 access remain unauthorized. Future live work needs separate exact authority. Historical Stage-2 proof and closure semantics remain unchanged; Stage 2 is not reopened. +The [DL-06-00 readiness specification](dual_lab_vrrp_ai_query_mvp.md#dl-06-00-transition-observer-readiness-and-specification) +composes accepted Stage-2 observations, DL-02 ordering and DL-03 facts into a +proposed outer timeline. Day35 remains the proven historical manual live failover +predecessor. The selected PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE permits no +authorization reuse. A valid sample with a positively classified observation-level +target failure may be followed by the next distinct preauthorized sample; +authorization/session-integrity or ambiguous failures terminate. The current +coarse failure mapping does not prove that distinction. No retry, fallback, +parallel execution, automatic failover or configuration mutation is permitted. + +The 30-second bound is hard whole-observer completion, including in-flight work +and cleanup. Shared deadline propagation/enforcement is NOT_ESTABLISHED, so +DL-06-03 repeated live observation remains BLOCKED until separately authorized +enforcement is implemented and proven; trusted failure classification is also +required. DL-06-01 offline transition-contract implementation is the next Owner +decision. This deadline blocker does not prevent that offline work or separately +authorized DL-06-02 single live Dual-Lab snapshot proof. The current-baseline +single proof remains required before repeated live observation and cannot claim +DL-06-03 readiness. No implementation or live authority is granted here. + ## 1. Purpose This planning reference defines the gate-based conditions that must be met before the Network Automation Lab can move from mock-only and dry-run automation toward actual automation integration. @@ -431,8 +451,9 @@ and seven pre-existing build warnings remain maintenance items. `DL_05_OWNER_ACCEPTANCE = ACCEPTED`, `DL_05_ACCEPTANCE_RESULT = PASS`, `DL_05_ACCEPTED = YES`, `COMPLETE_DUAL_LAB_MVP = YES`, and `OFFLINE_DUAL_LAB_MVP_ACCEPTED = YES`. -No live authority or readiness is granted. DL-06 remains NOT_ESTABLISHED and -requires separate future readiness/specification and exact Owner authorization. +No live authority or readiness is granted. DL-06-00 readiness/specification is +established; DL-06's execution contract remains NOT_ESTABLISHED. The next +possible Owner decision is offline transition-contract implementation only. Stage 3 remains NOT STARTED. Further live access or scope expansion requires the applicable capability gates and separate exact Owner approval. diff --git a/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md index 02b1218..926aa45 100644 --- a/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md +++ b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md @@ -16,6 +16,8 @@ readiness is granted. DL-06's execution contract remains NOT_ESTABLISHED. The [DL-05 record](#dl-05-reviewed-exact-sha-candidate) retains the reviewed candidate and [completed Owner acceptance](#completed-owner-acceptance-and-active-status). +The [DL-06-00 readiness specification](#dl-06-00-transition-observer-readiness-and-specification) +defines a future outer transition timeline; implementation and live execution remain unauthorized. The [DL-05 canonical contract](#dl-05-canonical-integration-and-safe-ci-contract) retains its requirements. Earlier specification and predecessor status snapshots are historical; the completed DL-05 acceptance below defines current status. @@ -1488,6 +1490,474 @@ NEXT_REQUIRED_OWNER_DECISION = AUTHORIZE_BOUNDED_DL_04_CLOSURE_EXECUTION ``` +## DL-06-00 transition observer readiness and specification + +**Decision: establish the offline transition specification; live execution remains +LOCKED.** Day35 already proved manual live VRRP failover. DL-06 adds bounded, +ordered observations and deterministic change facts through the accepted +Stage-2 / DL-02 / DL-03 composition. It does not create another Day35 tester. +This section is the current DL-06-00 planning record; earlier statements that +DL-06 readiness/specification had not begun are historical. The accepted DL-01 +through DL-05 implementation, evidence and decisions remain unchanged. + +Planning baseline: public `main@5431f7491015fe7ab2ae9227bc6a48814356641e`. +Accepted DL-05 implementation: `0e5dfbc1d143a6de7ba59e4bff9bd1788e809043`, +tree `6252c2c632d217b4f02804626aac797b546f87f8`. Specification does not grant +implementation authority, live readiness, a new signed approval, or execution. + +```text +DL_06_00_SPECIFICATION_STATUS = ESTABLISHED +DL_06_OFFLINE_TRANSITION_CONTRACT_STATUS = SPECIFIED_NOT_IMPLEMENTED +DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED +DL_06_REPEATED_LIVE_READINESS = BLOCKED_DEADLINE_ENFORCEMENT_UNPROVEN +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +PROVIDER_MODEL_INTEGRATION_AUTHORIZED = NO +``` + +### Evidence basis and actual delta + +The [Day35 implementation](../../mikrotik_day35_vrrp_failover_validation.py), +[tests](../../tests/test_mikrotik_day35_vrrp_failover_validation.py), +[profile](../../topology_profiles/day35_vrrp_failover_validation.json), +[plan](../roadmap/day35_vrrp_failover_validation_plan.md), and +[safety record](../roadmap/day35_vrrp_failover_validation_safety.md) establish: + +- A real-device command-based workflow using Paramiko through the inherited + `connect_ssh_with_auth_retry` and `run_raw_command` helpers. This is historical + architecture, not Stage-2 authorization and not a new invocation in this task. +- Three coarse phases: baseline expects Lab01 MASTER and Lab02 BACKUP; + operator disconnects Lab01 LAN cable, then an eight-second countdown precedes + failover collection; operator reconnects it, then another eight-second + countdown precedes recovery collection. Pings precede router collection. +- Seven read-only commands, including `/interface vrrp print detail`, plus + identity, IP address, interface, route, NAT and filter `print` commands. + The safety filter checks print-command shape and blocked operation words; + it is not the accepted Stage-2 exact-command authority chain. +- Source-specific PC pings to both lab interfaces, the VIP and LAN server; + three packets per target, with a 20-second subprocess timeout per ping. +- Day32 summary parsing plus Day35 flag fallback; scenario-specific PASS/FAIL + evaluation. Recovery without Lab01 preemption is PASS_WITH_NOTES rather + than an unconditional failure. JSON/HTML/TXT reports include phase evidence, + redacted command output, summary and explicit no-configuration-change text. + +[Day36](../roadmap/day36_vrrp_failover_evidence_review_report_hardening.md) +records historical PASS, Lab02 becoming MASTER after manual disconnection, +VIP reachability, and Lab01 MASTER after reconnection. It explicitly says exact +convergence timing was not measured. [Day37's evidence policy](../roadmap/day37_vrrp_report_regression_evidence_policy.md) +retains documentation and regression fixtures in Git; full runtime reports stay +local. The historical proof is accepted as documented, not rerun or newly +attested from raw reports here. Synthetic test fixtures alone are not live proof. + +The new value is: first observed changed role/MASTER target, ordered repeated +evidence around a transition, explicit unavailable/ambiguous observation gaps, +conservative sample time windows, and deterministic facts suitable for later +presentation. It cannot measure exact failover/convergence latency, continuous +availability, packet loss, causation, or simultaneous pair state. Intermediate +states can be missed between samples. A valid sample with a positively classified +observation-level target failure may be followed by a distinct preauthorized +sample while all session gates remain valid. Later observation or recovery is +not guaranteed within the finite schedule and hard duration limit. + +### Day35 reuse and replacement decisions + +These classifications apply only to future DL-06 composition. Day35 files and +historical semantics are retained unchanged; this is not a second safety matrix. + +| Day35 behavior | Classification | DL-06 decision | +| --- | --- | --- | +| Manual cable disconnection | REUSE_CONCEPT_ONLY | Human causes the event under a future exact run authorization; observer never triggers it. | +| Manual reconnect/recovery | REUSE_CONCEPT_ONLY | Human owns restoration; no blocking prompt inside the timed observer. | +| Baseline/failover/recovery phases | REUSE_CONCEPT_ONLY | Useful demonstration narrative, not mandatory expected states or a new evaluator. | +| Fixed eight-second waits | DO_NOT_REUSE | Use a finite sample plan and measured windows; do not assume convergence after a fixed wait. | +| Direct Paramiko connection | SUPERSEDE_WITH_STAGE2 | Use the accepted pinned transport through S2-RO-11, never the Day35 connection helper. | +| Direct RouterOS execution | SUPERSEDE_WITH_STAGE2 | Only the existing fixed VRRP operation and exact command policy. | +| Command allow/block filter | SUPERSEDE_WITH_STAGE2 | Preserve accepted request, Owner verification, replay, trust, credential and exact-command gates. | +| `/interface vrrp print detail` | REUSE_UNCHANGED | Same command text, resolved only by accepted Stage-2 policy. | +| Interface/route/firewall auxiliary commands | DO_NOT_REUSE | Outside the initial observer operation. | +| PC ping checks | DO_NOT_REUSE | No reachability claim; separately scoped possible follow-up. | +| VRRP parser/state extraction | SUPERSEDE_WITH_STAGE2 | Reuse strict Stage-2 normalized records; no second device parser. | +| PASS/FAIL phase evaluator | RETAIN_AS_HISTORICAL_PREDECESSOR | DL-06 emits observation facts, not Day35 scenario verdicts. | +| JSON/HTML/TXT evidence model | SUPERSEDE_WITH_DUAL_LAB | Preserve accepted single-snapshot evidence and summary; add only an outer canonical timeline. HTML/TXT are not MVP requirements. | +| Secret redaction | REUSE_CONCEPT_ONLY | Keep data minimization; exclude raw streams and authority material rather than importing Day35 regex filtering. | +| No-config-change safety statement | REUSE_UNCHANGED | NAL observes only; mutation and automated fault injection remain forbidden. | + +### Accepted composition and authorization + +[DL-01](../../validation_framework/dual_lab_vrrp_query_contract.py) owns the +unchanged query/aggregate schema. [DL-02](../../validation_framework/dual_lab_vrrp_query_orchestrator.py) +preflights both bundles, then invokes its private binding to +`run_stage2_vrrp_live_once()` at most once per eligible target, Lab1 then Lab2. +A Lab1 failure does not suppress an otherwise eligible Lab2 within that same +sample. [DL-03](../../validation_framework/dual_lab_vrrp_query_summary.py) +projects and revalidates facts without retaining authority-bearing inputs. +All three are reusable unchanged. S2-RO-11 and trusted composition remain the +live path; a new direct Day35 Paramiko path is not required. + +The [envelope and ledger](../../validation_framework/stage2_authorization_envelope_ledger.py) +enforce integer `max_attempts == 1`. Durable uniqueness is keyed by +`authorization_id` alone; duplicate consumption returns `REPLAY`, including +same-ID/different-envelope attempts. Consumption remains spent after downstream +failure. Existing non-rollback storage assumptions remain unchanged. + +Recommended model: **PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE**. This is a +finite trusted-caller input plan, not a scheduler service or reusable session +authorization. For every sample and target, require a distinct `run_id`, +`authorization_ref`, `authorization_id`, request hash and request-bound envelope, +and independently issued Owner approval. Query IDs are also unique per sample. +Requests and envelope bytes differ across samples; a failure cannot insert, +replace or replenish entries in the fixed preauthorized schedule. +Ten samples require at most twenty one-shot target invocations and twenty +distinct approval/envelope identities. A count is a ceiling, not a quota to fill. + +Before any future session, a trusted caller must validate the complete finite +plan using accepted inert parsers/binding rules, reject collisions across the +whole plan, and verify that all required trusted configurations are available. +Malformed/missing plan entries reject the session before any DL-02 invocation. +This preflight neither consumes nor authenticates approvals; Stage-2 retains +exclusive Owner verification and consumption on each actual invocation. +Expiry is rechecked by the accepted path; no validity extension is invented. + +No observer signer, dynamic minting, automatic approval generation, ledger +reset, fallback ledger, envelope reuse or automatic restart is allowed. +Unused entries are not replenished or reused by this session after termination. +Retained external evidence must bind the plan, exact baseline, query IDs and +accepted request/envelope identities without publishing their private contents. + +This model is security-consistent and manageable for at most twenty preissued +approvals, but current deployment readiness is unproven. The accepted envelope +lifetime is at most 300 seconds; external preparation and a current valid +start window must be demonstrated separately. No batch signer is assumed to +exist. A reusable session authorization would change replay and consumption +semantics and requires a separate security contract; it is not selected. + +### Proposed bounds and unresolved live deadline gate + +Select a maximum of **10 samples**, **2000 ms minimum delay** from completion of +one sample to admission of the next, and a proposed **30-second hard session +duration** from the first sample's admission. Setup/manual preparation occurs +before that clock starts. There is no catch-up, parallelism or compressed delay. +The ten-sample ceiling provides repeated evidence with at least 18 seconds of +inter-sample delay and leaves nominal time for observations; completing ten is +not guaranteed. Stop earlier when a bound or failure condition applies. + +The hard duration is a future requirement, **not an existing guarantee**. +[S2-RO-09](../../validation_framework/stage2_pinned_ssh_transport.py) currently +allows 15 seconds for connection/authentication, followed by a separate +30-second command phase per target, plus cleanup. S2-RO-10 validates a maximum +60,000 ms successful evidence duration after work; it does not implement a +30-second session cancellation mechanism. DL-02 has no absolute-deadline input +or cancellation boundary between its two target calls. Local acquisition time +also must be accounted for. Checking elapsed time between samples cannot +enforce the proposed whole-session limit. + +Accordingly, DL-06-03 is blocked until separately authorized deadline design, +implementation and negative validation establish the hard bound across both +targets, acquisition, command and cleanup without weakening security. The +present specification does not authorize modifying Stage-2 or the accepted +DL-02 contract to achieve that. Do not silently relabel the bound as an admission +window, shorten inherited timeouts, use a background worker, abandon a running +operation, patch private call bindings, or claim a killed process proves clean +termination. The selected requirement remains hard whole-observer completion +within 30 seconds. This deadline blocker applies to repeated live observation; +it does not block separately authorized offline timeline work or a separately +authorized single live Dual-Lab snapshot with its own explicit time budget. +Neither establishes repeated-observer readiness. + +```text +PROPOSED_MAX_SNAPSHOT_COUNT = 10 +PROPOSED_INTER_SAMPLE_DELAY_MS = 2000 +PROPOSED_MAX_OBSERVER_DURATION_SECONDS = 30 +HARD_WHOLE_OBSERVER_COMPLETION_LIMIT = YES +HARD_SESSION_DEADLINE_CURRENTLY_ENFORCEABLE = NOT_ESTABLISHED +DL_06_03_REPEATED_LIVE_OBSERVER_READY = NO +DL_06_03_BLOCK_REASON = WHOLE_SESSION_DEADLINE_PROPAGATION_AND_ENFORCEMENT_NOT_PROVEN +PARALLEL_EXECUTION_ALLOWED = NO +RETRY_ALLOWED = NO +FALLBACK_ALLOWED = NO +INFINITE_POLLING_ALLOWED = NO +DYNAMIC_AUTHORIZATION_MINTING_ALLOWED = NO +AUTOMATIC_FAILOVER_TRIGGER_ALLOWED = NO +CONFIGURATION_MUTATION_ALLOWED = NO +``` + +### Offline outer timeline contract + +Canonical schema identity: **`dual-lab-vrrp-transition.v1`**. DL-06-01 is an +inert transformer/validator, with no imports of orchestration, transport, +credential, authorization acquisition or device parsing. It consumes accepted +DL-03 summaries and caller-supplied elapsed metadata; data validity does not +attest live origin, timing authenticity, or authority. + +The exact top-level fields are `schema_version`, `timeline_id`, `execution_order`, +`planned_sample_count`, `inter_sample_delay_ms`, `max_duration_ms`, `samples`, +`termination`, and `facts`. `timeline_id` uses the accepted lowercase dotted +reference grammar with prefix `timeline.` and maximum 160 characters; +`execution_order` is `LAB1_THEN_LAB2`. Bounds are integer planned count 1..10, +delay exactly 2000, and duration exactly 30000; booleans are not integers. + +Each sample has exactly `sample_index`, `elapsed_start_ms`, `elapsed_finish_ms`, +and `summary`. Indices are consecutive starting at 1, with no skipped or +reordered entries, and count cannot exceed the planned count. Summary is the +unchanged JSON object of `dual-lab-vrrp-query-summary.v1`; re-encode canonically +and validate through `parse_summary_canonical_json`, including recomputation of +its cross-target observations. Reject repeated query IDs. Do not duplicate +Lab1/Lab2 records beside the embedded summary or create another snapshot schema. + +Elapsed values are exact nonnegative integers with start <= finish <= 30000; +first start is zero. Each later start is at least previous finish + 2000. +Values represent a monotonic session-relative observation window, not UTC or +individual device timestamps. No caller-provided float, negative, bool, +overlap, backdating, reordering or normalization is accepted. A live overrun +cannot be truncated to 30000 or certified as a valid bounded timeline; retain +prior valid evidence and report the overrun outside this valid-result schema. + +`termination` has exactly `reason` and `elapsed_ms`. Reasons are +`COUNT_REACHED`, `SESSION_INTEGRITY_FAILURE`, `DURATION_LIMIT`, `PRECHECK_REJECTED`, and +`INVALID_SAMPLE`. Elapsed is an integer 0..30000, no earlier than the final +sample finish. COUNT_REACHED requires the planned number of samples; valid +observation-level failures do not prohibit it. SESSION_INTEGRITY_FAILURE retains +the valid prefix (possibly empty), including the final failed aggregate if it +can be canonically summarized, and records termination when the fatal gate is +detected. It does not fabricate a sample for an uncollected target pair. +DURATION_LIMIT requires elapsed 30000 and fewer than the planned count. +PRECHECK_REJECTED requires no samples, no facts, and elapsed zero. INVALID_SAMPLE +requires fewer than the planned count and retains only the preceding valid +prefix (possibly empty); rejected bytes and exception details are excluded. +Retained valid samples in any prefix may contain observation-level failures. +No samples may follow termination. These are collection outcomes, not health +verdicts. Termination metadata is a caller claim, not proof of its cause. The +offline validator checks structure, bounds and derived facts, but cannot infer +authorization integrity from a coarse target failure category. Acceptance of +an offline failure/recovery narrative never authorizes live continuation; that +requires the separate trusted classification gate described below. + +For deterministic offline validation, every current DL-01 failure category +except TRUSTED_RUNTIME_FAILED explicitly denotes request, authorization, +configuration, evidence or internal integrity failure. Such a category may +appear only in the final retained sample and requires +SESSION_INTEGRITY_FAILURE. TRUSTED_RUNTIME_FAILED remains ambiguous: an offline +fixture may represent an observation-only failure and a later sample with that +preserved category, but no live caller may infer permission from its spelling. +No category is renamed or added to the accepted aggregate or summary schema. + +Canonical encoding follows existing compact sorted-key strict UTF-8 JSON: +no BOM/newline, NaN/Infinity, duplicate keys at any depth, unknown/missing fields, +alternate noncanonical byte representation or coercion. Maximum outer bytes: +4,194,304; maximum facts: 4096. Existing per-summary and 32-record-per-target +bounds remain in force. Reject over-limit input before returning any partial +timeline. Revalidate exact-type nested objects at serialization; return fresh +detached data, `execution_authorized=False`, and sanitized fixed errors. + +### Exact deterministic transition vocabulary + +Only the following nine fact kinds are permitted. Every fact has exactly +`kind`, `from_sample`, `to_sample`, `target_ref`, `subject`, `from_value`, and +`to_value`. Recompute all facts from validated samples; parser acceptance +requires byte equality with that complete ordered derivation. User-supplied +facts are never trusted. Adjacent pairs only: N -> N+1; never bridge a gap. + +| Kind | Exact derivation | +| --- | --- | +| TARGET_RESULT_STATUS_CHANGED | Per target, emit only if adjacent SUCCESS/FAILURE tags differ; null subject, string old/new status. | +| ROLE_CHANGED | Per target and unique `(instance_name, vrid)` present exactly once in both adjacent successful summaries, emit unequal role strings. | +| RUNNING_CHANGED | Same unique-key rule; emit unequal exact boolean `running` values. | +| PRIORITY_CHANGED | Same unique-key rule; emit unequal integer priority values. | +| INTERVAL_CHANGED | Same unique-key rule; emit unequal integer `interval_ms` values. | +| VERSION_CHANGED | Same unique-key rule; emit unequal integer version values. | +| VRID_SET_CHANGED | Per target, both adjacent results successful; unequal sorted distinct VRID integer arrays; null subject. | +| MASTER_TARGET_CHANGED | For one key, both targets must have exactly one record in each of the two successful samples; all four records have disabled=false and invalid=false, and each sample has exactly one MASTER and one BACKUP. Emit only when the observed MASTER target differs. `target_ref` is null, subject is that key, and old/new values are fixed logical target references. Running flags do not imply health or override role. | +| OBSERVATION_GAP | Emit one per target/sample FAILURE (from_sample=to_sample, null subject, from_value=null, to_value=the preserved failure category). For two adjacent successful results, emit one per key missing or duplicated at either end: subject is that key, old/new values are exact record counts. Do not select, collapse or pair duplicate records. | + +Record-specific subjects have exactly `instance_name` and `vrid`. Target refs +are only the accepted Lab1/Lab2 logical refs. Field facts are derived only from +uniquely paired records, even when disabled or invalid; those flags remain +visible in the complete embedded summaries. The nine-kind MVP does not emit +additional flag-change verdicts. Empty SUCCESS records are an observed empty +set, not a transport failure; changed VRID sets or missing-key gaps preserve it. + +Order facts by `(to_sample, from_sample, kind_rank, target_rank, subject_key)`. +Kind rank is the table order; target rank is Lab1, Lab2, then null; null subject +sorts before a key, whose order is Unicode code-point instance name then integer +VRID. Every derivation has one row per applicable target/key; duplicates or +omitted rows reject. First-sample failures produce gaps but no change facts. +An uncollected or invalid sample has no invented target records; termination +records its absence. + +MASTER_TARGET_CHANGED means the unique observed MASTER label changed across +two sequential sample windows. It does not mean both routers had those states +at one instant. Display, for example: "Between samples 3 and 4, the observed +MASTER target changed from Lab1 to Lab2; each sample observed Lab1 first." +Any approximate timing is limited to the enclosing interval from sample 3's +start through sample 4's finish. It is not an exact transition timestamp, +convergence duration, or proof that only one transition occurred. + +HEALTHY, UNHEALTHY, FAILOVER_SUCCESS, FAILOVER_READY and SPLIT_BRAIN are excluded. +With an unavailable target, a surviving target's ROLE_CHANGED can be recorded, +but MASTER_TARGET_CHANGED cannot infer the missing target's role. + +For example, assume the same unique VRRP key throughout the observed successful +results, with other fields unchanged: + +| Sample | Lab1 observation | Lab2 observation | +| --- | --- | --- | +| 1 | MASTER | BACKUP | +| 2 | MASTER | BACKUP | +| Human action between 2 and 3 | Owner manually disconnects Lab1 LAN | No observer-triggered action | +| 3 | Target observation FAILURE | MASTER | +| 4, if later observation is possible within the remaining plan and deadline | BACKUP | MASTER | + +The permitted facts are Lab1 TARGET_RESULT_STATUS_CHANGED from SUCCESS to +FAILURE (2 -> 3), Lab1 OBSERVATION_GAP at 3, Lab2 ROLE_CHANGED from BACKUP to +MASTER (2 -> 3), and Lab1 TARGET_RESULT_STATUS_CHANGED from FAILURE to SUCCESS +(3 -> 4). Emit them in the canonical ordering above. There is no Lab1 +ROLE_CHANGED bridging 2 -> 4 and no MASTER_TARGET_CHANGED across the missing +Lab1 observation. Do not infer Lab1's role at 3, simultaneous state, causation, +or FAILOVER_SUCCESS. If collection ends at 3, retain only those observed facts. + +### Failure, connectivity and manual-action boundaries + +**Observation-level target failure may permit the next scheduled sample; +authorization or session-integrity failure terminates fail-closed.** These are +future observer requirements, not capabilities granted to the current live path. +A valid canonical DL-02 aggregate alone does not establish which failure class +occurred. Preserve its exact accepted failure category and DL-03 summary. + +Observation-level failures include temporary target unavailability, transport +timeout or a target-local runtime failure only when trusted classification +establishes that authorization and session integrity remain valid. Record the +valid sample and derive OBSERVATION_GAP and any other exact permitted facts. +After at least 2000 ms from completion, the next already-preauthorized sample +may proceed only if it exists, has fresh identities for both targets, all +integrity gates remain valid, and the hard whole-session deadline can still be +enforced. There is no new attempt for the failed request or its authorization. + +| Condition | Required outcome | +| --- | --- | +| Positively classified observation-level target failure in a valid canonical sample | Retain evidence and applicable gap facts; permit only the distinct next planned sample while all guards hold. | +| Invalid/malformed session contract or schedule, missing authorization material, cardinality mismatch, prohibited identity collision, or unavailable/invalid trusted configuration found in complete-plan precheck | PRECHECK_REJECTED before DL-02; no authorization consumption or live calls. | +| Authorization expiry, replay, invalid binding, or Owner verification rejection at invocation | SESSION_INTEGRITY_FAILURE; no later scheduled sample, renewal, reuse or replacement authority. | +| Trust/credential/configuration rejection, opaque failure classification, internal accounting failure, or later schedule-integrity failure | SESSION_INTEGRITY_FAILURE; fail closed rather than guess that it is an observation failure. | +| Canonical evidence rejection | Preserve DL-02 CANONICAL_EVIDENCE_VALIDATION_FAILED if returned and canonically summarizable, then terminate for integrity failure; malformed aggregate/summary gives INVALID_SAMPLE without publishing rejected material. | +| Continuing would require inventing, repairing, replacing or dynamically generating authority | Terminate for integrity failure; no authority repair. | +| Hard whole-session deadline exhaustion | Terminate the entire observer, including in-flight activity and cleanup, within the hard bound. An overrun is not a valid bounded timeline. Until enforcement is proven, repeated live execution remains blocked. | + +Current mapping limitation: [S2-RO-11](../../validation_framework/stage2_vrrp_readonly_live_entrypoint.py) +maps TRANSPORT_FAILED and REPLAY_REJECTED, among other gate failures, to +TRUSTED_RUNTIME_FAILED. DL-02 and DL-03 preserve that coarse category. Therefore +neither a canonical summary nor a fresh next envelope proves that an opaque +failure was observation-only. It must remain fatal unless a separately approved +trusted classification mechanism proves the distinction. Do not infer cause +from exception text, private tracebacks or target unreachability assumptions. +No new classifier, public failure field, or accepted schema change is authorized +here. Classification and session-integrity enforcement must be designed, +implemented and negatively tested under a future gate before repeated live +continuation can be ready, in addition to the hard-deadline prerequisite. + +Retain DL-02's existing independent Lab1-then-Lab2 behavior inside a sample: +an eligible Lab2 can still run after Lab1 fails. At the outer sample boundary, +a fatal integrity result prevents every later scheduled sample. The present +API does not promise a new mid-sample integrity-abort hook. Any requirement to +abort the remaining target immediately, and deadline propagation through both +calls and cleanup, needs separately reviewed enforcement; this specification +does not silently alter accepted orchestration. + +For example, sample 4 uses requests R4-L1 / R4-L2 and authorizations A4-L1 / +A4-L2. If R4-L1 has an observation-level failure, R4-L1 is never executed again +and A4-L1 stays spent. Sample 5 was already planned and uses R5-L1 / R5-L2 with +A5-L1 / A5-L2: different run_id, authorization_ref, authorization_id, request +hash and envelope for each target. Its existence and position do not depend +on sample 4 failing. It is a distinct observation, not a retry, replacement +sample, hidden recovery probe or automatic restart. The finite original count +and hard duration still apply. + +```text +CONTINUE_AFTER_CANONICAL_TARGET_OBSERVATION_FAILURE = YES +NEXT_PREAUTHORIZED_SAMPLE_AFTER_OBSERVATION_GAP_ALLOWED = YES +FAILED_REQUEST_RETRIED = NO +FAILED_AUTHORIZATION_REUSED = NO +NEXT_SAMPLE_IS_RETRY = NO +SAME_REQUEST_REEXECUTION_ALLOWED = NO +AUTHORIZATION_REUSE_ALLOWED = NO +CONTINUE_AFTER_AUTHORIZATION_FAILURE = NO +CONTINUE_AFTER_REPLAY = NO +CONTINUE_AFTER_SCHEDULE_INTEGRITY_FAILURE = NO +CONTINUE_AFTER_HARD_DEADLINE = NO +DYNAMIC_AUTHORIZATION_REPAIR = NO +``` + +The two YES values specify the conditional future observation-only policy; +they do not classify every TargetFailure as continuable or grant live readiness. + +Initial connectivity scope is **VRRP_ROLE_STATE_ONLY**. No PC/device ping, +VIP reachability timeline, auxiliary commands or additional transport is added. +Reachability is a separately scoped possible follow-up, not required for the +transition observer. No observed role is presented as a connectivity result. + +`FAILOVER_TRIGGER_OWNED_BY_NAL = NO`, +`FAILOVER_TRIGGER_MODE = MANUAL_OPERATOR_ACTION`, and `NAL_ROLE = OBSERVE_ONLY`. +NAL must never disable/enable interfaces or VRRP, change priority or configuration, +reboot, inject a fault or run arbitrary commands. Human disconnect/reconnect +occurs outside observer control and grants no automatic recovery action. + +### Slice sequence and future validation gates + +| Slice | Bounded purpose | Gate / current status | +| --- | --- | --- | +| DL-06-00 | Day35 review and this canonical readiness/specification | Documentation only; no implementation or live grant. | +| DL-06-01 | Offline outer contract and deterministic derivation tests | Next Owner decision only; inert summaries and synthetic times, zero runtime calls. | +| DL-06-02 | Current-baseline single live Dual-Lab snapshot proof | Separate exact Owner authorization, baseline verification, trusted deployment and fresh one-shot approvals. One Lab1 invocation then one Lab2; no loop, failover or mutation. | +| DL-06-03 | Bounded transition observer with manual operator event | Separate implementation/review/live gates; single-snapshot proof required first; hard deadline enforcement and trusted observation-versus-integrity failure classification must be proven. | +| DL-06-04 | Optional presentation of validated timeline | Not required for observer completeness; any real provider/model use needs separate authority. | + +Day35 and the historical individual Stage-2 proofs do not prove the current +paired Dual-Lab composition live. Therefore +`DL_06_SINGLE_LIVE_SNAPSHOT_PROOF_REQUIRED = YES` and +`TRANSITION_OBSERVER_LIVE_RUN_ALLOWED_BEFORE_SINGLE_PROOF = NO`. +One successful live snapshot proves neither repeated-observation deadlines nor +reusable authority. DL-06-02 must define its own exact single-proof time budget; +this document does not impose a false 30-second guarantee on the existing path. +The repeated-observation blocker does not prevent independently authorized +DL-06-01 offline work or DL-06-02 single-proof work. Neither may claim DL-06-03 +readiness, and neither is implemented or executed by this documentation task. + +DL-06-01 acceptance requires canonical round trips and exact nine-kind fact +recomputation; zero/single/ten samples; every termination branch; empty and +failed targets; duplicate/missing keys; role and MASTER changes in both +directions; no bridge over gaps; stable Unicode ordering; booleans versus +integers; limits; unknown fields and facts; forged/missing/extra facts; +tampered nested objects; sanitized errors; and import/use no-I/O proof. +Fixtures must include a manual-event narrative with a target failure followed +by a later distinct scheduled sample, no inferred state across the gap, and +fatal integrity termination. Offline acceptance does not prove authority or +the cause of opaque failures. No Day35 runner or live-entrypoint call +belongs in these tests. Validate accepted summary compatibility and preserve +all accepted schemas and implementation bytes. + +Future live review must additionally prove whole-plan collision rejection, +missing/expired approvals, replay denial, spent-after-failure behavior, +independent within-sample target ordering, zero subsequent samples after +authorization/session-integrity or ambiguous failures, and continuation only +after positively classified observation failures with distinct next scheduled +identities and deterministic accounting. Prove no request retry, authority +reuse, dynamic repair or failure-driven schedule change; fixed count/delay, +non-overlap, and deadline behavior including stalled acquisition/transport/ +cleanup and no late command. A fake clock can validate +the offline representation; it cannot establish an actual runtime deadline. + +The observer and complete evidence must work without an external AI model. +Deterministic intent/UI or a later facts renderer is distinct from provider +access. No model/provider/voice/MCP integration is included. No second Day35 +implementation, direct Paramiko failover path, device parser, authorization +system or single-snapshot evidence model is created. + +The smallest next decision is +`AUTHORIZE_DL_06_01_OFFLINE_TRANSITION_CONTRACT_IMPLEMENTATION`. +It authorizes nothing until the Owner supplies that separate instruction. +P3 remains OPEN_NON_BLOCKING; seven existing build warnings and all prior +acceptance and Stage-2 closure boundaries remain unchanged. + ## DL-05 reviewed exact-SHA candidate **Accepted: Owner acceptance completed ACCEPT / PASS.** The candidate was From 016ef20f8bfa0c91be4e68195a98037ad03af1de Mon Sep 17 00:00:00 2001 From: RobinLee Date: Tue, 6 Oct 2026 11:49:44 +0800 Subject: [PATCH 2/3] Implement DL-06 offline transition contract --- .../dual_lab/test_dual_lab_vrrp_transition.py | 567 ++++++++++++++++++ .../dual_lab_vrrp_transition.py | 371 ++++++++++++ 2 files changed, 938 insertions(+) create mode 100644 tests/dual_lab/test_dual_lab_vrrp_transition.py create mode 100644 validation_framework/dual_lab_vrrp_transition.py diff --git a/tests/dual_lab/test_dual_lab_vrrp_transition.py b/tests/dual_lab/test_dual_lab_vrrp_transition.py new file mode 100644 index 0000000..0d37b07 --- /dev/null +++ b/tests/dual_lab/test_dual_lab_vrrp_transition.py @@ -0,0 +1,567 @@ +"""DL-06-01 canonical data tests; synthetic summaries, no observation runtime.""" + +import ast +import builtins +from copy import deepcopy +from dataclasses import FrozenInstanceError, fields +import importlib +import inspect +from itertools import product +import json +import socket +import subprocess +import sys +import time + +import pytest + +from validation_framework import dual_lab_vrrp_query_contract as contract +from validation_framework import dual_lab_vrrp_query_summary as summary +from validation_framework import dual_lab_vrrp_transition as m + + +LAB1, LAB2 = "target.mikrotik.lab01", "target.mikrotik.lab02" +SCHEMA = "dual-lab-vrrp-transition.v1" +KINDS = ("TARGET_RESULT_STATUS_CHANGED", "ROLE_CHANGED", "RUNNING_CHANGED", + "PRIORITY_CHANGED", "INTERVAL_CHANGED", "VERSION_CHANGED", + "VRID_SET_CHANGED", "MASTER_TARGET_CHANGED", "OBSERVATION_GAP") +SENTINEL = "synthetic-private-payload" + + +def canonical(raw): + return json.dumps(raw, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def record(**changes): + return {"instance_name": "vrrp-synthetic", "vrid": 88, "role": "BACKUP", + "running": False, "priority": 100, "interval_ms": 1000, "version": 3, + "disabled": False, "invalid": False, **changes} + + +def target(ref, records): + if records is None or type(records) is str: + return {"status": "FAILURE", "target_ref": ref, + "failure_category": records or "TRUSTED_RUNTIME_FAILED"} + return {"status": "SUCCESS", "target_ref": ref, "evidence": { + "schema_version": "1.0", "operation_id": "mikrotik.vrrp_status", + "run_id": "run.synthetic", "target_ref": ref, + "authorization_ref": "authorization.synthetic", + "command_policy_version": "policy.stage2.vrrp-readonly.v1", + "attempt_count": 1, "retry_count": 0, "duration_ms": 1, + "raw_output_byte_count": 0, "raw_output_sha256": "a" * 64, + "records": deepcopy(records)}} + + +def snapshot(index, left=(), right=()): + aggregate = {"schema_version": "dual-lab-vrrp-query.v1", "query_id": f"query.sample{index}", + "operation_id": "mikrotik.vrrp_status", "execution_order": "LAB1_THEN_LAB2", + "lab1": target(LAB1, left), "lab2": target(LAB2, right)} + return summary.project_dual_lab_vrrp_summary(canonical(aggregate)).to_dict() + + +def source(pairs=(((), ()),), *, planned=None, reason="COUNT_REACHED", elapsed=None): + samples = [{"sample_index": i, "elapsed_start_ms": (i - 1) * 2001, + "elapsed_finish_ms": (i - 1) * 2001 + 1, + "summary": snapshot(i, left, right)} + for i, (left, right) in enumerate(pairs, 1)] + return {"schema_version": SCHEMA, "timeline_id": "timeline.synthetic", + "execution_order": "LAB1_THEN_LAB2", "planned_sample_count": planned or len(samples) or 1, + "inter_sample_delay_ms": 2000, "max_duration_ms": 30000, "samples": samples, + "termination": {"reason": reason, "elapsed_ms": elapsed if elapsed is not None + else samples[-1]["elapsed_finish_ms"] if samples else 0}} + + +def project(raw): + return m.project_dual_lab_vrrp_transition(canonical(raw)) + + +def fact(kind, first, last, target_ref, before, after, subject=None): + return {"kind": kind, "from_sample": first, "to_sample": last, "target_ref": target_ref, + "subject": subject, "from_value": before, "to_value": after} + + +def key(name="vrrp-synthetic", vrid=88): + return {"instance_name": name, "vrid": vrid} + + +def reject(function, raw): + with pytest.raises(m.DualLabTransitionError) as caught: + function(raw) + error = caught.value + assert error.args == ("invalid Dual-Lab transition",) + assert error.__cause__ is None and error.__context__ is None + assert SENTINEL not in str(error) + repr(error) + repr(vars(error)) + + +def reject_source(raw): + reject(m.project_dual_lab_vrrp_transition, canonical(raw)) + reject(m.parse_transition_canonical_json, canonical({**raw, "facts": []})) + + +def test_single_identical_and_empty_success_samples_have_no_facts(): + for pairs in ((((), ()),), (([record()], [record()]),), + (([record()], [record()]), ([record()], [record()]))): + raw = source(pairs) + value = project(raw) + assert value.to_dict() == {**raw, "facts": []} + assert value.to_canonical_bytes() == canonical({**raw, "facts": []}) + assert m.parse_transition_canonical_json(value.to_canonical_bytes()).to_dict() == value.to_dict() + assert value.execution_authorized is False + assert {f.name for f in fields(value)} == set(raw) | {"facts"} + + +@pytest.mark.parametrize("field,new,kind", [ + ("role", "MASTER", "ROLE_CHANGED"), ("running", True, "RUNNING_CHANGED"), + ("priority", 150, "PRIORITY_CHANGED"), ("interval_ms", 2000, "INTERVAL_CHANGED"), + ("version", 2, "VERSION_CHANGED")]) +@pytest.mark.parametrize("target_index", [0, 1]) +def test_exact_field_changes_both_targets_and_directions(field, new, kind, target_index): + first, second = [[record()], [record()]], [[record()], [record()]] + second[target_index] = [record(**{field: new})] + for a, b in ((first, second), (second, first)): + raw = project(source((a, b))).to_dict() + assert raw["facts"] == [fact(kind, 1, 2, (LAB1, LAB2)[target_index], + a[target_index][0][field], b[target_index][0][field], key())] + + +def test_vrid_sets_are_sorted_distinct_and_unmatched_records_have_count_gaps(): + left = [record(vrid=9), record(vrid=2), record(vrid=2)] + right = [record(vrid=1), record(vrid=9)] + rows = project(source(((left, []), (right, [])))).to_dict()["facts"] + assert rows == [fact("VRID_SET_CHANGED", 1, 2, LAB1, [2, 9], [1, 9]), + fact("OBSERVATION_GAP", 1, 2, LAB1, 0, 1, key(vrid=1)), + fact("OBSERVATION_GAP", 1, 2, LAB1, 2, 0, key(vrid=2))] + + +def test_manual_transition_gap_then_later_sample_is_facts_only(): + master, backup = [record(role="MASTER")], [record()] + raw = source(((master, backup), (master, backup), (None, master), (backup, master))) + rows = project(raw).to_dict()["facts"] + assert rows == [fact("TARGET_RESULT_STATUS_CHANGED", 2, 3, LAB1, "SUCCESS", "FAILURE"), + fact("ROLE_CHANGED", 2, 3, LAB2, "BACKUP", "MASTER", key()), + fact("OBSERVATION_GAP", 3, 3, LAB1, None, "TRUSTED_RUNTIME_FAILED"), + fact("TARGET_RESULT_STATUS_CHANGED", 3, 4, LAB1, "FAILURE", "SUCCESS")] + assert not any(r["kind"] == "MASTER_TARGET_CHANGED" for r in rows) + assert m.parse_transition_canonical_json(project(raw).to_canonical_bytes()).to_dict()["facts"] == rows + + +@pytest.mark.parametrize("left,right", product((None, []), repeat=2)) +def test_first_and_consecutive_failure_samples_keep_gaps_without_inventing_state(left, right): + rows = project(source(((left, right), (left, right)))).to_dict()["facts"] + assert rows == [fact("OBSERVATION_GAP", i, i, ref, None, "TRUSTED_RUNTIME_FAILED") + for i in (1, 2) for ref, records in ((LAB1, left), (LAB2, right)) if records is None] + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_complete_master_movement_is_conservative_and_bidirectional(reverse): + a = ([record(role="MASTER")], [record()]) + b = ([record()], [record(role="MASTER")]) + if reverse: + a, b = b, a + rows = project(source((a, b))).to_dict()["facts"] + assert rows == [fact("ROLE_CHANGED", 1, 2, LAB1, a[0][0]["role"], b[0][0]["role"], key()), + fact("ROLE_CHANGED", 1, 2, LAB2, a[1][0]["role"], b[1][0]["role"], key()), + fact("MASTER_TARGET_CHANGED", 1, 2, None, + LAB2 if reverse else LAB1, LAB1 if reverse else LAB2, key())] + + +@pytest.mark.parametrize("position", range(4)) +@pytest.mark.parametrize("condition", ["failure", "empty", "duplicate", "disabled", "invalid", + "unknown", "both-master", "both-backup", "different-key"]) +def test_master_movement_never_guessed_with_insufficient_evidence(position, condition): + targets = [[record(role="MASTER")], [record()], [record()], [record(role="MASTER")]] + if condition == "failure": + targets[position] = None + elif condition == "empty": + targets[position] = [] + elif condition == "duplicate": + targets[position] *= 2 + else: + change = {"disabled": {"disabled": True}, "invalid": {"invalid": True}, + "unknown": {"role": "UNKNOWN"}, "both-master": {"role": "MASTER"}, + "both-backup": {"role": "BACKUP"}, "different-key": {"instance_name": "other"}}[condition] + targets[position][0].update(change) + # Ensure the chosen pair loses complementary roles for the pair cases. + if condition in ("both-master", "both-backup"): + targets[position ^ 1][0].update(change) + rows = project(source(((targets[0], targets[1]), (targets[2], targets[3])))).to_dict()["facts"] + assert "MASTER_TARGET_CHANGED" not in [row["kind"] for row in rows] + + +def test_running_is_not_a_master_health_predicate_and_flags_do_not_hide_field_changes(): + pairs = (([record(role="MASTER", running=False)], [record(running=True)]), + ([record(running=True)], [record(role="MASTER", running=False)])) + assert "MASTER_TARGET_CHANGED" in [r["kind"] for r in project(source(pairs)).to_dict()["facts"]] + rows = project(source((([record(disabled=True)], []), + ([record(role="MASTER", disabled=True, invalid=True)], [])))).to_dict()["facts"] + assert rows == [fact("ROLE_CHANGED", 1, 2, LAB1, "BACKUP", "MASTER", key())] + + +@pytest.mark.parametrize("a,b", [(0, 1), (1, 0), (1, 2), (2, 1), (2, 2), (32, 32)]) +def test_missing_duplicate_keys_keep_multiplicity_without_pairing(a, b): + rows = project(source((([record()] * a, []), ([record(role="MASTER")] * b, [])))).to_dict()["facts"] + assert rows[-1] == fact("OBSERVATION_GAP", 1, 2, LAB1, a, b, key()) + assert not any(row["kind"] in ("ROLE_CHANGED", "MASTER_TARGET_CHANGED") for row in rows) + + +def test_order_is_sample_then_kind_then_target_then_unicode_subject(): + names = ("Ω", "é", "a", "Z") + old = [record(instance_name=n) for n in names] + new = [record(instance_name=n, role="MASTER", running=True, priority=200, + interval_ms=2000, version=2) for n in reversed(names)] + value = project(source(((old, old), (new, new), (old, old)))) + rows = value.to_dict()["facts"] + expected = [] + for first, last, before, after in ((1, 2, old[0], new[0]), (2, 3, new[0], old[0])): + for kind, field in (("ROLE_CHANGED", "role"), ("RUNNING_CHANGED", "running"), + ("PRIORITY_CHANGED", "priority"), ("INTERVAL_CHANGED", "interval_ms"), + ("VERSION_CHANGED", "version")): + for ref in (LAB1, LAB2): + expected.extend(fact(kind, first, last, ref, before[field], after[field], key(n)) + for n in sorted(names)) + assert rows == expected + for _ in range(3): + assert project(source(((old[::-1], old), (new, new[::-1]), (old, old)))).to_canonical_bytes() == value.to_canonical_bytes() + + +@pytest.mark.parametrize("reason,planned,elapsed,pairs", [ + ("PRECHECK_REJECTED", 1, 0, ()), ("SESSION_INTEGRITY_FAILURE", 2, 0, ()), + ("INVALID_SAMPLE", 1, 0, ()), ("DURATION_LIMIT", 1, 30000, ()), + ("COUNT_REACHED", 1, 1, ((None, []),)), + ("DURATION_LIMIT", 2, 30000, ((None, []),)), + ("INVALID_SAMPLE", 2, 5, ((None, []),)), + ("SESSION_INTEGRITY_FAILURE", 2, 5, (([], []),)), + ("SESSION_INTEGRITY_FAILURE", 1, 1, (("INTERNAL_FAILURE", []),))]) +def test_all_termination_branches_and_valid_failure_prefixes(reason, planned, elapsed, pairs): + value = project(source(pairs, reason=reason, planned=planned, elapsed=elapsed)) + assert value.termination.reason == reason + assert m.parse_transition_canonical_json(value.to_canonical_bytes()).to_dict() == value.to_dict() + + +@pytest.mark.parametrize("category", [c.value for c in contract.FailureCategory + if c.value != "TRUSTED_RUNTIME_FAILED"]) +def test_known_integrity_categories_require_final_sample_and_fatal_termination(category): + value = project(source(((None, []), (category, [])), reason="SESSION_INTEGRITY_FAILURE")) + assert value.samples[-1].summary.lab1.failure_category == category + reject_source(source(((category, []),), reason="COUNT_REACHED")) + reject_source(source(((category, []), ([], [])), reason="SESSION_INTEGRITY_FAILURE")) + + +@pytest.mark.parametrize("reason,planned,elapsed,count", [ + ("COUNT_REACHED", 2, 1, 1), ("COUNT_REACHED", 1, 0, 0), + ("DURATION_LIMIT", 1, 30000, 1), ("DURATION_LIMIT", 2, 29999, 1), + ("PRECHECK_REJECTED", 2, 1, 1), ("PRECHECK_REJECTED", 1, 1, 0), + ("INVALID_SAMPLE", 1, 1, 1), ("SESSION_INTEGRITY_FAILURE", 1, 0, 1), + ("TARGET_FAILURE", 1, 1, 1), (True, 1, 1, 1)]) +def test_invalid_termination_combinations(reason, planned, elapsed, count): + reject_source(source(tuple(([], []) for _ in range(count)), planned=planned, reason=reason, elapsed=elapsed)) + + +def test_maximum_samples_exact_deadline_and_reference_length(): + raw = source(tuple(([], []) for _ in range(10))) + raw["samples"][-1]["elapsed_finish_ms"] = 30000 + raw["termination"]["elapsed_ms"] = 30000 + raw["timeline_id"] = "timeline." + "a" * 151 + value = project(raw) + assert len(value.samples) == 10 and value.samples[-1].elapsed_finish_ms == 30000 + assert value.to_dict() == {**raw, "facts": []} + raw["samples"][-1]["elapsed_finish_ms"] = 30001 + reject_source(raw) + reject_source(source(tuple(([], []) for _ in range(11)))) + + +@pytest.mark.parametrize("field,bad", [ + ("schema_version", "dual-lab-vrrp-transition.v2"), ("schema_version", None), + ("timeline_id", "query.synthetic"), ("timeline_id", "timeline."), + ("timeline_id", "timeline.A"), ("timeline_id", "timeline.a..b"), + ("timeline_id", "timeline.a\n"), ("timeline_id", "timeline.é"), + ("timeline_id", "timeline." + "a" * 152), ("timeline_id", True), + ("execution_order", "LAB2_THEN_LAB1"), ("planned_sample_count", 0), + ("planned_sample_count", 11), ("planned_sample_count", True), + ("planned_sample_count", 1.0), ("inter_sample_delay_ms", 1999), + ("inter_sample_delay_ms", 2001), ("inter_sample_delay_ms", True), + ("max_duration_ms", 30001), ("max_duration_ms", False), ("samples", {}), + ("termination", None)]) +def test_exact_top_level_domains(field, bad): + raw = source() + raw[field] = bad + reject_source(raw) + + +@pytest.mark.parametrize("where,field,bad", [ + (0, "sample_index", 0), (0, "sample_index", True), (1, "sample_index", 1), + (1, "sample_index", 3), (0, "elapsed_start_ms", -1), (0, "elapsed_start_ms", 1), + (0, "elapsed_start_ms", False), (0, "elapsed_finish_ms", -1), + (0, "elapsed_finish_ms", True), (0, "elapsed_finish_ms", 1.0), + (1, "elapsed_start_ms", 0), (1, "elapsed_start_ms", 2000), + (1, "elapsed_start_ms", 2003), (1, "elapsed_finish_ms", 30001), + (1, "summary", []), (1, "summary", None)]) +def test_indices_windows_and_sample_domains(where, field, bad): + raw = source((([], []), ([], []))) + raw["samples"][where][field] = bad + reject_source(raw) + + +def test_reordering_and_repeated_query_identity_reject_without_repair(): + raw = source((([], []), ([], []))) + raw["samples"].reverse() + reject_source(raw) + raw = source((([], []), ([], []))) + raw["samples"][1]["summary"]["query_id"] = raw["samples"][0]["summary"]["query_id"] + reject_source(raw) + + +@pytest.mark.parametrize("bad", [-1, True, 1.0, "1", 30001, None]) +def test_termination_elapsed_exact_integer_domain(bad): + raw = source() + raw["termination"]["elapsed_ms"] = bad + reject_source(raw) + + +def rich_source(): + return source((([record()], []), ([record(role="MASTER", running=True, vrid=88), record(vrid=99)], []))) + + +@pytest.mark.parametrize("where", ["top", "sample", "termination", "summary", "target", "record", "fact", "subject"]) +def test_every_nested_field_is_required_and_unknown_fields_rejected(where): + raw = project(rich_source()).to_dict() + row = next(row for row in raw["facts"] if row["kind"] == "ROLE_CHANGED") + container = {"top": raw, "sample": raw["samples"][0], "termination": raw["termination"], + "summary": raw["samples"][0]["summary"], "target": raw["samples"][0]["summary"]["lab1"], + "record": raw["samples"][0]["summary"]["lab1"]["records"][0], + "fact": row, "subject": row["subject"]}[where] + for name in list(container): + saved = container.pop(name) + reject(m.parse_transition_canonical_json, canonical(raw)) + container[name] = saved + for name in ("extra", "authorization_ref", "raw_stdout", "execution_authorized", "retry"): + container[name] = SENTINEL + reject(m.parse_transition_canonical_json, canonical(raw)) + del container[name] + + +@pytest.mark.parametrize("change", ["schema", "record", "forged-observation", "unsorted", "record-limit"]) +def test_embedded_summary_validation_is_reused(change): + raw = source((([record(instance_name="a"), record(instance_name="b")], []),)) + embedded = raw["samples"][0]["summary"] + if change == "schema": + embedded["schema_version"] = SCHEMA + elif change == "record": + embedded["lab1"]["records"][0]["priority"] = True + elif change == "forged-observation": + embedded["cross_target_observations"][0]["lab1_value"] = False + elif change == "unsorted": + embedded["lab1"]["records"].reverse() + else: + embedded["lab1"]["records"] *= 17 + reject_source(raw) + + +@pytest.mark.parametrize("change", ["missing", "extra", "reverse", "value", "bool-alias", "subject", + "index-alias", "target", "unknown", "fact-limit", "fact-type"]) +def test_facts_are_recomputed_never_trusted(change): + raw = project(rich_source()).to_dict() + rows = raw["facts"] + if change == "missing": + rows.pop() + elif change == "extra": + rows.append(deepcopy(rows[0])) + elif change == "reverse": + rows.reverse() + elif change == "value": + rows[0]["to_value"] = "BACKUP" + elif change == "bool-alias": + next(row for row in rows if row["kind"] == "RUNNING_CHANGED")["to_value"] = 1 + elif change == "subject": + rows[0]["subject"]["vrid"] = 99 + elif change == "index-alias": + rows[0]["from_sample"] = True + elif change == "target": + rows[0]["target_ref"] = LAB2 + elif change == "unknown": + rows[0]["kind"] = "NEW_FACT" + elif change == "fact-limit": + raw["facts"] = [rows[0]] * 4097 + else: + raw["facts"] = {} + reject(m.parse_transition_canonical_json, canonical(raw)) + + +@pytest.mark.parametrize("kind", ["HEALTHY", "UNHEALTHY", "FAILOVER_SUCCESS", "FAILOVER_READY", + "SPLIT_BRAIN", "RECOVERY_SUCCESS", "EXPECTED", "UNEXPECTED", + "RECOMMENDED_ACTION"]) +def test_interpretive_verdicts_are_not_fact_kinds(kind): + raw = project(rich_source()).to_dict() + raw["facts"][0]["kind"] = kind + reject(m.parse_transition_canonical_json, canonical(raw)) + # Names remain opaque observed data, never instructions or verdicts. + assert project(source((([record(instance_name=kind)], []),))).facts == () + + +@pytest.mark.parametrize("parser", ["project_dual_lab_vrrp_transition", "parse_transition_canonical_json"]) +def test_strict_bytes_types_framing_and_size(parser): + function = getattr(m, parser) + valid = canonical(source()) if parser.startswith("project") else project(source()).to_canonical_bytes() + class BytesSubclass(bytes): + pass + class Hostile: + def __bytes__(self): + pytest.fail("must not coerce inputs") + invalid = (None, {}, [], True, 1, valid.decode(), bytearray(valid), memoryview(valid), + BytesSubclass(valid), Hostile(), b"", b"\xff", b"\xef\xbb\xbf" + valid, + b" " + valid, valid + b"\n", b"null", b"[]", b"{}", b'{"x":NaN}', + b'{"x":Infinity}', b'{"x":-Infinity}', b"x" * 4_194_305, + b"[" * 2000 + b"0" + b"]" * 2000) + for raw in invalid: + reject(function, raw) + reject(function, json.dumps(json.loads(valid), indent=2).encode()) + reject(function, canonical(json.loads(valid)).replace(b'"schema_version"', b'"schema_versio\\u006e"', 1)) + + +@pytest.mark.parametrize("needle", [b'"schema_version":"dual-lab-vrrp-transition.v1"', + b'"sample_index":1', b'"reason":"COUNT_REACHED"', b'"query_id":"query.sample1"', + b'"target_ref":"target.mikrotik.lab01"', b'"priority":100', + b'"kind":"ROLE_CHANGED"', b'"instance_name":"vrrp-synthetic"']) +def test_duplicate_keys_rejected_at_all_depths(needle): + raw = project(rich_source()).to_canonical_bytes() + assert needle in raw + reject(m.parse_transition_canonical_json, raw.replace(needle, needle + b"," + needle, 1)) + + +def test_immutable_values_fresh_exports_and_bounded_repr(): + raw = rich_source() + original = deepcopy(raw) + value = project(raw) + expected = value.to_canonical_bytes() + assert raw == original + objects = [value, value.samples[0], value.termination, *value.facts, + *[row.subject for row in value.facts if row.subject is not None]] + for obj in objects: + assert obj.execution_authorized is False + assert not hasattr(obj, "__dict__") + assert repr(obj) == str(obj) == "" + with pytest.raises(FrozenInstanceError): + setattr(obj, fields(obj)[0].name, None) + assert value.samples[0].summary.execution_authorized is False + assert type(value.samples) is type(value.facts) is tuple + assert type(next(row for row in value.facts if row.kind == "VRID_SET_CHANGED").to_value) is tuple + raw["samples"].clear() + exported = value.to_dict() + exported["samples"][0]["summary"]["lab1"]["records"].clear() + exported["facts"][0]["subject"]["vrid"] = 1 + assert value.to_canonical_bytes() == expected + + +@pytest.mark.parametrize("where,field,bad", [ + ("top", "timeline_id", "bad"), ("top", "samples", []), ("top", "facts", ()), + ("sample", "sample_index", True), ("sample", "elapsed_finish_ms", 30001), + ("sample", "summary", {}), ("summary", "query_id", "bad"), + ("record", "priority", True), ("termination", "elapsed_ms", 0), + ("fact", "to_value", "wrong"), ("subject", "vrid", True)]) +def test_serializers_revalidate_exact_type_nested_tampering(where, field, bad): + value = project(rich_source()) + obj = {"top": value, "sample": value.samples[0], "summary": value.samples[0].summary, + "record": value.samples[0].summary.lab1.records[0], "termination": value.termination, + "fact": value.facts[0], "subject": value.facts[0].subject}[where] + object.__setattr__(obj, field, bad) + reject(lambda _: value.to_dict(), None) + reject(lambda _: value.to_canonical_bytes(), None) + assert value.execution_authorized is False + + +def test_factory_only_uninitialized_and_subclass_values_do_not_invoke_callbacks(): + calls = [] + class Forged(m.DualLabVrrpTransition): + def to_dict(self): + calls.append(True) + return {} + for value in (object.__new__(Forged), object.__new__(m.DualLabVrrpTransition)): + reject(lambda _: m.DualLabVrrpTransition.to_dict(value), None) + reject(lambda _: m.DualLabVrrpTransition.to_canonical_bytes(value), None) + reject(lambda _: m.DualLabVrrpTransition(), None) + class ForgedSummary(summary.DualLabVrrpSummary): + def to_dict(self): + calls.append(True) + return {} + value = project(source()) + object.__setattr__(value.samples[0], "summary", object.__new__(ForgedSummary)) + reject(lambda _: value.to_dict(), None) + assert calls == [] + + +def test_large_bounded_record_sets_round_trip_and_all_nine_kinds_exist(): + # Worst-case field churn is finite; the limit is contractual, not a quota. + a = [record(instance_name="é" * 64, vrid=i + 1) for i in range(32)] + b = [dict(r, role="MASTER", running=True, priority=200, interval_ms=2000, version=2) for r in a] + raw = source(tuple((a, b) if i % 2 == 0 else (b, a) for i in range(10))) + value = project(raw) + assert len(value.samples) == 10 and len(value.facts) == 3168 + assert len(value.to_canonical_bytes()) <= 4_194_304 + assert m.parse_transition_canonical_json(value.to_canonical_bytes()).to_dict() == value.to_dict() + cases = [raw, source(((a, []), ([], []))), source(((None, []), ([], [])))] + observed = {row["kind"] for case in cases for row in project(case).to_dict()["facts"]} + assert observed == set(KINDS) + + +def test_public_errors_discard_child_and_outer_context(monkeypatch, capsys): + try: + raise RuntimeError(SENTINEL) + except RuntimeError: + reject(m.parse_transition_canonical_json, b"bad") + raw = canonical(source()) + def broken(*args, **kwargs): + raise RuntimeError(SENTINEL) + monkeypatch.setattr(summary, "parse_summary_canonical_json", broken) + reject(m.project_dual_lab_vrrp_transition, raw) + assert capsys.readouterr() == ("", "") + + +def test_static_surface_and_import_projection_parsing_have_no_io(monkeypatch): + public = {"DualLabVrrpTransition", "DualLabTransitionError", + "project_dual_lab_vrrp_transition", "parse_transition_canonical_json"} + assert set(m.__all__) == public + assert {name for name in vars(m) if not name.startswith("_")} == public + tree = ast.parse(inspect.getsource(m)) + for node in ast.walk(tree): + assert not isinstance(node, (ast.AsyncFunctionDef, ast.Await, ast.Yield, ast.YieldFrom, ast.While)) + if isinstance(node, ast.Import): + assert all(alias.name in {"json", "re"} for alias in node.names) + if isinstance(node, ast.ImportFrom): + assert node.module in {"dataclasses", "functools", "validation_framework"} + if node.module == "validation_framework": + assert [alias.name for alias in node.names] == ["dual_lab_vrrp_query_summary"] + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + assert node.func.id not in {"open", "exec", "eval", "__import__", "print", "input"} + raw = canonical(rich_source()) + expected = project(rich_source()).to_canonical_bytes() + calls = [] + def forbidden(*args, **kwargs): + calls.append(True) + raise AssertionError("forbidden side effect") + with monkeypatch.context() as guard: + guard.setattr(builtins, "open", forbidden) + guard.setattr(socket, "socket", forbidden) + guard.setattr(socket, "create_connection", forbidden) + guard.setattr(socket, "getaddrinfo", forbidden) + guard.setattr(subprocess, "Popen", forbidden) + guard.setattr(time, "sleep", forbidden) + before = set(sys.modules) + importlib.reload(m) + assert m.project_dual_lab_vrrp_transition(raw).to_canonical_bytes() == expected + assert m.parse_transition_canonical_json(expected).to_canonical_bytes() == expected + reject(m.project_dual_lab_vrrp_transition, b"bad") + reject(m.parse_transition_canonical_json, b"bad") + added = set(sys.modules) - before + assert calls == [] + assert not any(any(part in name for part in ("orchestrator", "live_entrypoint", "paramiko", + "openai", "anthropic", "requests", "httpx", "network_lab")) for name in added) + + +def test_transition_parsing_never_uses_aggregate_or_summary_projection(monkeypatch): + raw = project(source()).to_canonical_bytes() + def forbidden(*args, **kwargs): + pytest.fail("must reuse only the summary parser") + monkeypatch.setattr(summary, "project_dual_lab_vrrp_summary", forbidden) + monkeypatch.setattr(contract, "parse_aggregate_canonical_json", forbidden) + assert m.parse_transition_canonical_json(raw).to_canonical_bytes() == raw diff --git a/validation_framework/dual_lab_vrrp_transition.py b/validation_framework/dual_lab_vrrp_transition.py new file mode 100644 index 0000000..68c140a --- /dev/null +++ b/validation_framework/dual_lab_vrrp_transition.py @@ -0,0 +1,371 @@ +"""DL-06-01 inert transition facts over sequential DL-03 observation windows. + +Elapsed metadata is caller-supplied data, not a runtime deadline guarantee. +An offline gap followed by a sample neither classifies a live failure nor +authorizes continuation. No clock, collection, authority or execution lives here. +""" + +from dataclasses import dataclass as _dataclass, fields as _members +from functools import wraps as _wraps +import json as _json +import re as _re + +from validation_framework import dual_lab_vrrp_query_summary as _summary + + +_SCHEMA = "dual-lab-vrrp-transition.v1" +_TOP = frozenset(("schema_version", "timeline_id", "execution_order", + "planned_sample_count", "inter_sample_delay_ms", "max_duration_ms", + "samples", "termination", "facts")) +_SAMPLE = frozenset(("sample_index", "elapsed_start_ms", "elapsed_finish_ms", "summary")) +_REFERENCE = _re.compile(r"timeline\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*") +_MAX_BYTES = 4_194_304 +_MAX_FACTS = 4096 +_TARGETS = ("target.mikrotik.lab01", "target.mikrotik.lab02") +_KINDS = ("TARGET_RESULT_STATUS_CHANGED", "ROLE_CHANGED", "RUNNING_CHANGED", + "PRIORITY_CHANGED", "INTERVAL_CHANGED", "VERSION_CHANGED", + "VRID_SET_CHANGED", "MASTER_TARGET_CHANGED", "OBSERVATION_GAP") +_CHANGES = (("role", "ROLE_CHANGED"), ("running", "RUNNING_CHANGED"), + ("priority", "PRIORITY_CHANGED"), ("interval_ms", "INTERVAL_CHANGED"), + ("version", "VERSION_CHANGED")) +_REASONS = ("COUNT_REACHED", "SESSION_INTEGRITY_FAILURE", "DURATION_LIMIT", + "PRECHECK_REJECTED", "INVALID_SAMPLE") + + +class DualLabTransitionError(ValueError): + """Fixed rejection; no rejected payload or upstream exception is exposed.""" + + def __init__(self): + super().__init__("invalid Dual-Lab transition") + + +def _reject(): + try: + raise DualLabTransitionError() from None + except DualLabTransitionError as error: + error.__context__ = None + raise + + +def _checked(function): + @_wraps(function) + def checked(*args, **kwargs): + try: + return function(*args, **kwargs) + except Exception: + pass + args = kwargs = None + _reject() + return checked + + +class _Inert: + __slots__ = () + + def __repr__(self): + return "" + + __str__ = __repr__ + + @property + def execution_authorized(self): + return False + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Sample(_Inert): + sample_index: int + elapsed_start_ms: int + elapsed_finish_ms: int + summary: _summary.DualLabVrrpSummary + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Termination(_Inert): + reason: str + elapsed_ms: int + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Subject(_Inert): + instance_name: str + vrid: int + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Fact(_Inert): + kind: str + from_sample: int + to_sample: int + target_ref: str | None + subject: _Subject | None + from_value: str | int | bool | tuple | None + to_value: str | int | bool | tuple | None + + +@_dataclass(frozen=True, slots=True, repr=False, init=False) +class DualLabVrrpTransition(_Inert): + """Factory-created immutable timeline; every export revalidates all data.""" + + schema_version: str + timeline_id: str + execution_order: str + planned_sample_count: int + inter_sample_delay_ms: int + max_duration_ms: int + samples: tuple + termination: _Termination + facts: tuple + + def __init__(self, *args, **kwargs): + _reject() + + @_checked + def to_dict(self) -> dict[str, object]: + if type(self) is not DualLabVrrpTransition: + _reject() + raw = _plain(self) + _validate(raw, with_facts=True) + _encode(raw) + return raw + + @_checked + def to_canonical_bytes(self) -> bytes: + return _encode(DualLabVrrpTransition.to_dict(self)) + + +_VALUES = (DualLabVrrpTransition, _Sample, _Termination, _Subject, _Fact) + + +def _plain(value): + if value is None or type(value) in (str, int, bool): + return value + if type(value) is tuple: + return [_plain(item) for item in value] + if type(value) is _summary.DualLabVrrpSummary: + return _summary.DualLabVrrpSummary.to_dict(value) + if type(value) in _VALUES: + return {field.name: _plain(getattr(value, field.name)) for field in _members(value)} + _reject() + + +def _fields(raw, expected): + if type(raw) is not dict or raw.keys() != set(expected): + _reject() + + +def _integer(value, minimum, maximum): + if type(value) is not int or not minimum <= value <= maximum: + _reject() + + +def _validate(raw, *, with_facts): + _fields(raw, _TOP if with_facts else _TOP - {"facts"}) + for field, expected in (("schema_version", _SCHEMA), ("execution_order", "LAB1_THEN_LAB2")): + if type(raw[field]) is not str or raw[field] != expected: + _reject() + identity = raw["timeline_id"] + if type(identity) is not str or not 1 <= len(identity) <= 160 or not _REFERENCE.fullmatch(identity): + _reject() + _integer(raw["planned_sample_count"], 1, 10) + _integer(raw["inter_sample_delay_ms"], 2000, 2000) + _integer(raw["max_duration_ms"], 30000, 30000) + samples = raw["samples"] + if type(samples) is not list or len(samples) > raw["planned_sample_count"]: + _reject() + termination = raw["termination"] + _fields(termination, ("reason", "elapsed_ms")) + reason = termination["reason"] + if type(reason) is not str or reason not in _REASONS: + _reject() + _integer(termination["elapsed_ms"], 0, 30000) + summaries, identities = [], set() + previous_finish = 0 + for index, sample in enumerate(samples, 1): + _fields(sample, _SAMPLE) + _integer(sample["sample_index"], index, index) + start, finish = sample["elapsed_start_ms"], sample["elapsed_finish_ms"] + _integer(start, 0, 30000) + _integer(finish, start, 30000) + if (index == 1 and start != 0) or (index > 1 and start < previous_finish + 2000): + _reject() + previous_finish = finish + summary = _summary.parse_summary_canonical_json(_encode(sample["summary"])) + if summary.query_id in identities: + _reject() + identities.add(summary.query_id) + summaries.append(summary) + for target in (summary.lab1, summary.lab2): + if target.status == "FAILURE" and target.failure_category != "TRUSTED_RUNTIME_FAILED": + if index != len(samples) or reason != "SESSION_INTEGRITY_FAILURE": + _reject() + if termination["elapsed_ms"] < previous_finish: + _reject() + count, planned = len(samples), raw["planned_sample_count"] + if reason == "COUNT_REACHED" and count != planned: + _reject() + if reason == "DURATION_LIMIT" and (count >= planned or termination["elapsed_ms"] != 30000): + _reject() + if reason == "PRECHECK_REJECTED" and (count or termination["elapsed_ms"] != 0): + _reject() + if reason == "INVALID_SAMPLE" and count >= planned: + _reject() + derived = _derive(samples) + if with_facts: + facts = raw["facts"] + if type(facts) is not list or len(facts) > _MAX_FACTS: + _reject() + # Exact canonical bytes distinguish bool/int and close every nested + # fact field, kind, reference, subject and value domain by recomputation. + if _encode(facts) != _encode(derived): + _reject() + return summaries, derived + + +def _groups(target): + groups = {} + for record in target["records"]: + groups.setdefault((record["instance_name"], record["vrid"]), []).append(record) + return groups + + +def _fact(kind, first, last, target, before, after, key=None): + return {"kind": kind, "from_sample": first, "to_sample": last, "target_ref": target, + "subject": None if key is None else {"instance_name": key[0], "vrid": key[1]}, + "from_value": before, "to_value": after} + + +def _order(row): + subject = row["subject"] + key = (0, "", 0) if subject is None else (1, subject["instance_name"], subject["vrid"]) + return (row["to_sample"], row["from_sample"], _KINDS.index(row["kind"]), + (*_TARGETS, None).index(row["target_ref"]), key) + + +def _derive(samples): + rows = [] + for index, sample in enumerate(samples, 1): + current = sample["summary"] + for name, target_ref in zip(("lab1", "lab2"), _TARGETS): + target = current[name] + if target["status"] == "FAILURE": + rows.append(_fact("OBSERVATION_GAP", index, index, target_ref, + None, target["failure_category"])) + if index == 1: + continue + before = samples[index - 2]["summary"][name] + if before["status"] != target["status"]: + rows.append(_fact("TARGET_RESULT_STATUS_CHANGED", index - 1, index, + target_ref, before["status"], target["status"])) + if before["status"] != "SUCCESS" or target["status"] != "SUCCESS": + continue + left, right = _groups(before), _groups(target) + old_vrids = sorted({key[1] for key in left}) + new_vrids = sorted({key[1] for key in right}) + if old_vrids != new_vrids: + rows.append(_fact("VRID_SET_CHANGED", index - 1, index, + target_ref, old_vrids, new_vrids)) + for key in sorted(left.keys() | right.keys()): + a, b = left.get(key, []), right.get(key, []) + if len(a) != 1 or len(b) != 1: + rows.append(_fact("OBSERVATION_GAP", index - 1, index, + target_ref, len(a), len(b), key)) + continue + for field, kind in _CHANGES: + if a[0][field] != b[0][field]: + rows.append(_fact(kind, index - 1, index, target_ref, + a[0][field], b[0][field], key)) + if index > 1: + rows.extend(_master_changes(samples[index - 2]["summary"], current, index)) + if len(rows) > _MAX_FACTS: + _reject() + return sorted(rows, key=_order) + + +def _master_changes(before, after, index): + targets = (before["lab1"], before["lab2"], after["lab1"], after["lab2"]) + if any(target["status"] != "SUCCESS" for target in targets): + return [] + groups = [_groups(target) for target in targets] + common = set(groups[0]).intersection(*(set(group) for group in groups[1:])) + rows = [] + for key in sorted(common): + if any(len(group[key]) != 1 for group in groups): + continue + records = [group[key][0] for group in groups] + if any(record["disabled"] or record["invalid"] for record in records): + continue + old, new = [record["role"] for record in records[:2]], [record["role"] for record in records[2:]] + if sorted(old) != ["BACKUP", "MASTER"] or sorted(new) != ["BACKUP", "MASTER"]: + continue + if old != new: + rows.append(_fact("MASTER_TARGET_CHANGED", index - 1, index, None, + _TARGETS[old.index("MASTER")], _TARGETS[new.index("MASTER")], key)) + return rows + + +def _build(raw, *, with_facts): + summaries, facts = _validate(raw, with_facts=with_facts) + complete = {**raw, "facts": facts} + _encode(complete) + value = object.__new__(DualLabVrrpTransition) + for name in _TOP - {"samples", "termination", "facts"}: + object.__setattr__(value, name, raw[name]) + object.__setattr__(value, "samples", tuple( + _Sample(sample["sample_index"], sample["elapsed_start_ms"], sample["elapsed_finish_ms"], summary) + for sample, summary in zip(raw["samples"], summaries))) + object.__setattr__(value, "termination", _Termination(**raw["termination"])) + object.__setattr__(value, "facts", tuple(_Fact( + row["kind"], row["from_sample"], row["to_sample"], row["target_ref"], + None if row["subject"] is None else _Subject(**row["subject"]), + tuple(row["from_value"]) if type(row["from_value"]) is list else row["from_value"], + tuple(row["to_value"]) if type(row["to_value"]) is list else row["to_value"]) + for row in facts)) + return value + + +def _encode(raw): + encoded = _json.dumps(raw, allow_nan=False, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8", errors="strict") + if not 1 <= len(encoded) <= _MAX_BYTES: + _reject() + return encoded + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + _reject() + result[key] = value + return result + + +def _decode(raw): + if type(raw) is not bytes or not 1 <= len(raw) <= _MAX_BYTES or raw.startswith(b"\xef\xbb\xbf"): + _reject() + parsed = _json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=_unique, + parse_constant=lambda _: _reject()) + if _encode(parsed) != raw: + _reject() + return parsed + + +@_checked +def project_dual_lab_vrrp_transition(raw: bytes) -> DualLabVrrpTransition: + """Derive facts from canonical outer fields excluding `facts`. + + Input embeds unchanged DL-03 summary objects in the specified samples; + each is independently revalidated. No authority or live provenance is inferred. + """ + return _build(_decode(raw), with_facts=False) + + +@_checked +def parse_transition_canonical_json(raw: bytes) -> DualLabVrrpTransition: + """Validate the complete canonical timeline and recompute all nine fact kinds.""" + return _build(_decode(raw), with_facts=True) + + +__all__ = ("DualLabVrrpTransition", "DualLabTransitionError", + "project_dual_lab_vrrp_transition", "parse_transition_canonical_json") From 9ff347a586b16a42e78eed6df5873e89ed872543 Mon Sep 17 00:00:00 2001 From: RobinLee Date: Tue, 6 Oct 2026 16:51:14 +0800 Subject: [PATCH 3/3] Record DL-06-01 acceptance status --- README.md | 77 +++++-- .../actual_automation_integration_plan.md | 73 ++++-- .../dual_lab_vrrp_ai_query_mvp.md | 215 ++++++++++++++++-- 3 files changed, 307 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 0b80a09..99dbcbd 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,8 @@ release lanes; they are not performed by documentation-only changes. | Stage-0 Network Automation Lab | **CLOSED** at `main@aff250735ade18e4c274be8ac53c9672bb2cb07f` | | Stage-1 Read-only Lab Integration Planning | Historical planning-entry record; see the Stage-2 closure candidate | | Stage-2 bounded MikroTik VRRP validation | Lab1 historical proof retained; Lab2 proof PASS/CLOSED on repository baseline `ea73196281e38a01af7bf959cc5e1bc60b0b2499`; persistent Lab2 startup reconstruction and independent binding review PASS; overall Stage 2 is a **CLOSURE CANDIDATE** | -| Dual-Lab AI Query | **DL-02, DL-03, DL-04, and DL-05 accepted; bounded offline MVP complete**; PR #93 merged; pre/post-merge Safe CI PASS, fresh exact-SHA review PASS, Owner acceptance ACCEPT / PASS; 0 unresolved material findings; P3 open/non-blocking; 7 maintenance build warnings retained; no live authority; DL-06 contract not established | +| Dual-Lab AI Query | **DL-02, DL-03, DL-04, and DL-05 accepted; bounded offline MVP complete**; PR #93 merged; pre/post-merge Safe CI PASS, fresh exact-SHA review PASS, Owner acceptance ACCEPT / PASS; 0 unresolved material findings; P3 open/non-blocking; 7 maintenance build warnings retained; no live authority | +| DL-06 offline transition contract | **DL-06-00 specification established; DL-06-01 Owner accepted / PASS, local and NOT_PUBLISHED**; 0 unresolved material findings; 3 retained non-blocking findings; DL-06-02 NOT_AUTHORIZED; DL-06-03 BLOCKED; no live authority | | Stage 3 | **NOT STARTED / requires separate Owner authorization** | | Canonical reviewer interface | Flask dashboard on `127.0.0.1:5000` | | Secondary Next.js interface | Available as a bounded Stage-0 evidence surface | @@ -547,25 +548,57 @@ remain maintenance items. `LIVE_AUTHORITY_GRANTED = NO` and `LIVE_READINESS_GRANTED = NO`; configuration mutation, provider/model integration, and production readiness remain unauthorized. `DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED`. The [DL-06-00 readiness specification](docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md#dl-06-00-transition-observer-readiness-and-specification) -is established for an offline transition contract; no implementation or live -authority is granted. Day35 remains the proven historical manual live failover -predecessor. DL-06 composes accepted Stage-2, DL-02 and DL-03 using a -PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE with no authorization reuse. A valid -sample with a positively classified observation-level target failure may lead -to the next distinct preauthorized sample; authorization/session-integrity or -ambiguous failures terminate. The current coarse failure mapping does not yet -prove that distinction. No retry, fallback, parallel execution, automatic -failover or configuration mutation is permitted. +is established at `d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b`. +**DL-06-01 implementation `016ef20f8bfa0c91be4e68195a98037ad03af1de` is +Owner accepted / PASS for `OFFLINE_TRANSITION_CONTRACT_ONLY`.** Fresh governed +review passed; the separate Owner decision passed all seven predicates. +The [canonical acceptance record](docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md#dl-06-01-accepted-offline-transition-contract) +retains exact hashes, evidence provenance and the three open findings. + +The accepted `dual-lab-vrrp-transition.v1` schema has exactly nine fact kinds. +It preserves finite ordered evidence, deterministic derivation and Lab1-then-Lab2 +sequential observation windows. Missing roles are never guessed, and +`MASTER_TARGET_CHANGED` is not inferred through insufficient evidence or gaps. +Day35 remains the historical operator-triggered real-device failover proof using +direct commands/Paramiko. DL-06-01 has no live transport or Day35 runtime. + +**Accepted fresh governed review evidence:** 198 focused, 850 Dual-Lab, +1,892 Stage-2 and 4,870 full pytest tests passed, with zero skipped, failed or +errors in the full run. Accepted report-index evidence is WARN for 13 optional +missing reports, with zero failures, mandatory missing or unknown results. +These application tests were not rerun during documentation reconciliation. + +There are zero unresolved material findings and three retained non-blocking +findings: 12 npm-reported vulnerabilities (`OPEN_NON_BLOCKING_DEPENDENCY_MAINTENANCE`), +four redundant parametrizations (`OPEN_NON_BLOCKING_TEST_MAINTENANCE`), and an +oversize test that also uses invalid JSON (`OPEN_NON_BLOCKING_TEST_PRECISION`). +The fresh review separately proved rejection before decoding for both parsers. +The candidate changed neither package manifest nor lockfile; this does not +declare dependency vulnerabilities harmless generally. No finding is repaired here. + +The accepted implementation remains local: `DL_06_01_PUBLICATION_STATUS = NOT_PUBLISHED` and +`REMOTE_MAIN_CONTAINS_DL_06_01 = NO`, verified against authoritative remote +`main@5431f7491015fe7ab2ae9227bc6a48814356641e`. This documentation descendant +does not replace the accepted implementation or inherit its test/review result. +The next separately required Owner decision is +`AUTHORIZE_DL_06_01_PUBLICATION_READINESS_REVIEW`: review the documentation and +exact branch scope before proposing publication. It grants no push, PR or merge. The 30-second limit requires hard whole-observer completion, including in-flight work and cleanup. Shared deadline propagation/enforcement is NOT_ESTABLISHED; DL-06-03 repeated live observation remains BLOCKED until separately authorized enforcement is implemented and proven, with trusted failure classification also -required. DL-06-01 offline transition-contract implementation is the next Owner -decision. The deadline blocker does not prevent that offline work or a separately -authorized DL-06-02 single live Dual-Lab snapshot proof. That current-baseline -single proof is required before repeated observation and cannot claim DL-06-03 -readiness. Historical Stage-2 proof and closure semantics remain unchanged. +required. `HARD_WHOLE_OBSERVER_COMPLETION_LIMIT_SECONDS = 30`, +`LIVE_HARD_DEADLINE_ENFORCEMENT_IMPLEMENTED = NO`, and +`HARD_SESSION_DEADLINE_RUNTIME_PROVEN = NO`; this is not an admission-window-only +limit. `DL_06_02_AUTHORIZED = NO` and `DL_06_03_REPEATED_LIVE_OBSERVER_READY = NO`. +The deadline blocker applies to repeated live observation; a single snapshot +still needs separate exact authority and its own time budget. No live authority, +readiness, automatic failover, configuration mutation or provider/model authority +is granted. DL-06-01 implements no runtime loop, authorization schedule or retry. +The future preauthorized schedule and trusted failure-classification requirements +remain unimplemented live gates. DL-06-04 is NOT_STARTED / OPTIONAL. +Historical Stage-2 proof and closure semantics remain unchanged. `DEFERRED_SECURITY_RESEARCH_BLOCKED` is not a Security PASS, completion, activation, or integration claim. Detailed experimental research evidence is @@ -588,18 +621,20 @@ future capabilities stay behind their separate authorization and safety gates. acceptance gates. - Address Node/NVM coexistence only if a later environment-maintenance task requires it. -- DL-05 Owner acceptance and canonical reconciliation are complete for the - bounded offline MVP. Future documentation publication requires separate - authority. DL-06's execution contract is not established and requires separate - future specification/readiness and exact Owner authorization. +- DL-05 remains accepted. DL-06-01 acceptance documentation is reconciled locally; + publication readiness review requires separate Owner authorization. DL-06-02 + is NOT_AUTHORIZED and DL-06-03 remains blocked; the live execution contract is + NOT_ESTABLISHED. Offline acceptance grants no live authority. - P3 remains OPEN_NON_BLOCKING: DL-05 does not use the affected wrapper, Safe CI validity is unaffected, and remediation was not required before acceptance. The seven retained Next.js tracing warnings remain non-blocking maintenance items. These findings remain open; no further Lab1/Lab2 operation is authorized. -- Keep the post-merge Safe CI maintenance findings OPEN: npm has 5 +- Keep the historical post-merge Safe CI maintenance findings OPEN: that run reported 5 vulnerabilities (2 moderate, 2 high, 1 critical), and Next.js emitted 7 filesystem-tracing warnings. These are non-Stage-2-blocking maintenance items; - the Stage-2 closure candidate does not resolve them. + the Stage-2 closure candidate does not resolve them. The later DL-06-01 + environment-restoration observation reported 12 vulnerabilities, retained above; + these are distinct observations, not evidence of remediation. Current limitations also include variable local report availability, no guarantee that every clean checkout contains generated evidence, and a diff --git a/docs/automation_readiness/actual_automation_integration_plan.md b/docs/automation_readiness/actual_automation_integration_plan.md index aaaf4b9..7295cd0 100644 --- a/docs/automation_readiness/actual_automation_integration_plan.md +++ b/docs/automation_readiness/actual_automation_integration_plan.md @@ -33,7 +33,11 @@ remain non-blocking maintenance items. | DL-03 | ACCEPTED | | DL-04 | ACCEPTED | | DL-05 | ACCEPTED; bounded offline MVP integration and acceptance complete | -| DL-06 | DL-06-00 readiness specification established; offline contract not implemented; execution contract NOT_ESTABLISHED and live authority absent | +| DL-06-00 | SPECIFICATION_ESTABLISHED | +| DL-06-01 | OWNER_ACCEPTED_OFFLINE_TRANSITION_CONTRACT; local, NOT_PUBLISHED | +| DL-06-02 | NOT_AUTHORIZED | +| DL-06-03 | BLOCKED_PENDING_HARD_DEADLINE_PROPAGATION_AND_ENFORCEMENT; trusted failure classification also unproven | +| DL-06-04 | NOT_STARTED / OPTIONAL | `DL_05_OWNER_ACCEPTANCE = ACCEPTED`, `DL_05_ACCEPTANCE_RESULT = PASS`, `DL_05_ACCEPTED = YES`, `COMPLETE_DUAL_LAB_MVP = YES`, and @@ -58,24 +62,58 @@ remain unauthorized. Future live work needs separate exact authority. Historical Stage-2 proof and closure semantics remain unchanged; Stage 2 is not reopened. The [DL-06-00 readiness specification](dual_lab_vrrp_ai_query_mvp.md#dl-06-00-transition-observer-readiness-and-specification) -composes accepted Stage-2 observations, DL-02 ordering and DL-03 facts into a -proposed outer timeline. Day35 remains the proven historical manual live failover -predecessor. The selected PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE permits no -authorization reuse. A valid sample with a positively classified observation-level -target failure may be followed by the next distinct preauthorized sample; -authorization/session-integrity or ambiguous failures terminate. The current -coarse failure mapping does not prove that distinction. No retry, fallback, -parallel execution, automatic failover or configuration mutation is permitted. +is established at `d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b`. +**DL-06-01 implementation `016ef20f8bfa0c91be4e68195a98037ad03af1de` is +Owner accepted / PASS for `OFFLINE_TRANSITION_CONTRACT_ONLY`.** Fresh governed +review passed and the separate acceptance decision passed all seven predicates. +The [canonical DL-06-01 record](dual_lab_vrrp_ai_query_mvp.md#dl-06-01-accepted-offline-transition-contract) +retains source/test hashes and decision provenance. Its +`dual-lab-vrrp-transition.v1` schema has exactly nine fact kinds, reusing accepted +DL-03 summaries for finite ordered evidence and deterministic facts. Lab1-then-Lab2 +windows remain sequential; gaps never supply missing roles or justify +`MASTER_TARGET_CHANGED` through insufficient evidence. Day35 remains the historical +operator-triggered real-device proof through direct commands/Paramiko. +DL-06-01 is offline and has no live transport or Day35 runtime. + +**Accepted fresh governed review evidence:** 198 focused, 850 Dual-Lab, +1,892 Stage-2 and 4,870 full pytest tests passed; the full run had zero skipped, +failed or errors. Accepted report-index evidence is WARN: 13 optional missing, +zero failures, mandatory missing or unknown. These application tests were not +rerun by documentation reconciliation. Zero material findings remain unresolved. + +The three retained findings are 12 npm-reported vulnerabilities +(`OPEN_NON_BLOCKING_DEPENDENCY_MAINTENANCE`), four redundant parametrizations +(`OPEN_NON_BLOCKING_TEST_MAINTENANCE`), and oversize-test precision +(`OPEN_NON_BLOCKING_TEST_PRECISION`). The oversized test is also invalid JSON; +the fresh review independently proved pre-decoder rejection by both parsers. +Package manifest/lock identities were unchanged by the candidate. The dependency +finding does not declare the vulnerabilities harmless generally; no finding is repaired. + +`DL_06_01_PUBLICATION_STATUS = NOT_PUBLISHED` and +`REMOTE_MAIN_CONTAINS_DL_06_01 = NO`; authoritative remote main remains +`5431f7491015fe7ab2ae9227bc6a48814356641e`. This local documentation descendant +preserves the accepted implementation identity and does not inherit its review +or application validation. The next separately required Owner decision is +`AUTHORIZE_DL_06_01_PUBLICATION_READINESS_REVIEW`, limited to documentation and +exact branch-scope review before proposing publication. Push, PR and merge are +separate operations and are not performed here. The 30-second bound is hard whole-observer completion, including in-flight work and cleanup. Shared deadline propagation/enforcement is NOT_ESTABLISHED, so DL-06-03 repeated live observation remains BLOCKED until separately authorized enforcement is implemented and proven; trusted failure classification is also -required. DL-06-01 offline transition-contract implementation is the next Owner -decision. This deadline blocker does not prevent that offline work or separately -authorized DL-06-02 single live Dual-Lab snapshot proof. The current-baseline -single proof remains required before repeated live observation and cannot claim -DL-06-03 readiness. No implementation or live authority is granted here. +required. `HARD_WHOLE_OBSERVER_COMPLETION_LIMIT_SECONDS = 30`, +`LIVE_HARD_DEADLINE_ENFORCEMENT_IMPLEMENTED = NO`, and +`HARD_SESSION_DEADLINE_RUNTIME_PROVEN = NO`; the bound must not become an +admission-window-only limit. `DL_06_02_AUTHORIZED = NO` and +`DL_06_03_REPEATED_LIVE_OBSERVER_READY = NO`. A separately authorized single +snapshot may use its own explicit time budget; it cannot prove repeated-observer +readiness. No live device was accessed by this reconciliation, and no live, +automatic failover, configuration mutation or provider/model authority is granted. +DL-06-01 implements no runtime loop, authorization schedule, retry or authority +reuse. The future PREAUTHORIZED_ONE_SHOT_SNAPSHOT_SCHEDULE and trusted distinction +between observation failures and session-integrity failures remain live gates; +opaque failures cannot authorize continuation. ## 1. Purpose @@ -452,8 +490,11 @@ and seven pre-existing build warnings remain maintenance items. `DL_05_ACCEPTED = YES`, `COMPLETE_DUAL_LAB_MVP = YES`, and `OFFLINE_DUAL_LAB_MVP_ACCEPTED = YES`. No live authority or readiness is granted. DL-06-00 readiness/specification is -established; DL-06's execution contract remains NOT_ESTABLISHED. The next -possible Owner decision is offline transition-contract implementation only. +established, and DL-06-01 is Owner accepted for the offline transition contract. +The accepted implementation remains local and NOT_PUBLISHED; DL-06's execution +contract remains NOT_ESTABLISHED. The next Owner decision is publication readiness +review only. DL-06-02 is NOT_AUTHORIZED and DL-06-03 remains blocked by unproven +hard whole-observer deadline enforcement and trusted failure classification. Stage 3 remains NOT STARTED. Further live access or scope expansion requires the applicable capability gates and separate exact Owner approval. diff --git a/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md index 926aa45..8057df7 100644 --- a/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md +++ b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md @@ -1,4 +1,12 @@ -# Dual-Lab VRRP AI Query MVP: DL-05 Accepted +# Dual-Lab VRRP AI Query MVP: DL-05 and Offline DL-06-01 Accepted + +**Current DL-06 decision: DL-06-01 is Owner accepted / PASS for the offline +transition contract, local and NOT_PUBLISHED.** Specification +`d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b` and accepted implementation +`016ef20f8bfa0c91be4e68195a98037ad03af1de` remain distinct from this documentation +descendant. The [DL-06-01 acceptance record](#dl-06-01-accepted-offline-transition-contract) +records the completed review and decision. DL-06-02 is NOT_AUTHORIZED; +DL-06-03 remains blocked. No live authority or readiness is granted. **Decision summary: DL-05 is ACCEPTED / PASS; the bounded offline Dual-Lab MVP is complete.** The separate Owner acceptance decision accepted implementation @@ -17,10 +25,13 @@ readiness is granted. DL-06's execution contract remains NOT_ESTABLISHED. The [DL-05 record](#dl-05-reviewed-exact-sha-candidate) retains the reviewed candidate and [completed Owner acceptance](#completed-owner-acceptance-and-active-status). The [DL-06-00 readiness specification](#dl-06-00-transition-observer-readiness-and-specification) -defines a future outer transition timeline; implementation and live execution remain unauthorized. +defines the accepted offline outer transition contract and the still-unimplemented +live-observer requirements. DL-06-01 review and Owner acceptance have completed; +live execution remains unauthorized. The [DL-05 canonical contract](#dl-05-canonical-integration-and-safe-ci-contract) retains its requirements. Earlier specification and predecessor status snapshots -are historical; the completed DL-05 acceptance below defines current status. +are historical; the completed DL-05 and DL-06-01 acceptance records define their +respective current status. The [accepted DL-04 closure record](#dl-04-reviewed-closure-candidate) and [DL-04 closure contract](#dl-04-canonical-closure-contract) retain their accepted identities, evidence, and safety boundaries. @@ -33,15 +44,17 @@ specification, accepted implementation, completed governed review, and completed Owner acceptance decision, and the DL-04 closure specification, completed closure execution, completed fresh governed review, and completed Owner acceptance. It also establishes the DL-05 integration, Safe CI, evidence, fresh exact-SHA -review, and Owner-acceptance contract. +review, and Owner-acceptance contract, plus the DL-06-00 specification and +accepted DL-06-01 offline implementation. The DL-03 specification resolved the readiness review's `BLOCKED_CANONICAL_SCOPE_AMBIGUITY`; the separately authorized implementation, review, and acceptance are recorded below. The prior DL-04 post-acceptance reconciliation recorded that completed decision. The current Owner authorization permits post-acceptance status reconciliation in this record, README, and the automation plan, followed by one local documentation -commit above pre-acceptance record `c275b1b58a696b85a9d516ab1b15cceb77432ad2`. -It records the completed decision without repeating acceptance or review. +commit directly above accepted DL-06-01 implementation +`016ef20f8bfa0c91be4e68195a98037ad03af1de`. +It records the completed DL-06-01 decision without repeating acceptance or review. No source, test, workflow, dependency, sealed-evidence, external-wrapper, or Stage-2 closure change is authorized. No application validation, Safe CI rerun, push, merge, branch deletion, P3 remediation, or live operation is performed. @@ -1490,14 +1503,167 @@ NEXT_REQUIRED_OWNER_DECISION = AUTHORIZE_BOUNDED_DL_04_CLOSURE_EXECUTION ``` +## DL-06-01 accepted offline transition contract + +**Decision: Owner acceptance completed ACCEPT / PASS for +`OFFLINE_TRANSITION_CONTRACT_ONLY`.** The exact implementation below passed +fresh governed review, then a separate Owner-authorized acceptance decision +with all seven predicates satisfied. This reconciliation records those completed +decisions; it does not repeat them or publish the implementation. + +### Exact accepted identities and provenance + +| Identity | Accepted value | +| --- | --- | +| DL-06-00 specification commit | `d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b` | +| DL-06-01 implementation commit | `016ef20f8bfa0c91be4e68195a98037ad03af1de` | +| Implementation parent | `d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b` | +| Implementation commit message | `Implement DL-06 offline transition contract` | +| Accepted source | [dual_lab_vrrp_transition.py](../../validation_framework/dual_lab_vrrp_transition.py) | +| Source SHA256 | `9f1f1a39d1576dc4a075018a8cd326b5983308729f1c7f67dbe231d4e5b7dcb2` | +| Accepted tests | [test_dual_lab_vrrp_transition.py](../../tests/dual_lab/test_dual_lab_vrrp_transition.py) | +| Test SHA256 | `e357331c4a64468ea4cd4d7d61341fa271ea48a06af56f71594628c2b8f453e5` | +| Authoritative remote main at reconciliation | `5431f7491015fe7ab2ae9227bc6a48814356641e` | + +The candidate added exactly those two files. Accepted predecessors, Stage-2, +Day35, documentation, workflows, `package.json` and `package-lock.json` were +unchanged by the implementation. The `DL_06_01_FRESH_GOVERNED_REVIEW_ONLY` +response and later `DL_06_01_OWNER_ACCEPTANCE_ONLY` response in the same +conversation are the review and decision records, reaffirmed by this task's +Owner authorization. No separate-human or separate-agent review identity is +claimed, and no external review receipt is invented. + +The acceptance task rechecked the exact commit, parent, two-file scope, hashes, +remote baseline and clean worktree; it changed no files and reran no tests. +All seven predicates passed: identity, scope, specification conformance, safety +properties, conservative inference, validation evidence and the closed live boundary. + +### Accepted behavior and fresh governed review evidence + +The inert `dual-lab-vrrp-transition.v1` layer reuses accepted DL-03 summaries. +It provides ordered finite transition evidence and deterministic facts, with +conservative observation-gap handling. Missing roles are never guessed; opaque +failure is never a role, and `MASTER_TARGET_CHANGED` is not inferred through +insufficient evidence or across gaps. Each sample preserves Lab1-then-Lab2 +sequential observation semantics, not simultaneous pair state. Output is facts-only. + +Exactly nine fact kinds are accepted: `TARGET_RESULT_STATUS_CHANGED`, +`ROLE_CHANGED`, `RUNNING_CHANGED`, `PRIORITY_CHANGED`, `INTERVAL_CHANGED`, +`VERSION_CHANGED`, `VRID_SET_CHANGED`, `MASTER_TARGET_CHANGED`, and +`OBSERVATION_GAP`. Their derivation and ordering remain the unchanged +[DL-06-00 contract](#exact-deterministic-transition-vocabulary). + +**Accepted fresh governed review evidence**, tied to the implementation SHA above: + +| Command run by the fresh review | Accepted result | +| --- | --- | +| `python -m pytest tests/dual_lab/test_dual_lab_vrrp_transition.py -q` | 198 passed | +| `python -m pytest tests/dual_lab -q` | 850 passed | +| `python -m pytest tests/stage2 -q` | 1,892 passed | +| `python -m pytest` | 4,870 collected and passed; 0 skipped, 0 failed, 0 errors | +| `python -B network_lab.py --task report-index` | WARN; 13 optional missing, 0 failures, 0 mandatory missing, 0 unknown | + +The requirement-to-test mapping was COMPLETE with zero material untested +requirements. Gap non-inference, fail-closed canonical parsing, deterministic +serialization and immutability reviews passed. Static inspection and guarded +use established no live I/O dependency or execution authority. A separate +in-memory review probe confirmed oversized-input rejection before JSON decoding +by both public parsers. These results are retained evidence, not application +tests rerun by documentation reconciliation. + +### Three retained non-blocking findings + +| Finding | Evidence and retained status | +| --- | --- | +| Dependency maintenance | Environment-restoration npm reported 12 vulnerabilities. The candidate changed neither package manifest nor lockfile. `OPEN_NON_BLOCKING_DEPENDENCY_MAINTENANCE`; this is not a claim that vulnerabilities are harmless generally. | +| Redundant parametrization | Four transition-test parametrizations repeat input coverage. `OPEN_NON_BLOCKING_TEST_MAINTENANCE`; the 198-test count does not imply 198 distinct scenarios. | +| Oversize-test precision | One oversized-input payload is also invalid JSON. The fresh review independently confirmed both parsers reject oversize input before decoding. `OPEN_NON_BLOCKING_TEST_PRECISION`. | + +`UNRESOLVED_MATERIAL_FINDINGS = 0` and `NONBLOCKING_FINDINGS = 3`. +No finding is repaired by acceptance or this documentation task. Earlier +dependency observations, P3 and seven retained build warnings remain historical +or predecessor maintenance evidence; they are not silently replaced or resolved. + +### Active slice status and live restrictions + +```text +DL_06_00_SPECIFICATION_STATUS = ESTABLISHED +DL_06_00_SPECIFICATION_SHA = d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b +DL_06_01_IMPLEMENTED = YES +DL_06_01_REVIEW_RESULT = PASS +DL_06_01_OWNER_ACCEPTED = YES +DL_06_01_ACCEPTANCE_RESULT = PASS +DL_06_01_ACCEPTED_IMPLEMENTATION_SHA = 016ef20f8bfa0c91be4e68195a98037ad03af1de +DL_06_01_ACCEPTANCE_SCOPE = OFFLINE_TRANSITION_CONTRACT_ONLY +DL_06_01_TRANSITION_SCHEMA = dual-lab-vrrp-transition.v1 +DL_06_01_TRANSITION_FACT_COUNT = 9 +DL_06_OFFLINE_TRANSITION_CONTRACT_STATUS = OWNER_ACCEPTED_OFFLINE_TRANSITION_CONTRACT +DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED +LIVE_DEVICE_ACCESSED = NO +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +DL_06_02_AUTHORIZED = NO +DL_06_03_REPEATED_LIVE_OBSERVER_READY = NO +AUTOMATIC_FAILOVER_AUTHORITY_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +PROVIDER_MODEL_INTEGRATION_AUTHORIZED = NO +RUNTIME_LOOP_IMPLEMENTED = NO +AUTHORIZATION_SCHEDULE_IMPLEMENTED = NO +RETRY_IMPLEMENTED = NO +HARD_WHOLE_OBSERVER_COMPLETION_LIMIT_SECONDS = 30 +LIVE_HARD_DEADLINE_ENFORCEMENT_IMPLEMENTED = NO +HARD_SESSION_DEADLINE_RUNTIME_PROVEN = NO +DL_06_01_PUBLICATION_STATUS = NOT_PUBLISHED +REMOTE_MAIN_CONTAINS_DL_06_01 = NO +``` + +These runtime and access statements describe DL-06-01 and this reconciliation; +they do not erase the separately authorized historical Stage-2 or Day35 proofs. +Day35 remains a real-device, operator-triggered failover predecessor using direct +commands/Paramiko, not Stage-2 authorization. DL-06-01 adds no live transport, +device parser, Day35 evaluator, authorization consumption or execution path. + +The 30-second requirement is hard whole-observer completion, including in-flight +work and cleanup. It must not become an admission-window-only limit. Repeated live +observation remains blocked pending separately authorized deadline propagation, +enforcement and proof; trusted observation-versus-integrity failure classification +is also required. The blocker does not prohibit a separately authorized single +Dual-Lab snapshot with its own time budget. DL-06-02 is not authorized here and +cannot establish repeated-observer readiness. + +### Local reconciliation and next Owner decision + +The local documentation commit directly descends from the accepted implementation +and uses message `Record DL-06-01 acceptance status`. Its actual SHA and parent +are reported after commit; its identity never replaces the accepted implementation +SHA or inherits that implementation's application tests or review. Authoritative +public main remains the verified baseline above and contains neither accepted +transition file. No push, PR or merge is performed by this task. + +Documentation validation covers identity/status consistency, links, readability, +protected-file integrity and whitespace. Repository-standard report-index is +run separately as a documentation check, with optional-only WARN acceptable; +its actual result is reported by this task. Full pytest and the application test +matrix are not rerun. No new implementation, live proof or acceptance occurs. + +Following the established separation of documentation and publication authority, +the next required Owner decision is +`AUTHORIZE_DL_06_01_PUBLICATION_READINESS_REVIEW`: a read-only review of this +documentation descendant and the exact branch scope before proposing publication. +This does not grant push, PR, merge, DL-06-02 or DL-06-03 authority, and that next +action is not performed by reconciliation. + ## DL-06-00 transition observer readiness and specification -**Decision: establish the offline transition specification; live execution remains -LOCKED.** Day35 already proved manual live VRRP failover. DL-06 adds bounded, +**Specification established; DL-06-01 is now Owner accepted offline, while live +execution remains LOCKED.** Day35 already proved manual live VRRP failover. +DL-06 adds bounded, ordered observations and deterministic change facts through the accepted Stage-2 / DL-02 / DL-03 composition. It does not create another Day35 tester. -This section is the current DL-06-00 planning record; earlier statements that -DL-06 readiness/specification had not begun are historical. The accepted DL-01 +This section retains the DL-06-00 contract established at +`d481dfb41e9a5547ad2b2dd12f5e6a21fc8cfd5b`; the acceptance record above governs +current implementation status. Earlier statements that DL-06 readiness/specification +had not begun are historical. The accepted DL-01 through DL-05 implementation, evidence and decisions remain unchanged. Planning baseline: public `main@5431f7491015fe7ab2ae9227bc6a48814356641e`. @@ -1505,6 +1671,9 @@ Accepted DL-05 implementation: `0e5dfbc1d143a6de7ba59e4bff9bd1788e809043`, tree `6252c2c632d217b4f02804626aac797b546f87f8`. Specification does not grant implementation authority, live readiness, a new signed approval, or execution. +Historical status at specification establishment, before separately authorized +implementation, review and acceptance: + ```text DL_06_00_SPECIFICATION_STATUS = ESTABLISHED DL_06_OFFLINE_TRANSITION_CONTRACT_STATUS = SPECIFIED_NOT_IMPLEMENTED @@ -1905,11 +2074,11 @@ occurs outside observer control and grants no automatic recovery action. | Slice | Bounded purpose | Gate / current status | | --- | --- | --- | -| DL-06-00 | Day35 review and this canonical readiness/specification | Documentation only; no implementation or live grant. | -| DL-06-01 | Offline outer contract and deterministic derivation tests | Next Owner decision only; inert summaries and synthetic times, zero runtime calls. | -| DL-06-02 | Current-baseline single live Dual-Lab snapshot proof | Separate exact Owner authorization, baseline verification, trusted deployment and fresh one-shot approvals. One Lab1 invocation then one Lab2; no loop, failover or mutation. | -| DL-06-03 | Bounded transition observer with manual operator event | Separate implementation/review/live gates; single-snapshot proof required first; hard deadline enforcement and trusted observation-versus-integrity failure classification must be proven. | -| DL-06-04 | Optional presentation of validated timeline | Not required for observer completeness; any real provider/model use needs separate authority. | +| DL-06-00 | Day35 review and this canonical readiness/specification | SPECIFICATION_ESTABLISHED; no live grant. | +| DL-06-01 | Offline outer contract and deterministic derivation tests | OWNER_ACCEPTED_OFFLINE_TRANSITION_CONTRACT; local, NOT_PUBLISHED; zero runtime calls. | +| DL-06-02 | Current-baseline single live Dual-Lab snapshot proof | NOT_AUTHORIZED; needs separate exact Owner authorization, baseline verification, trusted deployment and fresh one-shot approvals. One Lab1 invocation then one Lab2; no loop, failover or mutation. | +| DL-06-03 | Bounded transition observer with manual operator event | BLOCKED_PENDING_HARD_DEADLINE_PROPAGATION_AND_ENFORCEMENT; separate implementation/review/live gates, single-snapshot proof and trusted observation-versus-integrity failure classification also required. | +| DL-06-04 | Optional presentation of validated timeline | NOT_STARTED / OPTIONAL; not required for observer completeness; real provider/model use needs separate authority. | Day35 and the historical individual Stage-2 proofs do not prove the current paired Dual-Lab composition live. Therefore @@ -1922,7 +2091,8 @@ The repeated-observation blocker does not prevent independently authorized DL-06-01 offline work or DL-06-02 single-proof work. Neither may claim DL-06-03 readiness, and neither is implemented or executed by this documentation task. -DL-06-01 acceptance requires canonical round trips and exact nine-kind fact +DL-06-01 acceptance criteria, satisfied by the accepted fresh review above, +require canonical round trips and exact nine-kind fact recomputation; zero/single/ten samples; every termination branch; empty and failed targets; duplicate/missing keys; role and MASTER changes in both directions; no bridge over gaps; stable Unicode ordering; booleans versus @@ -1952,9 +2122,10 @@ access. No model/provider/voice/MCP integration is included. No second Day35 implementation, direct Paramiko failover path, device parser, authorization system or single-snapshot evidence model is created. -The smallest next decision is +Historically, the next decision at DL-06-00 establishment was `AUTHORIZE_DL_06_01_OFFLINE_TRANSITION_CONTRACT_IMPLEMENTATION`. -It authorizes nothing until the Owner supplies that separate instruction. +Implementation, review and Owner acceptance subsequently completed; the current +next decision is in the DL-06-01 acceptance record above. P3 remains OPEN_NON_BLOCKING; seven existing build warnings and all prior acceptance and Stage-2 closure boundaries remain unchanged. @@ -2183,9 +2354,11 @@ protected-file hashes, and unstaged/staged whitespace. Application tests, report-index, Safe CI, exact-SHA review, and Owner acceptance are not repeated. Sealed evidence, P3, and the seven maintenance warnings remain unchanged. -No further Owner decision is required to complete this documentation task. -Future publication needs separate authority. DL-06 remains unestablished and -requires its own readiness/specification and exact Owner authorization; it is +At the historical DL-05 post-acceptance reconciliation, no further Owner decision +was required to complete that documentation task. The following DL-06 status +describes that earlier point; the DL-06-01 acceptance record above supersedes it. +Future publication needed separate authority. DL-06 remained unestablished and +required its own readiness/specification and exact Owner authorization; it was not started. Offline acceptance grants no standing Lab1/Lab2 access, live query, configuration mutation, provider/model integration, or production readiness. Historical Stage-2 proof and closure semantics remain unchanged.